diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 041f1158836..060000d4b71 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -2531,7 +2531,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private invalidateProviders(): void { this.providersGeneration++ - this.providersQueued = false this.cachedProvidersMessage = null this.postMessage({ type: "providersLoading" }) } @@ -4547,14 +4546,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async reloadAfterAuthChange(): Promise { this.invalidateProviderUsage() this.invalidateProviders() - await this.fetchAndSendConfig() await Promise.all([ this.fetchAndSendProviders(), - this.fetchAndSendAgents(), - this.fetchAndSendSkills(), - this.fetchAndSendCommands(), - this.fetchAndSendIndexingStatus(), - this.fetchAndSendNotifications(), + this.fetchAndSendConfig().then(() => + Promise.all([ + this.fetchAndSendAgents(), + this.fetchAndSendSkills(), + this.fetchAndSendCommands(), + this.fetchAndSendIndexingStatus(), + this.fetchAndSendNotifications(), + ]), + ), ]) } diff --git a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx index 94f45c40ee9..3b897536913 100644 --- a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx +++ b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx @@ -58,7 +58,10 @@ const { MemoryProvider } = await import("../../webview-ui/src/context/memory") const { SpeechToTextModelsProvider } = await import("../../webview-ui/src/context/speech-to-text-models") const { drafts, imageDrafts, savePromptDraft } = await import("../../webview-ui/src/utils/draft-store") -const [settings, setSettings] = createSignal<{ model?: string; agent?: Record }>({}) +const [settings, setSettings] = createSignal<{ + model?: string + agent?: Record +}>({}) const config = { config: settings, globalConfig: () => ({}), @@ -209,7 +212,7 @@ const settle = async () => { await window.happyDOM.waitUntilComplete() } const emit = async (data: unknown) => { - post(data) + post(structuredClone(data)) await settle() } const state = (id: string) => { @@ -304,7 +307,11 @@ try { organizationId, ready, providers: { - kilo: { id: "kilo", name: "Kilo", models: Object.fromEntries(ids.map((id) => [id, { id, name: id }])) }, + kilo: { + id: "kilo", + name: "Kilo", + models: Object.fromEntries(ids.map((id) => [id, { id, name: id, variants: { low: {}, high: {} } }])), + }, openai: { id: "openai", name: "OpenAI", models: { external: { id: "external", name: "External" } } }, }, connected: ["kilo", "openai"], @@ -475,6 +482,239 @@ try { choice(value.selected(), recommended) assert.deepEqual(writes(), remembered) + const snapshot = (scope?: string) => + JSON.stringify({ + session: value.currentSessionID(), + draft: value.draftSessionID(), + agent: value.selectedAgent(scope), + model: value.selected(scope), + variant: value.currentVariant(scope), + foreground: [value.selectedAgent(), value.selected(), value.currentVariant()], + modes: ["code", "ask"].map((name) => [ + value.modelForAgent(name), + value.variantForAgent(name, value.modelForAgent(name)), + ]), + recents: value.recentModels(), + usage: value.modelUsageHistory(), + sessions: value.sessions(), + messages: value.allMessages(), + submitting: value.submitting(), + cleared: value.userClearedSession(), + }) + await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID) + await emit({ type: "modelSelectionsLoaded", selections: { code: first, ask: recommended } }) + value.selectAgent("ask") + value.selectVariant("low") + for (const scope of [undefined, "ses_command", "ses_background-command", "command-draft"]) { + value.setCurrentSessionID(undefined) + value.selectAgent("code") + value.selectVariant("low") + if (scope) { + value.setSessionAgent(scope, "code") + value.setSessionModel(scope, personal.providerID, personal.modelID) + value.selectVariant("low", scope) + } + value.setCurrentSessionID( + scope === "ses_background-command" ? "selection" : scope === "command-draft" ? undefined : scope, + ) + value.setDraftSessionID(scope === "command-draft" ? scope : undefined) + await settle() + const initial = snapshot(scope) + for (const reason of ["retained", "loading", "empty", "invalid", "malformed"]) { + if (reason === "retained") + await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID, false) + if (reason === "loading") await emit({ type: "providersLoading" }) + if (reason === "empty") await catalog("org-a", []) + const before = snapshot(scope) + const count = sent.length + assert.equal( + value.sendCommand( + "review-test", + "preserve selection", + personal.providerID, + personal.modelID, + undefined, + scope === "command-draft" ? scope : undefined, + undefined, + scope === "command-draft" ? null : scope, + { + agent: "ask", + model: reason === "invalid" ? "kilo/unavailable" : reason === "malformed" ? "invalid" : undefined, + variant: "high", + }, + ), + false, + `${scope ?? "new"}: ${reason}`, + ) + await settle() + assert.equal(snapshot(scope), before, `${scope ?? "new"}: ${reason} mutated selection`) + assert.deepEqual(sent.slice(count), [], "Rejected commands must not persist, seed, or send") + await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID) + assert.equal(snapshot(scope), initial, "Restoring the catalog must restore the untouched model and variant") + } + } + + for (const configured of [false, true]) { + const scope = `ses_command-${configured ? "configured" : "preferred"}` + setSettings(configured ? { agent: { ask: { model: "kilo/z-first", variant: "high" } } } : {}) + await emit({ type: "modelSelectionsLoaded", selections: { code: first, ask: recommended } }) + value.setCurrentSessionID(scope) + value.setSessionAgent(scope, "code") + value.setSessionModel(scope, personal.providerID, personal.modelID) + await settle() + assert.equal( + value.sendCommand( + "review-test", + "agent model", + personal.providerID, + personal.modelID, + undefined, + undefined, + undefined, + undefined, + { agent: "ask" }, + ), + true, + ) + const request = requests().at(-1) + assert(request?.type === "sendCommand") + assert.equal(request.sessionID, scope) + assert.equal(request.agent, "ask") + assert.equal(request.modelID, configured ? first.modelID : recommended.modelID) + assert.equal(request.variant, configured ? "high" : "low") + assert.equal(value.selectedAgent(scope), "ask") + choice(value.selected(scope), configured ? first : recommended) + } + setSettings({}) + await catalog(null, [auto.modelID, personal.modelID, first.modelID, recommended.modelID]) + await emit({ type: "modelSelectionsLoaded", selections: {} }) + value.setCurrentSessionID(undefined) + value.selectAgent("ask") + await settle() + value.selectAgent("code") + await settle() + setSettings({ agent: { ask: { model: "kilo/a-recommended", variant: "high" } } }) + value.setCurrentSessionID("ses_command-cached") + await settle() + assert.equal( + value.sendCommand( + "review-test", + "configured mode", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { agent: "ask" }, + ), + true, + ) + const configured = requests().at(-1) + assert(configured?.type === "sendCommand") + assert.equal(configured.modelID, recommended.modelID) + assert.equal(configured.variant, "high") + choice(value.selected(), recommended) + + setSettings({}) + await catalog("org-a", [personal.modelID, first.modelID, recommended.modelID], recommended.modelID) + await emit({ type: "modelSelectionsLoaded", selections: { code: first, ask: recommended } }) + value.setCurrentSessionID(undefined) + value.selectAgent("ask") + value.setCurrentSessionID("selection") + assert.equal( + value.sendCommand( + "review-test", + "pending agent", + personal.providerID, + personal.modelID, + undefined, + undefined, + undefined, + null, + { variant: "high" }, + ), + true, + ) + const pending = requests().at(-1) + assert(pending?.type === "sendCommand") + assert(pending.draftID) + assert.equal(pending.sessionID, undefined) + assert.equal(pending.agent, "ask") + assert.equal(pending.modelID, recommended.modelID) + assert.equal(pending.variant, "high") + assert.equal(value.selectedAgent(pending.draftID), "ask") + choice(value.selected(pending.draftID), recommended) + assert.equal(value.currentVariant(pending.draftID), "high") + assert.equal(value.variantForAgent("ask", recommended), "low") + const persisted = sent.length + assert.equal( + value.sendCommand( + "review-test", + "explicit model", + first.providerID, + first.modelID, + undefined, + undefined, + undefined, + null, + { agent: "ask", model: "kilo/personal", variant: "high" }, + ), + true, + ) + const accepted = requests().at(-1) + assert(accepted?.type === "sendCommand") + assert.equal(accepted.sessionID, undefined) + assert(accepted.draftID) + assert.equal(accepted.agent, "ask") + assert.equal(accepted.modelID, personal.modelID) + assert.equal(accepted.variant, "high") + assert.equal(value.currentSessionID(), "selection") + assert.equal(value.draftSessionID(), accepted.draftID) + choice(value.selected(accepted.draftID), personal) + assert.equal(value.selectedAgent(accepted.draftID), "ask") + assert.equal(value.currentVariant(accepted.draftID), "high") + choice(value.modelForAgent("ask"), recommended) + assert.equal( + sent.slice(persisted).some((message) => message.type === "persistModelSelection"), + false, + ) + await emit({ type: "sessionCreated", session: info("ses_command-promoted"), draftID: accepted.draftID }) + choice(value.selected("ses_command-promoted"), personal) + assert.equal(value.selectedAgent("ses_command-promoted"), "ask") + assert.equal(value.currentVariant("ses_command-promoted"), "high") + assert( + sent + .slice(persisted) + .some( + (message) => + message.type === "persistVariant" && + message.key === "session/ses_command-promoted/kilo/personal" && + message.value === "high", + ), + ) + assert.equal( + value.sendCommand( + "review-test", + "scoped draft", + first.providerID, + first.modelID, + undefined, + "command-draft", + undefined, + null, + ), + true, + ) + const scoped = requests().at(-1) + assert(scoped?.type === "sendCommand") + assert.equal(scoped.draftID, "command-draft") + assert.equal(scoped.agent, "code") + assert.equal(scoped.modelID, personal.modelID) + assert.equal(scoped.variant, "low") + assert.equal(value.currentSessionID(), "ses_command-promoted") + value.setDraftSessionID(undefined) + const key = "acceptance:session:composer" const image = { id: "image", filename: "image.png", mime: "image/png", dataUrl: "data:image/png;base64,cGl4ZWw=" } const input = () => { 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 04619a51720..a6d949d0502 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-catalog.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-catalog.test.ts @@ -1,34 +1,65 @@ import { describe, expect, it } from "bun:test" +import type { Config } from "@kilocode/sdk/v2/client" +import type { AuthContext } from "../../src/kilo-provider/handlers/auth" 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` } } }], - connected: ["kilo"], - default: { kilo: "kilo-auto/free" }, + all: [{ id: "kilo", name: "Kilo Gateway", models: { [`${org}/model`]: { id: `${org}/model` } } }, external], + connected: ["kilo", "external"], + default: { kilo: "kilo-auto/free", external: "model" }, }, }) type Internals = { connectionState: string + cachedConfigMessage: unknown cachedProvidersMessage: unknown + providersRefresh: Promise | null + authCtx: AuthContext fetchAndSendProviders(): Promise invalidateProviders(): void + handleEvent(event: unknown, directory?: string): void + reloadAfterAuthChange(): Promise } function setup(list: () => Promise>, org: () => string) { const client = { provider: { list, auth: async () => ({ data: {} }) }, kilo: { authStatus: async () => ({ data: { authenticated: true, type: "oauth", organizationId: org() } }) }, - config: { providers: async () => ({ data: { default: { kilo: `${org()}/model` } } }) }, + 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: {} }) } }, } - const provider = new KiloProvider({} as never, { getClient: () => client } as never) + const provider = new KiloProvider( + {} as never, + { getClient: () => client, resolveEventSessionId: () => undefined } as never, + ) const internal = provider as unknown as Internals - internal.connectionState = "connected" + Object.assign(internal, { + connectionState: "connected", + fetchAndSendAgents: async () => {}, + fetchAndSendSkills: async () => {}, + fetchAndSendCommands: async () => {}, + fetchAndSendIndexingStatus: async () => {}, + fetchAndSendNotifications: async () => {}, + }) + const reloads: Promise[] = [] + const reload = internal.reloadAfterAuthChange.bind(internal) + internal.reloadAfterAuthChange = () => { + const task = reload() + reloads.push(task) + return task + } const messages: Array> = [] provider.postMessage = (message) => void messages.push(message as Record) - return { internal, messages } + return { internal, messages, client, reloads } } describe("KiloProvider catalog refresh", () => { @@ -79,6 +110,117 @@ describe("KiloProvider catalog refresh", () => { }) }) + it.each([false, true])("preserves a queued refresh through auth invalidation (failure: %s)", async (fail) => { + const first = Promise.withResolvers>() + let org = "a" + let calls = 0 + const { internal, messages } = setup( + async () => (++calls === 1 ? first.promise : catalog(org)), + () => org, + ) + const before = internal.fetchAndSendProviders() + const queued = internal.fetchAndSendProviders() + + org = "b" + internal.authCtx.invalidateProviders() + if (fail) first.reject(new Error("Old catalog unavailable")) + if (!fail) first.resolve(catalog("a")) + await Promise.all([before, queued]) + + expect(calls).toBe(2) + expect(messages).toHaveLength(2) + expect(messages.at(0)).toEqual({ type: "providersLoading" }) + expect(messages.at(-1)).toMatchObject({ + type: "providersLoaded", + organizationId: "b", + ready: true, + defaults: { kilo: "b/model" }, + providers: { kilo: { models: { "b/model": { id: "b/model" } } }, external }, + }) + }) + + it.each(["global.disposed", "server.instance.disposed"])( + "%s restores a fresh catalog without waiting for config", + async (type) => { + const config = Promise.withResolvers<{ data: Config }>() + const { internal, messages, client, reloads } = setup( + async () => catalog("org"), + () => "org", + ) + const preference = { model: "external/model" } + internal.cachedConfigMessage = { config: preference } + client.config.get = () => config.promise + await internal.fetchAndSendProviders() + const fresh = internal.cachedProvidersMessage + + internal.handleEvent( + { type, properties: { directory: "/repo" } }, + type === "global.disposed" ? "global" : "/repo", + ) + try { + expect(messages.at(-1)).toEqual({ type: "providersLoading" }) + expect(internal.providersRefresh).not.toBeNull() + await internal.providersRefresh + + expect(internal.cachedProvidersMessage).toEqual(fresh) + expect(messages.at(-1)).toMatchObject({ + type: "providersLoaded", + ready: true, + providers: { external }, + defaultSelection: { providerID: "external", modelID: "model" }, + }) + expect(messages.some((message) => message.type === "configLoaded")).toBe(false) + expect(internal.cachedConfigMessage).toEqual({ config: preference }) + } finally { + config.resolve({ data: preference }) + await Promise.all(reloads) + } + expect(internal.cachedProvidersMessage).toEqual(fresh) + }, + ) + + it("global disposal invalidates every view and retries only the new Org while config is delayed", async () => { + const config = Promise.withResolvers<{ data: Config }>() + const first = Promise.withResolvers>() + let org = "a" + let delayed = false + const views = Array.from({ length: 2 }, () => + setup( + async () => (delayed && org === "a" ? first.promise : catalog(org)), + () => org, + ), + ) + await Promise.all(views.map((view) => view.internal.fetchAndSendProviders())) + delayed = true + const pending = views.map((view) => view.internal.fetchAndSendProviders()) + const queued = views.map((view) => view.internal.fetchAndSendProviders()) + org = "b" + views.at(0)!.internal.authCtx.invalidateProviders() + for (const view of views) { + view.client.config.get = () => config.promise + view.internal.handleEvent({ type: "global.disposed", properties: {} }, "global") + expect(view.internal.cachedProvidersMessage).toBeNull() + expect(view.messages.at(-1)).toEqual({ type: "providersLoading" }) + } + first.resolve(catalog("a")) + try { + await Promise.all([...pending, ...queued]) + for (const view of views) { + expect(view.messages.filter((message) => message.type === "providersLoaded")).toHaveLength(2) + expect(view.internal.cachedProvidersMessage).toMatchObject({ + organizationId: "b", + ready: true, + defaults: { kilo: "b/model" }, + providers: { kilo: { models: { "b/model": { id: "b/model" } } }, external }, + }) + expect(view.messages.some((message) => message.type === "configLoaded")).toBe(false) + } + } finally { + config.resolve({ data: {} }) + await Promise.all(views.flatMap((view) => view.reloads)) + } + }) + it("cannot republish an in-flight old catalog after invalidation", async () => { const first = Promise.withResolvers>() const { internal, messages } = setup( diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts index 340473b836c..7195a0cffaa 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -150,13 +150,16 @@ describe("KiloProvider indexing refresh", () => { ]) }) - it("reloadAfterAuthChange fetches config first, then indexing status", async () => { + it("reloadAfterAuthChange refreshes providers immediately but waits for config before indexing", async () => { const provider = new KiloProvider({} as never, {} as never) const internal = provider as unknown as Internals const calls: string[] = [] + const config = Promise.withResolvers() internal.fetchAndSendConfig = async () => { calls.push("config") + await config.promise + calls.push("configured") } internal.fetchAndSendProviders = async () => { calls.push("providers") @@ -177,10 +180,17 @@ describe("KiloProvider indexing refresh", () => { calls.push("indexing") } - await internal.reloadAfterAuthChange() + const pending = internal.reloadAfterAuthChange() + try { + expect(calls).toContain("providers") + expect(calls).toContain("config") + expect(calls).not.toContain("indexing") + } finally { + config.resolve() + await pending + } - expect(calls[0]).toBe("config") - expect(calls.includes("indexing")).toBe(true) + expect(calls.indexOf("indexing")).toBeGreaterThan(calls.indexOf("configured")) }) it("handleUpdateConfig no longer eagerly fetches indexing status", async () => { diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index d031bb96f45..327657d69ad 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -85,7 +85,7 @@ describe("sendCommand dismisses pending tool requests", () => { expect(body).toContain("if (overrides?.agent)") expect(body).toContain("selectAgent(overrides.agent, scope)") expect(body).toContain("if (overrides?.model)") - expect(body).toContain("selectModel(parsed.providerID, parsed.modelID, scope)") + expect(body).toContain("selectModel(effectiveSelection.providerID, effectiveSelection.modelID, scope)") expect(body).toContain("if (overrides?.variant)") expect(body).toContain("selectVariant(overrides.variant, scope)") }) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index f12df7749fb..191d1e6b0a3 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -2169,6 +2169,18 @@ export const SessionProvider: ParentComponent = (props) => { } const sid = origin === undefined ? currentSessionID() : (origin ?? undefined) + const effectiveSelection = (() => { + if (overrides?.model) return parseModelString(overrides.model) + const scope = draftID ?? sid + const model = overrides?.agent + ? modelForAgent(overrides.agent) + : scope + ? selected(scope) + : getSelected(preferences(), environment(), undefined, pendingAgentSelection() ?? defaultAgent()) + return model ?? (providerID && modelID ? { providerID, modelID } : null) + })() + if (!available(effectiveSelection)) return false + const effectiveDraftID = !sid && !draftID ? crypto.randomUUID() : draftID const scope = effectiveDraftID ?? sid if (!sid && !draftID && effectiveDraftID) agentDrafts.seed(effectiveDraftID) @@ -2177,16 +2189,12 @@ export const SessionProvider: ParentComponent = (props) => { selectAgent(overrides.agent, scope) } if (overrides?.model) { - const parsed = parseModelString(overrides.model) - if (!available(parsed)) return false - selectModel(parsed.providerID, parsed.modelID, scope) + selectModel(effectiveSelection.providerID, effectiveSelection.modelID, scope) } if (overrides?.variant) { selectVariant(overrides.variant, scope) } - const effectiveSelection = selected(scope) ?? (providerID && modelID ? { providerID, modelID } : null) - if (!available(effectiveSelection)) return false const effectiveProvider = effectiveSelection.providerID const effectiveModel = effectiveSelection.modelID recordModelUsage(effectiveProvider, effectiveModel) diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts index 2042e1fb5f6..9cb6031a93f 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilo-gateway.ts @@ -87,8 +87,11 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo", const cfg = yield* config.get() const organizationId = catalogOrganization(cfg.provider?.kilo?.options, info) const type = getToken(info) && (info?.type === "api" || info?.type === "oauth") ? info.type : undefined - if (!type) return { authenticated: false, organizationId } - return { authenticated: true, type, organizationId } + return { + authenticated: !!type, + ...(type ? { type } : {}), + ...(organizationId == null ? {} : { organizationId }), + } }) const proxyAuth = Effect.fn("KiloGatewayHttpApi.proxyAuth")(function* () { 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 ef79747b21c..d6db98fefb6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -7,6 +7,7 @@ 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 { Option } from "effect" // kilocode_change import { disposeAllInstancesAfterProviderAuthCallback, invalidatePresence, @@ -60,8 +61,8 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" } const connected = yield* provider.list() // kilocode_change start - const info = yield* access.get("kilo").pipe(Effect.orDie) - if (organization(config.provider?.kilo?.options, info)) delete filtered.kilo + const info = yield* access.get("kilo").pipe(Effect.option) + if (Option.isNone(info) || organization(config.provider?.kilo?.options, info.value)) delete filtered.kilo const providers = filterPromptTrainingModels( Object.assign( mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), @@ -69,6 +70,7 @@ 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() @@ -85,7 +87,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" metadata: providerMetadata(item.id), })), // kilocode_change default: Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models).length > 0)), - connected: Object.keys(connected), + connected: Object.keys(connected).filter((id) => id !== "kilo" || Option.isSome(info)), failed, } // kilocode_change end 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 62847277b6d..4588228ce2f 100644 --- a/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts +++ b/packages/opencode/test/kilocode/server/kilo-gateway-statuses.test.ts @@ -114,7 +114,9 @@ describe("Kilo gateway HttpApi statuses", () => { }), ) - for (const context of ["config", "oauth", "env", "url", "personal", "anonymous"] as const) { + 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`, () => Effect.gen(function* () { const previous = { ...state } @@ -127,17 +129,16 @@ describe("Kilo gateway HttpApi statuses", () => { : 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" } : {}), - }) - if (context === "personal") delete process.env.KILO_ORG_ID + state.info = 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" } : {}), + }) + if (personal) delete process.env.KILO_ORG_ID else process.env.KILO_ORG_ID = "org-env" }), () => @@ -151,9 +152,9 @@ describe("Kilo gateway HttpApi statuses", () => { const response = yield* HttpClient.get(KiloGatewayPaths.authStatus) expect(response.status).toBe(200) expect(yield* response.json).toEqual({ - authenticated: context !== "anonymous", - ...(context !== "anonymous" ? { type: "oauth" } : {}), - ...(context !== "personal" ? { organizationId: `org-${context === "anonymous" ? "env" : context}` } : {}), + authenticated: !anonymous, + ...(!anonymous ? { type: "oauth" } : {}), + ...(!personal ? { organizationId: `org-${anonymous ? "env" : context}` } : {}), }) }), ) diff --git a/packages/opencode/test/kilocode/server/provider-auth-failure.test.ts b/packages/opencode/test/kilocode/server/provider-auth-failure.test.ts new file mode 100644 index 00000000000..961a1ff7179 --- /dev/null +++ b/packages/opencode/test/kilocode/server/provider-auth-failure.test.ts @@ -0,0 +1,158 @@ +import { describe, expect } from "bun:test" +import { NodeHttpServer } from "@effect/platform-node" +import { ModelsDev } from "@opencode-ai/core/models-dev" +import { Effect, Layer } from "effect" +import { HttpClient, HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi" +import { Auth } from "../../../src/auth" +import { KiloViewers } from "../../../src/kilocode/presence/service" +import { InstanceStore } from "../../../src/project/instance-store" +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 { ProviderApi } from "../../../src/server/routes/instance/httpapi/groups/provider" +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" +import { + WorkspaceRouteContext, + WorkspaceRoutingMiddleware, +} from "../../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { schemaErrorLayer } from "../../../src/server/routes/instance/httpapi/middleware/schema-error" +import { TestConfig } from "../../fixture/config" +import { testEffect } from "../../lib/effect" + +function catalog(id: 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 }, + }, + }, + } +} + +const catalogs = { external: catalog("external"), kilo: catalog("kilo") } +const providers = { + external: Provider.fromModelsDevProvider(catalogs.external), + kilo: Provider.fromModelsDevProvider(catalogs.kilo), +} +const state = { failure: false, connected: true } +const layer = HttpRouter.serve( + HttpApiBuilder.layer(HttpApi.make("opencode-instance").addHttpApi(ProviderApi)).pipe( + Layer.provide(providerHandlers), + Layer.provide(schemaErrorLayer), + Layer.provide([ + TestConfig.layer({ get: () => Effect.succeed({ enabled_providers: ["external", "kilo"] }) }), + Layer.mock(Provider.Service)({ + list: () => Effect.succeed(state.connected ? providers : { external: providers.external }), + }), + Layer.mock(ProviderAuth.Service)({}), + Layer.mock(ModelCache.Service)({ failedProviders: () => Effect.succeed([]) }), + Layer.mock(Auth.Service)({ + get: () => + state.failure + ? Effect.fail(new Auth.AuthError({ message: "Cannot read credentials" })) + : Effect.succeed(undefined), + }), + Layer.succeed( + Authorization, + Authorization.of((effect) => effect), + ), + Layer.succeed( + InstanceContextMiddleware, + InstanceContextMiddleware.of((effect) => effect), + ), + Layer.succeed( + WorkspaceRoutingMiddleware, + WorkspaceRoutingMiddleware.of((effect) => + effect.pipe( + Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory: process.cwd() })), + ), + ), + ), + ]), + ), + { disableListenLog: true, disableLogger: true }, +).pipe( + Layer.provide([ + Layer.mock(ModelsDev.Service)({ get: () => Effect.succeed(catalogs) }), + Layer.mock(InstanceStore.Service)({}), + Layer.mock(Session.Service)({}), + Layer.mock(KiloViewers.Service)({}), + ]), + Layer.provideMerge(NodeHttpServer.layerTest), +) +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 }) + delete process.env.KILO_ORG_ID + return previous + }), + (previous) => + Effect.sync(() => { + Object.assign(state, previous.state) + if (previous.env === undefined) { + delete process.env.KILO_ORG_ID + return + } + process.env.KILO_ORG_ID = previous.env + }), + ) +} + +describe("provider catalog authentication failures", () => { + for (const connected of [false, true]) { + it.live(`keeps other providers available 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"], + }) + expect(JSON.stringify(body)).not.toContain('"kilo"') + + 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" }, + connected: connected ? ["external", "kilo"] : ["external"], + }) + }), + ) + } + + it.live("keeps the Personal catalog when credentials are absent rather than unreadable", () => + 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"], + }) + }), + ) +})