fix: unify model defaults and credential precedence

This commit is contained in:
webreflection
2026-09-02 17:23:50 +02:00
parent 52b6e8ea42
commit acd3d1ff9b
19 changed files with 1093 additions and 174 deletions
+1 -1
View File
@@ -4,4 +4,4 @@
"@kilocode/kilo-gateway": patch
---
Use organization model defaults after login and team switching while preserving valid model preferences. Keep unavailable organization catalogs from falling back to public models.
Use organization model defaults in VS Code while preserving valid preferences. Return consistent defaults from the CLI provider APIs, respect environment credential overrides, and prevent public-model fallbacks for Org accounts.
+1 -1
View File
@@ -240,7 +240,7 @@ async function fetchRawKiloModels(options?: {
}
if (!response.ok) {
if (response.status === 401 && token && !organizationId && !baseURL.includes("/api/organizations/")) {
if (response.status === 401 && token && !organizationId && !finalBaseURL.includes("/api/organizations/")) {
return fetchRawKiloModels({})
}
const kind = response.status === 401 || response.status === 403 ? "unauthorized" : "http"
@@ -127,6 +127,11 @@ test.each([
{ kilocodeOrganizationId: "org-123" },
{ kilocodeToken: "expired-token", baseURL: "https://api.kilo.ai/api/organizations/org-123" },
{ kilocodeToken: "expired-token", baseURL: "https://gateway.test/api/organizations/org-123" },
{ kilocodeToken: "https://gateway.test/api/organizations/org-token:expired-token" },
{
kilocodeToken: "https://gateway.test/api/organizations/org-token:expired-token",
baseURL: "https://api.kilo.ai/api/openrouter",
},
])("never retries an organization-scoped 401 against the public catalog: %j", async (options) => {
const fetch = spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 401 }))
try {
+3 -13
View File
@@ -63,22 +63,11 @@ export async function fetchProviderData(client: KiloClient, dir: string) {
.authStatus({ directory: dir }, { throwOnError: true })
.then((r) => r.data)
.catch(() => undefined)
const recommendation = kiloRequest.then(async (auth) => {
if (!auth?.organizationId) return undefined
return client.config
.providers({ directory: dir }, { throwOnError: true })
.then((r) => r.data?.default.kilo)
.catch((error: unknown) => {
console.warn("[Kilo New] Failed to fetch organization model default:", error)
return undefined
})
})
const [{ data: response }, authMethods, kiloAuth, recommended] = await Promise.all([
const [{ data: response }, authMethods, kiloAuth] = await Promise.all([
client.provider.list({ directory: dir }, { throwOnError: true }),
authRequest,
kiloRequest,
recommendation,
])
const authStates: Record<string, AuthState> = {}
const storedKeys: Record<string, StoredProviderKey> = {}
@@ -105,7 +94,8 @@ export async function fetchProviderData(client: KiloClient, dir: string) {
const defaults = { ...response.default }
if (organizationId) {
const models = all.find((item) => item.id === KILO_PROVIDER_ID)?.models ?? {}
const model = recommended && models[recommended] ? recommended : Object.keys(models).at(0)
const recommended = response.default[KILO_PROVIDER_ID]
const model = recommended && Object.hasOwn(models, recommended) ? recommended : Object.keys(models).at(0)
if (model) defaults[KILO_PROVIDER_ID] = model
if (!model) delete defaults[KILO_PROVIDER_ID]
}
@@ -7,9 +7,16 @@ const { KiloProvider } = await import("../../src/KiloProvider")
const external = { id: "external", name: "External", models: { model: { id: "model" } } }
const catalog = (org: string) => ({
data: {
all: [{ id: "kilo", name: "Kilo Gateway", models: { [`${org}/model`]: { id: `${org}/model` } } }, external],
all: [
{
id: "kilo",
name: "Kilo Gateway",
models: { [`${org}/first`]: { id: `${org}/first` }, [`${org}/model`]: { id: `${org}/model` } },
},
external,
],
connected: ["kilo", "external"],
default: { kilo: "kilo-auto/free", external: "model" },
default: { kilo: `${org}/model`, external: "model" },
},
})
@@ -32,7 +39,6 @@ function setup(list: () => Promise<ReturnType<typeof catalog>>, org: () => strin
config: {
get: async (): Promise<{ data: Config }> => ({ data: {} }),
overlay: async () => ({ data: {} }),
providers: async () => ({ data: { default: { kilo: `${org()}/model` } } }),
},
global: { config: { get: async () => ({ data: {} }) } },
experimental: { capabilities: { get: async () => ({ data: {} }) } },
@@ -439,37 +439,56 @@ describe("disconnectProvider", () => {
describe("fetchProviderData", () => {
for (const item of [
{ name: "uses the allowed organization default", recommended: "org/default", expected: "org/default" },
{ name: "uses the allowed organization API default", recommended: "org/default", expected: "org/default" },
{ name: "uses the first allowed model when no default exists", recommended: undefined, expected: "org/first" },
{ name: "uses the first allowed model when the default is empty", recommended: "", expected: "org/first" },
{
name: "ignores a default outside the organization catalog",
recommended: "kilo-auto/free",
expected: "org/first",
},
{ name: "uses the first allowed model when defaults cannot load", error: true, expected: "org/first" },
{ name: "ignores inherited catalog properties", recommended: "toString", expected: "org/first" },
{
name: "does not invent a default for an empty catalog",
empty: true,
recommended: "org/default",
expected: undefined,
},
{
name: "does not retain a default without a Kilo provider",
missing: true,
recommended: "org/default",
expected: undefined,
},
]) {
it(item.name, async () => {
const directories: string[] = []
let calls = 0
const external = {
id: "anthropic",
name: "Anthropic",
models: { claude: { id: "claude" } },
metadata: { priority: 1 },
}
const client = {
provider: {
list: async () => ({
data: {
all: [
{
id: "kilo",
name: "Kilo Gateway",
models: item.empty ? {} : { "org/first": { id: "org/first" }, "org/default": { id: "org/default" } },
},
{ id: "anthropic", name: "Anthropic", models: { claude: { id: "claude" } }, metadata: { priority: 1 } },
...(item.missing
? []
: [
{
id: "kilo",
name: "Kilo Gateway",
models: item.empty
? {}
: { "org/first": { id: "org/first" }, "org/default": { id: "org/default" } },
},
]),
{ ...external, key: "sk-test" },
],
connected: ["kilo", "anthropic"],
default: { kilo: "kilo-auto/free", anthropic: "claude" },
connected: item.missing ? ["anthropic"] : ["kilo", "anthropic"],
default: { ...(item.recommended === undefined ? {} : { kilo: item.recommended }), anthropic: "claude" },
},
}),
auth: async () => ({ data: {} }),
@@ -478,10 +497,9 @@ describe("fetchProviderData", () => {
authStatus: async () => ({ data: { authenticated: true, type: "oauth", organizationId: "org" } }),
},
config: {
providers: async (input: { directory: string }) => {
directories.push(input.directory)
if (item.error) throw new Error("Defaults unavailable")
return { data: { default: { kilo: item.recommended, anthropic: "unrelated" } } }
providers: async () => {
calls++
return { data: { default: { kilo: "org/first", anthropic: "unrelated" } } }
},
},
} as unknown as Parameters<typeof fetchProviderData>[0]
@@ -489,16 +507,16 @@ describe("fetchProviderData", () => {
const result = await fetchProviderData(client, "/workspace")
expect(result.response.default.kilo).toBe(item.expected)
expect(result.response.default.anthropic).toBe("claude")
expect(result.response.all.find((provider) => provider.id === "anthropic")).toMatchObject({
metadata: { priority: 1 },
})
expect(result.response.all.find((provider) => provider.id === "anthropic")).toEqual(external)
expect(result.response.connected).toEqual(item.missing ? ["anthropic"] : ["kilo", "anthropic"])
expect(result.authStates).toEqual({ kilo: "oauth", anthropic: "api" })
expect(result.organizationId).toBe("org")
expect(result.ready).toBe(true)
expect(directories).toEqual(["/workspace"])
expect(calls).toBe(0)
})
}
it("distinguishes failed auth context from Personal and removes unverified Kilo models", async () => {
it.each([false, true])("removes unverified Kilo data without auth context (failure: %s)", async (fail) => {
const client = {
provider: {
list: async () => ({
@@ -515,7 +533,8 @@ describe("fetchProviderData", () => {
},
kilo: {
authStatus: async () => {
throw new Error("Context unavailable")
if (fail) throw new Error("Context unavailable")
return { data: undefined }
},
},
} as unknown as Parameters<typeof fetchProviderData>[0]
@@ -1,31 +1,50 @@
import type { Auth } from "@/auth"
import type { Provider } from "@/provider/provider"
import { fetchDefaultModel } from "@kilocode/kilo-gateway"
import { fetchDefaultModel, getKiloUrlFromToken, KILO_API_BASE } from "@kilocode/kilo-gateway"
export function organization(
options: { kilocodeOrganizationId?: string; baseURL?: string } | undefined,
info: Auth.Info | undefined,
) {
type Options = { kilocodeOrganizationId?: string; baseURL?: string; apiKey?: string; kilocodeToken?: string }
export function token(options: Options | undefined, info: Auth.Info | undefined) {
if (process.env.KILO_API_KEY) return process.env.KILO_API_KEY
if (info?.type === "oauth") return info.access
if (info?.type === "api") return info.key
if (options?.kilocodeToken != null) return options.kilocodeToken
return options?.apiKey || undefined
}
function scoped(url: string) {
return URL.parse(url)
?.pathname.match(/\/api\/organizations\/([^/]+)/)
?.at(1)
}
export function organization(options: Options | undefined, info: Auth.Info | undefined) {
return (
options?.kilocodeOrganizationId ??
URL.parse(options?.baseURL ?? "")
?.pathname.match(/\/api\/organizations\/([^/]+)/)
?.at(1) ??
(info?.type === "oauth" ? info.accountId : undefined) ??
process.env.KILO_ORG_ID
process.env.KILO_ORG_ID ||
(info?.type === "oauth" ? info.accountId : undefined) ||
options?.kilocodeOrganizationId ||
scoped(getKiloUrlFromToken(options?.baseURL ?? "", token(options, info) ?? ""))
)
}
export function compatible(options: { baseURL?: string; kilocodeToken?: string; kilocodeOrganizationId?: string }) {
const org = scoped(getKiloUrlFromToken(options.baseURL ?? "", options.kilocodeToken ?? ""))
return !org || !options.kilocodeOrganizationId || org === options.kilocodeOrganizationId
}
export async function recommend(
models: Provider.Info["models"],
options: { kilocodeOrganizationId?: string; baseURL?: string; apiKey?: string } | undefined,
models: Readonly<Record<string, unknown>>,
options: Options | undefined,
info: Auth.Info | undefined,
known = true,
) {
const first = Object.keys(models).at(0)
if (!first || !known) return first
const org = organization(options, info)
const stored = info?.type === "oauth" ? info.access : info?.key
const token = org ? process.env.KILO_API_KEY || stored || options?.apiKey : stored
const fallback = org ? Object.keys(models).at(0) : undefined
if (org && !fallback) return undefined
const model = await fetchDefaultModel(token, org, fallback)
const key = token(options, info)
if (!compatible({ baseURL: options?.baseURL, kilocodeToken: key, kilocodeOrganizationId: org })) return undefined
const fallback = org ? first : undefined
const endpoint = getKiloUrlFromToken(options?.baseURL || KILO_API_BASE, key ?? "")
if (URL.parse(endpoint)?.origin !== URL.parse(KILO_API_BASE)?.origin) return fallback
const model = await fetchDefaultModel(key, org, fallback)
return Object.hasOwn(models, model) ? model : fallback
}
@@ -17,6 +17,9 @@ import type { LanguageModelV3 } from "@ai-sdk/provider"
import { mapValues, omit, pickBy } from "remeda"
import { reasoningSummary } from "./reasoning-summary"
import type { Provider } from "@/provider/provider"
import type { Auth } from "@/auth"
import type { Config } from "@/config/config"
import { organization, token } from "./catalog"
/** Default timeout (ms) for provider HTTP requests (connection phase). */
export const REQUEST_TIMEOUT_MS = 300_000 // 5 minutes
@@ -167,6 +170,24 @@ export function patchKiloProviderPrivacy(provider: { options?: Record<string, an
provider.options = { ...provider.options, dataCollection: "deny" }
}
export function patchKiloProviderAuth(
provider: Provider.Info | undefined,
config: Config.Info,
info: Auth.Info | undefined,
) {
if (!provider) return
const options = config.provider?.kilo?.options
const key = token(options, info)
const org = organization(options, info)
if (key !== undefined) provider.options.kilocodeToken = key
if (org !== undefined) provider.options.kilocodeOrganizationId = org
}
export function publicKiloProvider(provider: Provider.Info): Provider.Info {
if (provider.id !== "kilo") return provider
return { ...provider, key: undefined, options: omit(provider.options, ["apiKey", "kilocodeToken"]) }
}
export function kiloCustomLoaders(dep: CustomDep): Record<string, CustomLoader> {
return {
"github-copilot-enterprise": () =>
@@ -4,7 +4,7 @@ import { Context, Deferred, Duration, Effect, Exit, Layer, Schema, Scope } from
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Config } from "../config/config"
import { Auth } from "../auth"
import { organization } from "@/kilocode/provider/catalog"
import { compatible, organization, token } from "@/kilocode/provider/catalog"
import type { Provider } from "@opencode-ai/core/models-dev"
import * as Log from "@opencode-ai/core/util/log"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
@@ -126,14 +126,9 @@ export const layer: Layer.Layer<
if (providerID === "kilo") {
const item = config.provider?.[providerID]
if (item?.options?.apiKey) options.kilocodeToken = item.options.apiKey
const info = yield* auth.get(providerID)
options.kilocodeOrganizationId = organization(item?.options, info)
if (info?.type === "api") options.kilocodeToken = info.key
if (info?.type === "oauth") options.kilocodeToken = info.access
if (process.env.KILO_API_KEY) options.kilocodeToken = process.env.KILO_API_KEY
options.kilocodeToken = token(item?.options, info)
log.debug("auth options resolved", {
providerID,
hasToken: !!options.kilocodeToken,
@@ -176,7 +171,9 @@ export const layer: Layer.Layer<
}),
),
)
return yield* fetchModels(providerID, { ...resolved, ...options })
const input = { ...resolved, ...options }
if (providerID === "kilo" && !compatible(input)) return { models: {}, error: { kind: "schema" as const } }
return yield* fetchModels(providerID, input)
})
const key = (providerID: string, options?: Options) => {
+5 -4
View File
@@ -6,7 +6,7 @@ import * as Core from "@opencode-ai/core/models-dev"
import { Context, Effect, Layer } from "effect"
import { AI_SDK_PROVIDERS, KILO_OPENROUTER_BASE, PROMPTS } from "@kilocode/kilo-gateway"
import { overlay } from "@/kilocode/anaconda-desktop/provider"
import { organization } from "@/kilocode/provider/catalog"
import { compatible, organization, token } from "@/kilocode/provider/catalog"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change
@@ -84,8 +84,9 @@ export const layer: Layer.Layer<Service, never, Core.Service | Config.Service |
...(url ? { baseURL: url } : {}),
...(org ? { kilocodeOrganizationId: org } : {}),
}
const fetched = yield* cache.fetch("kilo", fetch).pipe(Effect.catch(() => Effect.succeed({})))
const models = org || Object.keys(fetched).length > 0 ? fetched : (fallback?.models ?? {})
const valid = compatible({ ...fetch, kilocodeToken: token(opts, info) })
const fetched = valid ? yield* cache.fetch("kilo", fetch).pipe(Effect.catch(() => Effect.succeed({}))) : {}
const models = !valid || org || Object.keys(fetched).length > 0 ? fetched : (fallback?.models ?? {})
providers.kilo = {
id: "kilo",
name: "Kilo Gateway",
@@ -94,7 +95,7 @@ export const layer: Layer.Layer<Service, never, Core.Service | Config.Service |
npm: "@kilocode/kilo-gateway",
models,
}
if (!org && Object.keys(fetched).length === 0)
if (valid && !org && Object.keys(fetched).length === 0)
yield* cache.refresh("kilo", fetch).pipe(Effect.ignore, Effect.forkDetach)
yield* addApertis()
return providers
+4 -1
View File
@@ -40,6 +40,8 @@ import {
customProviderVariants,
patchCustomLoaderResult,
patchKiloProviderPrivacy,
patchKiloProviderAuth,
publicKiloProvider,
kiloSmallModelPriority,
buildTimeoutSignal,
requestTimeout,
@@ -1133,7 +1135,7 @@ export function toPublicInfo(provider: Info): Info {
return JSON.parse(
JSON.stringify(
{
...provider,
...publicKiloProvider(provider), // kilocode_change
models: Object.fromEntries(Object.entries(provider.models).filter(([, model]) => Schema.is(Model)(model))),
},
(_, value) => {
@@ -1679,6 +1681,7 @@ const layer = Layer.effect(
mergeProvider(providerID, partial)
}
patchKiloProviderPrivacy(providers[ProviderV2.ID.make("kilo")], cfg) // kilocode_change
patchKiloProviderAuth(providers[ProviderV2.ID.make("kilo")], cfg, auths["kilo"]) // kilocode_change
const gitlab = ProviderV2.ID.make("gitlab")
if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) {
@@ -2,6 +2,7 @@ import { Config } from "@/config/config"
// kilocode_change start - preserve Kilo API default model overlay
import { recommend } from "@/kilocode/provider/catalog"
import { Auth } from "@/auth"
import { Option } from "effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { filterPromptTrainingModels, nonEmptyProviders } from "@/kilocode/provider/model-filter"
@@ -9,7 +10,7 @@ import { filterPromptTrainingModels, nonEmptyProviders } from "@/kilocode/provid
import { Provider } from "@/provider/provider"
import * as InstanceState from "@/effect/instance-state"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" // kilocode_change
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { markInstanceForDisposal } from "../lifecycle"
@@ -17,6 +18,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h
Effect.gen(function* () {
const providerSvc = yield* Provider.Service
const configSvc = yield* Config.Service
const auth = yield* Auth.Service // kilocode_change
const get = Effect.fn("ConfigHttpApi.get")(function* () {
return yield* configSvc.get()
@@ -45,11 +47,15 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h
// kilocode_change end
// kilocode_change start - Fetch default model from Kilo API when the kilo provider is available.
if (providers[ProviderV2.ID.kilo]) {
const auth = yield* Auth.Service
const info = yield* auth.get("kilo").pipe(Effect.mapError(() => new HttpApiError.Unauthorized({}))) // kilocode_change
if (defaults[ProviderV2.ID.kilo]) {
const info = yield* auth.get("kilo").pipe(Effect.option)
const model = yield* Effect.promise(() =>
recommend(providers[ProviderV2.ID.kilo].models, config.provider?.kilo?.options, info),
recommend(
providers[ProviderV2.ID.kilo].models,
config.provider?.kilo?.options,
Option.getOrUndefined(info),
Option.isSome(info),
),
)
if (model && providers[ProviderV2.ID.kilo]?.models[model]) defaults[ProviderV2.ID.kilo] = ModelV2.ID.make(model)
}
@@ -6,7 +6,8 @@ import { Provider } from "@/provider/provider"
import { mapValues, pickBy } from "remeda" // kilocode_change
import { ModelCache } from "@/provider/model-cache" // kilocode_change
import { Auth } from "@/auth" // kilocode_change
import { organization } from "@/kilocode/provider/catalog" // kilocode_change
import { organization, recommend } from "@/kilocode/provider/catalog" // kilocode_change
import { ModelV2 } from "@opencode-ai/core/model" // kilocode_change
import { Option } from "effect" // kilocode_change
import {
disposeAllInstancesAfterProviderAuthCallback,
@@ -62,6 +63,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
const connected = yield* provider.list()
// kilocode_change start
const info = yield* access.get("kilo").pipe(Effect.option)
const unavailable = Option.isNone(info) && ("kilo" in filtered || "kilo" in connected)
if (Option.isNone(info) || organization(config.provider?.kilo?.options, info.value)) delete filtered.kilo
const providers = filterPromptTrainingModels(
Object.assign(
@@ -70,25 +72,37 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
),
config.hide_prompt_training_models === true,
)
if (Option.isNone(info)) delete providers.kilo
// kilocode_change end
// kilocode_change start
const failed = yield* cache.failedProviders()
// Note: connected only contains providers with non-empty models after Provider.Service.list(),
// so failed must be checked explicitly for providers whose fetch returned an error.
const failedSet = new Set(failed)
if (unavailable) failedSet.add("kilo")
const validProviders = pickBy(
providers,
(item, id) => Object.keys(item.models).length > 0 || id in connected || failedSet.has(id),
)
const defaults = Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models).length > 0))
if (connected[ProviderV2.ID.kilo] && defaults[ProviderV2.ID.kilo]) {
const model = yield* Effect.promise(() =>
recommend(
validProviders.kilo.models,
config.provider?.kilo?.options,
Option.getOrUndefined(info),
Option.isSome(info),
),
)
if (model) defaults[ProviderV2.ID.kilo] = ModelV2.ID.make(model)
}
return {
all: Object.values(validProviders).map((item) => ({
...Provider.toPublicInfo(item),
metadata: providerMetadata(item.id),
})), // kilocode_change
default: Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models).length > 0)),
connected: Object.keys(connected).filter((id) => id !== "kilo" || Option.isSome(info)),
failed,
default: defaults,
connected: Object.keys(connected),
failed: [...failedSet],
}
// kilocode_change end
})
@@ -135,6 +135,26 @@ function layer(options?: { config?: Config.Info; info?: Auth.Info; fetch?: Model
const it = testEffect(testInstanceStoreLayer)
function environment(values: Record<string, string | undefined>) {
return Effect.acquireRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]))
for (const [key, value] of Object.entries(values)) {
if (value === undefined) delete process.env[key]
if (value !== undefined) process.env[key] = value
}
return previous
}),
(previous) =>
Effect.sync(() => {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key]
if (value !== undefined) process.env[key] = value
}
}),
)
}
it.live("assembles paid Kilo models without auth", () =>
Effect.gen(function* () {
const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe(
@@ -169,17 +189,7 @@ for (const context of ["config", "oauth", "env", "url"] as const) {
for (const outcome of ["empty", "unauthorized", "network", "throw"] as const) {
it.live(`keeps ${context} Org ${outcome} catalogs unavailable without public fallback or detached refresh`, () =>
Effect.gen(function* () {
const env = process.env.KILO_ORG_ID
yield* Effect.acquireRelease(
Effect.sync(() => {
process.env.KILO_ORG_ID = "org-env"
}),
() =>
Effect.sync(() => {
if (env === undefined) delete process.env.KILO_ORG_ID
else process.env.KILO_ORG_ID = env
}),
)
yield* environment({ KILO_API_KEY: undefined, KILO_ORG_ID: context === "env" ? "org-env" : undefined })
const calls: Parameters<ModelCache.KiloModels["fetch"]>[0][] = []
const config: Config.Info =
context === "config"
@@ -195,7 +205,7 @@ for (const context of ["config", "oauth", "env", "url"] as const) {
access: "test-token",
refresh: "test-refresh",
expires: 0,
accountId: "org-oauth",
...(context === "oauth" ? { accountId: "org-oauth" } : {}),
})
const fetch: ModelCache.KiloModels["fetch"] = (options) =>
Effect.gen(function* () {
@@ -216,6 +226,124 @@ for (const context of ["config", "oauth", "env", "url"] as const) {
}
}
for (const scenario of [
{
name: "environment",
env: "org-env",
account: "org-oauth",
configured: "org-config",
baseURL: "https://gateway.test",
org: "org-env",
url: "https://gateway.test/api/organizations/org-env",
},
{
name: "OAuth",
env: undefined,
account: "org-oauth",
configured: "org-config",
baseURL: "https://gateway.test",
org: "org-oauth",
url: "https://gateway.test/api/organizations/org-oauth",
},
{
name: "configured",
env: undefined,
account: undefined,
configured: "org-config",
baseURL: "https://gateway.test",
org: "org-config",
url: "https://gateway.test/api/organizations/org-config",
},
{
name: "scoped URL",
env: undefined,
account: undefined,
configured: undefined,
baseURL: "https://gateway.test/api/organizations/org-url",
org: "org-url",
url: "https://gateway.test/api/organizations/org-url",
},
]) {
it.live(`wrapper and cache use the same ${scenario.name} organization and credentials`, () =>
Effect.gen(function* () {
yield* environment({ KILO_ORG_ID: scenario.env, KILO_API_KEY: "env-token" })
const calls: Parameters<ModelCache.KiloModels["fetch"]>[0][] = []
const config: Config.Info = {
provider: {
kilo: {
options: {
apiKey: "configured-token",
kilocodeOrganizationId: scenario.configured,
baseURL: scenario.baseURL,
},
},
},
}
const info = new Auth.Oauth({
type: "oauth",
access: "stored-token",
refresh: "refresh",
expires: 0,
accountId: scenario.account,
})
const providers = yield* ModelsDev.Service.use((models) => models.get()).pipe(
Effect.provide(
layer({
config,
info,
fetch: (options) => {
calls.push(options)
return Effect.succeed({
models: { allowed: { id: "allowed", name: "Allowed", limit: { context: 128000, output: 4096 } } },
})
},
}),
),
provideInstance(process.cwd()),
)
expect(Object.keys(providers.kilo.models)).toEqual(["allowed"])
expect(calls).toHaveLength(1)
expect(calls.at(0)).toMatchObject({
kilocodeOrganizationId: scenario.org,
kilocodeToken: "env-token",
baseURL: scenario.url,
})
}),
)
}
it.live("does not serve a warm or public catalog after an Org-scoped URL conflicts with the selected Org", () =>
Effect.gen(function* () {
yield* environment({ KILO_ORG_ID: "org-env", KILO_API_KEY: "env-token" })
const options = { baseURL: "https://gateway.test/api/organizations/org-env" }
const calls: Parameters<ModelCache.KiloModels["fetch"]>[0][] = []
yield* ModelsDev.Service.use((models) =>
Effect.gen(function* () {
expect(Object.keys((yield* models.get()).kilo.models)).toEqual(["allowed"])
options.baseURL = "https://gateway.test/api/organizations/org-other"
expect((yield* models.get()).kilo.models).toEqual({})
expect((yield* models.get()).kilo.models).toEqual({})
options.baseURL = "https://gateway.test/api/organizations/org-env"
expect(Object.keys((yield* models.get()).kilo.models)).toEqual(["allowed"])
expect(calls).toHaveLength(1)
}),
).pipe(
Effect.provide(
layer({
config: { provider: { kilo: { options } } },
fetch: (input) => {
calls.push(input)
return Effect.succeed({
models: { allowed: { id: "allowed", name: "Allowed", limit: { context: 128000, output: 4096 } } },
})
},
}),
),
provideInstance(process.cwd()),
)
}),
)
it.live("preserves Personal public snapshot fallback", () =>
Effect.gen(function* () {
const env = process.env.KILO_ORG_ID
@@ -2,7 +2,7 @@
// When a user logs in via OAuth and selects an enterprise organization, the model fetch
// should use the organization-specific endpoint, not the personal endpoint.
import { expect } from "bun:test"
import { expect, spyOn } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Ref } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import * as Log from "@opencode-ai/core/util/log"
@@ -10,13 +10,18 @@ import * as Log from "@opencode-ai/core/util/log"
Log.init({ print: false })
import { Auth } from "../../src/auth"
import { recommend } from "../../src/kilocode/provider/catalog"
import { ModelCache } from "../../src/provider/model-cache"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
type Options = Parameters<ModelCache.KiloModels["fetch"]>[0]
function layer(info: Auth.Info | undefined, captured: Ref.Ref<Options | undefined>) {
function layer(
info: Auth.Info | undefined,
captured: Ref.Ref<Options | undefined>,
options: Record<string, string> = {},
) {
const auth = Layer.mock(Auth.Service)({
get: (id) => Effect.succeed(id === "kilo" ? info : undefined),
})
@@ -40,7 +45,7 @@ function layer(info: Auth.Info | undefined, captured: Ref.Ref<Options | undefine
)
return Layer.fresh(ModelCache.layer).pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(TestConfig.layer()),
Layer.provide(TestConfig.layer({ get: () => Effect.succeed({ provider: { kilo: { options } } }) })),
Layer.provide(auth),
Layer.provide(models),
)
@@ -48,6 +53,191 @@ function layer(info: Auth.Info | undefined, captured: Ref.Ref<Options | undefine
const it = testEffect(Layer.empty)
function environment(values: Record<string, string | undefined>) {
return Effect.acquireRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]))
for (const [key, value] of Object.entries(values)) {
if (value === undefined) delete process.env[key]
if (value !== undefined) process.env[key] = value
}
return previous
}),
(previous) =>
Effect.sync(() => {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key]
if (value !== undefined) process.env[key] = value
}
}),
)
}
for (const org of [false, true]) {
for (const item of [
{ name: "environment", auth: "oauth", key: "env-token", env: "org-env", token: "env-token", org: "org-env" },
{ name: "OAuth", auth: "oauth", key: undefined, env: undefined, token: "stored-token", org: "org-stored" },
{ name: "API", auth: "api", key: undefined, env: undefined, token: "stored-token", org: "org-config" },
{ name: "configured", auth: "none", key: undefined, env: undefined, token: "configured-token", org: "org-config" },
{
name: "Kilo token",
auth: "none",
key: undefined,
env: undefined,
token: "configured-kilo-token",
org: "org-config",
},
{ name: "empty Kilo token", auth: "none", key: undefined, env: undefined, token: "", org: "org-config" },
{ name: "empty environment", auth: "oauth", key: "", env: "", token: "stored-token", org: "org-stored" },
{ name: "empty stored token", auth: "empty", key: undefined, env: undefined, token: "", org: "org-stored" },
]) {
it.live(`catalog and default requests share ${item.name} credentials (Org: ${org})`, () =>
Effect.gen(function* () {
yield* environment({ KILO_API_KEY: item.key, KILO_ORG_ID: org ? item.env : undefined })
const captured = yield* Ref.make<Options | undefined>(undefined)
const info =
item.auth === "api"
? new Auth.Api({ type: "api", key: "stored-token" })
: item.auth === "none"
? undefined
: new Auth.Oauth({
type: "oauth",
access: item.auth === "empty" ? "" : "stored-token",
refresh: "stored-refresh",
expires: 0,
...(org ? { accountId: "org-stored" } : {}),
})
const options = {
apiKey: "configured-token",
...(["Kilo token", "empty Kilo token"].includes(item.name) ? { kilocodeToken: item.token } : {}),
...(org ? { kilocodeOrganizationId: "org-config" } : {}),
}
const requests: Array<{ path: string; authorization: string | null }> = []
yield* Effect.acquireRelease(
Effect.sync(() => {
const original = globalThis.fetch
return spyOn(globalThis, "fetch").mockImplementation(
Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url)
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
requests.push({ path: url.pathname, authorization: headers.get("authorization") })
return Response.json({ defaultModel: "selected", defaultFreeModel: "free" })
},
{ preconnect: original.preconnect },
),
)
}),
(fetch) => Effect.sync(() => fetch.mockRestore()),
)
yield* ModelCache.Service.use((cache) => cache.fetch("kilo")).pipe(
Effect.provide(layer(info, captured, options)),
)
expect((yield* Ref.get(captured))?.kilocodeToken).toBe(item.token)
expect((yield* Ref.get(captured))?.kilocodeOrganizationId).toBe(org ? item.org : undefined)
expect(yield* Effect.promise(() => recommend({ first: {}, selected: {}, free: {} }, options, info))).toBe(
item.token ? "selected" : "free",
)
expect(requests).toEqual([
{
path: org ? `/api/organizations/${item.org}/defaults` : "/api/defaults",
authorization: item.token ? `Bearer ${item.token}` : null,
},
])
}),
)
}
}
it.live("anonymous Personal defaults do not borrow a configured or stored credential", () =>
Effect.gen(function* () {
yield* environment({ KILO_API_KEY: undefined, KILO_ORG_ID: undefined })
const requests: Array<string | null> = []
yield* Effect.acquireRelease(
Effect.sync(() => {
const original = globalThis.fetch
return spyOn(globalThis, "fetch").mockImplementation(
Object.assign(
async (_input: RequestInfo | URL, init?: RequestInit) => {
requests.push(new Headers(init?.headers).get("authorization"))
return Response.json({ defaultModel: "selected", defaultFreeModel: "free" })
},
{ preconnect: original.preconnect },
),
)
}),
(fetch) => Effect.sync(() => fetch.mockRestore()),
)
expect(yield* Effect.promise(() => recommend({ selected: {}, free: {} }, undefined, undefined))).toBe("free")
expect(requests).toEqual([null])
}),
)
it.live("explicit fetch credentials override environment values, including explicit clearing", () =>
Effect.gen(function* () {
yield* environment({ KILO_API_KEY: "env-token", KILO_ORG_ID: "org-env" })
const captured = yield* Ref.make<Options | undefined>(undefined)
const info = new Auth.Oauth({
type: "oauth",
access: "stored-token",
refresh: "refresh",
expires: 0,
accountId: "org-stored",
})
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
yield* cache.fetch("kilo", { kilocodeToken: "explicit-token", kilocodeOrganizationId: "org-explicit" })
expect(yield* Ref.get(captured)).toMatchObject({
kilocodeToken: "explicit-token",
kilocodeOrganizationId: "org-explicit",
})
yield* cache.refresh("kilo", { kilocodeToken: undefined, kilocodeOrganizationId: undefined })
expect((yield* Ref.get(captured))?.kilocodeToken).toBeUndefined()
expect((yield* Ref.get(captured))?.kilocodeOrganizationId).toBeUndefined()
yield* cache.refresh("kilo", { kilocodeToken: "", kilocodeOrganizationId: "" })
expect((yield* Ref.get(captured))?.kilocodeToken).toBe("")
expect((yield* Ref.get(captured))?.kilocodeOrganizationId).toBe("")
}),
).pipe(Effect.provide(layer(info, captured, { apiKey: "configured-token", kilocodeOrganizationId: "org-config" })))
}),
)
it.live("rejects a model endpoint whose pinned Org conflicts with the selected environment Org", () =>
Effect.gen(function* () {
yield* environment({ KILO_API_KEY: "env-token", KILO_ORG_ID: "org-env" })
const captured = yield* Ref.make<Options | undefined>(undefined)
yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
expect(yield* cache.fetch("kilo", { baseURL: "https://gateway.test/api/organizations/org-pinned" })).toEqual({})
expect(yield* cache.getFailure("kilo")).toEqual({ kind: "schema" })
expect(yield* Ref.get(captured)).toBeUndefined()
}),
).pipe(Effect.provide(layer(undefined, captured)))
}),
)
for (const source of ["configured", "token", "personal", "conflict"] as const) {
it.live(`does not send ${source} transport credentials to a different defaults service`, () =>
Effect.gen(function* () {
yield* environment({ KILO_API_KEY: undefined, KILO_ORG_ID: source === "conflict" ? "org-env" : undefined })
const url =
source === "personal"
? "https://gateway.test/api/openrouter"
: "https://gateway.test/api/organizations/org-pinned"
const info = source === "token" ? new Auth.Api({ type: "api", key: `${url}:private-token` }) : undefined
const options = source === "token" ? undefined : { apiKey: "private-token", baseURL: url }
const fetch = yield* Effect.acquireRelease(
Effect.sync(() => spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected defaults request"))),
(fetch) => Effect.sync(() => fetch.mockRestore()),
)
expect(yield* Effect.promise(() => recommend({ first: {}, selected: {} }, options, info))).toBe(
source === "personal" || source === "conflict" ? undefined : "first",
)
expect(fetch).not.toHaveBeenCalled()
}),
)
}
it.live("switch invalidation drops warm Personal and delayed prior catalogs", () =>
Effect.gen(function* () {
const account = yield* Ref.make<string | undefined>(undefined)
@@ -1,11 +1,18 @@
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import type { Auth } from "../../src/auth"
import { Config } from "../../src/config/config"
import { ModelsDev } from "../../src/provider/models"
import { Provider } from "../../src/provider/provider"
import { TestConfig } from "../fixture/config"
import { testInstanceStoreLayer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(AppNodeBuilder.build(Provider.node))
const inference = testEffect(testInstanceStoreLayer)
const auth = <A, E, R>(value: Record<string, unknown>, effect: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
@@ -77,3 +84,306 @@ it.instance(
),
{ config: {} },
)
function environment(values: Record<string, string | undefined>) {
return Effect.acquireRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]))
for (const [key, value] of Object.entries(values)) {
if (value === undefined) delete process.env[key]
if (value !== undefined) process.env[key] = value
}
return previous
}),
(previous) =>
Effect.sync(() => {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key]
if (value !== undefined) process.env[key] = value
}
}),
)
}
const oauth = {
type: "oauth",
refresh: "stored-refresh",
access: "stored-token",
accountId: "stored-org",
expires: Date.now() + 3_600_000,
} satisfies Auth.Info
const configured = {
apiKey: "configured-key",
kilocodeToken: "configured-token",
kilocodeOrganizationId: "configured-org",
}
const scenarios: {
name: string
info?: Auth.Info
env?: string
organization?: string
options?: Record<string, string>
key: string
org?: string
token?: string
}[] = [
{
name: "environment over OAuth and config",
info: oauth,
env: "env-token",
organization: "env-org",
options: configured,
key: "env-token",
org: "env-org",
token: "env-token",
},
{
name: "environment over saved API and config",
info: { type: "api", key: "stored-key" },
env: "env-token",
organization: "env-org",
options: configured,
key: "env-token",
org: "env-org",
token: "env-token",
},
{
name: "OAuth over config",
info: oauth,
options: configured,
key: "stored-token",
org: "stored-org",
token: "stored-token",
},
{
name: "saved API over config",
info: { type: "api", key: "stored-key" },
options: configured,
key: "stored-key",
org: "configured-org",
token: "stored-key",
},
{
name: "configured token alias over apiKey",
options: configured,
key: "configured-token",
org: "configured-org",
token: "configured-token",
},
{
name: "configured apiKey",
options: { apiKey: "configured-key", kilocodeOrganizationId: "configured-org" },
key: "configured-key",
org: "configured-org",
token: "configured-key",
},
{
name: "empty environment falls back to OAuth",
info: oauth,
env: "",
organization: "",
options: configured,
key: "stored-token",
org: "stored-org",
token: "stored-token",
},
{
name: "empty OAuth token overrides config",
info: { ...oauth, access: "" },
options: configured,
key: "",
org: "stored-org",
token: "",
},
{
name: "empty saved API token overrides config",
info: { type: "api", key: "" },
options: configured,
key: "",
org: "configured-org",
token: "",
},
{
name: "empty configured token overrides apiKey",
options: { ...configured, kilocodeToken: "" },
key: "",
org: "configured-org",
token: "",
},
{ name: "empty configured apiKey remains empty", options: { apiKey: "" }, key: "" },
{
name: "empty OAuth Org falls back to config",
info: { ...oauth, accountId: "" },
options: configured,
key: "stored-token",
org: "configured-org",
token: "stored-token",
},
{
name: "environment token keeps OAuth Org",
info: oauth,
env: "env-token",
options: configured,
key: "env-token",
org: "stored-org",
token: "env-token",
},
{
name: "environment Org keeps OAuth token",
info: oauth,
organization: "env-org",
options: configured,
key: "stored-token",
org: "env-org",
token: "stored-token",
},
{
name: "configured URL Org fallback",
options: { apiKey: "configured-key", baseURL: "https://gateway.test/api/organizations/url-org" },
key: "configured-key",
org: "url-org",
token: "configured-key",
},
{
name: "token URL Org fallback",
options: { kilocodeToken: "https://gateway.test/api/organizations/token-org:configured-token" },
key: "https://gateway.test/api/organizations/token-org:configured-token",
org: "token-org",
token: "https://gateway.test/api/organizations/token-org:configured-token",
},
{
name: "environment without stored or configured credentials",
env: "env-token",
organization: "env-org",
key: "env-token",
org: "env-org",
token: "env-token",
},
{ name: "anonymous without credentials", key: "anonymous" },
{ name: "empty Org stays personal", options: { kilocodeOrganizationId: "" }, key: "anonymous", org: "" },
]
for (const scenario of scenarios) {
inference.instance(`Kilo inference uses ${scenario.name}`, () =>
Effect.gen(function* () {
yield* environment({
KILO_API_KEY: scenario.env,
KILO_ORG_ID: scenario.organization,
KILO_AUTH_CONTENT: JSON.stringify(scenario.info ? { kilo: scenario.info } : {}),
})
const calls: Headers[] = []
const fetch = async (_input: RequestInfo | URL, init?: RequestInit) => {
calls.push(new Headers(init?.headers))
return Response.json({ error: { message: "test inference response" } }, { status: 401 })
}
const config: Config.Info = {
provider: {
kilo: {
npm: "@kilocode/kilo-gateway",
env: ["KILO_API_KEY"],
options: { ...scenario.options, headers: { "x-custom": "preserved" }, fetch },
models: { "test-model": { name: "Test Model", limit: { context: 128000, output: 4096 } } },
},
},
}
yield* Effect.gen(function* () {
const provider = yield* Provider.Service
const item = yield* provider.getProvider(ProviderV2.ID.kilo)
expect(item.options.kilocodeToken).toBe(scenario.token)
expect(item.options.kilocodeOrganizationId).toBe(scenario.org)
expect(item.options.fetch).toBe(fetch)
expect(item.options.headers).toEqual({ "x-custom": "preserved" })
const output = Provider.toPublicInfo(item)
expect(output.key).toBeUndefined()
expect(output.options.apiKey).toBeUndefined()
expect(output.options.kilocodeToken).toBeUndefined()
expect(output.options.headers).toEqual({ "x-custom": "preserved" })
expect(item.options.kilocodeToken).toBe(scenario.token)
const model = yield* provider.getModel(ProviderV2.ID.kilo, ModelV2.ID.make("test-model"))
const language = yield* provider.getLanguage(model)
const error = yield* Effect.tryPromise(() =>
language.doGenerate({ prompt: [{ role: "user", content: [{ type: "text", text: "test" }] }] }),
).pipe(Effect.flip)
expect(error.cause).toMatchObject({ message: "test inference response" })
expect(calls).toHaveLength(1)
expect(calls.at(0)?.get("authorization")).toBe(`Bearer ${scenario.key}`.trim())
expect(calls.at(0)?.get("x-kilocode-organizationid")).toBe(scenario.org || null)
expect(calls.at(0)?.get("x-custom")).toBe("preserved")
}).pipe(
Effect.provide(
AppNodeBuilder.build(Provider.node, [
[Config.node, TestConfig.layer({ get: () => Effect.succeed(config) })],
[ModelsDev.node, Layer.mock(ModelsDev.Service)({ get: () => Effect.succeed({}) })],
]),
),
)
}),
)
}
inference.instance("non-Kilo inference keeps OAuth over environment and configured API keys", () =>
Effect.gen(function* () {
yield* environment({
KILO_API_KEY: "kilo-env-token",
KILO_ORG_ID: "kilo-env-org",
OPENAI_API_KEY: "openai-env-token",
KILO_AUTH_CONTENT: JSON.stringify({ openai: oauth }),
})
const calls: Headers[] = []
yield* Effect.acquireRelease(
Effect.sync(() => {
const original = globalThis.fetch
globalThis.fetch = Object.assign(
async (_input: RequestInfo | URL, init?: RequestInit) => {
calls.push(new Headers(init?.headers))
return Response.json({ error: { message: "test inference response" } }, { status: 401 })
},
{ preconnect: original.preconnect },
)
return original
}),
(original) =>
Effect.sync(() => {
globalThis.fetch = original
}),
)
const config: Config.Info = {
provider: {
openai: {
npm: "@ai-sdk/openai",
env: ["OPENAI_API_KEY"],
options: { apiKey: "configured-openai-key", headers: { "x-custom": "preserved" } },
models: { "gpt-5": { name: "GPT-5", limit: { context: 128000, output: 4096 } } },
},
},
}
yield* Effect.gen(function* () {
const provider = yield* Provider.Service
const item = yield* provider.getProvider(ProviderV2.ID.openai)
expect(item.key).toBeUndefined()
expect(item.options.kilocodeToken).toBeUndefined()
expect(item.options.kilocodeOrganizationId).toBeUndefined()
expect(item.options.apiKey).toBe("configured-openai-key")
expect(typeof item.options.fetch).toBe("function")
expect(Provider.toPublicInfo(item).options.apiKey).toBe("configured-openai-key")
const model = yield* provider.getModel(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))
const language = yield* provider.getLanguage(model)
const error = yield* Effect.tryPromise(() =>
language.doGenerate({ prompt: [{ role: "user", content: [{ type: "text", text: "test" }] }] }),
).pipe(Effect.flip)
expect(error.cause).toMatchObject({ message: "test inference response" })
expect(calls).toHaveLength(1)
expect(calls.at(0)?.get("authorization")).toBe("Bearer stored-token")
expect(calls.at(0)?.get("chatgpt-account-id")).toBe("stored-org")
expect(calls.at(0)?.get("x-kilocode-organizationid")).toBeNull()
expect(calls.at(0)?.get("x-custom")).toBe("preserved")
}).pipe(
Effect.provide(
AppNodeBuilder.build(Provider.node, [
[Config.node, TestConfig.layer({ get: () => Effect.succeed(config) })],
[ModelsDev.node, Layer.mock(ModelsDev.Service)({ get: () => Effect.succeed({}) })],
]),
),
)
}),
)
@@ -114,32 +114,49 @@ describe("Kilo gateway HttpApi statuses", () => {
}),
)
for (const context of ["config", "oauth", "env", "url", "personal", "anonymous", "anonymous-personal"] as const) {
const anonymous = context.startsWith("anonymous")
const personal = context.endsWith("personal")
it.live(`reports ${context} organization context locally without secrets`, () =>
for (const context of [
{ name: "config", config: true, organization: "org-config" },
{ name: "oauth", config: true, oauth: true, organization: "org-oauth" },
{ name: "env", config: true, oauth: true, env: true, organization: "org-env" },
{ name: "url", url: true, organization: "org-url" },
{ name: "oauth over url", url: true, oauth: true, organization: "org-oauth" },
{ name: "env over url", url: true, oauth: true, env: true, organization: "org-env" },
{ name: "personal" },
{ name: "anonymous", anonymous: true, env: true, organization: "org-env" },
{ name: "anonymous config", anonymous: true, config: true, organization: "org-config" },
{ name: "anonymous url", anonymous: true, url: true, organization: "org-url" },
{ name: "anonymous-personal", anonymous: true },
] satisfies {
name: string
config?: boolean
oauth?: boolean
env?: boolean
url?: boolean
anonymous?: boolean
organization?: string
}[]) {
it.live(`reports ${context.name} organization context locally without secrets`, () =>
Effect.gen(function* () {
const previous = { ...state }
const env = process.env.KILO_ORG_ID
yield* Effect.acquireRelease(
Effect.sync(() => {
state.config =
context === "config"
? { provider: { kilo: { options: { kilocodeOrganizationId: "org-config" } } } }
: context === "url"
? { provider: { kilo: { options: { baseURL: "https://gateway.test/api/organizations/org-url" } } } }
: {}
state.info = anonymous
state.config = context.config
? { provider: { kilo: { options: { kilocodeOrganizationId: "org-config" } } } }
: context.url
? { provider: { kilo: { options: { baseURL: "https://gateway.test/api/organizations/org-url" } } } }
: {}
state.info = context.anonymous
? undefined
: new Auth.Oauth({
type: "oauth",
access: "test-token",
refresh: "private-refresh",
expires: Date.now() + 3600000,
...(["config", "oauth", "url"].includes(context) ? { accountId: "org-oauth" } : {}),
...(context.oauth ? { accountId: "org-oauth" } : {}),
})
if (personal) delete process.env.KILO_ORG_ID
else process.env.KILO_ORG_ID = "org-env"
if (context.env) process.env.KILO_ORG_ID = "org-env"
else delete process.env.KILO_ORG_ID
}),
() =>
Effect.sync(() => {
@@ -152,9 +169,9 @@ describe("Kilo gateway HttpApi statuses", () => {
const response = yield* HttpClient.get(KiloGatewayPaths.authStatus)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({
authenticated: !anonymous,
...(!anonymous ? { type: "oauth" } : {}),
...(!personal ? { organizationId: `org-${anonymous ? "env" : context}` } : {}),
authenticated: !context.anonymous,
...(!context.anonymous ? { type: "oauth" } : {}),
...(context.organization ? { organizationId: context.organization } : {}),
})
}),
)
@@ -1,6 +1,8 @@
import { afterEach, expect } from "bun:test"
import { Effect } from "effect"
import { afterEach, expect, spyOn } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Auth } from "../../../src/auth"
import { ModelCache } from "../../../src/provider/model-cache"
import { Server } from "../../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
@@ -8,7 +10,7 @@ import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
import { resetDatabase } from "../../fixture/db"
import { testEffectShared } from "../../lib/effect"
const it = testEffectShared(AppNodeBuilder.build(ModelCache.node))
const it = testEffectShared(Layer.merge(AppNodeBuilder.build(ModelCache.node), AppNodeBuilder.build(Auth.node)))
void Log.init({ print: false })
@@ -48,6 +50,17 @@ for (const scenario of [
] as const) {
it.live(`keeps Org catalogs and recommendations safe: ${scenario}`, () =>
Effect.gen(function* () {
yield* Effect.acquireRelease(
Effect.sync(() => {
const previous = Flag.KILO_DISABLE_MODELS_FETCH
Flag.KILO_DISABLE_MODELS_FETCH = true
return previous
}),
(previous) =>
Effect.sync(() => {
Flag.KILO_DISABLE_MODELS_FETCH = previous
}),
)
const cache = yield* ModelCache.Service
yield* cache.clear("kilo")
const env = {
@@ -102,7 +115,7 @@ for (const scenario of [
: undefined,
})
}
if (url.pathname === "/api/organizations/org-config/models") {
if (url.pathname === "/api/organizations/org-env/models") {
if (scenario === "unauthorized") return new Response(null, { status: 401 })
if (scenario === "error") return new Response(null, { status: 500 })
if (scenario === "empty") return Response.json({ data: [] })
@@ -153,7 +166,7 @@ for (const scenario of [
expect(yield* request("/kilo/auth-status", tmp.path)).toEqual({
authenticated: true,
type: "oauth",
organizationId: "org-config",
organizationId: "org-env",
})
const unavailable = ["empty", "error", "unauthorized", "filtered"].includes(scenario)
expect(models(all, "all")).toEqual(unavailable ? [] : ["test/private", "test/z-last"])
@@ -161,13 +174,44 @@ for (const scenario of [
expect(connected.default.kilo).toBe(
unavailable ? undefined : scenario === "valid" ? "test/z-last" : "test/private",
)
expect(all.default.kilo).toBe(connected.default.kilo)
expect(connected.default.external).toBe("independent")
expect(all.default.external).toBe("independent")
expect(all.connected).toContain("external")
expect(paths.filter((path) => path.endsWith("/models"))).toEqual(["/api/organizations/org-config/models"])
expect(paths.filter((path) => path.endsWith("/models"))).toEqual(["/api/organizations/org-env/models"])
expect(paths.filter((path) => path.endsWith("/defaults"))).toEqual(
unavailable ? [] : ["/api/organizations/org-config/defaults"],
unavailable ? [] : ["/api/organizations/org-env/defaults", "/api/organizations/org-env/defaults"],
)
if (scenario === "valid") {
const auth = yield* Auth.Service
yield* Effect.acquireUseRelease(
Effect.sync(() =>
spyOn(auth, "get").mockImplementation(() =>
Effect.fail(new Auth.AuthError({ message: "Cannot read credentials after provider initialization" })),
),
),
() =>
Effect.gen(function* () {
const retained = yield* request("/provider", tmp.path)
const configured = yield* request("/config/providers", tmp.path)
expect(models(retained, "all")).toEqual(["test/private", "test/z-last"])
expect(models(configured, "providers")).toEqual(["test/private", "test/z-last"])
expect(retained.connected).toEqual(all.connected)
expect(retained.failed).toEqual(["kilo"])
expect(retained.default).toEqual({ external: "independent", kilo: "test/private" })
expect(configured.default).toEqual(retained.default)
expect(paths.filter((path) => path.endsWith("/defaults"))).toHaveLength(2)
expect(paths.filter((path) => path.endsWith("/models"))).toHaveLength(1)
}),
(spy) => Effect.sync(() => spy.mockRestore()),
)
const recovered = yield* request("/provider", tmp.path)
const configured = yield* request("/config/providers", tmp.path)
expect(recovered.default.kilo).toBe("test/z-last")
expect(configured.default.kilo).toBe(recovered.default.kilo)
expect(recovered.failed).toEqual([])
expect(paths.filter((path) => path.endsWith("/defaults"))).toHaveLength(4)
}
}),
)
}
@@ -11,7 +11,9 @@ import { Session } from "../../../src/session/session"
import { ModelCache } from "../../../src/provider/model-cache"
import { Provider } from "../../../src/provider/provider"
import { ProviderAuth } from "../../../src/provider/auth"
import { ConfigApi } from "../../../src/server/routes/instance/httpapi/groups/config"
import { ProviderApi } from "../../../src/server/routes/instance/httpapi/groups/provider"
import { configHandlers } from "../../../src/server/routes/instance/httpapi/handlers/config"
import { providerHandlers } from "../../../src/server/routes/instance/httpapi/handlers/provider"
import { Authorization } from "../../../src/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "../../../src/server/routes/instance/httpapi/middleware/instance-context"
@@ -23,48 +25,81 @@ import { schemaErrorLayer } from "../../../src/server/routes/instance/httpapi/mi
import { TestConfig } from "../../fixture/config"
import { testEffect } from "../../lib/effect"
function catalog(id: string): ModelsDev.Provider {
function catalog(id: string, models: string[]): ModelsDev.Provider {
return {
id,
name: id,
env: [],
models: {
model: {
id: "model",
name: "Model",
release_date: "2026-01-01",
attachment: false,
reasoning: false,
temperature: true,
tool_call: true,
limit: { context: 32000, output: 4096 },
},
},
models: Object.fromEntries(
models.map((id) => [
id,
{
id,
name: id,
release_date: "2026-01-01",
attachment: false,
reasoning: false,
temperature: true,
tool_call: true,
limit: { context: 32000, output: 4096 },
},
]),
),
}
}
const catalogs = { external: catalog("external"), kilo: catalog("kilo") }
const catalogs = { external: catalog("external", ["model"]), kilo: catalog("kilo", ["public/leak"]) }
const providers = {
external: Provider.fromModelsDevProvider(catalogs.external),
kilo: Provider.fromModelsDevProvider(catalogs.kilo),
kilo: Provider.fromModelsDevProvider(
catalog("kilo", ["connected/training", "connected/z-local", "connected/a-remote"]),
),
}
providers.kilo.models["connected/training"].mayTrainOnYourPrompts = true
const state = {
failure: false,
connected: true,
disabled: false,
excluded: false,
initial: false,
empty: false,
failed: [] as string[],
requests: [] as string[],
reads: 0,
}
const state = { failure: false, connected: true }
const layer = HttpRouter.serve(
HttpApiBuilder.layer(HttpApi.make("opencode-instance").addHttpApi(ProviderApi)).pipe(
Layer.provide(providerHandlers),
HttpApiBuilder.layer(HttpApi.make("opencode-instance").addHttpApi(ProviderApi).addHttpApi(ConfigApi)).pipe(
Layer.provide([providerHandlers, configHandlers]),
Layer.provide(schemaErrorLayer),
Layer.provide([
TestConfig.layer({ get: () => Effect.succeed({ enabled_providers: ["external", "kilo"] }) }),
TestConfig.layer({
get: () =>
Effect.succeed({
enabled_providers: state.excluded ? ["external"] : ["external", "kilo"],
disabled_providers: state.disabled ? ["kilo"] : [],
hide_prompt_training_models: true,
}),
}),
Layer.mock(Provider.Service)({
list: () => Effect.succeed(state.connected ? providers : { external: providers.external }),
list: () =>
state.initial
? Effect.die(new Auth.AuthError({ message: "Cannot initialize providers" }))
: Effect.succeed(
state.connected && !state.disabled && !state.excluded
? { ...providers, kilo: { ...providers.kilo, models: state.empty ? {} : providers.kilo.models } }
: { external: providers.external },
),
}),
Layer.mock(ProviderAuth.Service)({}),
Layer.mock(ModelCache.Service)({ failedProviders: () => Effect.succeed([]) }),
Layer.mock(ModelCache.Service)({ failedProviders: () => Effect.succeed(state.failed) }),
Layer.mock(Auth.Service)({
get: () =>
state.failure
? Effect.fail(new Auth.AuthError({ message: "Cannot read credentials" }))
: Effect.succeed(undefined),
Effect.suspend(() => {
state.reads++
return state.failure
? Effect.fail(new Auth.AuthError({ message: "Cannot read credentials" }))
: Effect.succeed(undefined)
}),
}),
Layer.succeed(
Authorization,
@@ -99,60 +134,174 @@ const it = testEffect(layer)
function configure(failure: boolean, connected: boolean) {
return Effect.acquireRelease(
Effect.sync(() => {
const previous = { state: { ...state }, env: process.env.KILO_ORG_ID }
Object.assign(state, { failure, connected })
const previous = {
state: { ...state },
env: { KILO_ORG_ID: process.env.KILO_ORG_ID, KILO_API_KEY: process.env.KILO_API_KEY },
fetch: globalThis.fetch,
}
Object.assign(state, { failure, connected, requests: [], reads: 0 })
delete process.env.KILO_ORG_ID
delete process.env.KILO_API_KEY
globalThis.fetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url)
if (url.pathname.endsWith("/defaults")) {
state.requests.push(url.pathname)
return Response.json({ defaultModel: "connected/a-remote", defaultFreeModel: "connected/a-remote" })
}
return previous.fetch(input, init)
},
{ preconnect: previous.fetch.preconnect },
)
return previous
}),
(previous) =>
Effect.sync(() => {
Object.assign(state, previous.state)
if (previous.env === undefined) {
delete process.env.KILO_ORG_ID
return
globalThis.fetch = previous.fetch
for (const [key, value] of Object.entries(previous.env)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
process.env.KILO_ORG_ID = previous.env
}),
)
}
function request(path: string) {
return Effect.gen(function* () {
const response = yield* HttpClient.get(path)
expect(response.status).toBe(200)
return yield* response.json
})
}
function record(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null && !Array.isArray(input)
}
function result(input: unknown, key: "all" | "providers") {
if (!record(input) || !Array.isArray(input[key])) throw new Error("Expected provider catalog")
return input[key].map((provider: unknown) => {
if (!record(provider) || typeof provider.id !== "string" || !record(provider.models)) {
throw new Error("Expected provider models")
}
return { id: provider.id, models: Object.keys(provider.models) }
})
}
const external = { id: "external", models: ["model"] }
const kilo = { id: "kilo", models: ["connected/z-local", "connected/a-remote"] }
describe("provider catalog authentication failures", () => {
for (const connected of [false, true]) {
it.live(`keeps other providers available when Kilo auth fails (connected: ${connected})`, () =>
it.live(`retains only safe catalogs when Kilo auth fails (connected: ${connected})`, () =>
Effect.gen(function* () {
yield* configure(true, connected)
const response = yield* HttpClient.get("/provider")
expect(response.status).toBe(200)
const body = yield* response.json
expect(body).toMatchObject({
all: [{ id: "external" }],
default: { external: "model" },
connected: ["external"],
state.failed = ["existing"]
const all = yield* request("/provider")
const config = yield* request("/config/providers")
expect(result(all, "all")).toEqual(connected ? [external, kilo] : [external])
expect(result(config, "providers")).toEqual(connected ? [external, kilo] : [external])
const defaults = { external: "model", ...(connected ? { kilo: "connected/z-local" } : {}) }
expect(all).toMatchObject({
default: defaults,
connected: connected ? ["external", "kilo"] : ["external"],
failed: ["existing", "kilo"],
})
expect(JSON.stringify(body)).not.toContain('"kilo"')
expect(config).toMatchObject({ default: defaults })
expect(JSON.stringify([all, config])).not.toContain("public/leak")
expect(state.requests).toEqual([])
state.failure = false
const recovered = yield* HttpClient.get("/provider")
expect(recovered.status).toBe(200)
expect(yield* recovered.json).toMatchObject({
all: [{ id: "external" }, { id: "kilo" }],
default: { external: "model", kilo: "model" },
const recovered = yield* request("/provider")
const configured = yield* request("/config/providers")
expect(result(recovered, "all")).toEqual([external, connected ? kilo : { id: "kilo", models: ["public/leak"] }])
expect(result(configured, "providers")).toEqual(connected ? [external, kilo] : [external])
expect(recovered).toMatchObject({
default: { external: "model", kilo: connected ? "connected/a-remote" : "public/leak" },
connected: connected ? ["external", "kilo"] : ["external"],
failed: ["existing"],
})
expect(configured).toMatchObject({
default: { external: "model", ...(connected ? { kilo: "connected/a-remote" } : {}) },
})
expect(state.requests).toHaveLength(connected ? 2 : 0)
}),
)
it.live(`distinguishes anonymous auth success from failure (connected: ${connected})`, () =>
Effect.gen(function* () {
yield* configure(false, connected)
const all = yield* request("/provider")
const config = yield* request("/config/providers")
expect(result(all, "all")).toEqual([external, connected ? kilo : { id: "kilo", models: ["public/leak"] }])
expect(result(config, "providers")).toEqual(connected ? [external, kilo] : [external])
expect(all).toMatchObject({
default: { external: "model", kilo: connected ? "connected/a-remote" : "public/leak" },
connected: connected ? ["external", "kilo"] : ["external"],
failed: [],
})
expect(config).toMatchObject({
default: { external: "model", ...(connected ? { kilo: "connected/a-remote" } : {}) },
})
expect(state.requests).toHaveLength(connected ? 2 : 0)
}),
)
}
it.live("keeps the Personal catalog when credentials are absent rather than unreadable", () =>
it.live("does not duplicate an existing Kilo failure", () =>
Effect.gen(function* () {
yield* configure(false, false)
const response = yield* HttpClient.get("/provider")
expect(response.status).toBe(200)
expect(yield* response.json).toMatchObject({
all: [{ id: "external" }, { id: "kilo" }],
default: { external: "model", kilo: "model" },
connected: ["external"],
})
yield* configure(true, true)
state.failed = ["kilo", "existing"]
expect(yield* request("/provider")).toMatchObject({ failed: ["kilo", "existing"] })
expect(state.failed).toEqual(["kilo", "existing"])
expect(state.requests).toEqual([])
}),
)
for (const restriction of ["disabled", "excluded"] as const) {
it.live(`does not flag ${restriction} Kilo when auth fails`, () =>
Effect.gen(function* () {
yield* configure(true, false)
state[restriction] = true
state.failed = ["existing"]
const all = yield* request("/provider")
const config = yield* request("/config/providers")
expect(result(all, "all")).toEqual([external])
expect(result(config, "providers")).toEqual([external])
expect(all).toMatchObject({ default: { external: "model" }, connected: ["external"], failed: ["existing"] })
expect(config).toMatchObject({ default: { external: "model" } })
expect(state.requests).toEqual([])
}),
)
}
it.live("keeps empty connected catalogs without an unsafe fallback", () =>
Effect.gen(function* () {
yield* configure(true, true)
state.empty = true
const all = yield* request("/provider")
const config = yield* request("/config/providers")
expect(result(all, "all")).toEqual([external, { id: "kilo", models: [] }])
expect(result(config, "providers")).toEqual([external, { id: "kilo", models: [] }])
expect(all).toMatchObject({ default: { external: "model" }, connected: ["external", "kilo"], failed: ["kilo"] })
expect(config).toMatchObject({ default: { external: "model" } })
expect(JSON.stringify([all, config])).not.toContain("public/leak")
expect(state.requests).toEqual([])
}),
)
it.live("does not hide initial provider initialization failure with the public snapshot", () =>
Effect.gen(function* () {
yield* configure(true, true)
state.initial = true
for (const path of ["/provider", "/config/providers"]) {
const response = yield* HttpClient.get(path)
expect(response.status).toBe(500)
expect(yield* response.text).not.toContain("public/leak")
}
expect(state.reads).toBe(0)
expect(state.requests).toEqual([])
}),
)
})