diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index aca02597566..ff01b402d0b 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -54,9 +54,7 @@ export const Flag = { // Experimental KILO_EXPERIMENTAL, - KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe( - Config.withDefault(false), - ), + KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)), KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe( Config.withDefault(false), ), diff --git a/packages/kilo-docs/components/FlowDiagram/index.tsx b/packages/kilo-docs/components/FlowDiagram/index.tsx index 5f18ec04e92..d76f53f7f1f 100644 --- a/packages/kilo-docs/components/FlowDiagram/index.tsx +++ b/packages/kilo-docs/components/FlowDiagram/index.tsx @@ -16,12 +16,11 @@ export function FlowDiagram({ name, height = "400px" }: { name: string; height?: const [cssLoaded, setCssLoaded] = useState(false) useEffect(() => { - Promise.all([ - import("@xyflow/react"), - import("@xyflow/react/dist/style.css").then(() => setCssLoaded(true)), - ]).then(([xyflow]) => { - setMod(xyflow) - }) + Promise.all([import("@xyflow/react"), import("@xyflow/react/dist/style.css").then(() => setCssLoaded(true))]).then( + ([xyflow]) => { + setMod(xyflow) + }, + ) }, []) const diagram = diagrams[name] diff --git a/packages/kilo-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index 8712f3d47bd..a251eb47d5c 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -3,6 +3,11 @@ import { getKiloUrlFromToken } from "../auth/token.js" import { getDefaultHeaders, buildKiloHeaders } from "../headers.js" import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, AI_SDK_PROVIDERS } from "./constants.js" +export type KiloModelsResult = { + models: Record + error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number } +} + /** * OpenRouter model schema */ @@ -63,13 +68,13 @@ function parseApiPrice(price: string | null | undefined): number | undefined { * Fetch models from Kilo API (OpenRouter-compatible endpoint) * * @param options - Configuration options - * @returns Record of models in ModelsDev.Model format + * @returns Typed result with models and optional error info */ export async function fetchKiloModels(options?: { kilocodeToken?: string kilocodeOrganizationId?: string baseURL?: string -}): Promise> { +}): Promise { const token = options?.kilocodeToken const organizationId = options?.kilocodeOrganizationId @@ -84,54 +89,60 @@ export async function fetchKiloModels(options?: { // Construct models endpoint const modelsURL = `${finalBaseURL}/models` - try { - // Fetch models with timeout - const response = await fetch(modelsURL, { - headers: { - ...getDefaultHeaders(), - ...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }), - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), - }) + const response = await fetch(modelsURL, { + headers: { + ...getDefaultHeaders(), + ...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), + }).catch((err: unknown) => err as Error) + + if (response instanceof Error) { + return { models: {}, error: { kind: "network" } } + } - if (!response.ok) { - throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`) + if (!response.ok) { + // 401 with auth credentials: fall back to unauthenticated public endpoint + if (response.status === 401 && (token || organizationId)) { + return fetchKiloModels({}) } + const kind = response.status === 401 || response.status === 403 ? "unauthorized" : "http" + return { models: {}, error: { kind, status: response.status } } + } - const json = await response.json() + const json = await response.json().catch(() => null) - // Validate response schema - const result = openRouterModelsResponseSchema.safeParse(json) + if (json === null) { + return { models: {}, error: { kind: "schema" } } + } - if (!result.success) { - console.error("Kilo models response validation failed:", result.error.format()) - return {} - } + // Validate response schema + const result = openRouterModelsResponseSchema.safeParse(json) - // Transform models to ModelsDev.Model format - const models: Record = {} + if (!result.success) { + return { models: {}, error: { kind: "schema" } } + } - for (const model of result.data.data) { - // Skip image generation models - if (model.architecture?.output_modalities?.includes("image")) { - continue - } + // Transform models to ModelsDev.Model format + const models: Record = {} - // Skip models that don't support tools — Kilo requires tool calling - if (!model.supported_parameters?.includes("tools")) { - continue - } + for (const model of result.data.data) { + // Skip image generation models + if (model.architecture?.output_modalities?.includes("image")) { + continue + } - const transformedModel = transformToModelDevFormat(model) - models[model.id] = transformedModel + // Skip models that don't support tools — Kilo requires tool calling + if (!model.supported_parameters?.includes("tools")) { + continue } - return models - } catch (error) { - console.error("Error fetching Kilo models:", error) - return {} + const transformedModel = transformToModelDevFormat(model) + models[model.id] = transformedModel } + + return { models } } /** diff --git a/packages/kilo-gateway/src/api/modes.ts b/packages/kilo-gateway/src/api/modes.ts index 85493804c52..ac67d8332d1 100644 --- a/packages/kilo-gateway/src/api/modes.ts +++ b/packages/kilo-gateway/src/api/modes.ts @@ -77,7 +77,6 @@ export async function fetchOrganizationModes(token: string, organizationId: stri }) if (!response.ok) { - console.warn(`[Kilo Gateway] Failed to fetch organization modes: ${response.status}`) return [] } @@ -85,7 +84,6 @@ export async function fetchOrganizationModes(token: string, organizationId: stri const parsed = ResponseSchema.safeParse(json) if (!parsed.success) { - console.warn("[Kilo Gateway] Organization modes response validation failed:", parsed.error.format()) return [] } diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index b3c2d8ace5e..cfd97e06e90 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -33,7 +33,7 @@ export { getKiloDefaultModel, promptOrganizationSelection, } from "./api/profile.js" -export { fetchKiloModels } from "./api/models.js" +export { fetchKiloModels, type KiloModelsResult } from "./api/models.js" export { EMPTY_KILO_EMBEDDING_MODEL_CATALOG, fetchKiloEmbeddingModelCatalog, diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts new file mode 100644 index 00000000000..c9224687459 --- /dev/null +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -0,0 +1,124 @@ +// Verifies fetchKiloModels typed result and 401 fallback behaviour. + +import { test, expect } from "bun:test" +import { fetchKiloModels } from "../../src/api/models.js" + +const VALID_RESPONSE = JSON.stringify({ + data: [ + { + id: "test/model-a", + name: "Test Model A", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["tools", "temperature"], + }, + ], +}) + +function stubFetch(fn: (input: string | URL | Request, init?: RequestInit) => Promise) { + ;(globalThis as any).fetch = fn +} + +test("returns empty models and error when both auth and public requests return 401", async () => { + const orig = globalThis.fetch + stubFetch(async () => new Response("Unauthorized", { status: 401, statusText: "Unauthorized" })) + + const result = await fetchKiloModels({ kilocodeToken: "bad-token" }) + + ;(globalThis as any).fetch = orig + + expect(result.models).toEqual({}) + expect(result.error).toBeDefined() +}) + +test("falls back to public endpoint on 401 and returns models", async () => { + const orig = globalThis.fetch + let callCount = 0 + + stubFetch(async () => { + callCount++ + if (callCount === 1) { + return new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }) + } + return new Response(VALID_RESPONSE, { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) + + const result = await fetchKiloModels({ + kilocodeToken: "expired-token", + kilocodeOrganizationId: "org-123", + }) + + ;(globalThis as any).fetch = orig + + expect(callCount).toBe(2) + expect(result.error).toBeUndefined() + expect(Object.keys(result.models).length).toBeGreaterThan(0) +}) + +test("returns error with kind=network on fetch exception", async () => { + const orig = globalThis.fetch + stubFetch(async () => { + throw new Error("network error") + }) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.models).toEqual({}) + expect(result.error?.kind).toBe("network") +}) + +test("returns error with kind=http on non-auth HTTP error (e.g. 500)", async () => { + const orig = globalThis.fetch + stubFetch(async () => new Response("Server Error", { status: 500, statusText: "Internal Server Error" })) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.models).toEqual({}) + expect(result.error?.kind).toBe("http") + expect(result.error?.status).toBe(500) +}) + +test("returns models without error on success", async () => { + const orig = globalThis.fetch + stubFetch(async () => + new Response(VALID_RESPONSE, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.error).toBeUndefined() + expect(Object.keys(result.models).length).toBeGreaterThan(0) +}) + +test("returns error with kind=schema when response body is invalid JSON", async () => { + const orig = globalThis.fetch + stubFetch(async () => + new Response("not valid json{{{{", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.models).toEqual({}) + expect(result.error?.kind).toBe("schema") +}) diff --git a/packages/kilo-indexing/src/indexing/config-manager.ts b/packages/kilo-indexing/src/indexing/config-manager.ts index 00c9efd5278..1c5124de2dd 100644 --- a/packages/kilo-indexing/src/indexing/config-manager.ts +++ b/packages/kilo-indexing/src/indexing/config-manager.ts @@ -164,7 +164,8 @@ export class CodeIndexConfigManager { // LanceDB doesn't need a qdrant URL; qdrant does const hasStore = isLancedb || !!qdrant - if (provider === "kilo") return !!(this.kiloOptions?.apiKey && this.modelId && this.currentModelDimension && hasStore) + if (provider === "kilo") + return !!(this.kiloOptions?.apiKey && this.modelId && this.currentModelDimension && hasStore) if (provider === "openai") return !!(this.openAiOptions?.apiKey && hasStore) if (provider === "ollama") return !!(this.ollamaOptions?.baseUrl && hasStore) if (provider === "openai-compatible") diff --git a/packages/kilo-vscode/webview-ui/src/stories/history.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/history.stories.tsx index e2ae1757f8c..751a75f66a6 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/history.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/history.stories.tsx @@ -118,7 +118,7 @@ const WithSessions: ParentComponent<{ sessions?: typeof mockSessions }> = (props session_diff: {}, message: {}, part: {}, - provider: { all: [], connected: [] as string[], default: {} as any }, + provider: { all: [], connected: [] as string[], default: {} as any, failed: [] as string[] }, }} directory="/project/" > diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index 5e409636e15..c5b7cd4f928 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -70,3 +70,7 @@ Hono-based HTTP server with OpenAPI spec generation. SSE for real-time events. W ## Providers and Models Uses the **Vercel AI SDK** as the abstraction layer. Providers are loaded from a bundled map or dynamically installed at runtime. Models come from models.dev (external API), cached locally. + +## Fork Isolation Rule + +`opencode/` is a fork of upstream opencode. When a change must touch a shared upstream file, extract the Kilo-specific logic into a mirror file under `src/kilocode/.ts` (tests under `test/kilocode/.test.ts`) and call into it from the upstream file behind a single `kilocode_change` marker. Example: a Kilo override for `src/cli/cmd/tui/component/dialog-provider.tsx` lives at `src/kilocode/cli/cmd/tui/component/dialog-provider.tsx`. Avoid inlining Kilo-specific logic directly into shared upstream files. Files and directories whose path contains `kilocode` never need `kilocode_change` markers. diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 06c1dfdd423..556e63354fc 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -1,4 +1,3 @@ - import { EOL } from "os" import { Effect } from "effect" import { Provider } from "@/provider/provider" diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index fef50316783..d35dcd794f2 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -33,14 +33,20 @@ export function createDialogProviderOptions() { map((provider) => { const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id) const connected = sync.data.provider_next.connected.includes(provider.id) + // kilocode_change start + const failed = sync.data.provider_next.failed ?? [] + const failedGutter = KiloProvider.renderGutter(provider.id, failed, theme) + const failedDesc = KiloProvider.failedDescription(provider.id, failed) + const baseDesc = KiloProvider.PROVIDER_DESCRIPTIONS[provider.id] + // kilocode_change end return { title: KiloProvider.PROVIDER_TITLES[provider.id] ?? provider.name, // kilocode_change value: provider.id, - description: KiloProvider.PROVIDER_DESCRIPTIONS[provider.id], // kilocode_change + description: failedDesc ?? baseDesc, // kilocode_change footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", - gutter: connected && onboarded() ? () => : undefined, + gutter: failedGutter ?? (connected && onboarded() ? () => : undefined), // kilocode_change async onSelect() { if (consoleManaged) return diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index e1abfbc6d8e..e45ac9f777e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -98,6 +98,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ all: [], default: {}, connected: [], + failed: [], // kilocode_change }, console_state: emptyConsoleState, provider_auth: {}, diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index 7a51e758785..b8a4061b41e 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -432,13 +432,7 @@ export function patchAgents( description: "Get answers and explanations without making changes to the codebase.", prompt: PROMPT_ASK, options: {}, - permission: Permission.merge( - defaults, - askGuard(kilo.mcpRules), - user, - askEditGuard(), - denies(user), - ), + permission: Permission.merge(defaults, askGuard(kilo.mcpRules), user, askEditGuard(), denies(user)), mode: "primary", native: true, } diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx index 53c837c7bca..1b38f8b0d43 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx @@ -11,6 +11,36 @@ import type { RGBA } from "@opentui/core" import type { ProviderAuthAuthorization } from "@kilocode/sdk/v2" import { KiloAutoMethod } from "@/kilocode/components/dialog-kilo-auto-method" +// --------------------------------------------------------------------------- +// Failed-state gutter/description helpers +// --------------------------------------------------------------------------- + +/** + * Returns a red `!` gutter element when the provider is in a failed auth state, + * or `undefined` if not failed and not connected (falls through to default check). + */ +export function renderGutter( + providerID: string, + failed: string[], + theme: { error: RGBA }, +): (() => JSX.Element) | undefined { + if (!failed.includes(providerID)) return undefined + return () => ! +} + +/** + * Returns a description suffix when the provider has encountered an error, + * or `undefined` to leave the default description unchanged. + * + * NOTE: The sync state only carries failed provider IDs, not the error kind. + * A generic message is used so it remains accurate for auth, network, and + * schema failure types alike. + */ +export function failedDescription(providerID: string, failed: string[]): string | undefined { + if (!failed.includes(providerID)) return undefined + return "(connection error — click to reconnect)" +} + // --------------------------------------------------------------------------- // Provider priority (replaces upstream map entirely) // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/feedback.ts b/packages/opencode/src/kilocode/cli/cmd/tui/feedback.ts index 813bfdd1e61..bd35009e441 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/feedback.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui/feedback.ts @@ -28,9 +28,9 @@ export function submitFeedback(rating: "up" | "down", dialog: DialogContext, ctx return } const revertID = ctx.session()?.revert?.messageID - const lastAssistant = ctx.messages().findLast( - (msg): msg is AssistantMessage => msg.role === "assistant" && (!revertID || msg.id < revertID), - ) + const lastAssistant = ctx + .messages() + .findLast((msg): msg is AssistantMessage => msg.role === "assistant" && (!revertID || msg.id < revertID)) if (!lastAssistant) { ctx.toast.show({ message: "No assistant messages found", variant: "error" }) dialog.clear() diff --git a/packages/opencode/src/kilocode/components/dialog-indexing.tsx b/packages/opencode/src/kilocode/components/dialog-indexing.tsx index 4edf85fc8dd..49c59256f45 100644 --- a/packages/opencode/src/kilocode/components/dialog-indexing.tsx +++ b/packages/opencode/src/kilocode/components/dialog-indexing.tsx @@ -9,10 +9,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { DialogPrompt } from "@tui/ui/dialog-prompt" -import { - getKiloEmbeddingModel, - normalizeKiloEmbeddingModelId, -} from "@kilocode/kilo-indexing/embedding-models" +import { getKiloEmbeddingModel, normalizeKiloEmbeddingModelId } from "@kilocode/kilo-indexing/embedding-models" import { useSync } from "@tui/context/sync" import { useToast } from "@tui/ui/toast" import { createResource } from "solid-js" @@ -522,13 +519,17 @@ export function DialogIndexing(props: DialogIndexingProps) { } case "projectToggle": { if (globalCfg().enabled) { - toast.show({ message: "Global indexing is enabled, so this project is already covered.", variant: "info" }) + toast.show({ + message: "Global indexing is enabled, so this project is already covered.", + variant: "info", + }) dialog.replace(() => ) break } const current = getIndexing(sync) const enabled = !indexing.enabled - const updated = enabled && !current.provider && hasKiloAuth(sync) ? { ...defaultIndexing(sync), enabled } : { enabled } + const updated = + enabled && !current.provider && hasKiloAuth(sync) ? { ...defaultIndexing(sync), enabled } : { enabled } await saveProjectIndexing(sdk, sync, updated, toast) dialog.replace(() => ) break diff --git a/packages/opencode/src/kilocode/session/compaction-payload-recovery.ts b/packages/opencode/src/kilocode/session/compaction-payload-recovery.ts index 10865a3ee38..a21afcf2e5a 100644 --- a/packages/opencode/src/kilocode/session/compaction-payload-recovery.ts +++ b/packages/opencode/src/kilocode/session/compaction-payload-recovery.ts @@ -26,10 +26,7 @@ export namespace KiloCompactionPayloadRecovery { ].join("\n\n") } - export function strip(input: { - messages: MessageV2.WithParts[] - update: Update - }) { + export function strip(input: { messages: MessageV2.WithParts[]; update: Update }) { return Effect.forEach( input.messages, (msg) => diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index be01a7c0bd1..c7fdad6ed5f 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -79,26 +79,28 @@ export namespace KiloSessionPrompt { yield* input.sessions.removeMessage({ sessionID: input.sessionID, messageID: tail.info.id }) }) - export const recoverProviderFinishError = Effect.fn("KiloSessionPrompt.recoverProviderFinishError")(function* (input: { - sessionID: SessionID - status: Pick - sessions: Pick - }) { - const state = yield* input.status.get(input.sessionID) - if (state.type !== "idle") return + export const recoverProviderFinishError = Effect.fn("KiloSessionPrompt.recoverProviderFinishError")( + function* (input: { + sessionID: SessionID + status: Pick + sessions: Pick + }) { + const state = yield* input.status.get(input.sessionID) + if (state.type !== "idle") return - const msgs = yield* input.sessions.messages({ sessionID: input.sessionID, limit: 2 }) - const tail = msgs.at(-1) - if (!tail || tail.info.role !== "assistant") return - if (tail.info.finish !== "error" || tail.info.error) return - if (!tail.parts.some((part) => part.type === "step-finish" && part.reason === "error")) return + const msgs = yield* input.sessions.messages({ sessionID: input.sessionID, limit: 2 }) + const tail = msgs.at(-1) + if (!tail || tail.info.role !== "assistant") return + if (tail.info.finish !== "error" || tail.info.error) return + if (!tail.parts.some((part) => part.type === "step-finish" && part.reason === "error")) return - const prev = msgs.at(-2) - if (!prev || prev.info.role !== "user") return - if (tail.info.parentID !== prev.info.id) return + const prev = msgs.at(-2) + if (!prev || prev.info.role !== "user") return + if (tail.info.parentID !== prev.info.id) return - yield* input.sessions.removeMessage({ sessionID: input.sessionID, messageID: tail.info.id }) - }) + yield* input.sessions.removeMessage({ sessionID: input.sessionID, messageID: tail.info.id }) + }, + ) export function guardPermissions(input: { agent: { name: string; permission: Permission.Ruleset } diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 30a7d5fb0ab..8ffb780aef9 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -39,17 +39,24 @@ export const layer = Layer.effect( const run = Effect.gen(function* () { const ctx = yield* InstanceState.context - yield* Effect.logInfo("bootstrapping", { directory: ctx.directory }) + yield* Effect.logDebug("bootstrapping", { directory: ctx.directory }) // kilocode_change - was logInfo; downgraded to avoid printing to TUI on every startup // everything depends on config so eager load it for nice traces yield* config.get() // Plugin can mutate config so it has to be initialized before anything else. yield* plugin.init() yield* Effect.promise(() => KilocodeBootstrap.init()).pipe(Effect.forkDetach) // kilocode_change + // kilocode_change start - shareNext removed from list, handled by KilocodeBootstrap yield* Effect.all( - [lsp, /* shareNext, kilocode_change - handled by KilocodeBootstrap */ format, file, fileWatcher, vcs, snapshot].map( - (s) => Effect.forkDetach(s.init()), - ), + [ + lsp, + format, + file, + fileWatcher, + vcs, + snapshot, + ].map((s) => Effect.forkDetach(s.init())), ).pipe(Effect.withSpan("InstanceBootstrap.init")) + // kilocode_change end const projectID = ctx.project.id yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => { diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index e9eef6d96a2..37cb47d53ef 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -1,5 +1,5 @@ -// kilocode_change new file -import { fetchKiloModels } from "@kilocode/kilo-gateway" +// kilocode_change - new file +import { fetchKiloModels, type KiloModelsResult } from "@kilocode/kilo-gateway" import { Config } from "../config/config" import { Auth } from "../auth" import * as Log from "@opencode-ai/core/util/log" @@ -19,6 +19,23 @@ export namespace ModelCache { const TTL = 5 * 60 * 1000 // 5 minutes const inFlightRefresh = new Map>>() + // Per-provider failure tracking + const failures = new Map() + + /** + * Get the failure state for a provider (undefined = no failure) + */ + export function getFailure(providerID: string): KiloModelsResult["error"] | undefined { + return failures.get(providerID) + } + + /** + * Get all provider IDs that have a failure state + */ + export function failedProviders(): string[] { + return [...failures.keys()] + } + /** * Get cached models if available and not expired * @param providerID - Provider identifier (e.g., "kilo") @@ -61,24 +78,30 @@ export namespace ModelCache { // Cache miss - fetch models log.info("fetching models", { providerID }) - try { - const authOptions = await getAuthOptions(providerID) - const mergedOptions = { ...authOptions, ...options } + const authOptions = await getAuthOptions(providerID).catch((err) => { + log.warn("getAuthOptions failed", { providerID, err }) + return {} + }) + const mergedOptions = { ...authOptions, ...options } - const models = await fetchModels(providerID, mergedOptions) + const result = await fetchModels(providerID, mergedOptions) + const { models } = result - // Store in cache - cache.set(providerID, { - models, - timestamp: Date.now(), - }) - - log.info("models fetched and cached", { providerID, count: Object.keys(models).length }) - return models - } catch (error) { - log.error("failed to fetch models", { providerID, error }) - return {} + if (result.error) { + failures.set(providerID, result.error) + log.warn("model fetch error", { providerID, error: result.error }) + } else { + failures.delete(providerID) } + + // Store in cache (even on error, to avoid hammering the API) + cache.set(providerID, { + models, + timestamp: Date.now(), + }) + + log.info("models fetched and cached", { providerID, count: Object.keys(models).length }) + return models } /** @@ -100,32 +123,29 @@ export namespace ModelCache { const refreshPromise = (async () => { log.info("refreshing models", { providerID }) - try { - const authOptions = await getAuthOptions(providerID) - const mergedOptions = { ...authOptions, ...options } - - const models = await fetchModels(providerID, mergedOptions) + const authOptions = await getAuthOptions(providerID).catch((err) => { + log.warn("getAuthOptions failed during refresh", { providerID, err }) + return {} + }) + const mergedOptions = { ...authOptions, ...options } - // Update cache with new models - cache.set(providerID, { - models, - timestamp: Date.now(), - }) + const result = await fetchModels(providerID, mergedOptions) + const { models } = result - log.info("models refreshed", { providerID, count: Object.keys(models).length }) - return models - } catch (error) { - log.error("failed to refresh models", { providerID, error }) + if (result.error) { + failures.set(providerID, result.error) + log.warn("model refresh error", { providerID, error: result.error }) + } else { + failures.delete(providerID) + } - // Return existing cache or empty object - const cached = cache.get(providerID) - if (cached) { - log.debug("returning stale cache after refresh failure", { providerID }) - return cached.models - } + cache.set(providerID, { + models, + timestamp: Date.now(), + }) - return {} - } + log.info("models refreshed", { providerID, count: Object.keys(models).length }) + return models })() // Track in-flight refresh @@ -145,6 +165,7 @@ export namespace ModelCache { */ export function clear(providerID: string): void { const deleted = cache.delete(providerID) + failures.delete(providerID) if (deleted) { log.info("cache cleared", { providerID }) } else { @@ -158,20 +179,21 @@ export namespace ModelCache { * @param options - Provider options * @returns Fetched models */ - async function fetchModels(providerID: string, options: any): Promise> { + async function fetchModels(providerID: string, options: any): Promise { if (providerID === "kilo") { return fetchKiloModels(options) } // kilocode_change start if (providerID === "apertis") { - return fetchApertisModels(options) + const models = await fetchApertisModels(options) + return { models } } // kilocode_change end // Other providers not implemented yet log.debug("provider not implemented", { providerID }) - return {} + return { models: {} } } // kilocode_change start diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2f069343149..4a0c81091f1 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -956,6 +956,7 @@ export const ListResult = Schema.Struct({ all: Schema.Array(Info), default: DefaultModelIDs, connected: Schema.Array(Schema.String), + failed: Schema.Array(Schema.String), // kilocode_change }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type ListResult = Types.DeepMutable> 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 f9df530a926..ede3df0563c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -3,7 +3,8 @@ import { Config } from "@/config/config" import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { ProviderID } from "@/provider/schema" -import { mapValues } from "remeda" +import { mapValues, pickBy } from "remeda" // kilocode_change +import { ModelCache } from "@/provider/model-cache" // kilocode_change import { Effect, Schema } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" @@ -29,11 +30,22 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)), connected, ) + // kilocode_change start + const failed = ModelCache.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) + const validProviders = pickBy( + providers, + (item, id) => Object.keys(item.models).length > 0 || id in connected || failedSet.has(id), + ) return { - all: Object.values(providers), - default: Provider.defaultModelIDs(providers), + all: Object.values(validProviders), + default: Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models).length > 0)), connected: Object.keys(connected), + failed, } + // kilocode_change end }) const auth = Effect.fn("ProviderHttpApi.auth")(function* () { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index a14e138e0d6..67fcdb80ff0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -274,19 +274,22 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof PromptPayload.Type }) { - // kilocode_change - cast to bridge schema-readonly→PromptInput-mutable; matches legacy Hono session.ts - yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput).pipe( - Effect.catchCause((cause) => - Effect.gen(function* () { - yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) - yield* bus.publish(Session.Event.Error, { - sessionID: ctx.params.sessionID, - error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), - }) - }), - ), - Effect.forkIn(scope, { startImmediately: true }), - ) + // kilocode_change start - cast to bridge schema-readonly→PromptInput-mutable; matches legacy Hono session.ts + yield* promptSvc + .prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput) + .pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause }) + yield* bus.publish(Session.Event.Error, { + sessionID: ctx.params.sessionID, + error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), + }) + }), + ), + Effect.forkIn(scope, { startImmediately: true }), + ) + // kilocode_change end return HttpApiSchema.NoContent.make() }) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index dbca75c1959..00bb4357e3b 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -82,7 +82,14 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await InstanceStore.reloadInstance({ directory: dir, worktree: dir, project: next, init: await getBootstrapRunEffect() }) + // kilocode_change start + await InstanceStore.reloadInstance({ + directory: dir, + worktree: dir, + project: next, + init: await getBootstrapRunEffect(), + }) + // kilocode_change end return c.json(next) }, ) diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts index 7ecbc8c84d0..0d291e238b0 100644 --- a/packages/opencode/src/server/routes/instance/provider.ts +++ b/packages/opencode/src/server/routes/instance/provider.ts @@ -7,6 +7,7 @@ import { ModelsDev } from "@/provider/models" import { ProviderAuth } from "@/provider/auth" import { ProviderID } from "@/provider/schema" import { mapValues, pickBy } from "remeda" // kilocode_change +import { ModelCache } from "@/provider/model-cache" // kilocode_change import { errors } from "../../error" import { lazy } from "@/util/lazy" import { Effect } from "effect" @@ -50,12 +51,21 @@ export const ProviderRoutes = lazy(() => mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)), connected, ) - // kilocode_change start: Filter out providers with no models to prevent crashes - const validProviders = pickBy(providers, (item) => Object.keys(item.models).length > 0) + // kilocode_change start + const failed = ModelCache.failedProviders() + // Keep connected or failed providers even when they have 0 models so /connect can re-auth them. + // Note: connected only contains providers whose model list is non-empty after Provider.Service.list(), + // so failed must be checked explicitly for providers whose fetch returned an error. + const failedSet = new Set(failed) + const validProviders = pickBy( + providers, + (item, id) => Object.keys(item.models).length > 0 || id in connected || failedSet.has(id), + ) return { all: Object.values(validProviders), - default: Provider.defaultModelIDs(validProviders), + default: Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models).length > 0)), connected: Object.keys(connected), + failed, } // kilocode_change end }), diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index cff5587be28..d0be1dd401d 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -84,9 +84,7 @@ export const layer: Layer.Layer< } // kilocode_change - prefer KILO_CONFIG_DIR profile when set, else fall back to global.config const root = Flag.KILO_CONFIG_DIR ?? global.config - return yield* fs - .globUp(instruction, root, root) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + return yield* fs.globUp(instruction, root, root).pipe(Effect.catch(() => Effect.succeed([] as string[]))) // kilocode_change }) const read = Effect.fnUntraced(function* (filepath: string) { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 4ec8b05140f..6c64c82cec9 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -307,7 +307,7 @@ export const layer: Layer.Layer< // kilocode_change start !!process.env["KILO_E2E_LLM_URL"] || (input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")) - // kilocode_change end + // kilocode_change end if (tool.id === ApplyPatchTool.id) return usePatch if (tool.id === EditTool.id) return !usePatch // kilocode_change diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index a05275d5455..1255b372990 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -22,23 +22,26 @@ const writeConfig = (dir: string, agent: Config.Info["agent"]) => ), ) -it.live("agent color parsed from project config", () => +// kilocode_change start +it.live("agent color parsed from project config", () => Effect.gen(function* () { const dir = yield* tmpdirScoped() yield* writeConfig(dir, { - code: { color: "#FFA500" }, // kilocode_change + code: { color: "#FFA500" }, plan: { color: "primary" }, }) - yield* Effect.gen(function* () { + yield* Effect.gen(function* () { const cfg = yield* Effect.promise(() => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))) - expect(cfg.agent?.["code"]?.color).toBe("#FFA500") // kilocode_change + expect(cfg.agent?.["code"]?.color).toBe("#FFA500") expect(cfg.agent?.["plan"]?.color).toBe("primary") }).pipe(provideInstance(dir)) }), ) +// kilocode_change end -it.live("Agent.get includes color from config", () => +// kilocode_change start +it.live("Agent.get includes color from config", () => Effect.gen(function* () { const dir = yield* tmpdirScoped() yield* writeConfig(dir, { @@ -54,6 +57,7 @@ it.live("Agent.get includes color from config", () => }).pipe(provideInstance(dir)) }), ) +// kilocode_change end test("Color.hexToAnsiBold converts valid hex to ANSI", () => { const result = Color.hexToAnsiBold("#FFA500") diff --git a/packages/opencode/test/kilocode/indexing-auth.test.ts b/packages/opencode/test/kilocode/indexing-auth.test.ts index 9a9a47aedba..a9ad886087b 100644 --- a/packages/opencode/test/kilocode/indexing-auth.test.ts +++ b/packages/opencode/test/kilocode/indexing-auth.test.ts @@ -22,10 +22,12 @@ describe("Kilo indexing auth resolution", () => { expect(resolveKiloIndexingAuth({ provider: { options: { kilocodeToken: "provider-token" } } }).apiKey).toBe( "provider-token", ) - expect(resolveKiloIndexingAuth({ auth: { type: "oauth", access: "oauth-token", accountId: "org_oauth" } })).toEqual({ - apiKey: "oauth-token", - organizationId: "org_oauth", - }) + expect(resolveKiloIndexingAuth({ auth: { type: "oauth", access: "oauth-token", accountId: "org_oauth" } })).toEqual( + { + apiKey: "oauth-token", + organizationId: "org_oauth", + }, + ) expect(resolveKiloIndexingAuth({ env: { KILO_API_KEY: "env-token", KILO_ORG_ID: "org_env" } })).toEqual({ apiKey: "env-token", organizationId: "org_env", diff --git a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts index 628c790f9c8..3d3ea375269 100644 --- a/packages/opencode/test/kilocode/kilo-loader-auth.test.ts +++ b/packages/opencode/test/kilocode/kilo-loader-auth.test.ts @@ -17,17 +17,19 @@ const real = await import("@kilocode/kilo-gateway") mock.module("@kilocode/kilo-gateway", () => ({ ...real, fetchKiloModels: async () => ({ - "free-model": { - id: "free-model", - name: "Free Model", - cost: { input: 0, output: 0 }, - limit: { context: 128000, output: 4096 }, - }, - "paid-model": { - id: "paid-model", - name: "Paid Model", - cost: { input: 1.0, output: 2.0 }, - limit: { context: 128000, output: 4096 }, + models: { + "free-model": { + id: "free-model", + name: "Free Model", + cost: { input: 0, output: 0 }, + limit: { context: 128000, output: 4096 }, + }, + "paid-model": { + id: "paid-model", + name: "Paid Model", + cost: { input: 1.0, output: 2.0 }, + limit: { context: 128000, output: 4096 }, + }, }, }), })) diff --git a/packages/opencode/test/kilocode/kilo-models-401-fallback.test.ts b/packages/opencode/test/kilocode/kilo-models-401-fallback.test.ts new file mode 100644 index 00000000000..2f048c302dc --- /dev/null +++ b/packages/opencode/test/kilocode/kilo-models-401-fallback.test.ts @@ -0,0 +1,53 @@ +// kilocode_change - new file +// Integration: when fetchKiloModels returns a 401 error result, ModelCache +// surfaces the failure and caches empty models (allowing re-auth via /connect). +// The real 401-fallback unit test lives in packages/kilo-gateway/test/api/models.test.ts. + +import { test, expect, mock } from "bun:test" +import path from "path" +import * as Log from "@opencode-ai/core/util/log" + +Log.init({ print: false }) + +// Simulate a 401 typed error result from the gateway +mock.module("@kilocode/kilo-gateway", () => ({ + fetchKiloModels: async () => ({ + models: {}, + error: { kind: "unauthorized", status: 401 }, + }), + KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter", +})) + +mock.module("opencode-copilot-auth", () => ({ default: () => ({}) })) +mock.module("opencode-anthropic-auth", () => ({ default: () => ({}) })) +mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: () => ({}) })) + +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { ModelCache } from "../../src/provider/model-cache" + +const CONFIG = JSON.stringify({ $schema: "https://app.kilo.ai/config.json" }) + +async function withInstance(fn: () => Promise): Promise { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "kilo.json"), CONFIG) + }, + }) + return Instance.provide({ directory: tmp.path, fn }) +} + +test("401 from gateway sets provider as failed in ModelCache", async () => { + ModelCache.clear("kilo") + await withInstance(() => ModelCache.fetch("kilo")) + expect(ModelCache.failedProviders()).toContain("kilo") + expect(ModelCache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 }) +}) + +test("401 from gateway caches empty models (not undefined)", async () => { + ModelCache.clear("kilo") + await withInstance(() => ModelCache.fetch("kilo")) + const cached = ModelCache.get("kilo") + expect(cached).toBeDefined() + expect(Object.keys(cached!)).toHaveLength(0) +}) diff --git a/packages/opencode/test/kilocode/model-cache-org.test.ts b/packages/opencode/test/kilocode/model-cache-org.test.ts index 688558daa38..219c268210a 100644 --- a/packages/opencode/test/kilocode/model-cache-org.test.ts +++ b/packages/opencode/test/kilocode/model-cache-org.test.ts @@ -16,11 +16,13 @@ mock.module("@kilocode/kilo-gateway", () => ({ fetchKiloModels: async (options: any) => { captured = options return { - "test-model": { - id: "test-model", - name: "Test Model", - cost: { input: 0.001, output: 0.002 }, - limit: { context: 128000, output: 4096 }, + models: { + "test-model": { + id: "test-model", + name: "Test Model", + cost: { input: 0.001, output: 0.002 }, + limit: { context: 128000, output: 4096 }, + }, }, } }, diff --git a/packages/opencode/test/kilocode/provider-list-failed-state.test.ts b/packages/opencode/test/kilocode/provider-list-failed-state.test.ts new file mode 100644 index 00000000000..13c53b246e5 --- /dev/null +++ b/packages/opencode/test/kilocode/provider-list-failed-state.test.ts @@ -0,0 +1,91 @@ +// kilocode_change - new file +// Verifies that: +// 1. ModelCache.failedProviders() surfaces providers that encountered errors. +// 2. ModelCache.getFailure() returns the typed error for a failed provider. +// 3. Clear removes failure state. + +import { test, expect, mock } from "bun:test" +import path from "path" +import * as Log from "@opencode-ai/core/util/log" + +Log.init({ print: false }) + +// Stub fetchKiloModels to return controlled typed results. +let stubbedResult: { models: Record; error?: { kind: string; status?: number } } = { models: {} } + +mock.module("@kilocode/kilo-gateway", () => ({ + fetchKiloModels: async () => stubbedResult, + KILO_OPENROUTER_BASE: "https://api.kilo.ai/api/openrouter", +})) + +mock.module("opencode-copilot-auth", () => ({ default: () => ({}) })) +mock.module("opencode-anthropic-auth", () => ({ default: () => ({}) })) +mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: () => ({}) })) + +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { ModelCache } from "../../src/provider/model-cache" + +const CONFIG = JSON.stringify({ $schema: "https://app.kilo.ai/config.json" }) + +async function withInstance(fn: () => Promise): Promise { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "kilo.json"), CONFIG) + }, + }) + return Instance.provide({ directory: tmp.path, fn }) +} + +test("failedProviders returns empty array when no fetch has occurred", () => { + ModelCache.clear("kilo") + expect(ModelCache.failedProviders()).not.toContain("kilo") +}) + +test("getFailure returns undefined when fetch succeeds", async () => { + stubbedResult = { + models: { + "test/model": { id: "test/model", name: "Test", cost: { input: 1, output: 2 }, limit: { context: 128000, output: 4096 } }, + }, + } + ModelCache.clear("kilo") + await withInstance(() => ModelCache.fetch("kilo")) + expect(ModelCache.getFailure("kilo")).toBeUndefined() + expect(ModelCache.failedProviders()).not.toContain("kilo") +}) + +test("failedProviders includes provider after auth error", async () => { + stubbedResult = { models: {}, error: { kind: "unauthorized", status: 401 } } + ModelCache.clear("kilo") + await withInstance(() => ModelCache.fetch("kilo")) + expect(ModelCache.failedProviders()).toContain("kilo") + expect(ModelCache.getFailure("kilo")).toMatchObject({ kind: "unauthorized", status: 401 }) +}) + +test("clear removes failure state", async () => { + stubbedResult = { models: {}, error: { kind: "network" } } + ModelCache.clear("kilo") + await withInstance(() => ModelCache.fetch("kilo")) + expect(ModelCache.failedProviders()).toContain("kilo") + + ModelCache.clear("kilo") + expect(ModelCache.failedProviders()).not.toContain("kilo") + expect(ModelCache.getFailure("kilo")).toBeUndefined() +}) + +test("failure state is cleared when subsequent fetch succeeds", async () => { + stubbedResult = { models: {}, error: { kind: "unauthorized", status: 401 } } + ModelCache.clear("kilo") + await withInstance(() => ModelCache.fetch("kilo")) + expect(ModelCache.failedProviders()).toContain("kilo") + + stubbedResult = { + models: { + "test/model": { id: "test/model", name: "Test", cost: { input: 1, output: 2 }, limit: { context: 128000, output: 4096 } }, + }, + } + ModelCache.clear("kilo") + await withInstance(() => ModelCache.fetch("kilo")) + expect(ModelCache.failedProviders()).not.toContain("kilo") + expect(ModelCache.getFailure("kilo")).toBeUndefined() +}) diff --git a/packages/opencode/test/kilocode/session-list.test.ts b/packages/opencode/test/kilocode/session-list.test.ts index 49a7ef979fe..154992f7bb3 100644 --- a/packages/opencode/test/kilocode/session-list.test.ts +++ b/packages/opencode/test/kilocode/session-list.test.ts @@ -37,9 +37,7 @@ describe("Kilo Session.list", () => { db.update(SessionTable).set({ project_id: project }).where(eq(SessionTable.id, session.id)).run() }) - const sessions = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.list({ directory: tmp.path })), - ) + const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list({ directory: tmp.path }))) const ids = sessions.map((item) => item.id) expect(ids).toContain(session.id) diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 7a3d8d04838..dcb5f999efc 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -203,7 +203,8 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () = await Instance.provide({ directory: tmp.path, - fn: async () => { // kilocode_change + fn: async () => { + // kilocode_change openShouldFail = false // kilocode_change const events: Array<{ mcpName: string; url: string }> = [] @@ -259,7 +260,8 @@ test("open() is called with the authorization URL", async () => { await Instance.provide({ directory: tmp.path, - fn: async () => { // kilocode_change + fn: async () => { + // kilocode_change openShouldFail = false // kilocode_change openCalledWith = undefined diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8eefe5bfe98..18cbd3e94a2 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -3029,6 +3029,7 @@ export type ProviderListResponses = { [key: string]: string } connected: Array + failed: Array } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8f5d162e056..59f9411de92 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5128,6 +5128,7 @@ export type ProviderListResponses = { [key: string]: string } connected: Array + failed: Array } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index f7e50855d5c..02225c95efe 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -415,9 +415,9 @@ ] } }, - "/experimental/workspace/adaptor": { + "/experimental/workspace/adapter": { "get": { - "operationId": "experimental.workspace.adaptor.list", + "operationId": "experimental.workspace.adapter.list", "parameters": [ { "in": "query", @@ -434,11 +434,11 @@ } } ], - "summary": "List workspace adaptors", - "description": "List all available workspace adaptors for the current project.", + "summary": "List workspace adapters", + "description": "List all available workspace adapters for the current project.", "responses": { "200": { - "description": "Workspace adaptors", + "description": "Workspace adapters", "content": { "application/json": { "schema": { @@ -466,7 +466,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})" + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.adapter.list({\n ...\n})" } ] } @@ -5549,9 +5549,15 @@ "items": { "type": "string" } + }, + "failed": { + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["all", "default", "connected"] + "required": ["all", "default", "connected", "failed"] } } } @@ -8332,6 +8338,47 @@ ] } }, + "/indexing/status": { + "get": { + "operationId": "indexing.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Get indexing status", + "description": "Retrieve the current code indexing status for the active project.", + "responses": { + "200": { + "description": "Indexing status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IndexingStatus" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.indexing.status({\n ...\n})" + } + ] + } + }, "/suggestion": { "get": { "operationId": "suggestion.list", @@ -8607,6 +8654,72 @@ ] } }, + "/telemetry/setEnabled": { + "post": { + "operationId": "telemetry.setEnabled", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Set PostHog telemetry enabled state", + "description": "Update the PostHog client's opt-in/out state at runtime. The CLI reads KILO_TELEMETRY_LEVEL once at spawn — this route lets clients (e.g. the VS Code extension) propagate runtime telemetry consent changes.", + "responses": { + "200": { + "description": "State updated", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": ["enabled"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.telemetry.setEnabled({\n ...\n})" + } + ] + } + }, "/remote/enable": { "post": { "operationId": "remote.enable", @@ -12469,6 +12582,102 @@ }, "required": ["type", "properties"] }, + "Event.workspace.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.failed" + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.restore": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.restore" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.status" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"] + } + }, + "required": ["type", "properties"] + }, "Event.worktree.ready": { "type": "object", "properties": { @@ -12627,102 +12836,6 @@ }, "required": ["type", "properties"] }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["type", "properties"] - }, "OutputFormatText": { "type": "object", "properties": { @@ -14113,9 +14226,7 @@ "maximum": 9007199254740991 }, "archived": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "type": "number" } }, "required": ["created", "updated"] @@ -14229,13 +14340,19 @@ "type": "string" }, "processedFiles": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "totalFiles": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, "percent": { - "type": "number" + "type": "integer", + "minimum": 0, + "maximum": 100 } }, "required": ["state", "message", "processedFiles", "totalFiles", "percent"] @@ -14670,9 +14787,7 @@ "archived": { "anyOf": [ { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "type": "number" }, { "type": "null" @@ -14904,34 +15019,34 @@ "$ref": "#/components/schemas/Event.kilo-sessions.remote-status-changed" }, { - "$ref": "#/components/schemas/Event.worktree.ready" + "$ref": "#/components/schemas/Event.workspace.ready" }, { - "$ref": "#/components/schemas/Event.worktree.failed" + "$ref": "#/components/schemas/Event.workspace.failed" }, { - "$ref": "#/components/schemas/Event.pty.created" + "$ref": "#/components/schemas/Event.workspace.restore" }, { - "$ref": "#/components/schemas/Event.pty.updated" + "$ref": "#/components/schemas/Event.workspace.status" }, { - "$ref": "#/components/schemas/Event.pty.exited" + "$ref": "#/components/schemas/Event.worktree.ready" }, { - "$ref": "#/components/schemas/Event.pty.deleted" + "$ref": "#/components/schemas/Event.worktree.failed" }, { - "$ref": "#/components/schemas/Event.workspace.ready" + "$ref": "#/components/schemas/Event.pty.created" }, { - "$ref": "#/components/schemas/Event.workspace.failed" + "$ref": "#/components/schemas/Event.pty.updated" }, { - "$ref": "#/components/schemas/Event.workspace.restore" + "$ref": "#/components/schemas/Event.pty.exited" }, { - "$ref": "#/components/schemas/Event.workspace.status" + "$ref": "#/components/schemas/Event.pty.deleted" }, { "$ref": "#/components/schemas/Event.message.updated" @@ -17056,9 +17171,7 @@ "maximum": 9007199254740991 }, "archived": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "type": "number" } }, "required": ["created", "updated"] @@ -17669,34 +17782,34 @@ "$ref": "#/components/schemas/Event.kilo-sessions.remote-status-changed" }, { - "$ref": "#/components/schemas/Event.worktree.ready" + "$ref": "#/components/schemas/Event.workspace.ready" }, { - "$ref": "#/components/schemas/Event.worktree.failed" + "$ref": "#/components/schemas/Event.workspace.failed" }, { - "$ref": "#/components/schemas/Event.pty.created" + "$ref": "#/components/schemas/Event.workspace.restore" }, { - "$ref": "#/components/schemas/Event.pty.updated" + "$ref": "#/components/schemas/Event.workspace.status" }, { - "$ref": "#/components/schemas/Event.pty.exited" + "$ref": "#/components/schemas/Event.worktree.ready" }, { - "$ref": "#/components/schemas/Event.pty.deleted" + "$ref": "#/components/schemas/Event.worktree.failed" }, { - "$ref": "#/components/schemas/Event.workspace.ready" + "$ref": "#/components/schemas/Event.pty.created" }, { - "$ref": "#/components/schemas/Event.workspace.failed" + "$ref": "#/components/schemas/Event.pty.updated" }, { - "$ref": "#/components/schemas/Event.workspace.restore" + "$ref": "#/components/schemas/Event.pty.exited" }, { - "$ref": "#/components/schemas/Event.workspace.status" + "$ref": "#/components/schemas/Event.pty.deleted" }, { "$ref": "#/components/schemas/Event.message.updated" diff --git a/packages/ui/src/assets/icons/provider/frogbot.svg b/packages/ui/src/assets/icons/provider/frogbot.svg new file mode 100644 index 00000000000..4c7cab9038b --- /dev/null +++ b/packages/ui/src/assets/icons/provider/frogbot.svg @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/packages/ui/src/assets/icons/provider/kiro.svg b/packages/ui/src/assets/icons/provider/kiro.svg new file mode 100644 index 00000000000..086e9aa1fca --- /dev/null +++ b/packages/ui/src/assets/icons/provider/kiro.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/neuralwatt.svg b/packages/ui/src/assets/icons/provider/neuralwatt.svg new file mode 100644 index 00000000000..720199389db --- /dev/null +++ b/packages/ui/src/assets/icons/provider/neuralwatt.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/src/components/provider-icons/sprite.svg b/packages/ui/src/components/provider-icons/sprite.svg index d50d73a0c53..e72d2525207 100644 --- a/packages/ui/src/components/provider-icons/sprite.svg +++ b/packages/ui/src/components/provider-icons/sprite.svg @@ -45,6 +45,24 @@ fill="currentColor" > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -563,6 +650,20 @@ fill="currentColor" > + + + + + + + + + + + + + + + + + + + + + + + Abliteration + .ai + +