diff --git a/packages/core/src/kilocode/gateway-rpc.ts b/packages/core/src/kilocode/gateway-rpc.ts index 8f5b4bee1ecd..01b3e7b099b6 100644 --- a/packages/core/src/kilocode/gateway-rpc.ts +++ b/packages/core/src/kilocode/gateway-rpc.ts @@ -20,8 +20,34 @@ 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), + 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 +61,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..5165a6455ca1 100644 --- a/packages/core/src/kilocode/gateway.ts +++ b/packages/core/src/kilocode/gateway.ts @@ -1,17 +1,35 @@ -import { KiloAccount, KiloDevice, KiloHeaders, KiloProfile, KiloServer } from "@kilocode/gateway-core" +import { + KiloAccount, + KiloDefaults, + KiloDevice, + KiloHeaders, + KiloModels, + 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" -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) +const MAX_SNAPSHOTS = 10 +const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]) +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({ @@ -21,75 +39,375 @@ 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 connection?: ConnectionInfo + readonly envActive: boolean + readonly models: readonly KiloModels.Model[] + readonly defaultModelID?: string + readonly failed: boolean + readonly anonymous: boolean + readonly skippedOfflineTest?: 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 cancelRetry = Effect.gen(function* () { + if (!retryFiber) return + const fiber = retryFiber + retryFiber = undefined + yield* Fiber.interrupt(fiber) + }) - const load = Effect.gen(function* () { + 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, + connection: undefined, + 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, + connection, + 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 }), + 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) } - if (account) { - if (envActive) { - provider.activation = "enabled" - provider.settings = { ...settings, apiKey: account.token } - return + lastGood.set(writeKey, models) + 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)) + } } - provider.activation = "auto" - provider.settings = settings + } + yield* ctx.storage.set(writeKey, JSON.stringify(models)).pipe(Effect.catch(() => Effect.void)) + }) + + 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 connection = resolved.connection + const envActive = resolved.envActive + const base = current?.server ?? server + const organizationID = current?.organizationID ?? null + 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`, `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, + envActive, + models: [], + failed: false, + anonymous: !token, + skippedOfflineTest: true, + } + 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) { + 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, connection, 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 + 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 + ? undefined + : snapshotKey(base, fetched.success.anonymous ? undefined : token) + if (writeKey && models.length > 0) { + yield* saveSnapshot(writeKey, models) + } + catalog = { + account: current, + connection, + envActive, + models, + defaultModelID: resolvedDefault, + failed: false, + anonymous: fetched.success.anonymous, + } + if (models.length === 0) { + yield* scheduleRetry({ kind: "network" }, targetEpoch, attempt) + } + return + } + + // Team scope always fails closed on error: never fall back to personal, anonymous, or models.dev models. + if (organizationID) { + catalog = { account: current, connection, 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, + connection, + envActive, + models: stored, + defaultModelID, + failed: false, + anonymous: !token, + } + } else { + catalog = { account: current, connection, 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) || 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( + 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.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) + 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] + } + + 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"`. + 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 + const sourceConnection = + !account || catalog.anonymous || catalog.envActive ? undefined : catalog.connection + + 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)), + ...(sourceConnection ? { sourceConnection } : {}), }) }) - 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 && + models.list(id).some((model) => model.id === Model.ID.make(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 +419,133 @@ 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.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 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) + } }), }) +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 { + const route = KiloServer.gateway(server) + if (!token) return `catalog:anonymous:${route}` + const hash = createHash("sha256").update(`${route}:${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 8520845a61a6..8c48112289b0 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" @@ -38,7 +40,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) => { @@ -47,6 +49,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) @@ -128,70 +131,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://ai-gateway.kilo.ai/api/v1", 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/v1`, 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/v1`) + 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]()), ), ) @@ -219,7 +344,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")) @@ -263,17 +390,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..83c7e79fb5fd --- /dev/null +++ b/packages/kilo-gateway-core/src/defaults.ts @@ -0,0 +1,48 @@ +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 organizationID = scope.token ? (scope.organizationID ?? null) : null + 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(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 + 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..7511d77409ca --- /dev/null +++ b/packages/kilo-gateway-core/src/models.ts @@ -0,0 +1,355 @@ +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 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)), + variants: Schema.optionalKey(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))), + ai_sdk_provider: Schema.optionalKey(Schema.Unknown), + }), + ), +}) + +export const CatalogEnvelope = Schema.Struct({ + data: Schema.Array(Schema.Unknown), +}) + +type RawModel = typeof RawModel.Type +const decodeEnvelope = Schema.decodeUnknownOption(CatalogEnvelope) +const decodeRawModel = Schema.decodeUnknownOption(RawModel) + +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 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 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 `${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) + const organizationID = scope.token ? (scope.organizationID ?? null) : null + const url = KiloServer.modelsUrl(server, organizationID) + const headers: Record = { + ...KiloHeaders.provider({ version: scope.version ?? "unknown", organizationID }), + ...(scope.token ? { Authorization: `Bearer ${scope.token}` } : {}), + } + const response = yield* request(url, { 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 = decodeEnvelope(raw) + if (Option.isNone(parsed)) return yield* Effect.fail({ kind: "schema" } satisfies Failure) + const anonymous = !scope.token + return { + models: catalogModels(parsed.value, anonymous), + anonymous, + } + }) +} + +// 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) +} + +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.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 }), + }), + 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/server.ts b/packages/kilo-gateway-core/src/server.ts index af699bacbe65..cf9b6c258850 100644 --- a/packages/kilo-gateway-core/src/server.ts +++ b/packages/kilo-gateway-core/src/server.ts @@ -70,3 +70,21 @@ export function gateway(server?: string, env: Record if (isConfiguredBase(server, env)) return aiGatewayFromEnv(env) return apiUrl(server!, "gateway") } + +// Resolves the models catalog endpoint, matching `fetchRawKiloModels` / `CloudCatalog.models` on main: +// - For the configured AI gateway base: `${aiGatewayBase}/models` or `${aiGatewayBase}/organizations/${id}/models`. +// - For custom or token-embedded server URLs: `${openrouterBase}/models` or `${server}/api/organizations/${id}/models`. +export function modelsUrl( + server?: string, + organizationID?: string | null, + env: Record = process.env, +) { + if (isConfiguredBase(server, env)) { + const base = aiGatewayFromEnv(env) + return organizationID ? `${base}/organizations/${encodeURIComponent(organizationID)}/models` : `${base}/models` + } + if (organizationID) { + return apiUrl(server!, "organizations", encodeURIComponent(organizationID), "models") + } + return apiUrl(server!, "openrouter", "models") +} diff --git a/packages/kilo-gateway-core/src/testing.ts b/packages/kilo-gateway-core/src/testing.ts index 6642d44c3fde..fae5cee488de 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,29 @@ 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" || + path === "/api/gateway/v1/models" || + path === "/api/v1/models" + ) { + 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) + } + if (/^\/api\/(?:gateway\/v1\/|v1\/)?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..3a765e30498d --- /dev/null +++ b/packages/kilo-gateway-core/test/models.test.ts @@ -0,0 +1,175 @@ +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", + 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", + 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 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", + 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]?.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", + ) + }) +})