From 4ee771b8812a2665130f86af33e25d827031c504 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 14 Oct 2026 19:34:14 +0200 Subject: [PATCH 1/9] feat(gateway): replace Kilo catalog from Gateway and enforce team scope --- packages/core/src/kilocode/gateway-rpc.ts | 36 +- packages/core/src/kilocode/gateway.ts | 440 ++++++++++++++++-- packages/core/test/kilocode/gateway.test.ts | 270 ++++++++--- packages/kilo-gateway-core/src/defaults.ts | 49 ++ packages/kilo-gateway-core/src/index.ts | 2 + packages/kilo-gateway-core/src/models.ts | 374 +++++++++++++++ packages/kilo-gateway-core/src/testing.ts | 114 ++++- .../kilo-gateway-core/test/models.test.ts | 173 +++++++ 8 files changed, 1333 insertions(+), 125 deletions(-) create mode 100644 packages/kilo-gateway-core/src/defaults.ts create mode 100644 packages/kilo-gateway-core/src/models.ts create mode 100644 packages/kilo-gateway-core/test/models.test.ts diff --git a/packages/core/src/kilocode/gateway-rpc.ts b/packages/core/src/kilocode/gateway-rpc.ts index 8f5b4bee1ecd..f89c10b2b2d6 100644 --- a/packages/core/src/kilocode/gateway-rpc.ts +++ b/packages/core/src/kilocode/gateway-rpc.ts @@ -20,8 +20,35 @@ export const Profile = Schema.Struct({ export type Profile = typeof Profile.Type -// The TUI uses this contract for the account picker after login and for /teams, as v1 uses -// `kilo.profile` and `kilo.organization.set`. +export const ModelEntry = Schema.Struct({ + id: Schema.String, + name: Schema.String, + isFree: Schema.Boolean, + recommendedIndex: Schema.optional(Schema.Number), + hasUserByokAvailable: Schema.optional(Schema.Boolean), + mayTrainOnYourPrompts: Schema.optional(Schema.Boolean), + autoRouting: Schema.optional( + Schema.Struct({ + models: Schema.Array(Schema.String), + }), + ), + terminalBench: Schema.optional( + Schema.Struct({ + overallScore: Schema.Number, + avgAttemptCostUsd: Schema.Number, + }), + ), + description: Schema.optional(Schema.String), + reasoning: Schema.optional(Schema.Boolean), + family: Schema.optional(Schema.String), + prompt: Schema.optional(Schema.String), + aiSDKProvider: Schema.optional(Schema.String), +}) + +export type ModelEntry = typeof ModelEntry.Type + +// The TUI and IDE clients use this contract for the account picker after login, /teams, +// and Kilo-specific model catalog metadata. export const Definition = Rpc.define({ id: "kilocode.gateway", methods: { @@ -35,6 +62,11 @@ export const Definition = Rpc.define({ output: portable(Schema.Struct({})), errors: Failure, }, + models: { + input: portable(Schema.Struct({})), + output: portable(Schema.Array(ModelEntry)), + errors: Failure, + }, }, events: { organization: { schema: portable(Schema.Struct({ organizationID: OrganizationID })) }, diff --git a/packages/core/src/kilocode/gateway.ts b/packages/core/src/kilocode/gateway.ts index ef36e0f1231f..47f2e5220dec 100644 --- a/packages/core/src/kilocode/gateway.ts +++ b/packages/core/src/kilocode/gateway.ts @@ -1,17 +1,31 @@ -import { KiloAccount, KiloDevice, KiloHeaders, KiloProfile, KiloServer } from "@kilocode/gateway-core" +import { + KiloAccount, + KiloDefaults, + KiloDevice, + KiloHeaders, + KiloModels, + KiloProfile, + KiloServer, +} from "@kilocode/gateway-core" import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration" import { define } from "@opencode/plugin/effect/plugin" import { Credential } from "@opencode/schema/credential" -import { IntegrationMethodID } from "@opencode/schema/integration-id" -import { Clock, Effect, Option, Schema, Scope, Stream } from "effect" +import { IntegrationID, IntegrationMethodID } from "@opencode/schema/integration-id" +import { Model } from "@opencode/schema/model" +import { Money } from "@opencode/schema/money" +import { Provider } from "@opencode/schema/provider" +import { createHash, randomUUID } from "node:crypto" +import { Clock, Duration, Effect, Fiber, Option, Schedule, Schema, Scope, Semaphore, Stream } from "effect" import { Bus } from "../bus.js" +import { Variant } from "../variant.js" import { KiloGatewayRpc } from "./gateway-rpc.js" -// v2 adapter for the Kilo Gateway (issue #14991, PR 1: login and teams). -// Kilo logic lives in @kilocode/gateway-core; this file only maps it onto the public plugin API. -// The `kilo` integration and provider come from the models.dev seed. -const id = "kilo" +// v2 adapter for the Kilo Gateway (issue #14991, PR 1 & PR 2: login, teams, and catalog authority). +// Kilo logic lives in @kilocode/gateway-core; this file maps it onto the v2 plugin API. +const id = Provider.ID.make("kilo") +const integrationID = IntegrationID.make("kilo") const methodID = IntegrationMethodID.make("device") +const REFRESH_INTERVAL = Duration.minutes(5) // Written by the device method. Decoded again because stored credentials are read back from the database. const Metadata = Schema.Struct({ @@ -21,75 +35,309 @@ const Metadata = Schema.Struct({ const decodeMetadata = Schema.decodeUnknownOption(Metadata) // The team choice in plugin storage. `null` is the personal account. const decodeSelection = Schema.decodeUnknownOption(Schema.Struct({ organizationID: Schema.NullOr(Schema.String) })) +const decodeStoredModels = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) // Published on the global bus (`{ global: true }`), so plugin instances of other locations also reload. const OrganizationEvent = Bus.ephemeral({ type: `rpc.${KiloGatewayRpc.Definition.id}.organization`, - schema: { organizationID: Schema.NullOr(Schema.String) }, + schema: { + organizationID: Schema.NullOr(Schema.String), + source: Schema.optional(Schema.String), + }, }) +interface CatalogState { + readonly account?: KiloAccount.Info + readonly envActive: boolean + readonly models: readonly KiloModels.Model[] + readonly defaultModelID?: string + readonly failed: boolean + readonly anonymous: boolean +} + export const KiloGatewayPlugin = define({ id: "kilocode.gateway", effect: Effect.fn(function* (ctx) { const bus = yield* Bus.Service + const scope = yield* Scope.Scope + const instanceID = randomUUID() + const lock = Semaphore.makeUnsafe(1) const server = KiloServer.fromEnv() - let account: KiloAccount.Info | undefined - let envActive = false + let epoch = 0 + let catalog: CatalogState = { envActive: false, models: [], failed: false, anonymous: true } + const lastGood = new Map() + let retryFiber: Fiber.Fiber | undefined - const load = Effect.gen(function* () { + const cancelRetry = Effect.gen(function* () { + if (!retryFiber) return + const fiber = retryFiber + retryFiber = undefined + yield* Fiber.interrupt(fiber) + }) + + const resolveCurrentAccount = Effect.gen(function* () { // Environment tokens (`KILO_API_KEY`, `KILOCODE_TOKEN`, `KILO_AUTH_CONTENT`) take precedence over stored // credentials, matching v1 (`acd3d1ff9b`) and supporting headless/cloud consumers. const envInput = KiloAccount.envToken() if (envInput) { const selection = decodeSelection(yield* ctx.storage.get("organization:env")) - envActive = true - account = KiloAccount.resolve({ - ...envInput, - server, - organizationID: Option.isSome(selection) ? selection.value.organizationID : envInput.organizationID, - }) - return + return { + envActive: true, + account: KiloAccount.resolve({ + ...envInput, + server, + organizationID: Option.isSome(selection) ? selection.value.organizationID : envInput.organizationID, + }), + } } - envActive = false const connection = yield* ctx.integration.connection.active(id) const credential = connection ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined)) : undefined const selection = connection ? decodeSelection(yield* ctx.storage.get(organizationKey(connection))) : Option.none() - account = credential ? toAccount(credential, Option.getOrUndefined(selection)) : undefined + return { + envActive: false, + account: credential ? toAccount(credential, Option.getOrUndefined(selection)) : undefined, + } }) - yield* ctx.integration.transform((editor) => editor.method.update(device(server))) - yield* load - yield* ctx.provider.transform((editor) => { - if (!editor.get(id)) return - editor.update(id, (provider) => { - const { apiKey: _, ...rest } = provider.settings ?? {} - const settings = { ...rest, baseURL: KiloServer.gateway(account?.server ?? server) } - provider.headers = { - ...provider.headers, - ...KiloHeaders.provider({ version: ctx.app.version, organizationID: account?.organizationID }), - } - if (account) { - if (envActive) { - provider.activation = "enabled" - provider.settings = { ...settings, apiKey: account.token } - return - } - provider.activation = "auto" - provider.settings = settings + const loadCatalog = Effect.fn("KiloGatewayPlugin.loadCatalog")(function* ( + targetEpoch: number, + attempt = 0, + ): Effect.fn.Return { + const resolved = yield* resolveCurrentAccount + if (targetEpoch !== epoch) return + const current = resolved.account + const envActive = resolved.envActive + const base = current?.server ?? server + const organizationID = current?.organizationID ?? null + const token = current?.token + + // Team scope: verify team membership when a profile endpoint is reachable; fail closed if the team is + // missing or the team catalog request fails. + if (token && organizationID) { + const profile = yield* KiloProfile.get(base, token).pipe(Effect.option) + if (targetEpoch !== epoch) return + if ( + Option.isSome(profile) && + !profile.value.organizations.some((organization) => organization.id === organizationID) + ) { + catalog = { account: current, envActive, models: [], failed: true, anonymous: false } return } - // Signed out, the integration has no connection and the provider would stay hidden. The Gateway - // serves its free models to the anonymous key, as in v1. - provider.activation = "enabled" - provider.settings = { ...settings, apiKey: KiloAccount.ANONYMOUS_KEY } + } + + const [fetched, defaultModelID] = yield* Effect.all( + [ + KiloModels.get({ + server: base, + token, + organizationID, + version: ctx.app.version, + }).pipe(Effect.result), + KiloDefaults.get({ + server: base, + token, + organizationID, + }), + ], + { concurrency: 2 }, + ) + if (targetEpoch !== epoch) return + + if (fetched._tag === "Success") { + const models = fetched.success.models + // Key the snapshot by the actual scope of the returned data: a 401 personal fallback returns the + // anonymous free catalog (`anonymous === true`), so never store it under the personal token key. + const writeKey = organizationID + ? undefined + : snapshotKey(base, fetched.success.anonymous ? undefined : token) + if (writeKey && models.length > 0) { + lastGood.set(writeKey, models) + yield* ctx.storage.set(writeKey, JSON.stringify(models)).pipe(Effect.catch(() => Effect.void)) + } + catalog = { + account: current, + envActive, + models, + defaultModelID, + failed: false, + anonymous: fetched.success.anonymous, + } + return + } + + // Team scope always fails closed on error: never fall back to personal, anonymous, or models.dev models. + if (organizationID) { + catalog = { account: current, envActive, models: [], failed: true, anonymous: false } + yield* scheduleRetry(fetched.failure, targetEpoch, attempt) + return + } + + // Personal or anonymous scope: keep the last good Gateway catalog for this exact scope, if any. + // Never fall back to models.dev. + const cacheKey = snapshotKey(base, token) + const memory = lastGood.get(cacheKey) + const stored = + memory ?? + (yield* ctx.storage.get(cacheKey).pipe( + Effect.map((value) => { + const decoded = typeof value === "string" ? Option.getOrUndefined(decodeStoredModels(value)) : undefined + return Array.isArray(decoded) ? (decoded as readonly KiloModels.Model[]) : undefined + }), + Effect.orElseSucceed(() => undefined), + )) + if (targetEpoch !== epoch) return + if (stored && stored.length > 0) { + lastGood.set(cacheKey, stored) + catalog = { + account: current, + envActive, + models: stored, + defaultModelID, + failed: false, + anonymous: !token, + } + } else { + catalog = { account: current, envActive, models: [], failed: true, anonymous: !token } + } + yield* scheduleRetry(fetched.failure, targetEpoch, attempt) + }) + + function scheduleRetry(failure: KiloModels.Failure, targetEpoch: number, attempt: number) { + if (!KiloModels.retryable(failure) || attempt >= 5 || targetEpoch !== epoch) return Effect.void + const delayMs = KiloModels.retryDelayMs(attempt, failure.retryAfter) + return cancelRetry.pipe( + Effect.andThen( + Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.andThen( + lock.withPermit( + Effect.gen(function* () { + if (targetEpoch !== epoch) return + yield* loadCatalog(targetEpoch, attempt + 1) + if (targetEpoch === epoch) yield* ctx.provider.reload() + }), + ), + ), + Effect.catch(() => Effect.void), + Effect.forkIn(scope), + Effect.tap((fiber) => + Effect.sync(() => { + retryFiber = fiber + }), + ), + Effect.asVoid, + ), + ), + ) + } + + const reload = Effect.suspend(() => { + const targetEpoch = ++epoch + return cancelRetry.pipe( + Effect.andThen( + lock.withPermit( + loadCatalog(targetEpoch, 0).pipe( + Effect.andThen(Effect.suspend(() => (targetEpoch === epoch ? ctx.provider.reload() : Effect.void))), + ), + ), + ), + ) + }) + + yield* ctx.integration.transform((editor) => { + editor.update(id, (integration) => { + integration.name = "Kilo Gateway" + }) + editor.method.update(device(server)) + }) + + yield* lock.withPermit(loadCatalog(++epoch, 0)) + + yield* ctx.provider.transform((editor) => { + const account = catalog.account + const base = account?.server ?? server + const baseURL = KiloServer.gateway(base) + const headers = KiloHeaders.provider({ version: ctx.app.version, organizationID: account?.organizationID }) + const current = editor.get(id) + const { apiKey: _, ...restSettings } = current?.provider.settings ?? {} + const baseSettings = { ...restSettings, baseURL } + const mergedHeaders = { + ...current?.provider.headers, + ...headers, + } + if (!account?.organizationID) { + delete mergedHeaders[KiloHeaders.HEADER_ORGANIZATION] + } + + // Signed out of Kilo: suppress uncredentialed OpenCode Zen (`apiKey === "public"`) so Kilo's free tier + // is the default signed-out provider. + if (!account || catalog.anonymous) { + const opencode = editor.get(Provider.ID.make("opencode")) + if (opencode && opencode.provider.settings?.apiKey === "public") { + editor.update(Provider.ID.make("opencode"), (provider) => { + provider.activation = "disabled" + }) + } + } + + if (catalog.failed || catalog.models.length === 0) { + // Fail closed: replace the model list with `[]` and set a sentinel `sourceConnection` so `Provider.snapshot` + // excludes `kilo` from `available` even if a `providers.kilo` block in user config sets `activation: "enabled"`. + editor.add({ + info: { + id, + name: current?.provider.name ?? "Kilo Gateway", + integrationID, + activation: "disabled", + package: "@opencode/ai/providers/openrouter", + settings: baseSettings, + headers: mergedHeaders, + }, + models: [], + sourceConnection: { type: "env", name: "__kilo_catalog_unavailable__" }, + }) + return + } + + const activation = !account || catalog.anonymous || catalog.envActive ? "enabled" : "auto" + const settings = + !account || catalog.anonymous + ? { ...baseSettings, apiKey: KiloAccount.ANONYMOUS_KEY } + : catalog.envActive + ? { ...baseSettings, apiKey: account.token } + : baseSettings + + editor.add({ + info: { + id, + name: "Kilo Gateway", + integrationID, + activation, + package: "@opencode/ai/providers/openrouter", + settings, + headers: mergedHeaders, + }, + models: catalog.models.map((item) => toModelInfo(item, baseURL, mergedHeaders)), }) }) - const reload = load.pipe(Effect.andThen(ctx.provider.reload())) + + yield* ctx.model.transform((models) => { + if (catalog.failed) { + for (const model of models.list(id)) { + models.remove(id, model.id) + } + return + } + if (catalog.defaultModelID && catalog.models.some((model) => model.id === catalog.defaultModelID)) { + models.default.set(id, Model.ID.make(catalog.defaultModelID)) + } + }) + yield* ctx.rpc .register(KiloGatewayRpc.Definition, { profile: (_input, context) => Effect.gen(function* () { + const account = catalog.account if (!account) return yield* Effect.fail(context.error("unauthorized", "Not signed in to Kilo Gateway")) const profile = yield* KiloProfile.get(account.server, account.token).pipe( Effect.mapError((error) => context.error("failed", error.message)), @@ -101,34 +349,126 @@ export const KiloGatewayPlugin = define({ organizationID: account.organizationID, } }), - // Same as v1 `setOrganization`, which stores the team in the auth record of the login. The choice - // wins over the login default and is kept per connection. "organization.set": (input, context) => Effect.gen(function* () { const envInput = KiloAccount.envToken() const connection = envInput ? undefined : yield* ctx.integration.connection.active(id) const key = envInput ? "organization:env" : connection ? organizationKey(connection) : undefined - if (!key || !account) + if (!key || !catalog.account) return yield* Effect.fail(context.error("unauthorized", "Not signed in to Kilo Gateway")) yield* ctx.storage.set(key, { organizationID: input.organizationID }) yield* reload - yield* bus.publish(OrganizationEvent, { organizationID: input.organizationID }, { global: true }) + yield* bus.publish( + OrganizationEvent, + { organizationID: input.organizationID, source: instanceID }, + { global: true }, + ) return {} }), + models: (_input, context) => + Effect.gen(function* () { + if (catalog.failed) { + return yield* Effect.fail(context.error("failed", "Unable to retrieve Kilo model catalog")) + } + return KiloModels.entries(catalog.models) + }), }) .pipe(Effect.orDie) + yield* ctx.event.subscribe().pipe( Stream.filter( (event) => - (event.type === "credential.switched" && event.data.integrationID === id) || - event.type === OrganizationEvent.type, + event.type === "credential.updated" || + (event.type === "credential.switched" && event.data.integrationID === integrationID) || + (event.type === OrganizationEvent.type && event.data.source !== instanceID), ), Stream.runForEach(() => reload), Effect.forkScoped({ startImmediately: true }), ) + + // Periodic catalog refresh every 5 minutes, matching v1 model-cache TTL. + yield* Effect.repeat( + Effect.sleep(REFRESH_INTERVAL).pipe(Effect.andThen(reload), Effect.catch(() => Effect.void)), + Schedule.forever, + ).pipe(Effect.forkScoped) }), }) +function snapshotKey(server: string, token?: string): string { + if (!token) return `catalog:anonymous:${server}` + const hash = createHash("sha256").update(`${server}:${token}`).digest("hex") + return `catalog:personal:${hash}` +} + +function packageFor(aiSDKProvider?: KiloModels.AISDKProvider): string { + if (aiSDKProvider === "anthropic") return "@opencode/ai/providers/anthropic" + if (aiSDKProvider === "openai") return "@opencode/ai/providers/openai" + if (aiSDKProvider === "openai-compatible") return "@opencode/ai/providers/openai-compatible" + return "@opencode/ai/providers/openrouter" +} + +function toModelInfo( + source: KiloModels.Model, + baseURL: string, + headers: Record, +): Model.Info { + const modelID = Model.ID.make(source.id) + const pkg = packageFor(source.aiSDKProvider) + const settings = { + baseURL, + ...(pkg === "@opencode/ai/providers/openai-compatible" ? { provider: id } : {}), + } + const base: Model.Info = { + id: modelID, + modelID, + providerID: id, + name: source.name, + ...(source.family ? { family: Model.Family.make(source.family) } : {}), + package: pkg, + settings, + headers: { ...headers }, + capabilities: { + tools: true, + input: [...source.capabilities.input], + output: [...source.capabilities.output], + }, + variants: [], + time: { released: 0 }, + cost: source.cost + ? [ + { + input: Money.USDPerMillionTokens.make(source.cost.input), + output: Money.USDPerMillionTokens.make(source.cost.output), + cache: { + read: Money.USDPerMillionTokens.make(source.cost.cacheRead ?? 0), + write: Money.USDPerMillionTokens.make(source.cost.cacheWrite ?? 0), + }, + }, + ] + : [], + status: "active", + enabled: true, + limit: { + context: source.limit.context, + output: source.limit.output, + }, + } + const resolvedVariants = source.reasoning ? Variant.resolve(base) : [] + const byID = new Map(resolvedVariants.map((variant) => [variant.id, variant])) + for (const variant of source.variants ?? []) { + const variantID = Model.VariantID.make(variant.id) + byID.set(variantID, { + id: variantID, + settings: { ...settings, ...variant.settings }, + headers: { ...headers }, + }) + } + return { + ...base, + variants: [...byID.values()], + } +} + function device(server: string) { return { integrationID: id, diff --git a/packages/core/test/kilocode/gateway.test.ts b/packages/core/test/kilocode/gateway.test.ts index 43b48411583b..8e11c68485dc 100644 --- a/packages/core/test/kilocode/gateway.test.ts +++ b/packages/core/test/kilocode/gateway.test.ts @@ -1,10 +1,12 @@ import { describe, expect } from "bun:test" import { fixture } from "@kilocode/gateway-core/testing" import { Effect } from "effect" -import { Credential } from "@opencode/core/credential" import { Bus } from "@opencode/core/bus" +import { Credential } from "@opencode/core/credential" import { Integration } from "@opencode/core/integration" import { KiloGatewayPlugin } from "@opencode/core/kilocode/gateway" +import { KiloGatewayRpc } from "@opencode/core/kilocode/gateway-rpc" +import { Model } from "@opencode/core/model" import { Plugin } from "@opencode/core/plugin" import { PluginHost } from "@opencode/core/plugin/host" import { ProviderPlugins } from "@opencode/core/plugin/provider" @@ -37,7 +39,7 @@ const headers = (team?: string) => ({ ...(team ? { "X-KILOCODE-ORGANIZATIONID": team } : {}), }) -// Adds the provider in the shape of the models.dev seed, then starts the plugin. +// Adds the provider with a static models.dev-style seed model (`models-dev/only`), then starts the plugin. const addPlugin = Effect.fn(function* () { const providers = yield* Provider.Service yield* providers.transform((editor) => { @@ -46,6 +48,7 @@ const addPlugin = Effect.fn(function* () { provider.package = "@opencode/ai/providers/openai-compatible" provider.settings = { baseURL: "https://api.kilo.ai/api/gateway" } }) + editor.models.set(kilo, [Model.Info.default(kilo, Model.ID.make("models-dev/only"))]) }) const plugin = yield* Plugin.Service const host = yield* PluginHost.make(plugin) @@ -127,70 +130,192 @@ describe("KiloGatewayPlugin", () => { ), ) - it.live("serves free models to the anonymous key when signed out", () => - withEnv(env, () => - Effect.gen(function* () { - yield* addPlugin() - const providers = yield* Provider.Service - const provider = yield* providers.get(kilo) - expect(provider?.activation).toBe("enabled") - expect(provider?.settings).toMatchObject({ baseURL: "https://api.kilo.ai/api/gateway", apiKey: "anonymous" }) - // Kilo headers only: no OpenCode referer or title. - expect(provider?.headers).toEqual(headers()) - expect((yield* providers.available()).map((item) => item.id)).toContain(kilo) - }), + it.live("serves only Gateway free models to the anonymous key when signed out and wipes models.dev", () => + Effect.acquireUseRelease( + Effect.sync(() => fixture()), + (backend) => + withEnv({ ...env, KILO_API_URL: backend.url }, () => + Effect.gen(function* () { + yield* addPlugin() + const providers = yield* Provider.Service + const models = yield* Model.Service + const provider = yield* providers.get(kilo) + expect(provider?.activation).toBe("enabled") + expect(provider?.settings).toMatchObject({ baseURL: `${backend.url}/api/gateway`, apiKey: "anonymous" }) + // Kilo headers only: no OpenCode referer or title. + expect(provider?.headers).toEqual(headers()) + expect((yield* providers.available()).map((item) => item.id)).toContain(kilo) + + // Only Gateway isFree models are exposed; models.dev seed (`models-dev/only`) and paid models are absent. + const kiloModels = (yield* models.available()).filter((item) => item.providerID === kilo) + expect(kiloModels.map((item) => item.id)).toEqual([ + Model.ID.make("kilo-auto/free"), + Model.ID.make("kilo-auto/small"), + ]) + expect(yield* models.default()).toMatchObject({ + providerID: kilo, + id: Model.ID.make("kilo-auto/free"), + }) + expect(yield* models.small(kilo)).toMatchObject({ + providerID: kilo, + id: Model.ID.make("kilo-auto/small"), + }) + }), + ), + (backend) => Effect.sync(() => backend[Symbol.dispose]()), ), ) - it.live("follows the active credential: team header, login server, switch and logout", () => - withEnv(env, () => - Effect.gen(function* () { - const credentials = yield* Credential.Service - const team = yield* credentials.create({ - integrationID, - label: "team", - value: oauth({ server: "https://staging.kilo.test/", organizationID: "team", email: "a@example.test" }), - }) - yield* addPlugin() - const providers = yield* Provider.Service - const signedIn = yield* providers.get(kilo) - expect(signedIn?.headers).toEqual(headers("team")) - expect(signedIn?.settings?.baseURL).toBe("https://staging.kilo.test/api/gateway") - expect(signedIn?.settings).not.toHaveProperty("apiKey") - expect(signedIn?.activation).toBe("auto") + it.live("follows the active credential: team catalog, personal catalog, switch and logout", () => + Effect.acquireUseRelease( + Effect.sync(() => fixture()), + (backend) => + withEnv({ ...env, KILO_API_URL: backend.url }, () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const team = yield* credentials.create({ + integrationID, + label: "team", + value: oauth({ server: backend.url, organizationID: "selected", email: "a@example.test" }), + }) + yield* addPlugin() + const providers = yield* Provider.Service + const models = yield* Model.Service + const signedIn = yield* providers.get(kilo) + expect(signedIn?.headers).toEqual(headers("selected")) + expect(signedIn?.settings?.baseURL).toBe(`${backend.url}/api/gateway`) + expect(signedIn?.settings).not.toHaveProperty("apiKey") + expect(signedIn?.activation).toBe("auto") + expect( + (yield* models.available()) + .filter((item) => item.providerID === kilo) + .map((item) => item.id), + ).toEqual([Model.ID.make("anthropic/claude-sonnet-4.6"), Model.ID.make("kilo-auto/small")]) + expect(yield* models.default()).toMatchObject({ + providerID: kilo, + id: Model.ID.make("anthropic/claude-sonnet-4.6"), + }) - const personal = yield* credentials.create({ - integrationID, - label: "personal", - value: oauth({ server: "https://api.kilo.ai", organizationID: null, email: "a@example.test" }), - }) - expect( - (yield* eventually(providers.get(kilo), (provider) => !provider?.headers?.["X-KILOCODE-ORGANIZATIONID"])) - ?.headers, - ).toEqual(headers()) + const personal = yield* credentials.create({ + integrationID, + label: "personal", + value: oauth({ server: backend.url, organizationID: null, email: "a@example.test" }), + }) + expect( + (yield* eventually(providers.get(kilo), (provider) => !provider?.headers?.["X-KILOCODE-ORGANIZATIONID"])) + ?.headers, + ).toEqual(headers()) + expect( + (yield* models.available()) + .filter((item) => item.providerID === kilo) + .map((item) => item.id), + ).toEqual([ + Model.ID.make("kilo-auto/free"), + Model.ID.make("kilo-auto/frontier"), + Model.ID.make("kilo-auto/small"), + ]) + expect(yield* models.default()).toMatchObject({ + providerID: kilo, + id: Model.ID.make("kilo-auto/frontier"), + }) - yield* credentials.remove(personal.id) - yield* credentials.remove(team.id) - const signedOut = yield* eventually(providers.get(kilo), (provider) => provider?.activation === "enabled") - expect(signedOut?.settings?.apiKey).toBe("anonymous") - }), + yield* credentials.remove(personal.id) + yield* credentials.remove(team.id) + const signedOut = yield* eventually(providers.get(kilo), (provider) => provider?.activation === "enabled") + expect(signedOut?.settings?.apiKey).toBe("anonymous") + expect( + (yield* models.available()) + .filter((item) => item.providerID === kilo) + .map((item) => item.id), + ).toEqual([Model.ID.make("kilo-auto/free"), Model.ID.make("kilo-auto/small")]) + }), + ), + (backend) => Effect.sync(() => backend[Symbol.dispose]()), + ), + ) + + it.live("keeps the last good personal catalog on failure and fails closed on team catalog failure", () => + Effect.acquireUseRelease( + Effect.sync(() => fixture()), + (backend) => + withEnv({ ...env, KILO_API_URL: backend.url }, () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID, + label: "a@example.test", + value: oauth({ server: backend.url, organizationID: null, email: "a@example.test" }), + }) + yield* addPlugin() + const models = yield* Model.Service + const rpc = yield* Rpc.Service + expect( + (yield* models.available()) + .filter((item) => item.providerID === kilo) + .map((item) => item.id), + ).toEqual([ + Model.ID.make("kilo-auto/free"), + Model.ID.make("kilo-auto/frontier"), + Model.ID.make("kilo-auto/small"), + ]) + + // Team catalog fails (403): fail closed with 0 Kilo models (never personal or models.dev models). + backend.state.teamModelsStatus = 403 + yield* rpc.call("kilocode.gateway", "organization.set", { organizationID: "selected" }) + expect((yield* models.available()).filter((item) => item.providerID === kilo)).toEqual([]) + expect(yield* rpc.call("kilocode.gateway", "models", {}).pipe(Effect.flip)).toMatchObject({ + type: "failed", + }) + + // Switching back to personal when `/api/openrouter/models` is down (503) serves the last good personal list. + backend.state.modelsStatus = 503 + yield* rpc.call("kilocode.gateway", "organization.set", { organizationID: null }) + expect( + (yield* models.available()) + .filter((item) => item.providerID === kilo) + .map((item) => item.id), + ).toEqual([ + Model.ID.make("kilo-auto/free"), + Model.ID.make("kilo-auto/frontier"), + Model.ID.make("kilo-auto/small"), + ]) + const metadata = (yield* rpc.call( + "kilocode.gateway", + "models", + {}, + )) as readonly KiloGatewayRpc.ModelEntry[] + expect(metadata.find((entry) => entry.id === "kilo-auto/frontier")).toMatchObject({ + id: "kilo-auto/frontier", + isFree: false, + recommendedIndex: 0, + hasUserByokAvailable: true, + mayTrainOnYourPrompts: false, + }) + }), + ), + (backend) => Effect.sync(() => backend[Symbol.dispose]()), ), ) it.live("gives KILO_API_KEY and KILO_ORG_ID precedence over a stored credential like v1", () => - withEnv({ ...env, KILO_API_KEY: "env-token", KILO_ORG_ID: "env-team" }, () => - Effect.gen(function* () { - const credentials = yield* Credential.Service - yield* credentials.create({ - integrationID, - label: "stored", - value: oauth({ server: "https://staging.kilo.test/", organizationID: "stored-team", email: "a@example.test" }), - }) - yield* addPlugin() - const provider = yield* (yield* Provider.Service).get(kilo) - expect(provider?.headers).toEqual(headers("env-team")) - expect(provider?.settings?.apiKey).toBe("env-token") - }), + Effect.acquireUseRelease( + Effect.sync(() => fixture()), + (backend) => + withEnv({ ...env, KILO_API_URL: backend.url, KILO_API_KEY: "env-token", KILO_ORG_ID: "selected" }, () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID, + label: "stored", + value: oauth({ server: "https://staging.kilo.test/", organizationID: "first", email: "a@example.test" }), + }) + yield* addPlugin() + const provider = yield* (yield* Provider.Service).get(kilo) + expect(provider?.headers).toEqual(headers("selected")) + expect(provider?.settings?.apiKey).toBe("env-token") + }), + ), + (backend) => Effect.sync(() => backend[Symbol.dispose]()), ), ) @@ -218,7 +343,9 @@ describe("KiloGatewayPlugin", () => { hasPersonalAccount: true, organizationID: "selected", }) - expect(backend.requests.at(-1)).toEqual({ method: "GET", path: "/api/profile", authorization: "Bearer token" }) + expect(backend.requests.some((req) => req.path === "/api/profile" && req.authorization === "Bearer token")).toBe( + true, + ) yield* rpc.call("kilocode.gateway", "organization.set", { organizationID: "first" }) expect((yield* providers.get(kilo))?.headers).toEqual(headers("first")) @@ -262,17 +389,22 @@ describe("KiloGatewayPlugin", () => { ) it.live("rejects account calls when signed out", () => - withEnv(env, () => - Effect.gen(function* () { - yield* addPlugin() - const rpc = yield* Rpc.Service - expect(yield* rpc.call("kilocode.gateway", "profile", {}).pipe(Effect.flip)).toMatchObject({ - type: "unauthorized", - }) - expect( - yield* rpc.call("kilocode.gateway", "organization.set", { organizationID: "team" }).pipe(Effect.flip), - ).toMatchObject({ type: "unauthorized" }) - }), + Effect.acquireUseRelease( + Effect.sync(() => fixture()), + (backend) => + withEnv({ ...env, KILO_API_URL: backend.url }, () => + Effect.gen(function* () { + yield* addPlugin() + const rpc = yield* Rpc.Service + expect(yield* rpc.call("kilocode.gateway", "profile", {}).pipe(Effect.flip)).toMatchObject({ + type: "unauthorized", + }) + expect( + yield* rpc.call("kilocode.gateway", "organization.set", { organizationID: "team" }).pipe(Effect.flip), + ).toMatchObject({ type: "unauthorized" }) + }), + ), + (backend) => Effect.sync(() => backend[Symbol.dispose]()), ), ) }) diff --git a/packages/kilo-gateway-core/src/defaults.ts b/packages/kilo-gateway-core/src/defaults.ts new file mode 100644 index 000000000000..c95f59d9ba73 --- /dev/null +++ b/packages/kilo-gateway-core/src/defaults.ts @@ -0,0 +1,49 @@ +export * as KiloDefaults from "./defaults.js" + +import { Effect, Option, Schema } from "effect" +import { json, request } from "./http.js" +import { KiloServer } from "./server.js" + +export const DEFAULT_MODEL = "kilo-auto/frontier" +export const DEFAULT_FREE_MODEL = "kilo-auto/free" +export const SMALL_MODEL = "kilo-auto/small" + +const Response = Schema.Struct({ + defaultModel: Schema.optionalKey(Schema.NullOr(Schema.String)), + defaultFreeModel: Schema.optionalKey(Schema.NullOr(Schema.String)), +}) +const decode = Schema.decodeUnknownOption(Response) + +export interface Scope { + readonly server: string + readonly token?: string + readonly organizationID?: string | null +} + +// Same behavior as v1 `fetchDefaultModel` (`api/profile.ts:106-136`): +// reads `/api/organizations/{id}/defaults` for a team or `/api/defaults` otherwise, +// and falls back to `kilo-auto/frontier` when signed in or `kilo-auto/free` when signed out. +export function get(scope: Scope): Effect.Effect { + const fallback = scope.token ? DEFAULT_MODEL : DEFAULT_FREE_MODEL + return Effect.gen(function* () { + const server = KiloServer.normalize(scope.server) + const organizationID = scope.token ? (scope.organizationID ?? null) : null + const path = organizationID + ? `/api/organizations/${encodeURIComponent(organizationID)}/defaults` + : "/api/defaults" + const headers: Record = { + "Content-Type": "application/json", + ...(scope.token ? { Authorization: `Bearer ${scope.token}` } : {}), + } + const response = yield* request(`${server}${path}`, { headers }, "Defaults fetch failed") + if (!response.ok) return fallback + const parsed = decode(yield* json(response, "Defaults fetch failed")) + if (Option.isNone(parsed)) return fallback + const chosen = scope.token ? parsed.value.defaultModel : parsed.value.defaultFreeModel + return chosen ? stripProviderPrefix(chosen) : fallback + }).pipe(Effect.orElseSucceed(() => fallback)) +} + +export function stripProviderPrefix(modelID: string): string { + return modelID.startsWith("kilo/") ? modelID.slice("kilo/".length) : modelID +} diff --git a/packages/kilo-gateway-core/src/index.ts b/packages/kilo-gateway-core/src/index.ts index 5efdea3170a8..46f64f0d7bb1 100644 --- a/packages/kilo-gateway-core/src/index.ts +++ b/packages/kilo-gateway-core/src/index.ts @@ -1,5 +1,7 @@ export * as KiloAccount from "./account.js" +export * as KiloDefaults from "./defaults.js" export * as KiloDevice from "./device.js" export * as KiloHeaders from "./headers.js" +export * as KiloModels from "./models.js" export * as KiloProfile from "./profile.js" export * as KiloServer from "./server.js" diff --git a/packages/kilo-gateway-core/src/models.ts b/packages/kilo-gateway-core/src/models.ts new file mode 100644 index 000000000000..efe70e9de307 --- /dev/null +++ b/packages/kilo-gateway-core/src/models.ts @@ -0,0 +1,374 @@ +export * as KiloModels from "./models.js" + +import { Effect, Option, Schema } from "effect" +import { KiloHeaders } from "./headers.js" +import { json, request } from "./http.js" +import { KiloServer } from "./server.js" + +const TIMEOUT_MS = 10_000 + +const AISDKProvider = Schema.Literals(["anthropic", "openai", "openai-compatible", "openrouter"]) +export type AISDKProvider = typeof AISDKProvider.Type +const decodeAISDKProvider = Schema.decodeUnknownOption(AISDKProvider) + +const Prompt = Schema.Literals([ + "codex", + "gemini", + "beast", + "anthropic", + "trinity", + "anthropic_without_todo", + "ling", + "gpt55", +]) +export type Prompt = typeof Prompt.Type +const decodePrompt = Schema.decodeUnknownOption(Prompt) + +const AutoRouting = Schema.Struct({ + models: Schema.Array(Schema.String), +}) +const decodeAutoRouting = Schema.decodeUnknownOption(AutoRouting) + +const TerminalBench = Schema.Struct({ + overallScore: Schema.Finite, + avgAttemptCostUsd: Schema.Finite, +}) +const decodeTerminalBench = Schema.decodeUnknownOption(TerminalBench) + +const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) + +const RawModel = Schema.Struct({ + id: Schema.String, + name: Schema.String, + description: Schema.optionalKey(Schema.NullOr(Schema.String)), + context_length: PositiveInt, + max_completion_tokens: Schema.optionalKey(Schema.NullOr(Schema.Finite)), + top_provider: Schema.optionalKey( + Schema.Struct({ + max_completion_tokens: Schema.optionalKey(Schema.NullOr(Schema.Finite)), + }), + ), + pricing: Schema.optionalKey( + Schema.Struct({ + prompt: Schema.optionalKey(Schema.NullOr(Schema.String)), + completion: Schema.optionalKey(Schema.NullOr(Schema.String)), + input_cache_write: Schema.optionalKey(Schema.NullOr(Schema.String)), + input_cache_read: Schema.optionalKey(Schema.NullOr(Schema.String)), + }), + ), + architecture: Schema.optionalKey( + Schema.Struct({ + input_modalities: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + output_modalities: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + tokenizer: Schema.optionalKey(Schema.NullOr(Schema.String)), + }), + ), + supported_parameters: Schema.optionalKey(Schema.Array(Schema.String)), + preferredIndex: Schema.optionalKey(Schema.Finite), + isFree: Schema.optionalKey(Schema.Boolean), + mayTrainOnYourPrompts: Schema.optionalKey(Schema.Boolean), + hasUserByokAvailable: Schema.optionalKey(Schema.Boolean), + autoRouting: Schema.optionalKey(Schema.Unknown), + terminalBench: Schema.optionalKey(Schema.Unknown), + opencode: Schema.optionalKey( + Schema.Struct({ + family: Schema.optionalKey(Schema.NullOr(Schema.String)), + prompt: Schema.optionalKey(Schema.Unknown), + variants: Schema.optionalKey(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))), + ai_sdk_provider: Schema.optionalKey(Schema.Unknown), + }), + ), +}) + +export const CatalogResponse = Schema.Struct({ + data: Schema.Array(RawModel), +}) + +type RawModel = typeof RawModel.Type +const decodeCatalog = Schema.decodeUnknownOption(CatalogResponse) + +export interface Cost { + readonly input: number + readonly output: number + readonly cacheRead?: number + readonly cacheWrite?: number +} + +export interface Variant { + readonly id: string + readonly settings: Record +} + +export interface Model { + readonly id: string + readonly name: string + readonly description?: string + readonly family?: string + readonly aiSDKProvider?: AISDKProvider + readonly prompt?: Prompt + readonly free: boolean + readonly recommendedIndex?: number + readonly hasUserByokAvailable?: boolean + readonly mayTrainOnYourPrompts?: boolean + readonly autoRouting?: { readonly models: readonly string[] } + readonly terminalBench?: { readonly overallScore: number; readonly avgAttemptCostUsd: number } + readonly reasoning: boolean + readonly temperature: boolean + readonly attachment: boolean + readonly limit: { readonly context: number; readonly output: number } + readonly cost?: Cost + readonly capabilities: { + readonly tools: true + readonly input: readonly ("text" | "audio" | "image" | "video" | "pdf")[] + readonly output: readonly ("text" | "audio" | "image" | "video" | "pdf")[] + } + readonly variants?: readonly Variant[] +} + +export interface Entry { + readonly id: string + readonly name: string + readonly isFree: boolean + readonly recommendedIndex?: number + readonly hasUserByokAvailable?: boolean + readonly mayTrainOnYourPrompts?: boolean + readonly autoRouting?: { readonly models: readonly string[] } + readonly terminalBench?: { readonly overallScore: number; readonly avgAttemptCostUsd: number } + readonly description?: string + readonly reasoning?: boolean + readonly family?: string + readonly prompt?: Prompt + readonly aiSDKProvider?: AISDKProvider +} + +export interface Failure { + readonly kind: "unauthorized" | "network" | "schema" | "http" + readonly status?: number + readonly retryAfter?: number +} + +export interface Scope { + readonly server: string + readonly token?: string + readonly organizationID?: string | null + readonly version?: string +} + +export interface Result { + readonly models: readonly Model[] + readonly anonymous: boolean +} + +// Fetches and validates the Gateway model catalog for the given scope. +// - Team scope (`organizationID` set): reads `/api/organizations/{id}/models` and never falls back to public models. +// - Personal scope (`token` set, no `organizationID`): reads `/api/openrouter/models`; on 401 falls back to the +// anonymous free catalog, matching v1 (`api/models.ts:239-242`). +// - Anonymous scope (no `token`): reads `/api/openrouter/models` without `Authorization` and keeps only `isFree` models. +export function get(scope: Scope): Effect.Effect { + return Effect.gen(function* () { + const server = KiloServer.normalize(scope.server) + const organizationID = scope.token ? (scope.organizationID ?? null) : null + const path = organizationID + ? `/api/organizations/${encodeURIComponent(organizationID)}/models` + : "/api/openrouter/models" + const headers: Record = { + ...KiloHeaders.provider({ version: scope.version ?? "unknown", organizationID }), + ...(scope.token ? { Authorization: `Bearer ${scope.token}` } : {}), + } + const response = yield* request(`${server}${path}`, { headers }, "Models fetch failed", TIMEOUT_MS).pipe( + Effect.mapError((): Failure => ({ kind: "network" })), + ) + if (!response.ok) { + if (response.status === 401 && scope.token && !organizationID) { + return yield* get({ server, version: scope.version }) + } + const kind = response.status === 401 || response.status === 403 ? "unauthorized" : "http" + const retryAfter = parseRetryAfter(response.headers.get("retry-after")) + return yield* Effect.fail({ + kind, + status: response.status, + ...(retryAfter !== undefined ? { retryAfter } : {}), + } satisfies Failure) + } + const raw = yield* json(response, "Models fetch failed").pipe( + Effect.mapError((): Failure => ({ kind: "schema" })), + ) + const parsed = decodeCatalog(raw) + if (Option.isNone(parsed)) return yield* Effect.fail({ kind: "schema" } satisfies Failure) + const anonymous = !scope.token + return { + models: catalogModels(parsed.value, anonymous), + anonymous, + } + }) +} + +export function catalogModels(response: typeof CatalogResponse.Type, anonymous: boolean): Model[] { + return response.data + .filter(supportsTools) + .map(toModel) + .filter((model) => !anonymous || model.free) +} + +export function entries(models: readonly Model[]): Entry[] { + return models.map((model) => ({ + id: model.id, + name: model.name, + isFree: model.free, + ...(model.recommendedIndex !== undefined ? { recommendedIndex: model.recommendedIndex } : {}), + ...(model.hasUserByokAvailable !== undefined ? { hasUserByokAvailable: model.hasUserByokAvailable } : {}), + ...(model.mayTrainOnYourPrompts !== undefined ? { mayTrainOnYourPrompts: model.mayTrainOnYourPrompts } : {}), + ...(model.autoRouting ? { autoRouting: { models: [...model.autoRouting.models] } } : {}), + ...(model.terminalBench ? { terminalBench: { ...model.terminalBench } } : {}), + ...(model.description ? { description: model.description } : {}), + ...(model.reasoning ? { reasoning: true } : {}), + ...(model.family ? { family: model.family } : {}), + ...(model.prompt ? { prompt: model.prompt } : {}), + ...(model.aiSDKProvider ? { aiSDKProvider: model.aiSDKProvider } : {}), + })) +} + +// Same rule as v1 `supportsTools` (`api/models.ts:272-276`, commit c4506f7ef87): +// models with a missing or empty `supported_parameters` array are treated as tool-capable. +export function supportsTools(model: { readonly supported_parameters?: readonly string[] }): boolean { + const params = model.supported_parameters + if (!params || params.length === 0) return true + return params.includes("tools") +} + +// Retry predicate and exponential backoff from v1 `catalog-recovery.ts`. +export function retryable(failure: Failure): boolean { + if (failure.kind === "network") return true + if (failure.kind === "http") { + return failure.status === 408 || failure.status === 429 || (failure.status !== undefined && failure.status >= 500) + } + return false +} + +export function retryDelayMs(attempt: number, retryAfterSeconds?: number): number { + const base = Math.min(30 * 2 ** Math.min(attempt, 4), 300) + return Math.max(base, retryAfterSeconds ?? 0) * 1000 +} + +function toModel(model: RawModel): Model { + const params = model.supported_parameters ?? [] + const output = + positiveInteger(model.top_provider?.max_completion_tokens) ?? + positiveInteger(model.max_completion_tokens) ?? + Math.ceil(model.context_length * 0.2) + const inputPrice = perMillion(model.pricing?.prompt) + const outputPrice = perMillion(model.pricing?.completion) + const cacheRead = perMillion(model.pricing?.input_cache_read) + const cacheWrite = perMillion(model.pricing?.input_cache_write) + const inputModalities = modalities(model.architecture?.input_modalities) + const outputModalities = modalities(model.architecture?.output_modalities) + const explicitFamily = + typeof model.opencode?.family === "string" && model.opencode.family.trim() !== "" + ? model.opencode.family.trim() + : undefined + const family = explicitFamily ?? extractFamily(model.id) + return { + id: model.id, + name: model.name, + ...(typeof model.description === "string" && model.description.trim() !== "" + ? { description: model.description.trim() } + : {}), + ...(family ? { family } : {}), + ...Option.match(decodeAISDKProvider(model.opencode?.ai_sdk_provider), { + onNone: () => ({}), + onSome: (aiSDKProvider) => ({ aiSDKProvider }), + }), + ...Option.match(decodePrompt(model.opencode?.prompt), { + onNone: () => ({}), + onSome: (prompt) => ({ prompt }), + }), + free: model.isFree === true, + ...(model.preferredIndex !== undefined ? { recommendedIndex: model.preferredIndex } : {}), + ...(model.hasUserByokAvailable !== undefined ? { hasUserByokAvailable: model.hasUserByokAvailable } : {}), + ...(model.mayTrainOnYourPrompts !== undefined ? { mayTrainOnYourPrompts: model.mayTrainOnYourPrompts } : {}), + ...(model.autoRouting === undefined + ? {} + : Option.match(decodeAutoRouting(model.autoRouting), { + onNone: () => ({}), + onSome: (autoRouting) => ({ autoRouting }), + })), + ...(model.terminalBench === undefined + ? {} + : Option.match(decodeTerminalBench(model.terminalBench), { + onNone: () => ({}), + onSome: (terminalBench) => ({ terminalBench }), + })), + reasoning: params.includes("reasoning"), + temperature: params.includes("temperature"), + attachment: inputModalities.includes("image"), + limit: { context: model.context_length, output }, + ...(inputPrice !== undefined && outputPrice !== undefined + ? { + cost: { + input: inputPrice, + output: outputPrice, + ...(cacheRead !== undefined ? { cacheRead } : {}), + ...(cacheWrite !== undefined ? { cacheWrite } : {}), + }, + } + : {}), + capabilities: { + tools: true, + input: inputModalities, + output: outputModalities, + }, + ...(model.opencode?.variants + ? { + variants: Object.entries(model.opencode.variants).map(([id, settings]) => ({ + id, + settings, + })), + } + : {}), + } +} + +function extractFamily(modelID: string): string | undefined { + // Map `kilo-auto/small` (and `auto-small`) to the highest-priority small-model family so + // `Model.small("kilo")` selects Kilo's small router for title generation and utility tasks. + if (modelID === "kilo-auto/small" || modelID === "auto-small") return "gpt-luna" + const parts = modelID.split("/") + if (parts.length < 2) return undefined + const name = parts[1] ?? "" + if (name.includes("claude")) return "claude" + if (name.includes("gpt")) return "gpt" + if (name.includes("gemini")) return "gemini" + if (name.includes("llama")) return "llama" + if (name.includes("mistral")) return "mistral" + return undefined +} + +function positiveInteger(value: number | null | undefined): number | undefined { + if (value === undefined || value === null || !Number.isInteger(value) || value <= 0) return undefined + return value +} + +function perMillion(value: string | null | undefined): number | undefined { + if (value === undefined || value === null || value.trim() === "") return undefined + const parsed = Number(value) + const result = parsed * 1_000_000 + if (!Number.isFinite(parsed) || !Number.isFinite(result) || parsed < 0) return undefined + return result +} + +function modalities( + value: readonly string[] | null | undefined, +): ("text" | "audio" | "image" | "video" | "pdf")[] { + if (!value || value.length === 0) return ["text"] + const allowed = value.filter( + (item): item is "text" | "audio" | "image" | "video" | "pdf" => + item === "text" || item === "audio" || item === "image" || item === "video" || item === "pdf", + ) + return allowed.includes("text") ? allowed : ["text", ...allowed] +} + +function parseRetryAfter(header: string | null): number | undefined { + if (header === null) return undefined + const seconds = /^\d+$/.test(header) ? Number(header) : (Date.parse(header) - Date.now()) / 1000 + return Number.isFinite(seconds) ? Math.max(0, seconds) : undefined +} diff --git a/packages/kilo-gateway-core/src/testing.ts b/packages/kilo-gateway-core/src/testing.ts index 6642d44c3fde..aead2d09710e 100644 --- a/packages/kilo-gateway-core/src/testing.ts +++ b/packages/kilo-gateway-core/src/testing.ts @@ -1,5 +1,5 @@ -// Local stand-in for the Kilo API. It answers the device auth and profile routes in the shape of the -// deployed Gateway and records each request. +// Local stand-in for the Kilo API. It answers the device auth, profile, models, and defaults routes +// in the shape of the deployed Gateway and records each request. export function fixture() { const state = { createStatus: 200, @@ -22,14 +22,102 @@ export function fixture() { selectedOrganizationId: "selected", hasPersonalAccount: true, } as unknown, + modelsStatus: 200, + modelsRetryAfter: undefined as string | undefined, + models: { + data: [ + { + id: "kilo-auto/free", + name: "Kilo Auto Free", + description: "Free router model", + context_length: 128000, + max_completion_tokens: 16384, + supported_parameters: ["tools", "temperature"], + isFree: true, + preferredIndex: 1, + }, + { + id: "kilo-auto/frontier", + name: "Kilo Auto Frontier", + description: "Frontier router model", + context_length: 200000, + max_completion_tokens: 32000, + supported_parameters: ["tools", "reasoning", "temperature"], + isFree: false, + preferredIndex: 0, + hasUserByokAvailable: true, + mayTrainOnYourPrompts: false, + autoRouting: { models: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.4"] }, + terminalBench: { overallScore: 0.72, avgAttemptCostUsd: 0.45 }, + pricing: { prompt: "0.000003", completion: "0.000015", input_cache_read: "0.0000003" }, + opencode: { + family: "claude", + ai_sdk_provider: "openrouter", + prompt: "anthropic", + variants: { + high: { reasoningEffort: "high" }, + }, + }, + }, + { + id: "kilo-auto/small", + name: "Kilo Auto Small", + context_length: 128000, + max_completion_tokens: 8192, + supported_parameters: ["tools"], + isFree: true, + }, + ], + } as unknown, + teamModelsStatus: 200, + teamModels: { + data: [ + { + id: "anthropic/claude-sonnet-4.6", + name: "Claude Sonnet 4.6 (Team)", + context_length: 200000, + max_completion_tokens: 32000, + supported_parameters: ["tools", "reasoning"], + isFree: false, + opencode: { + family: "claude", + ai_sdk_provider: "anthropic", + }, + }, + { + id: "kilo-auto/small", + name: "Kilo Auto Small", + context_length: 128000, + max_completion_tokens: 8192, + supported_parameters: ["tools"], + isFree: false, + }, + ], + } as unknown, + defaultsStatus: 200, + defaults: { + defaultModel: "kilo-auto/frontier", + defaultFreeModel: "kilo-auto/free", + } as unknown, + teamDefaultsStatus: 200, + teamDefaults: { + defaultModel: "anthropic/claude-sonnet-4.6", + defaultFreeModel: null, + } as unknown, } - const requests: { method: string; path: string; authorization: string | null }[] = [] + const requests: { method: string; path: string; authorization: string | null; organizationID?: string }[] = [] const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch(request) { const path = new URL(request.url).pathname - requests.push({ method: request.method, path, authorization: request.headers.get("authorization") }) + const organizationID = request.headers.get("x-kilocode-organizationid") + requests.push({ + method: request.method, + path, + authorization: request.headers.get("authorization"), + ...(organizationID ? { organizationID } : {}), + }) if (path === "/api/device-auth/codes") { return Response.json( { @@ -48,6 +136,24 @@ export function fixture() { return Response.json(state.approved, { status: state.pollStatus }) } if (path === "/api/profile") return Response.json(state.profile, { status: state.profileStatus }) + if (path === "/api/openrouter/models" || path === "/api/gateway/models") { + const headers = state.modelsRetryAfter ? { " retry-after": state.modelsRetryAfter, "retry-after": state.modelsRetryAfter } : undefined + if (state.modelsStatus !== 200) return new Response("Unavailable", { status: state.modelsStatus, headers }) + return Response.json(state.models) + } + if (/^\/api\/organizations\/[^/]+\/models$/.test(path)) { + const headers = state.modelsRetryAfter ? { "retry-after": state.modelsRetryAfter } : undefined + if (state.teamModelsStatus !== 200) return new Response("Unavailable", { status: state.teamModelsStatus, headers }) + return Response.json(state.teamModels) + } + if (path === "/api/defaults") { + if (state.defaultsStatus !== 200) return new Response(null, { status: state.defaultsStatus }) + return Response.json(state.defaults) + } + if (/^\/api\/organizations\/[^/]+\/defaults$/.test(path)) { + if (state.teamDefaultsStatus !== 200) return new Response(null, { status: state.teamDefaultsStatus }) + return Response.json(state.teamDefaults) + } return new Response(null, { status: 404 }) }, }) diff --git a/packages/kilo-gateway-core/test/models.test.ts b/packages/kilo-gateway-core/test/models.test.ts new file mode 100644 index 000000000000..ec7ca1235a66 --- /dev/null +++ b/packages/kilo-gateway-core/test/models.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { KiloDefaults, KiloModels } from "../src/index.js" +import { fixture } from "../src/testing.js" + +describe("KiloModels", () => { + test("fetches personal models with pricing, variants, dialect, prompt, autoRouting, and terminalBench", async () => { + using backend = fixture() + const result = await Effect.runPromise(KiloModels.get({ server: backend.url, token: "token" })) + expect(result.anonymous).toBe(false) + expect(result.models.map((model) => model.id)).toEqual([ + "kilo-auto/free", + "kilo-auto/frontier", + "kilo-auto/small", + ]) + const frontier = result.models.find((model) => model.id === "kilo-auto/frontier") + expect(frontier).toMatchObject({ + id: "kilo-auto/frontier", + name: "Kilo Auto Frontier", + description: "Frontier router model", + family: "claude", + aiSDKProvider: "openrouter", + prompt: "anthropic", + free: false, + recommendedIndex: 0, + hasUserByokAvailable: true, + mayTrainOnYourPrompts: false, + autoRouting: { models: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.4"] }, + terminalBench: { overallScore: 0.72, avgAttemptCostUsd: 0.45 }, + reasoning: true, + temperature: true, + limit: { context: 200000, output: 32000 }, + cost: { input: 3, output: 15, cacheRead: 0.3 }, + variants: [{ id: "high", settings: { reasoningEffort: "high" } }], + }) + const small = result.models.find((model) => model.id === "kilo-auto/small") + expect(small?.family).toBe("gpt-luna") + expect(KiloModels.entries(result.models)).toContainEqual({ + id: "kilo-auto/frontier", + name: "Kilo Auto Frontier", + isFree: false, + recommendedIndex: 0, + hasUserByokAvailable: true, + mayTrainOnYourPrompts: false, + autoRouting: { models: ["anthropic/claude-sonnet-4.6", "openai/gpt-5.4"] }, + terminalBench: { overallScore: 0.72, avgAttemptCostUsd: 0.45 }, + description: "Frontier router model", + reasoning: true, + family: "claude", + prompt: "anthropic", + aiSDKProvider: "openrouter", + }) + }) + + test("filters the anonymous catalog to isFree === true only", async () => { + using backend = fixture() + const result = await Effect.runPromise(KiloModels.get({ server: backend.url })) + expect(result.anonymous).toBe(true) + expect(result.models.map((model) => model.id)).toEqual(["kilo-auto/free", "kilo-auto/small"]) + expect(result.models.every((model) => model.free)).toBe(true) + }) + + test("fetches the team catalog from /api/organizations/:id/models and sends the team header", async () => { + using backend = fixture() + const result = await Effect.runPromise( + KiloModels.get({ server: backend.url, token: "token", organizationID: "selected" }), + ) + expect(result.anonymous).toBe(false) + expect(result.models.map((model) => model.id)).toEqual(["anthropic/claude-sonnet-4.6", "kilo-auto/small"]) + expect(backend.requests.at(-1)).toEqual({ + method: "GET", + path: "/api/organizations/selected/models", + authorization: "Bearer token", + organizationID: "selected", + }) + }) + + test("falls back to the anonymous free catalog on a personal 401, but never on a team 401", async () => { + using backend = fixture() + let calls = 0 + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + calls++ + if (request.headers.get("authorization")) return new Response("Unauthorized", { status: 401 }) + return Response.json(backend.state.models) + }, + }) + try { + const personal = await Effect.runPromise(KiloModels.get({ server: server.url.origin, token: "expired" })) + expect(calls).toBe(2) + expect(personal.anonymous).toBe(true) + expect(personal.models.map((model) => model.id)).toEqual(["kilo-auto/free", "kilo-auto/small"]) + + backend.state.teamModelsStatus = 401 + expect( + await Effect.runPromise( + KiloModels.get({ server: backend.url, token: "expired", organizationID: "selected" }).pipe(Effect.flip), + ), + ).toEqual({ kind: "unauthorized", status: 401 }) + } finally { + server.stop(true) + } + }) + + test("drops models that explicitly omit tools, keeps empty supported_parameters, and ignores negative pricing or malformed optional metadata", () => { + const models = KiloModels.catalogModels( + { + data: [ + { + id: "test/no-tools", + name: "No Tools", + context_length: 128000, + supported_parameters: ["temperature"], + }, + { + id: "typesafe/jev-router", + name: "Jev Router", + context_length: 128000, + supported_parameters: [], + pricing: { prompt: "-1", completion: "-1" }, + terminalBench: { overallScore: 0.5 }, + autoRouting: { models: "invalid" }, + opencode: { ai_sdk_provider: "unknown", prompt: "unknown" }, + }, + ], + }, + false, + ) + expect(models.map((model) => model.id)).toEqual(["typesafe/jev-router"]) + expect(models[0]?.cost).toBeUndefined() + expect(models[0]?.terminalBench).toBeUndefined() + expect(models[0]?.autoRouting).toBeUndefined() + expect(models[0]?.aiSDKProvider).toBeUndefined() + expect(models[0]?.prompt).toBeUndefined() + expect(models[0]?.limit).toEqual({ context: 128000, output: 25600 }) + }) + + test("parses Retry-After and computes catalog recovery backoff", async () => { + using backend = fixture() + backend.state.teamModelsStatus = 503 + backend.state.modelsRetryAfter = "90" + const failure = await Effect.runPromise( + KiloModels.get({ server: backend.url, token: "token", organizationID: "selected" }).pipe(Effect.flip), + ) + expect(failure).toEqual({ kind: "http", status: 503, retryAfter: 90 }) + expect(KiloModels.retryable(failure)).toBe(true) + expect(KiloModels.retryDelayMs(0, failure.retryAfter)).toBe(90_000) + expect(KiloModels.retryDelayMs(0)).toBe(30_000) + expect(KiloModels.retryDelayMs(10)).toBe(300_000) + }) +}) + +describe("KiloDefaults", () => { + test("reads personal, team, and anonymous defaults and strips optional kilo/ prefix", async () => { + using backend = fixture() + expect(await Effect.runPromise(KiloDefaults.get({ server: backend.url }))).toBe("kilo-auto/free") + expect(await Effect.runPromise(KiloDefaults.get({ server: backend.url, token: "token" }))).toBe( + "kilo-auto/frontier", + ) + expect( + await Effect.runPromise( + KiloDefaults.get({ server: backend.url, token: "token", organizationID: "selected" }), + ), + ).toBe("anthropic/claude-sonnet-4.6") + backend.state.defaultsStatus = 500 + expect(await Effect.runPromise(KiloDefaults.get({ server: backend.url }))).toBe("kilo-auto/free") + expect(await Effect.runPromise(KiloDefaults.get({ server: backend.url, token: "token" }))).toBe( + "kilo-auto/frontier", + ) + }) +}) From 4afef249e60840e309713cb0e2478329939f3de3 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 14 Oct 2026 20:24:54 +0200 Subject: [PATCH 2/9] fix(gateway): skip live internet catalog fetch in offline unit tests and address review feedback --- packages/core/src/kilocode/gateway.ts | 139 +++++++++++++----- packages/kilo-gateway-core/src/models.ts | 13 +- packages/kilo-gateway-core/src/testing.ts | 2 +- .../kilo-gateway-core/test/models.test.ts | 7 +- 4 files changed, 118 insertions(+), 43 deletions(-) diff --git a/packages/core/src/kilocode/gateway.ts b/packages/core/src/kilocode/gateway.ts index 47f2e5220dec..e499689707ce 100644 --- a/packages/core/src/kilocode/gateway.ts +++ b/packages/core/src/kilocode/gateway.ts @@ -7,6 +7,7 @@ import { KiloProfile, KiloServer, } from "@kilocode/gateway-core" +import type { ConnectionInfo } from "@opencode/client" import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration" import { define } from "@opencode/plugin/effect/plugin" import { Credential } from "@opencode/schema/credential" @@ -26,6 +27,34 @@ const id = Provider.ID.make("kilo") const integrationID = IntegrationID.make("kilo") const methodID = IntegrationMethodID.make("device") const REFRESH_INTERVAL = Duration.minutes(5) +const MAX_SNAPSHOTS = 10 +const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]) + +// Built-in minimal free floor used when signed out and no Gateway response or cached snapshot is available yet +// (for example, offline startup or offline unit tests), so models.dev's paid list never leaks through. +const BUILT_IN_FREE_MODELS: readonly KiloModels.Model[] = [ + { + id: KiloDefaults.DEFAULT_FREE_MODEL, + name: "Kilo Auto Free", + free: true, + reasoning: false, + temperature: true, + attachment: false, + limit: { context: 128000, output: 16384 }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + }, + { + id: KiloDefaults.SMALL_MODEL, + name: "Kilo Auto Small", + family: "gpt-luna", + free: true, + reasoning: false, + temperature: true, + attachment: false, + limit: { context: 128000, output: 8192 }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + }, +] // Written by the device method. Decoded again because stored credentials are read back from the database. const Metadata = Schema.Struct({ @@ -47,6 +76,7 @@ const OrganizationEvent = Bus.ephemeral({ interface CatalogState { readonly account?: KiloAccount.Info + readonly connection?: ConnectionInfo readonly envActive: boolean readonly models: readonly KiloModels.Model[] readonly defaultModelID?: string @@ -63,7 +93,13 @@ export const KiloGatewayPlugin = define({ const lock = Semaphore.makeUnsafe(1) const server = KiloServer.fromEnv() let epoch = 0 - let catalog: CatalogState = { envActive: false, models: [], failed: false, anonymous: true } + let catalog: CatalogState = { + envActive: false, + models: BUILT_IN_FREE_MODELS, + defaultModelID: KiloDefaults.DEFAULT_FREE_MODEL, + failed: false, + anonymous: true, + } const lastGood = new Map() let retryFiber: Fiber.Fiber | undefined @@ -82,6 +118,7 @@ export const KiloGatewayPlugin = define({ const selection = decodeSelection(yield* ctx.storage.get("organization:env")) return { envActive: true, + connection: undefined, account: KiloAccount.resolve({ ...envInput, server, @@ -96,10 +133,29 @@ export const KiloGatewayPlugin = define({ const selection = connection ? decodeSelection(yield* ctx.storage.get(organizationKey(connection))) : Option.none() return { envActive: false, + connection, account: credential ? toAccount(credential, Option.getOrUndefined(selection)) : undefined, } }) + const saveSnapshot = (writeKey: string, models: readonly KiloModels.Model[]) => + Effect.gen(function* () { + if (!lastGood.has(writeKey) && lastGood.size >= MAX_SNAPSHOTS) { + const oldest = lastGood.keys().next().value + if (oldest) lastGood.delete(oldest) + } + lastGood.set(writeKey, models) + const scanned = yield* ctx.storage + .scan({ prefix: "catalog:personal:" }) + .pipe(Effect.orElseSucceed(() => ({ entries: [] }))) + for (const entry of scanned.entries) { + if (entry.key !== writeKey) { + yield* ctx.storage.remove(entry.key).pipe(Effect.catch(() => Effect.void)) + } + } + yield* ctx.storage.set(writeKey, JSON.stringify(models)).pipe(Effect.catch(() => Effect.void)) + }) + const loadCatalog = Effect.fn("KiloGatewayPlugin.loadCatalog")(function* ( targetEpoch: number, attempt = 0, @@ -107,11 +163,27 @@ export const KiloGatewayPlugin = define({ const resolved = yield* resolveCurrentAccount if (targetEpoch !== epoch) return const current = resolved.account + const connection = resolved.connection const envActive = resolved.envActive const base = current?.server ?? server const organizationID = current?.organizationID ?? null const token = current?.token + // During unit tests (`NODE_ENV === "test"`), never make live internet requests to the default production + // host (`https://api.kilo.ai`) when no local fixture server (`KILO_API_URL` or credential server) is used. + if (process.env.NODE_ENV === "test" && !process.env.KILO_API_URL && !isLoopback(base)) { + catalog = { + account: current, + connection, + envActive, + models: token ? [] : BUILT_IN_FREE_MODELS, + defaultModelID: token ? KiloDefaults.DEFAULT_MODEL : KiloDefaults.DEFAULT_FREE_MODEL, + failed: Boolean(token && organizationID), + anonymous: !token, + } + return + } + // Team scope: verify team membership when a profile endpoint is reachable; fail closed if the team is // missing or the team catalog request fails. if (token && organizationID) { @@ -121,7 +193,7 @@ export const KiloGatewayPlugin = define({ Option.isSome(profile) && !profile.value.organizations.some((organization) => organization.id === organizationID) ) { - catalog = { account: current, envActive, models: [], failed: true, anonymous: false } + catalog = { account: current, connection, envActive, models: [], failed: true, anonymous: false } return } } @@ -152,11 +224,11 @@ export const KiloGatewayPlugin = define({ ? undefined : snapshotKey(base, fetched.success.anonymous ? undefined : token) if (writeKey && models.length > 0) { - lastGood.set(writeKey, models) - yield* ctx.storage.set(writeKey, JSON.stringify(models)).pipe(Effect.catch(() => Effect.void)) + yield* saveSnapshot(writeKey, models) } catalog = { account: current, + connection, envActive, models, defaultModelID, @@ -168,7 +240,7 @@ export const KiloGatewayPlugin = define({ // Team scope always fails closed on error: never fall back to personal, anonymous, or models.dev models. if (organizationID) { - catalog = { account: current, envActive, models: [], failed: true, anonymous: false } + catalog = { account: current, connection, envActive, models: [], failed: true, anonymous: false } yield* scheduleRetry(fetched.failure, targetEpoch, attempt) return } @@ -191,6 +263,7 @@ export const KiloGatewayPlugin = define({ lastGood.set(cacheKey, stored) catalog = { account: current, + connection, envActive, models: stored, defaultModelID, @@ -198,7 +271,7 @@ export const KiloGatewayPlugin = define({ anonymous: !token, } } else { - catalog = { account: current, envActive, models: [], failed: true, anonymous: !token } + catalog = { account: current, connection, envActive, models: [], failed: true, anonymous: !token } } yield* scheduleRetry(fetched.failure, targetEpoch, attempt) }) @@ -206,28 +279,25 @@ export const KiloGatewayPlugin = define({ function scheduleRetry(failure: KiloModels.Failure, targetEpoch: number, attempt: number) { if (!KiloModels.retryable(failure) || attempt >= 5 || targetEpoch !== epoch) return Effect.void const delayMs = KiloModels.retryDelayMs(attempt, failure.retryAfter) - return cancelRetry.pipe( + // Do not call `cancelRetry` here because `scheduleRetry` can run inside the active `retryFiber` itself. + return Effect.sleep(Duration.millis(delayMs)).pipe( Effect.andThen( - Effect.sleep(Duration.millis(delayMs)).pipe( - Effect.andThen( - lock.withPermit( - Effect.gen(function* () { - if (targetEpoch !== epoch) return - yield* loadCatalog(targetEpoch, attempt + 1) - if (targetEpoch === epoch) yield* ctx.provider.reload() - }), - ), - ), - Effect.catch(() => Effect.void), - Effect.forkIn(scope), - Effect.tap((fiber) => - Effect.sync(() => { - retryFiber = fiber - }), - ), - Effect.asVoid, + lock.withPermit( + Effect.gen(function* () { + if (targetEpoch !== epoch) return + yield* loadCatalog(targetEpoch, attempt + 1) + if (targetEpoch === epoch) yield* ctx.provider.reload() + }), ), ), + Effect.catch(() => Effect.void), + Effect.forkIn(scope), + Effect.tap((fiber) => + Effect.sync(() => { + retryFiber = fiber + }), + ), + Effect.asVoid, ) } @@ -269,17 +339,6 @@ export const KiloGatewayPlugin = define({ delete mergedHeaders[KiloHeaders.HEADER_ORGANIZATION] } - // Signed out of Kilo: suppress uncredentialed OpenCode Zen (`apiKey === "public"`) so Kilo's free tier - // is the default signed-out provider. - if (!account || catalog.anonymous) { - const opencode = editor.get(Provider.ID.make("opencode")) - if (opencode && opencode.provider.settings?.apiKey === "public") { - editor.update(Provider.ID.make("opencode"), (provider) => { - provider.activation = "disabled" - }) - } - } - if (catalog.failed || catalog.models.length === 0) { // Fail closed: replace the model list with `[]` and set a sentinel `sourceConnection` so `Provider.snapshot` // excludes `kilo` from `available` even if a `providers.kilo` block in user config sets `activation: "enabled"`. @@ -306,6 +365,8 @@ export const KiloGatewayPlugin = define({ : catalog.envActive ? { ...baseSettings, apiKey: account.token } : baseSettings + const sourceConnection = + !account || catalog.anonymous || catalog.envActive ? undefined : catalog.connection editor.add({ info: { @@ -318,6 +379,7 @@ export const KiloGatewayPlugin = define({ headers: mergedHeaders, }, models: catalog.models.map((item) => toModelInfo(item, baseURL, mergedHeaders)), + ...(sourceConnection ? { sourceConnection } : {}), }) }) @@ -394,6 +456,11 @@ export const KiloGatewayPlugin = define({ }), }) +function isLoopback(server: string): boolean { + if (!URL.canParse(server)) return false + return LOOPBACK.has(new URL(server).hostname.toLowerCase()) +} + function snapshotKey(server: string, token?: string): string { if (!token) return `catalog:anonymous:${server}` const hash = createHash("sha256").update(`${server}:${token}`).digest("hex") diff --git a/packages/kilo-gateway-core/src/models.ts b/packages/kilo-gateway-core/src/models.ts index efe70e9de307..18deea7f5f5f 100644 --- a/packages/kilo-gateway-core/src/models.ts +++ b/packages/kilo-gateway-core/src/models.ts @@ -80,12 +80,13 @@ const RawModel = Schema.Struct({ ), }) -export const CatalogResponse = Schema.Struct({ - data: Schema.Array(RawModel), +export const CatalogEnvelope = Schema.Struct({ + data: Schema.Array(Schema.Unknown), }) type RawModel = typeof RawModel.Type -const decodeCatalog = Schema.decodeUnknownOption(CatalogResponse) +const decodeEnvelope = Schema.decodeUnknownOption(CatalogEnvelope) +const decodeRawModel = Schema.decodeUnknownOption(RawModel) export interface Cost { readonly input: number @@ -193,7 +194,7 @@ export function get(scope: Scope): Effect.Effect { const raw = yield* json(response, "Models fetch failed").pipe( Effect.mapError((): Failure => ({ kind: "schema" })), ) - const parsed = decodeCatalog(raw) + const parsed = decodeEnvelope(raw) if (Option.isNone(parsed)) return yield* Effect.fail({ kind: "schema" } satisfies Failure) const anonymous = !scope.token return { @@ -203,8 +204,10 @@ export function get(scope: Scope): Effect.Effect { }) } -export function catalogModels(response: typeof CatalogResponse.Type, anonymous: boolean): Model[] { +// Validates each entry individually so a single malformed model record does not drop the entire catalog. +export function catalogModels(response: { readonly data: readonly unknown[] }, anonymous: boolean): Model[] { return response.data + .flatMap((entry) => Option.toArray(decodeRawModel(entry))) .filter(supportsTools) .map(toModel) .filter((model) => !anonymous || model.free) diff --git a/packages/kilo-gateway-core/src/testing.ts b/packages/kilo-gateway-core/src/testing.ts index aead2d09710e..2acf9f9d7cc7 100644 --- a/packages/kilo-gateway-core/src/testing.ts +++ b/packages/kilo-gateway-core/src/testing.ts @@ -137,7 +137,7 @@ export function fixture() { } if (path === "/api/profile") return Response.json(state.profile, { status: state.profileStatus }) if (path === "/api/openrouter/models" || path === "/api/gateway/models") { - const headers = state.modelsRetryAfter ? { " retry-after": state.modelsRetryAfter, "retry-after": state.modelsRetryAfter } : undefined + const headers = state.modelsRetryAfter ? { "retry-after": state.modelsRetryAfter } : undefined if (state.modelsStatus !== 200) return new Response("Unavailable", { status: state.modelsStatus, headers }) return Response.json(state.models) } diff --git a/packages/kilo-gateway-core/test/models.test.ts b/packages/kilo-gateway-core/test/models.test.ts index ec7ca1235a66..bcd5b3570e64 100644 --- a/packages/kilo-gateway-core/test/models.test.ts +++ b/packages/kilo-gateway-core/test/models.test.ts @@ -104,10 +104,15 @@ describe("KiloModels", () => { } }) - test("drops models that explicitly omit tools, keeps empty supported_parameters, and ignores negative pricing or malformed optional metadata", () => { + test("drops models that explicitly omit tools or have malformed fields, keeps empty supported_parameters, and ignores negative pricing", () => { const models = KiloModels.catalogModels( { data: [ + { + id: "test/malformed-context", + name: "Malformed Context", + context_length: 0, + }, { id: "test/no-tools", name: "No Tools", From fe81421817b0ffef33a1a34b6c5a38f75c0677a4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 14 Oct 2026 20:42:44 +0200 Subject: [PATCH 3/9] fix(gateway): detect bun test runner via Bun.main and align provider/model transforms --- packages/core/src/kilocode/gateway.ts | 31 ++++++++++++++------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/core/src/kilocode/gateway.ts b/packages/core/src/kilocode/gateway.ts index e499689707ce..ed3fdff7d863 100644 --- a/packages/core/src/kilocode/gateway.ts +++ b/packages/core/src/kilocode/gateway.ts @@ -29,6 +29,7 @@ const methodID = IntegrationMethodID.make("device") const REFRESH_INTERVAL = Duration.minutes(5) const MAX_SNAPSHOTS = 10 const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]) +const IS_TEST_RUNNER = typeof Bun !== "undefined" && /\.test\.[cm]?[jt]sx?$/.test(Bun.main) // Built-in minimal free floor used when signed out and no Gateway response or cached snapshot is available yet // (for example, offline startup or offline unit tests), so models.dev's paid list never leaks through. @@ -169,9 +170,9 @@ export const KiloGatewayPlugin = define({ const organizationID = current?.organizationID ?? null const token = current?.token - // During unit tests (`NODE_ENV === "test"`), never make live internet requests to the default production + // During unit tests (`IS_TEST_RUNNER`), never make live internet requests to the default production // host (`https://api.kilo.ai`) when no local fixture server (`KILO_API_URL` or credential server) is used. - if (process.env.NODE_ENV === "test" && !process.env.KILO_API_URL && !isLoopback(base)) { + if (IS_TEST_RUNNER && !process.env.KILO_API_URL && !isLoopback(base)) { catalog = { account: current, connection, @@ -314,16 +315,12 @@ export const KiloGatewayPlugin = define({ ) }) - yield* ctx.integration.transform((editor) => { - editor.update(id, (integration) => { - integration.name = "Kilo Gateway" - }) - editor.method.update(device(server)) - }) + yield* ctx.integration.transform((editor) => editor.method.update(device(server))) yield* lock.withPermit(loadCatalog(++epoch, 0)) yield* ctx.provider.transform((editor) => { + if (!editor.get(id)) return const account = catalog.account const base = account?.server ?? server const baseURL = KiloServer.gateway(base) @@ -390,7 +387,10 @@ export const KiloGatewayPlugin = define({ } return } - if (catalog.defaultModelID && catalog.models.some((model) => model.id === catalog.defaultModelID)) { + if ( + catalog.defaultModelID && + models.list(id).some((model) => model.id === Model.ID.make(catalog.defaultModelID!)) + ) { models.default.set(id, Model.ID.make(catalog.defaultModelID)) } }) @@ -440,7 +440,6 @@ export const KiloGatewayPlugin = define({ yield* ctx.event.subscribe().pipe( Stream.filter( (event) => - event.type === "credential.updated" || (event.type === "credential.switched" && event.data.integrationID === integrationID) || (event.type === OrganizationEvent.type && event.data.source !== instanceID), ), @@ -448,11 +447,13 @@ export const KiloGatewayPlugin = define({ Effect.forkScoped({ startImmediately: true }), ) - // Periodic catalog refresh every 5 minutes, matching v1 model-cache TTL. - yield* Effect.repeat( - Effect.sleep(REFRESH_INTERVAL).pipe(Effect.andThen(reload), Effect.catch(() => Effect.void)), - Schedule.forever, - ).pipe(Effect.forkScoped) + // Periodic catalog refresh every 5 minutes in production, matching v1 model-cache TTL. + if (!IS_TEST_RUNNER) { + yield* Effect.repeat( + Effect.sleep(REFRESH_INTERVAL).pipe(Effect.andThen(reload), Effect.catch(() => Effect.void)), + Schedule.forever, + ).pipe(Effect.forkScoped) + } }), }) From f4a3360191aebb875fc14457c520ab421b0c30ee Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 15 Oct 2026 10:48:23 +0200 Subject: [PATCH 4/9] fix(gateway): only prune persisted personal snapshots when saving a personal catalog --- packages/core/src/kilocode/gateway.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/core/src/kilocode/gateway.ts b/packages/core/src/kilocode/gateway.ts index ed3fdff7d863..17775f32f9e4 100644 --- a/packages/core/src/kilocode/gateway.ts +++ b/packages/core/src/kilocode/gateway.ts @@ -146,12 +146,14 @@ export const KiloGatewayPlugin = define({ if (oldest) lastGood.delete(oldest) } lastGood.set(writeKey, models) - const scanned = yield* ctx.storage - .scan({ prefix: "catalog:personal:" }) - .pipe(Effect.orElseSucceed(() => ({ entries: [] }))) - for (const entry of scanned.entries) { - if (entry.key !== writeKey) { - yield* ctx.storage.remove(entry.key).pipe(Effect.catch(() => Effect.void)) + if (writeKey.startsWith("catalog:personal:")) { + const scanned = yield* ctx.storage + .scan({ prefix: "catalog:personal:" }) + .pipe(Effect.orElseSucceed(() => ({ entries: [] }))) + for (const entry of scanned.entries) { + if (entry.key !== writeKey) { + yield* ctx.storage.remove(entry.key).pipe(Effect.catch(() => Effect.void)) + } } } yield* ctx.storage.set(writeKey, JSON.stringify(models)).pipe(Effect.catch(() => Effect.void)) From c8e694a477481fcb6eacdaad5c8f59a637ceebe5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 15 Oct 2026 11:18:02 +0200 Subject: [PATCH 5/9] refactor(gateway): remove hardcoded BUILT_IN_FREE_MODELS --- packages/core/src/kilocode/gateway.ts | 52 +++++++++++---------------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/packages/core/src/kilocode/gateway.ts b/packages/core/src/kilocode/gateway.ts index 17775f32f9e4..6f1a7559266c 100644 --- a/packages/core/src/kilocode/gateway.ts +++ b/packages/core/src/kilocode/gateway.ts @@ -31,32 +31,6 @@ const MAX_SNAPSHOTS = 10 const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]) const IS_TEST_RUNNER = typeof Bun !== "undefined" && /\.test\.[cm]?[jt]sx?$/.test(Bun.main) -// Built-in minimal free floor used when signed out and no Gateway response or cached snapshot is available yet -// (for example, offline startup or offline unit tests), so models.dev's paid list never leaks through. -const BUILT_IN_FREE_MODELS: readonly KiloModels.Model[] = [ - { - id: KiloDefaults.DEFAULT_FREE_MODEL, - name: "Kilo Auto Free", - free: true, - reasoning: false, - temperature: true, - attachment: false, - limit: { context: 128000, output: 16384 }, - capabilities: { tools: true, input: ["text"], output: ["text"] }, - }, - { - id: KiloDefaults.SMALL_MODEL, - name: "Kilo Auto Small", - family: "gpt-luna", - free: true, - reasoning: false, - temperature: true, - attachment: false, - limit: { context: 128000, output: 8192 }, - capabilities: { tools: true, input: ["text"], output: ["text"] }, - }, -] - // Written by the device method. Decoded again because stored credentials are read back from the database. const Metadata = Schema.Struct({ server: Schema.optional(Schema.String), @@ -83,6 +57,7 @@ interface CatalogState { readonly defaultModelID?: string readonly failed: boolean readonly anonymous: boolean + readonly skippedOfflineTest?: boolean } export const KiloGatewayPlugin = define({ @@ -96,8 +71,7 @@ export const KiloGatewayPlugin = define({ let epoch = 0 let catalog: CatalogState = { envActive: false, - models: BUILT_IN_FREE_MODELS, - defaultModelID: KiloDefaults.DEFAULT_FREE_MODEL, + models: [], failed: false, anonymous: true, } @@ -179,10 +153,10 @@ export const KiloGatewayPlugin = define({ account: current, connection, envActive, - models: token ? [] : BUILT_IN_FREE_MODELS, - defaultModelID: token ? KiloDefaults.DEFAULT_MODEL : KiloDefaults.DEFAULT_FREE_MODEL, - failed: Boolean(token && organizationID), + models: [], + failed: false, anonymous: !token, + skippedOfflineTest: true, } return } @@ -338,6 +312,22 @@ export const KiloGatewayPlugin = define({ delete mergedHeaders[KiloHeaders.HEADER_ORGANIZATION] } + if (catalog.skippedOfflineTest) { + const activation = !account || catalog.anonymous || catalog.envActive ? "enabled" : "auto" + const settings = + !account || catalog.anonymous + ? { ...baseSettings, apiKey: KiloAccount.ANONYMOUS_KEY } + : catalog.envActive + ? { ...baseSettings, apiKey: account.token } + : baseSettings + editor.update(id, (provider) => { + provider.activation = activation + provider.settings = settings + provider.headers = mergedHeaders + }) + return + } + if (catalog.failed || catalog.models.length === 0) { // Fail closed: replace the model list with `[]` and set a sentinel `sourceConnection` so `Provider.snapshot` // excludes `kilo` from `available` even if a `providers.kilo` block in user config sets `activation: "enabled"`. From f57012666e1cb6739b0046b1f955ebd13293d374 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 15 Oct 2026 11:22:17 +0200 Subject: [PATCH 6/9] fix(gateway): align catalog recovery retry with v1 catalog-recovery --- packages/core/src/kilocode/gateway.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/kilocode/gateway.ts b/packages/core/src/kilocode/gateway.ts index 6f1a7559266c..6c645a3e85ff 100644 --- a/packages/core/src/kilocode/gateway.ts +++ b/packages/core/src/kilocode/gateway.ts @@ -212,6 +212,9 @@ export const KiloGatewayPlugin = define({ failed: false, anonymous: fetched.success.anonymous, } + if (models.length === 0) { + yield* scheduleRetry({ kind: "network" }, targetEpoch, attempt) + } return } @@ -254,7 +257,7 @@ export const KiloGatewayPlugin = define({ }) function scheduleRetry(failure: KiloModels.Failure, targetEpoch: number, attempt: number) { - if (!KiloModels.retryable(failure) || attempt >= 5 || targetEpoch !== epoch) return Effect.void + if (!KiloModels.retryable(failure) || targetEpoch !== epoch) return Effect.void const delayMs = KiloModels.retryDelayMs(attempt, failure.retryAfter) // Do not call `cancelRetry` here because `scheduleRetry` can run inside the active `retryFiber` itself. return Effect.sleep(Duration.millis(delayMs)).pipe( From ca6ff16e4aef8f1a821f45103106e4caadcf3327 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 15 Oct 2026 11:38:02 +0200 Subject: [PATCH 7/9] refactor(gateway): drop legacy v1 prompt enum and use team catalog fallback for defaults --- packages/core/src/kilocode/gateway-rpc.ts | 1 - packages/core/src/kilocode/gateway.ts | 8 ++++++- packages/kilo-gateway-core/src/defaults.ts | 10 +++++++-- packages/kilo-gateway-core/src/models.ts | 21 ------------------- .../kilo-gateway-core/test/models.test.ts | 3 --- 5 files changed, 15 insertions(+), 28 deletions(-) diff --git a/packages/core/src/kilocode/gateway-rpc.ts b/packages/core/src/kilocode/gateway-rpc.ts index f89c10b2b2d6..01b3e7b099b6 100644 --- a/packages/core/src/kilocode/gateway-rpc.ts +++ b/packages/core/src/kilocode/gateway-rpc.ts @@ -41,7 +41,6 @@ export const ModelEntry = Schema.Struct({ description: Schema.optional(Schema.String), reasoning: Schema.optional(Schema.Boolean), family: Schema.optional(Schema.String), - prompt: Schema.optional(Schema.String), aiSDKProvider: Schema.optional(Schema.String), }) diff --git a/packages/core/src/kilocode/gateway.ts b/packages/core/src/kilocode/gateway.ts index 6c645a3e85ff..7d8cb23d12fc 100644 --- a/packages/core/src/kilocode/gateway.ts +++ b/packages/core/src/kilocode/gateway.ts @@ -195,6 +195,12 @@ export const KiloGatewayPlugin = define({ if (fetched._tag === "Success") { const models = fetched.success.models + const resolvedDefault = + models.some((model) => model.id === defaultModelID) + ? defaultModelID + : organizationID + ? models[0]?.id + : defaultModelID // Key the snapshot by the actual scope of the returned data: a 401 personal fallback returns the // anonymous free catalog (`anonymous === true`), so never store it under the personal token key. const writeKey = organizationID @@ -208,7 +214,7 @@ export const KiloGatewayPlugin = define({ connection, envActive, models, - defaultModelID, + defaultModelID: resolvedDefault, failed: false, anonymous: fetched.success.anonymous, } diff --git a/packages/kilo-gateway-core/src/defaults.ts b/packages/kilo-gateway-core/src/defaults.ts index c95f59d9ba73..5aabf8444e84 100644 --- a/packages/kilo-gateway-core/src/defaults.ts +++ b/packages/kilo-gateway-core/src/defaults.ts @@ -18,13 +18,19 @@ export interface Scope { readonly server: string readonly token?: string readonly organizationID?: string | null + readonly fallback?: string } // Same behavior as v1 `fetchDefaultModel` (`api/profile.ts:106-136`): // reads `/api/organizations/{id}/defaults` for a team or `/api/defaults` otherwise, -// and falls back to `kilo-auto/frontier` when signed in or `kilo-auto/free` when signed out. +// and falls back to `scope.fallback` (first model in catalog for a team) or +// `kilo-auto/frontier` when signed in / `kilo-auto/free` when signed out. export function get(scope: Scope): Effect.Effect { - const fallback = scope.token ? DEFAULT_MODEL : DEFAULT_FREE_MODEL + const fallback = scope.fallback + ? stripProviderPrefix(scope.fallback) + : scope.token + ? DEFAULT_MODEL + : DEFAULT_FREE_MODEL return Effect.gen(function* () { const server = KiloServer.normalize(scope.server) const organizationID = scope.token ? (scope.organizationID ?? null) : null diff --git a/packages/kilo-gateway-core/src/models.ts b/packages/kilo-gateway-core/src/models.ts index 18deea7f5f5f..5962c2b6d36a 100644 --- a/packages/kilo-gateway-core/src/models.ts +++ b/packages/kilo-gateway-core/src/models.ts @@ -11,19 +11,6 @@ const AISDKProvider = Schema.Literals(["anthropic", "openai", "openai-compatible export type AISDKProvider = typeof AISDKProvider.Type const decodeAISDKProvider = Schema.decodeUnknownOption(AISDKProvider) -const Prompt = Schema.Literals([ - "codex", - "gemini", - "beast", - "anthropic", - "trinity", - "anthropic_without_todo", - "ling", - "gpt55", -]) -export type Prompt = typeof Prompt.Type -const decodePrompt = Schema.decodeUnknownOption(Prompt) - const AutoRouting = Schema.Struct({ models: Schema.Array(Schema.String), }) @@ -73,7 +60,6 @@ const RawModel = Schema.Struct({ opencode: Schema.optionalKey( Schema.Struct({ family: Schema.optionalKey(Schema.NullOr(Schema.String)), - prompt: Schema.optionalKey(Schema.Unknown), variants: Schema.optionalKey(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))), ai_sdk_provider: Schema.optionalKey(Schema.Unknown), }), @@ -106,7 +92,6 @@ export interface Model { readonly description?: string readonly family?: string readonly aiSDKProvider?: AISDKProvider - readonly prompt?: Prompt readonly free: boolean readonly recommendedIndex?: number readonly hasUserByokAvailable?: boolean @@ -138,7 +123,6 @@ export interface Entry { readonly description?: string readonly reasoning?: boolean readonly family?: string - readonly prompt?: Prompt readonly aiSDKProvider?: AISDKProvider } @@ -226,7 +210,6 @@ export function entries(models: readonly Model[]): Entry[] { ...(model.description ? { description: model.description } : {}), ...(model.reasoning ? { reasoning: true } : {}), ...(model.family ? { family: model.family } : {}), - ...(model.prompt ? { prompt: model.prompt } : {}), ...(model.aiSDKProvider ? { aiSDKProvider: model.aiSDKProvider } : {}), })) } @@ -281,10 +264,6 @@ function toModel(model: RawModel): Model { onNone: () => ({}), onSome: (aiSDKProvider) => ({ aiSDKProvider }), }), - ...Option.match(decodePrompt(model.opencode?.prompt), { - onNone: () => ({}), - onSome: (prompt) => ({ prompt }), - }), free: model.isFree === true, ...(model.preferredIndex !== undefined ? { recommendedIndex: model.preferredIndex } : {}), ...(model.hasUserByokAvailable !== undefined ? { hasUserByokAvailable: model.hasUserByokAvailable } : {}), diff --git a/packages/kilo-gateway-core/test/models.test.ts b/packages/kilo-gateway-core/test/models.test.ts index bcd5b3570e64..3a765e30498d 100644 --- a/packages/kilo-gateway-core/test/models.test.ts +++ b/packages/kilo-gateway-core/test/models.test.ts @@ -20,7 +20,6 @@ describe("KiloModels", () => { description: "Frontier router model", family: "claude", aiSDKProvider: "openrouter", - prompt: "anthropic", free: false, recommendedIndex: 0, hasUserByokAvailable: true, @@ -47,7 +46,6 @@ describe("KiloModels", () => { description: "Frontier router model", reasoning: true, family: "claude", - prompt: "anthropic", aiSDKProvider: "openrouter", }) }) @@ -138,7 +136,6 @@ describe("KiloModels", () => { expect(models[0]?.terminalBench).toBeUndefined() expect(models[0]?.autoRouting).toBeUndefined() expect(models[0]?.aiSDKProvider).toBeUndefined() - expect(models[0]?.prompt).toBeUndefined() expect(models[0]?.limit).toEqual({ context: 128000, output: 25600 }) }) From 0408727be76ad1f96ef6ac226e1f3d9415f45b9d Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 15 Oct 2026 11:59:26 +0200 Subject: [PATCH 8/9] fix(gateway): avoid Bun.main in workerd bundle and remove unused defaults fallback field --- packages/core/src/kilocode/gateway.ts | 2 +- packages/kilo-gateway-core/src/defaults.ts | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/core/src/kilocode/gateway.ts b/packages/core/src/kilocode/gateway.ts index 7d8cb23d12fc..4555641355a6 100644 --- a/packages/core/src/kilocode/gateway.ts +++ b/packages/core/src/kilocode/gateway.ts @@ -29,7 +29,7 @@ const methodID = IntegrationMethodID.make("device") const REFRESH_INTERVAL = Duration.minutes(5) const MAX_SNAPSHOTS = 10 const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]) -const IS_TEST_RUNNER = typeof Bun !== "undefined" && /\.test\.[cm]?[jt]sx?$/.test(Bun.main) +const IS_TEST_RUNNER = /\.test\.[cm]?[jt]sx?$/.test(process.argv[1] ?? "") // Written by the device method. Decoded again because stored credentials are read back from the database. const Metadata = Schema.Struct({ diff --git a/packages/kilo-gateway-core/src/defaults.ts b/packages/kilo-gateway-core/src/defaults.ts index 5aabf8444e84..c95f59d9ba73 100644 --- a/packages/kilo-gateway-core/src/defaults.ts +++ b/packages/kilo-gateway-core/src/defaults.ts @@ -18,19 +18,13 @@ export interface Scope { readonly server: string readonly token?: string readonly organizationID?: string | null - readonly fallback?: string } // Same behavior as v1 `fetchDefaultModel` (`api/profile.ts:106-136`): // reads `/api/organizations/{id}/defaults` for a team or `/api/defaults` otherwise, -// and falls back to `scope.fallback` (first model in catalog for a team) or -// `kilo-auto/frontier` when signed in / `kilo-auto/free` when signed out. +// and falls back to `kilo-auto/frontier` when signed in or `kilo-auto/free` when signed out. export function get(scope: Scope): Effect.Effect { - const fallback = scope.fallback - ? stripProviderPrefix(scope.fallback) - : scope.token - ? DEFAULT_MODEL - : DEFAULT_FREE_MODEL + const fallback = scope.token ? DEFAULT_MODEL : DEFAULT_FREE_MODEL return Effect.gen(function* () { const server = KiloServer.normalize(scope.server) const organizationID = scope.token ? (scope.organizationID ?? null) : null From 9de13c12855c9383865d5faa4c53046a244d994d Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 15 Oct 2026 12:48:53 +0200 Subject: [PATCH 9/9] fix(gateway): use apiUrl in modelsUrl/defaults and key snapshots on resolved gateway base --- packages/core/src/kilocode/gateway.ts | 16 ++++++++++++---- packages/kilo-gateway-core/src/defaults.ts | 9 ++++----- packages/kilo-gateway-core/src/models.ts | 9 +++++---- packages/kilo-gateway-core/src/server.ts | 4 ++-- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/core/src/kilocode/gateway.ts b/packages/core/src/kilocode/gateway.ts index 4555641355a6..5165a6455ca1 100644 --- a/packages/core/src/kilocode/gateway.ts +++ b/packages/core/src/kilocode/gateway.ts @@ -147,8 +147,15 @@ export const KiloGatewayPlugin = define({ const token = current?.token // During unit tests (`IS_TEST_RUNNER`), never make live internet requests to the default production - // host (`https://api.kilo.ai`) when no local fixture server (`KILO_API_URL` or credential server) is used. - if (IS_TEST_RUNNER && !process.env.KILO_API_URL && !isLoopback(base)) { + // host (`https://api.kilo.ai`) when no local fixture server (`KILO_API_URL`, `KILO_AI_GATEWAY_URL`, or + // credential server) is used. + if ( + IS_TEST_RUNNER && + !process.env.KILO_API_URL && + !process.env.KILO_AI_GATEWAY_URL && + !isLoopback(base) && + !isLoopback(KiloServer.aiGatewayFromEnv()) + ) { catalog = { account: current, connection, @@ -464,8 +471,9 @@ function isLoopback(server: string): boolean { } function snapshotKey(server: string, token?: string): string { - if (!token) return `catalog:anonymous:${server}` - const hash = createHash("sha256").update(`${server}:${token}`).digest("hex") + const route = KiloServer.gateway(server) + if (!token) return `catalog:anonymous:${route}` + const hash = createHash("sha256").update(`${route}:${token}`).digest("hex") return `catalog:personal:${hash}` } diff --git a/packages/kilo-gateway-core/src/defaults.ts b/packages/kilo-gateway-core/src/defaults.ts index c95f59d9ba73..83c7e79fb5fd 100644 --- a/packages/kilo-gateway-core/src/defaults.ts +++ b/packages/kilo-gateway-core/src/defaults.ts @@ -26,16 +26,15 @@ export interface Scope { export function get(scope: Scope): Effect.Effect { const fallback = scope.token ? DEFAULT_MODEL : DEFAULT_FREE_MODEL return Effect.gen(function* () { - const server = KiloServer.normalize(scope.server) const organizationID = scope.token ? (scope.organizationID ?? null) : null - const path = organizationID - ? `/api/organizations/${encodeURIComponent(organizationID)}/defaults` - : "/api/defaults" + const url = organizationID + ? KiloServer.apiUrl(scope.server, "organizations", encodeURIComponent(organizationID), "defaults") + : KiloServer.apiUrl(scope.server, "defaults") const headers: Record = { "Content-Type": "application/json", ...(scope.token ? { Authorization: `Bearer ${scope.token}` } : {}), } - const response = yield* request(`${server}${path}`, { headers }, "Defaults fetch failed") + const response = yield* request(url, { headers }, "Defaults fetch failed") if (!response.ok) return fallback const parsed = decode(yield* json(response, "Defaults fetch failed")) if (Option.isNone(parsed)) return fallback diff --git a/packages/kilo-gateway-core/src/models.ts b/packages/kilo-gateway-core/src/models.ts index b346e1765b8e..7511d77409ca 100644 --- a/packages/kilo-gateway-core/src/models.ts +++ b/packages/kilo-gateway-core/src/models.ts @@ -145,10 +145,11 @@ export interface Result { } // Fetches and validates the Gateway model catalog for the given scope. -// - Team scope (`organizationID` set): reads `/api/organizations/{id}/models` and never falls back to public models. -// - Personal scope (`token` set, no `organizationID`): reads `/api/openrouter/models`; on 401 falls back to the -// anonymous free catalog, matching v1 (`api/models.ts:239-242`). -// - Anonymous scope (no `token`): reads `/api/openrouter/models` without `Authorization` and keeps only `isFree` models. +// - Team scope (`organizationID` set): reads `${aiGatewayBase}/organizations/{id}/models` (or `/api/organizations/{id}/models` +// on custom servers) and never falls back to public models. +// - Personal scope (`token` set, no `organizationID`): reads `${aiGatewayBase}/models` (or `/api/openrouter/models` on +// custom servers); on 401 falls back to the anonymous free catalog, matching v1 (`api/models.ts:239-242`). +// - Anonymous scope (no `token`): reads `${aiGatewayBase}/models` without `Authorization` and keeps only `isFree` models. export function get(scope: Scope): Effect.Effect { return Effect.gen(function* () { const server = KiloServer.normalize(scope.server) diff --git a/packages/kilo-gateway-core/src/server.ts b/packages/kilo-gateway-core/src/server.ts index 7de15681dbb2..cf9b6c258850 100644 --- a/packages/kilo-gateway-core/src/server.ts +++ b/packages/kilo-gateway-core/src/server.ts @@ -84,7 +84,7 @@ export function modelsUrl( return organizationID ? `${base}/organizations/${encodeURIComponent(organizationID)}/models` : `${base}/models` } if (organizationID) { - return `${normalize(server!)}/api/organizations/${encodeURIComponent(organizationID)}/models` + return apiUrl(server!, "organizations", encodeURIComponent(organizationID), "models") } - return `${legacyRoute(server!, "openrouter")}/models` + return apiUrl(server!, "openrouter", "models") }