diff --git a/.changeset/org-default-model-selection.md b/.changeset/org-default-model-selection.md index 6a7d63dab9f..02315f5b0fb 100644 --- a/.changeset/org-default-model-selection.md +++ b/.changeset/org-default-model-selection.md @@ -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. diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index 7508d24a429..227c454aa14 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -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" diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts index 20fab7516c0..c4f19c7406b 100644 --- a/packages/kilo-gateway/test/api/models.test.ts +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -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 { diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index 86f92a8759d..8897765aad4 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -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 = {} const storedKeys: Record = {} @@ -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] } diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-catalog.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-catalog.test.ts index a6d949d0502..3f573cf6dab 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-catalog.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-catalog.test.ts @@ -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>, 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: {} }) } }, diff --git a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts index 9ae72b6b65c..9a468938738 100644 --- a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts +++ b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts @@ -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[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[0] diff --git a/packages/opencode/src/kilocode/provider/catalog.ts b/packages/opencode/src/kilocode/provider/catalog.ts index 2f67f25276c..cd986447ad3 100644 --- a/packages/opencode/src/kilocode/provider/catalog.ts +++ b/packages/opencode/src/kilocode/provider/catalog.ts @@ -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>, + 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 } diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index 5e06a0d2208..756ae0e4cbc 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -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 { return { "github-copilot-enterprise": () => diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index b5b755e9c6b..ac5b3195d42 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -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) => { diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 61b2de420c0..80d0c7fb66c 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -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 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 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)) { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts index 2f5113250f1..0fd41d897eb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -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) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index d6db98fefb6..dffaeb5b560 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -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 }) diff --git a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts index 2991f67be05..4bb3bba3e3c 100644 --- a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts +++ b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts @@ -135,6 +135,26 @@ function layer(options?: { config?: Config.Info; info?: Auth.Info; fetch?: Model const it = testEffect(testInstanceStoreLayer) +function environment(values: Record) { + 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[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[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[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 diff --git a/packages/opencode/test/kilocode/model-cache-org.test.ts b/packages/opencode/test/kilocode/model-cache-org.test.ts index 14ba2158376..39ca2dc458b 100644 --- a/packages/opencode/test/kilocode/model-cache-org.test.ts +++ b/packages/opencode/test/kilocode/model-cache-org.test.ts @@ -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[0] -function layer(info: Auth.Info | undefined, captured: Ref.Ref) { +function layer( + info: Auth.Info | undefined, + captured: Ref.Ref, + options: Record = {}, +) { 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 Effect.succeed({ provider: { kilo: { options } } }) })), Layer.provide(auth), Layer.provide(models), ) @@ -48,6 +53,191 @@ function layer(info: Auth.Info | undefined, captured: Ref.Ref) { + 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(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 = [] + 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(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(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(undefined) diff --git a/packages/opencode/test/kilocode/provider-saved-auth.test.ts b/packages/opencode/test/kilocode/provider-saved-auth.test.ts index 069a3762ce7..c4c33dfd3dc 100644 --- a/packages/opencode/test/kilocode/provider-saved-auth.test.ts +++ b/packages/opencode/test/kilocode/provider-saved-auth.test.ts @@ -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 = (value: Record, effect: Effect.Effect) => Effect.acquireUseRelease( @@ -77,3 +84,306 @@ it.instance( ), { config: {} }, ) + +function environment(values: Record) { + 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 + 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({}) })], + ]), + ), + ) + }), +) diff --git a/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts b/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts index 4588228ce2f..5eeab504f13 100644 --- a/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts +++ b/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts @@ -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 } : {}), }) }), ) diff --git a/packages/opencode/test/kilocode/server/prompt-training-model-filter.test.ts b/packages/opencode/test/kilocode/server/prompt-training-model-filter.test.ts index f31992c1aa9..287579f1fb6 100644 --- a/packages/opencode/test/kilocode/server/prompt-training-model-filter.test.ts +++ b/packages/opencode/test/kilocode/server/prompt-training-model-filter.test.ts @@ -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) + } }), ) } diff --git a/packages/opencode/test/kilocode/server/provider-auth-failure.test.ts b/packages/opencode/test/kilocode/server/provider-auth-failure.test.ts index 961a1ff7179..b417bc6af32 100644 --- a/packages/opencode/test/kilocode/server/provider-auth-failure.test.ts +++ b/packages/opencode/test/kilocode/server/provider-auth-failure.test.ts @@ -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 { + 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([]) }), ) })