From c05ccf0a8267ab28dafc07efc4ff23063594f35e Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" <260744684+kilo-maintainer[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:39:58 +0000 Subject: [PATCH 01/18] chore(jetbrains): bump CLI pin to v7.4.11 --- packages/kilo-jetbrains/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 82148591799..e988f663c74 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -8,7 +8,7 @@ "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" }, - "version": "7.4.9", + "version": "7.4.11", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} From 520d27d295f212c93b167d7199b9b936967bbb86 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Fri, 17 Jul 2026 18:17:52 +0300 Subject: [PATCH 02/18] feat(opencode): add AI/ML API (aimlapi.com) provider - inject aimlapi provider into the models.dev overlay (openai-compatible, AIMLAPI_API_KEY, dynamic model list from GET /models filtered to chat models; coding-tagged models carry recommendedIndex) - ModelCache: aimlapi fetcher with config/auth/env resolution (AIMLAPI_API_KEY, AIMLAPI_INFERENCE_URL overrides) - provider picker: aimlapi.com in Popular right after Kilo Gateway with "(1000+ models, one-click setup)" - settings metadata + en i18n note; changeset Co-Authored-By: Claude Fable 5 --- .changeset/aimlapi-provider.md | 6 ++ bun.lock | 2 +- packages/kilo-i18n/src/en.ts | 1 + .../cli/cmd/tui/component/dialog-provider.tsx | 13 +-- .../src/kilocode/provider/metadata.ts | 13 ++- packages/opencode/src/provider/model-cache.ts | 84 ++++++++++++++++++- packages/opencode/src/provider/models.ts | 21 +++++ 7 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 .changeset/aimlapi-provider.md diff --git a/.changeset/aimlapi-provider.md b/.changeset/aimlapi-provider.md new file mode 100644 index 00000000000..b3897417cc0 --- /dev/null +++ b/.changeset/aimlapi-provider.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +--- + +Add AI/ML API (aimlapi.com) provider: 300+ chat models with one API key, dynamic model list, available in the provider picker. diff --git a/bun.lock b/bun.lock index 44a2d204727..cc3e29d3f31 100644 --- a/bun.lock +++ b/bun.lock @@ -295,7 +295,7 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.9", + "version": "7.4.11", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index 9eb679e3a03..3b6d7b722ff 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -16,6 +16,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Recommended", "settings.providers.note.kilo": "Access 500+ AI models", + "settings.providers.note.aimlapi": "1000+ models from all major providers, one-click setup", "settings.providers.note.opencode": "Curated models including Claude, GPT, Gemini and more", "settings.providers.note.anthropic": "Direct access to Claude models, including Pro and Max", "settings.providers.note.deepseek": "DeepSeek models for reasoning and coding tasks", diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx index 342d708853c..d5fafd14cab 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx @@ -47,11 +47,12 @@ export function failedDescription(providerID: string, failed: string[]): string export const PROVIDER_PRIORITY: Record = { kilo: -1, - anthropic: 0, - "github-copilot": 1, - openai: 2, - google: 3, - "anaconda-desktop": 4, + aimlapi: 0, + anthropic: 1, + "github-copilot": 2, + openai: 3, + google: 4, + "anaconda-desktop": 5, } // --------------------------------------------------------------------------- @@ -60,12 +61,14 @@ export const PROVIDER_PRIORITY: Record = { export const PROVIDER_DESCRIPTIONS: Record = { kilo: "(Recommended)", + aimlapi: "(1000+ models, one-click setup)", anthropic: "(Claude Max or API key)", openai: "(ChatGPT login or API key)", "anaconda-desktop": "(Local models)", } export const PROVIDER_TITLES: Record = { + aimlapi: "aimlapi.com", openai: "OpenAI / Codex", } diff --git a/packages/opencode/src/kilocode/provider/metadata.ts b/packages/opencode/src/kilocode/provider/metadata.ts index 679f194c380..ebc5fd6bfc2 100644 --- a/packages/opencode/src/kilocode/provider/metadata.ts +++ b/packages/opencode/src/kilocode/provider/metadata.ts @@ -8,6 +8,7 @@ export type ProviderMetadata = { const notes: Record = { kilo: "settings.providers.note.kilo", + aimlapi: "settings.providers.note.aimlapi", opencode: "settings.providers.note.opencode", anthropic: "settings.providers.note.anthropic", deepseek: "settings.providers.note.deepseek", @@ -19,7 +20,17 @@ const notes: Record = { "anaconda-desktop": "settings.providers.note.anacondaDesktop", } -const order = ["kilo", "anthropic", "deepseek", "openai", "google", "anaconda-desktop", "openrouter", "vercel"] as const +const order = [ + "kilo", + "aimlapi", + "anthropic", + "deepseek", + "openai", + "google", + "anaconda-desktop", + "openrouter", + "vercel", +] as const const priority = new Map(order.map((id, index) => [id, index])) diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index 542ceab8b6e..b3ed38ac792 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -53,6 +53,22 @@ const ApertisItem = Schema.Struct({ id: Schema.String, owned_by: Schema.optional const ApertisResponse = Schema.Struct({ data: Schema.optional(Schema.Array(ApertisItem)) }) type ApertisItem = Schema.Schema.Type +const AIMLAPI_BASE_URL = "https://api.aimlapi.com/v1" +const AimlapiInfo = Schema.Struct({ + name: Schema.optional(Schema.String), + developer: Schema.optional(Schema.String), + releasedAt: Schema.optional(Schema.String), + contextLength: Schema.optional(Schema.Finite), +}) +const AimlapiItem = Schema.Struct({ + id: Schema.String, + type: Schema.optional(Schema.String), + info: Schema.optional(AimlapiInfo), + tags: Schema.optional(Schema.Array(Schema.String)), +}) +const AimlapiResponse = Schema.Struct({ data: Schema.optional(Schema.Array(AimlapiItem)) }) +type AimlapiItem = Schema.Schema.Type + export const layer: Layer.Layer< Service, never, @@ -115,8 +131,54 @@ export const layer: Layer.Layer< return Object.fromEntries((json.data ?? []).map((item) => [item.id, aperture(item)])) }) + const aimlapiModel = (item: AimlapiItem): Models[string] => ({ + id: item.id, + name: item.info?.name ?? item.id, + family: item.info?.developer ?? "", + release_date: item.info?.releasedAt ?? "", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + cost: { input: 0, output: 0 }, + limit: { context: item.info?.contextLength ?? 128000, output: 8192 }, + modalities: { input: ["text"], output: ["text"] }, + }) + + const fetchAimlapiModels = Effect.fn("ModelCache.fetchAimlapiModels")(function* (options: Options) { + const baseURL = options.baseURL ?? AIMLAPI_BASE_URL + if (!options.apiKey) { + log.debug("no API key for aimlapi, skipping model fetch") + return {} + } + + const url = `${baseURL.replace(/\/+$/, "")}/models` + const response = yield* HttpClientRequest.get(url).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(options.apiKey), + http.execute, + Effect.timeout("10 seconds"), + ) + if (response.status < 200 || response.status >= 300) { + log.error("aimlapi model fetch failed", { status: response.status }) + return {} + } + + const json = yield* HttpClientResponse.schemaBodyJson(AimlapiResponse)(response) + const chat = (json.data ?? []).filter((item) => item.type === "openai/chat-completions") + // Coding-tagged models rank first so the picker surfaces them on top. + let recommended = 0 + return Object.fromEntries( + chat.map((item) => { + const base = aimlapiModel(item) + const model = item.tags?.includes("playground:code") ? { ...base, recommendedIndex: recommended++ } : base + return [item.id, model] as const + }), + ) + }) + const authOptions = Effect.fn("ModelCache.authOptions")(function* (providerID: string) { - if (providerID !== "kilo" && providerID !== "apertis") return {} + if (providerID !== "kilo" && providerID !== "apertis" && providerID !== "aimlapi") return {} const config = yield* cfg.get() const options: Options = {} @@ -157,12 +219,29 @@ export const layer: Layer.Layer< }) } + if (providerID === "aimlapi") { + const item = config.provider?.[providerID] + if (item?.options?.apiKey) options.apiKey = item.options.apiKey + if (item?.options?.baseURL) options.baseURL = item.options.baseURL + + const info = yield* auth.get(providerID) + if (info?.type === "api") options.apiKey = info.key + if (process.env.AIMLAPI_API_KEY) options.apiKey = process.env.AIMLAPI_API_KEY + if (process.env.AIMLAPI_INFERENCE_URL) options.baseURL = process.env.AIMLAPI_INFERENCE_URL + log.debug("aimlapi auth options resolved", { + providerID, + hasKey: !!options.apiKey, + hasBaseURL: !!options.baseURL, + }) + } + return options }) const fetchModels = (providerID: string, options: Options): Effect.Effect => { if (providerID === "kilo") return kilo.fetch(options) if (providerID === "apertis") return fetchApertisModels(options).pipe(Effect.map((models) => ({ models }))) + if (providerID === "aimlapi") return fetchAimlapiModels(options).pipe(Effect.map((models) => ({ models }))) log.debug("provider not implemented", { providerID }) return Effect.succeed({ models: {} }) } @@ -183,7 +262,8 @@ export const layer: Layer.Layer< if (providerID === "kilo") { return JSON.stringify([providerID, options?.baseURL, options?.kilocodeOrganizationId, options?.kilocodeToken]) } - if (providerID === "apertis") return JSON.stringify([providerID, options?.baseURL, options?.apiKey]) + if (providerID === "apertis" || providerID === "aimlapi") + return JSON.stringify([providerID, options?.baseURL, options?.apiKey]) return providerID } diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 6a693cde481..8279f6cede1 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -67,7 +67,27 @@ export const layer: Layer.Layer Effect.succeed({}))) + providers.aimlapi = { + id: "aimlapi", + name: "aimlapi.com", + env: ["AIMLAPI_API_KEY"], + api: aimlURL, + npm: "@ai-sdk/openai-compatible", + models, + } + if (Object.keys(models).length === 0) + yield* cache.refresh("aimlapi", aimlOpts).pipe(Effect.ignore, Effect.forkDetach) + }) + if (!allowed) { + yield* addAimlapi() yield* addApertis() return providers } @@ -90,6 +110,7 @@ export const layer: Layer.Layer Date: Fri, 17 Jul 2026 19:22:27 +0300 Subject: [PATCH 03/18] feat(opencode): AI/ML API guided onboarding in the provider connect flow Selecting aimlapi.com now opens a guided setup instead of the generic API-key prompt: - paste an existing key (validated with a live balance probe), or - sign in by email: existing accounts get a 6-digit code and an auto-issued key; new accounts register passwordless, top up via the hosted checkout (browser + fallback link, session polling, idempotent retry) and receive their key automatically - reconfigure screen when the provider is already connected Implementation lives in the kilocode overlay (src/kilocode/aimlapi/): config with prod defaults + AIMLAPI_*_URL env overrides, HTTP client, UI-agnostic flow state machine (unit-tested), SolidJS TUI screens; wired through the existing KiloProvider.selectProvider hook. Co-Authored-By: Claude Fable 5 --- .changeset/aimlapi-provider.md | 2 +- packages/opencode/package.json | 24 +- .../opencode/src/kilocode/aimlapi/client.ts | 345 ++++++++++++++++++ .../opencode/src/kilocode/aimlapi/config.ts | 116 ++++++ .../opencode/src/kilocode/aimlapi/flow.ts | 341 +++++++++++++++++ .../src/kilocode/aimlapi/tui/setup.tsx | 237 ++++++++++++ .../cli/cmd/tui/component/dialog-provider.tsx | 10 +- .../test/kilocode/aimlapi-flow.test.ts | 311 ++++++++++++++++ 8 files changed, 1372 insertions(+), 14 deletions(-) create mode 100644 packages/opencode/src/kilocode/aimlapi/client.ts create mode 100644 packages/opencode/src/kilocode/aimlapi/config.ts create mode 100644 packages/opencode/src/kilocode/aimlapi/flow.ts create mode 100644 packages/opencode/src/kilocode/aimlapi/tui/setup.tsx create mode 100644 packages/opencode/test/kilocode/aimlapi-flow.test.ts diff --git a/.changeset/aimlapi-provider.md b/.changeset/aimlapi-provider.md index b3897417cc0..832a495fcd2 100644 --- a/.changeset/aimlapi-provider.md +++ b/.changeset/aimlapi-provider.md @@ -3,4 +3,4 @@ "kilo-code": minor --- -Add AI/ML API (aimlapi.com) provider: 300+ chat models with one API key, dynamic model list, available in the provider picker. +Add AI/ML API (aimlapi.com) provider with guided onboarding: 300+ chat models with one API key and a dynamic model list, plus an in-TUI connect flow — paste an existing key, or sign in by email (passwordless code for existing accounts; registration with checkout top-up and automatic key issuance for new ones). diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 231bb477aec..bee051299a2 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -110,21 +110,27 @@ "@npmcli/arborist": "9.4.0", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", + "@openauthjs/openauth": "catalog:", "@opencode-ai/llm": "workspace:*", "@opencode-ai/script": "workspace:*", + "@opencode-ai/server": "workspace:*", "@opencode-ai/ui": "workspace:*", "@openrouter/ai-sdk-provider": "2.9.0", "@opentelemetry/api": "1.9.0", + "@opentelemetry/context-async-hooks": "2.6.1", + "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", + "@pierre/diffs": "catalog:", "@secretlint/core": "10.2.2", "@secretlint/secretlint-rule-preset-recommend": "10.2.2", "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", + "@standard-schema/spec": "1.0.0", "@types/ws": "8.18.1", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", @@ -132,6 +138,7 @@ "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chardet": "2.1.1", + "chokidar": "4.0.3", "cli-sound": "1.1.3", "clipboardy": "4.0.0", "cross-spawn": "catalog:", @@ -141,6 +148,7 @@ "effect": "catalog:", "fuzzysort": "3.1.0", "gitlab-ai-provider": "6.8.0", + "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", "htmlparser2": "8.0.2", @@ -150,11 +158,13 @@ "jsonc-parser": "3.3.1", "mammoth": "1.12.0", "mime-types": "3.0.2", + "minimatch": "10.0.3", "npm-package-arg": "13.0.2", "open": "10.1.2", "opencode-gitlab-auth": "2.0.1", "opencode-poe-auth": "0.0.1", "opentui-spinner": "catalog:", + "partial-json": "0.1.7", "remeda": "catalog:", "ripgrep": "0.3.1", "semver": "^7.6.3", @@ -171,20 +181,10 @@ "web-tree-sitter": "0.25.10", "which": "6.0.1", "ws": "8.21.0", + "xdg-basedir": "5.1.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "yargs": "18.0.0", - "zod": "catalog:", - "@openauthjs/openauth": "catalog:", - "@opencode-ai/server": "workspace:*", - "@opentelemetry/context-async-hooks": "2.6.1", - "@opentelemetry/exporter-trace-otlp-http": "0.214.0", - "@pierre/diffs": "catalog:", - "@standard-schema/spec": "1.0.0", - "chokidar": "4.0.3", - "glob": "13.0.5", - "minimatch": "10.0.3", - "partial-json": "0.1.7", - "xdg-basedir": "5.1.0" + "zod": "catalog:" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/opencode/src/kilocode/aimlapi/client.ts b/packages/opencode/src/kilocode/aimlapi/client.ts new file mode 100644 index 00000000000..dbd538c3822 --- /dev/null +++ b/packages/opencode/src/kilocode/aimlapi/client.ts @@ -0,0 +1,345 @@ +// kilocode_change - new file +// +// AI/ML API passwordless onboarding and partner-checkout HTTP client. +// Protocol mirrors the AIMLAPI integrations shipped in Zero and OpenClaude. + +import type { AimlapiEndpoints } from "./config" + +export type PartnerCheckoutSessionStatus = + | "pending_auth" + | "pending_payment" + | "paid" + | "exchanging" + | "exchanged" + | "cancelled" + | "expired" + | "failed" + +export type PartnerCheckoutSession = { + id: string + sessionToken: string + partnerId: string + partnerName: string | null + userId: number | null + amountUsdMinor: number | null + status: PartnerCheckoutSessionStatus + issuedKeyId: string | null + returnUrl: string | null +} + +export type PaymentSession = { + providerSessionId: string + payUrl: string | null +} + +export type PayResult = { + checkout: PaymentSession + partnerCheckout: PartnerCheckoutSession +} + +export type ExchangeResult = { apiKey: string; apiKeyId: string } +export type AuthResult = { token: string; exp: number } +export type AccountCheckResult = { + action: "sign-in" | "sign-up" + provider?: string | null +} +export type CreatedKey = { key: string; id: string } +export type BalanceResult = { + balance: number + lowBalance: boolean + lowBalanceThreshold: number +} + +const REQUEST_TIMEOUT_MS = 60_000 +const MAX_RESPONSE_BODY_BYTES = 1 << 20 + +function requestLabel(url: string): string { + try { + return new URL(url).origin + } catch { + return "AI/ML API endpoint" + } +} + +function redactRequestSecrets(message: string, url: string, bearer: string | undefined): string { + const secrets = new Set() + if (bearer?.trim()) secrets.add(bearer.trim()) + try { + for (const segment of new URL(url).pathname.split("/")) { + if (segment.length < 6) continue + secrets.add(segment) + try { + secrets.add(decodeURIComponent(segment)) + } catch { + // keep the encoded segment when it is not valid percent-encoding + } + } + } catch { + // the request label already handles malformed URLs without exposing them + } + let redacted = message + for (const secret of secrets) { + if (secret) redacted = redacted.split(secret).join("[REDACTED]") + } + return redacted +} + +function isBalanceResult(value: unknown): value is BalanceResult { + if (typeof value !== "object" || value === null) return false + const result = value as Record + return ( + typeof result["balance"] === "number" && + Number.isFinite(result["balance"]) && + typeof result["lowBalance"] === "boolean" && + typeof result["lowBalanceThreshold"] === "number" && + Number.isFinite(result["lowBalanceThreshold"]) + ) +} + +export class AimlapiApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly body: string, + ) { + super(message) + this.name = "AimlapiApiError" + } +} + +async function readResponseText(response: Response): Promise { + if (!response.body) return "" + const reader = response.body.getReader() + const decoder = new TextDecoder() + let totalBytes = 0 + let text = "" + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + totalBytes += value.byteLength + if (totalBytes > MAX_RESPONSE_BODY_BYTES) { + try { + await reader.cancel() + } catch { + // keep the deterministic size-limit error if cancellation fails + } + throw new AimlapiApiError(`AI/ML API response body exceeds ${MAX_RESPONSE_BODY_BYTES} bytes`, 0, "") + } + text += decoder.decode(value, { stream: true }) + } + return text + decoder.decode() + } finally { + reader.releaseLock() + } +} + +export class AimlapiClient { + constructor(private readonly endpoints: AimlapiEndpoints) {} + + /** S4 branch point: does this email belong to an existing account? */ + async checkAccount(email: string, signal?: AbortSignal): Promise { + return this.request(`${this.endpoints.authBaseUrl}/v1/auth/account`, { + method: "PATCH", + body: { email }, + signal, + }) + } + + async sendSignInCode(email: string, signal?: AbortSignal): Promise { + await this.request(`${this.endpoints.authBaseUrl}/v1/auth/sign-in/code`, { + method: "POST", + body: { email }, + signal, + expectJson: false, + }) + } + + async verifySignInCode(email: string, code: string, signal?: AbortSignal): Promise { + const result = await this.request(`${this.endpoints.authBaseUrl}/v1/auth/sign-in/code/verify`, { + method: "POST", + body: { email, code }, + signal, + }) + if (!result.token?.trim()) throw new Error("AI/ML API did not return an auth token.") + return result + } + + async createPasswordlessAccount(email: string, signal?: AbortSignal): Promise { + const result = await this.request(`${this.endpoints.authBaseUrl}/v1/auth/account/passwordless`, { + method: "POST", + body: { email }, + signal, + }) + if (!result.token?.trim()) throw new Error("AI/ML API did not return an auth token.") + return result + } + + async createKey(bearer: string, name: string, signal?: AbortSignal): Promise { + const result = await this.request(`${this.endpoints.appBaseUrl}/v1/keys`, { + method: "POST", + bearer, + body: name.trim() ? { name: name.trim() } : {}, + signal, + }) + if (!result.key?.trim()) throw new Error("AI/ML API did not return an API key.") + return result + } + + /** Also serves as the pasted-key validation probe (S3). */ + async getBalance(apiKey: string, signal?: AbortSignal): Promise { + const url = `${this.endpoints.inferenceBaseUrl.replace(/\/+$/, "")}/billing/balance` + const result = await this.request(url, { method: "GET", bearer: apiKey, signal }) + if (!isBalanceResult(result)) { + throw new AimlapiApiError(`GET ${requestLabel(url)} returned invalid balance response`, 200, "") + } + return result + } + + async createSession( + input: { partnerId: string; partnerName?: string | null; returnUrl?: string | null }, + signal?: AbortSignal, + ): Promise { + return this.request(`${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions`, { + method: "POST", + body: { + partnerId: input.partnerId, + ...(input.partnerName ? { partnerName: input.partnerName } : {}), + ...(input.returnUrl ? { returnUrl: input.returnUrl } : {}), + }, + signal, + }) + } + + async getSession(sessionToken: string, signal?: AbortSignal): Promise { + return this.request( + `${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}`, + { method: "GET", signal }, + ) + } + + async pay( + bearer: string, + sessionToken: string, + input: { + amountUsdMinor: number + paymentSessionId: string + successUrl?: string + cancelUrl?: string + autoTopUp?: boolean + }, + signal?: AbortSignal, + ): Promise { + return this.request( + `${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}/pay`, + { + method: "POST", + bearer, + body: { + amountUsdMinor: input.amountUsdMinor, + paymentSessionId: input.paymentSessionId, + method: "card", + ...(input.successUrl ? { successUrl: input.successUrl } : {}), + ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), + ...(input.autoTopUp ? { autoTopUp: true } : {}), + }, + signal, + }, + ) + } + + /** Top-up for a user who already holds a key (S12 → top-up path). */ + async topUpByKey( + apiKey: string, + input: { + sessionToken: string + amountUsdMinor: number + paymentSessionId: string + successUrl?: string + cancelUrl?: string + autoTopUp?: boolean + }, + signal?: AbortSignal, + ): Promise { + const inferenceBase = this.endpoints.inferenceBaseUrl + .trim() + .replace(/\/+$/, "") + .replace(/\/v1$/i, "") + return this.request(`${inferenceBase}/v2/billing/topup`, { + method: "POST", + bearer: apiKey, + body: { + sessionToken: input.sessionToken, + amountUsdMinor: input.amountUsdMinor, + paymentSessionId: input.paymentSessionId, + ...(input.successUrl ? { successUrl: input.successUrl } : {}), + ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), + ...(input.autoTopUp ? { autoTopUp: true } : {}), + }, + signal, + }) + } + + async exchange(bearer: string, sessionToken: string, signal?: AbortSignal): Promise { + return this.request( + `${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}/exchange`, + { method: "POST", bearer, signal }, + ) + } + + private async request( + url: string, + options: { + method: "GET" | "POST" | "PATCH" + body?: unknown + bearer?: string + signal?: AbortSignal + expectJson?: boolean + }, + ): Promise { + const label = requestLabel(url) + const headers: Record = { Accept: "application/json" } + if (options.body !== undefined) headers["Content-Type"] = "application/json" + if (options.bearer) headers["Authorization"] = `Bearer ${options.bearer.trim()}` + + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS) + const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout + + let response: Response + try { + response = await fetch(url, { + method: options.method, + headers, + signal, + ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), + }) + } catch (error) { + if (options.signal?.aborted) throw error + const reason = redactRequestSecrets(error instanceof Error ? error.message : String(error), url, options.bearer) + throw new AimlapiApiError(`Network request to ${label} failed: ${reason}`, 0, "") + } + + let text: string + try { + text = await readResponseText(response) + } catch (error) { + if (options.signal?.aborted) throw error + if (error instanceof AimlapiApiError) throw error + const reason = redactRequestSecrets(error instanceof Error ? error.message : String(error), url, options.bearer) + throw new AimlapiApiError(`Network response from ${label} failed: ${reason}`, 0, "") + } + + if (!response.ok) { + throw new AimlapiApiError(`${options.method} ${label} -> ${response.status}`, response.status, text) + } + if (!text.trim()) { + if (options.expectJson === false) return undefined as T + throw new AimlapiApiError(`${options.method} ${label} returned empty body`, response.status, "") + } + try { + return JSON.parse(text) as T + } catch { + throw new AimlapiApiError(`${options.method} ${label} returned non-JSON body`, response.status, text) + } + } +} diff --git a/packages/opencode/src/kilocode/aimlapi/config.ts b/packages/opencode/src/kilocode/aimlapi/config.ts new file mode 100644 index 00000000000..231080f80e4 --- /dev/null +++ b/packages/opencode/src/kilocode/aimlapi/config.ts @@ -0,0 +1,116 @@ +// kilocode_change - new file +// +// AI/ML API (aimlapi.com) guided onboarding — endpoint & attribution config. +// +// Production URLs are the compiled-in defaults; every host is overridable via +// the corresponding AIMLAPI_*_URL env var (used by the AIMLAPI team to test +// against staging). Do not hardcode non-production URLs here. + +export type AimlapiEndpoints = { + /** auth service - mints the user access (Bearer) token. */ + authBaseUrl: string + /** app BFF - hosts `/v3/partner-checkout/*` and `/v1/keys`. */ + appBaseUrl: string + /** hosted checkout frontend base URL. */ + payBaseUrl: string + /** OpenAI-compatible inference base URL (provider `api`). */ + inferenceBaseUrl: string + /** browser landing page after checkout / consent completes. */ + verificationBaseUrl: string +} + +const DEFAULT_ENDPOINTS: AimlapiEndpoints = { + authBaseUrl: "https://auth.aimlapi.com", + appBaseUrl: "https://app.aimlapi.com", + payBaseUrl: "https://pay.aimlapi.com", + inferenceBaseUrl: "https://api.aimlapi.com/v1", + verificationBaseUrl: "https://aimlapi.com/app", +} + +/** + * Partner id (`^part_[A-Za-z0-9]{1,64}$`) — rebate attribution for Kilo Code. + * Must match an active partner registered with AI/ML API. Overridable via + * AIMLAPI_PARTNER_ID (the AIMLAPI team uses a test partner on staging). + * + * TODO(aimlapi): replace with the production Kilo Code partner id before the + * upstream PR ships. + */ +export const DEFAULT_PARTNER_ID = "" +export const DEFAULT_PARTNER_NAME = "Kilo Code" + +/** Name attached to API keys issued through this flow. */ +export const ISSUED_KEY_NAME = "Kilo CLI" + +/** Top-up bounds enforced by the backend DTO (USD minor units / cents). */ +export const MIN_AMOUNT_USD_MINOR = 2000 // $20 +export const MAX_AMOUNT_USD_MINOR = 1_000_000 // $10,000 +export const DEFAULT_AMOUNT_USD_MINOR = 2500 // $25 + +export const DEFAULT_RETURN_URL = "https://aimlapi.com/app" + +export function resolveEndpoints(): AimlapiEndpoints { + return { + authBaseUrl: process.env["AIMLAPI_AUTH_URL"]?.trim() || DEFAULT_ENDPOINTS.authBaseUrl, + appBaseUrl: process.env["AIMLAPI_APP_URL"]?.trim() || DEFAULT_ENDPOINTS.appBaseUrl, + payBaseUrl: process.env["AIMLAPI_PAY_URL"]?.trim() || DEFAULT_ENDPOINTS.payBaseUrl, + inferenceBaseUrl: process.env["AIMLAPI_INFERENCE_URL"]?.trim() || DEFAULT_ENDPOINTS.inferenceBaseUrl, + verificationBaseUrl: + process.env["AIMLAPI_VERIFICATION_BASE_URL"]?.trim() || DEFAULT_ENDPOINTS.verificationBaseUrl, + } +} + +/** Resolve checkout attribution with one shared precedence. */ +export function resolvePartnerId(explicit?: string): string { + return explicit?.trim() || process.env["AIMLAPI_PARTNER_ID"]?.trim() || DEFAULT_PARTNER_ID +} + +/** + * Build the co-branded checkout return URLs the hosted payment page redirects + * to after the user pays or cancels. `sessionToken` + `partnerCheckout=1` make + * the AI/ML API `/checkout` page render the co-branded result screen. + */ +export function buildPartnerCheckoutReturnUrls( + payBaseUrl: string, + sessionToken: string, +): { successUrl?: string; cancelUrl?: string } { + const base = safeHttpBaseUrl(payBaseUrl) + if (!base) return {} + const token = encodeURIComponent(sessionToken) + const query = (status: string): string => `checkout=${status}&partnerCheckout=1&sessionToken=${token}` + return { + successUrl: `${base}/checkout?${query("success")}`, + cancelUrl: `${base}/checkout?${query("cancel")}`, + } +} + +/** + * Browser landing URL after checkout. The CLI learns success by polling, so + * this must be an ordinary HTTPS page rather than an unregistered custom scheme. + */ +export function buildPartnerReturnUrl(frontendBaseUrl: string): string { + const override = safeHttpBaseUrl(process.env["AIMLAPI_RETURN_URL"]) + if (override) return override + return safeHttpBaseUrl(frontendBaseUrl) ?? DEFAULT_RETURN_URL +} + +function safeHttpBaseUrl(value: string | undefined): string | null { + const candidate = value?.trim() + if (!candidate) return null + try { + const url = new URL(candidate) + const loopback = + url.hostname === "localhost" || /^127(?:\.\d+){3}$/.test(url.hostname) || url.hostname === "[::1]" + if ( + (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) || + url.username || + url.password || + url.search || + url.hash + ) { + return null + } + return url.href.replace(/\/+$/, "") + } catch { + return null + } +} diff --git a/packages/opencode/src/kilocode/aimlapi/flow.ts b/packages/opencode/src/kilocode/aimlapi/flow.ts new file mode 100644 index 00000000000..be5ee3f2938 --- /dev/null +++ b/packages/opencode/src/kilocode/aimlapi/flow.ts @@ -0,0 +1,341 @@ +// kilocode_change - new file +// +// AI/ML API guided-onboarding flow controller (UI-agnostic state machine). +// +// Screens map to the shared AIMLAPI onboarding spec: +// choice (S2) -> paste-key (S3) | email (S4) -> code (S10) | credits (S5) +// -> checkout (S6) -> topup-success (S7) -> ready (S11); reconfigure (S12). +// +// The TUI component renders phases and forwards user input; all HTTP, +// validation, retry-idempotency and error-copy logic lives here so it can be +// unit-tested without a terminal. + +import { + DEFAULT_AMOUNT_USD_MINOR, + DEFAULT_PARTNER_NAME, + ISSUED_KEY_NAME, + MAX_AMOUNT_USD_MINOR, + MIN_AMOUNT_USD_MINOR, + buildPartnerCheckoutReturnUrls, + buildPartnerReturnUrl, + resolveEndpoints, + resolvePartnerId, +} from "./config" +import { AimlapiApiError, AimlapiClient, type PartnerCheckoutSession } from "./client" + +// Copy deck — keep verbatim with the shared spec / Figma mockups. +export const COPY = { + choiceTitle: "Do you have aimlapi.com key?", + choiceNewUser: "I am a new user", + choiceHaveKey: "I already have aimlapi.com key", + pasteKeyTitle: "Enter your aimlapi.com key.", + pasteKeyPlaceholder: "Paste your key...", + errInvalidKey: "API key is invalid. Please make sure you enter a valid aimlapi.com key.", + emailTitle: "Enter your email.", + emailPlaceholder: "you@example.com", + errEmailFormat: "Email format is incorrect.", + codeTitle: (email: string) => `Enter the 6-digit code sent to ${email}.`, + codePlaceholder: "123456", + errCodeWrong: "Code is incorrect.", + errCodeExpired: "Invalid or expired code", + creditsTitle: "Add credits (min $20).", + creditsAutoTopUp: "Auto top-up", + errAmountMissing: "Please enter a top-up amount.", + errAmountMin: "Minimum top-up is $20.", + errTopupFailed: "Top up failed. Please try again.", + checkoutTitle: "Opening checkout in browser...", + checkoutBody: "If the browser did not open automatically please use this link to top up your account:", + topupSuccessTitle: (amountUsd: string) => `Top-up successful - $${amountUsd} credited to your account`, + topupSuccessBody: (email: string) => + `We’ve emailed you a magic link to ${email}. Use it to access your aimlapi.com account and review your usage.`, + readyTitle: "Everything is ready.", + reconfigureTitle: "aimlapi.com account is already configured", + reconfigureContinue: "Continue with your saved API key", + reconfigureNewKey: "Set up a new key or switch account", +} as const + +export type FlowPhase = + | { id: "choice" } + | { id: "paste-key"; error?: string } + | { id: "email"; error?: string } + | { id: "code"; email: string; error?: string } + | { id: "credits"; error?: string } + | { id: "checkout"; payUrl: string | null } + | { id: "topup-success"; amountUsd: string; email: string } + | { id: "ready" } + | { id: "reconfigure" } + | { id: "busy"; message: string } + +export type FlowResult = { apiKey: string; apiKeyId?: string } + +export type FlowDeps = { + client?: AimlapiClient + /** Open a URL in the user's browser. */ + openUrl: (url: string) => Promise + change: (phase: FlowPhase) => void + /** Terminal success: persist the key and move to the model picker. */ + complete: (result: FlowResult) => void + now?: () => number + pollIntervalMs?: number + pollTimeoutMs?: number +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/ +const POLL_INTERVAL_MS = 3000 +const POLL_TIMEOUT_MS = 20 * 60 * 1000 + +export function parseAmountUsd(input: string): { ok: true; usdMinor: number } | { ok: false; error: string } { + const raw = input.trim().replace(/^\$/, "") + if (!raw) return { ok: false, error: COPY.errAmountMissing } + const value = Number(raw) + if (!Number.isFinite(value) || value <= 0) return { ok: false, error: COPY.errAmountMissing } + const usdMinor = Math.round(value * 100) + if (usdMinor < MIN_AMOUNT_USD_MINOR) return { ok: false, error: COPY.errAmountMin } + if (usdMinor > MAX_AMOUNT_USD_MINOR) return { ok: false, error: COPY.errTopupFailed } + return { ok: true, usdMinor } +} + +export function validEmail(email: string): boolean { + return EMAIL_RE.test(email.trim()) +} + +function formatUsd(usdMinor: number): string { + const dollars = usdMinor / 100 + return Number.isInteger(dollars) ? String(dollars) : dollars.toFixed(2) +} + +async function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) throw (signal.reason ?? new DOMException("The operation was aborted.", "AbortError")) + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort) + resolve() + }, ms) + const onAbort = () => { + clearTimeout(timer) + reject(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError")) + } + signal?.addEventListener("abort", onAbort, { once: true }) + }) +} + +/** + * One onboarding attempt. Created per dialog; retains checkout idempotency + * state (paymentSessionId + resume token) for the lifetime of the dialog so + * retries never create a duplicate charge. + */ +export class AimlapiFlow { + private readonly endpoints = resolveEndpoints() + private readonly client: AimlapiClient + private readonly abort = new AbortController() + + private email = "" + private authToken = "" + /** Idempotency handle: stable per (amount, autoTopUp) intent. */ + private paymentSessionId = "" + private intentKey = "" + private checkoutSessionToken = "" + + constructor(private readonly deps: FlowDeps) { + this.client = deps.client ?? new AimlapiClient(this.endpoints) + } + + get signal(): AbortSignal { + return this.abort.signal + } + + cancel() { + this.abort.abort() + } + + start(alreadyConfigured: boolean) { + this.deps.change(alreadyConfigured ? { id: "reconfigure" } : { id: "choice" }) + } + + chooseHaveKey() { + this.deps.change({ id: "paste-key" }) + } + + chooseNewUser() { + this.deps.change({ id: "email" }) + } + + chooseReconfigure() { + this.deps.change({ id: "choice" }) + } + + /** S3: validate the pasted key with a live balance probe, then finish. */ + async submitKey(raw: string) { + const key = raw.trim() + if (!key) { + this.deps.change({ id: "paste-key", error: COPY.errInvalidKey }) + return + } + this.deps.change({ id: "busy", message: "Validating key..." }) + try { + await this.client.getBalance(key, this.signal) + } catch (error) { + if (this.signal.aborted) return + this.deps.change({ id: "paste-key", error: COPY.errInvalidKey }) + return + } + this.deps.change({ id: "ready" }) + this.deps.complete({ apiKey: key }) + } + + /** S4: branch on account existence (backend decides). */ + async submitEmail(raw: string) { + const email = raw.trim().toLowerCase() + if (!validEmail(email)) { + this.deps.change({ id: "email", error: COPY.errEmailFormat }) + return + } + this.email = email + this.deps.change({ id: "busy", message: "Checking account..." }) + try { + const account = await this.client.checkAccount(email, this.signal) + if (account.action === "sign-in") { + await this.client.sendSignInCode(email, this.signal) + this.deps.change({ id: "code", email }) + return + } + const auth = await this.client.createPasswordlessAccount(email, this.signal) + this.authToken = auth.token + this.deps.change({ id: "credits" }) + } catch (error) { + if (this.signal.aborted) return + this.deps.change({ id: "email", error: this.describe(error) }) + } + } + + /** S10: verify the 6-digit code, issue a key, finish. */ + async submitCode(raw: string) { + const code = raw.trim() + if (!/^\d{6}$/.test(code)) { + this.deps.change({ id: "code", email: this.email, error: COPY.errCodeWrong }) + return + } + this.deps.change({ id: "busy", message: "Verifying code..." }) + try { + const auth = await this.client.verifySignInCode(this.email, code, this.signal) + this.authToken = auth.token + const created = await this.client.createKey(this.authToken, ISSUED_KEY_NAME, this.signal) + this.deps.change({ id: "ready" }) + this.deps.complete({ apiKey: created.key, apiKeyId: created.id }) + } catch (error) { + if (this.signal.aborted) return + if (error instanceof AimlapiApiError && error.status >= 400 && error.status < 500) { + const expired = error.body.toLowerCase().includes("expired") + this.deps.change({ + id: "code", + email: this.email, + error: expired ? COPY.errCodeExpired : COPY.errCodeWrong, + }) + return + } + this.deps.change({ id: "code", email: this.email, error: this.describe(error) }) + } + } + + /** S5 -> S6 -> S7: create checkout, open browser, poll, exchange the key. */ + async submitAmount(rawAmount: string, autoTopUp: boolean) { + const parsed = parseAmountUsd(rawAmount) + if (!parsed.ok) { + this.deps.change({ id: "credits", error: parsed.error }) + return + } + if (!this.authToken) { + this.deps.change({ id: "email", error: COPY.errTopupFailed }) + return + } + // Same intent -> same paymentSessionId, so a retry can never double-charge. + const intentKey = `${parsed.usdMinor}:${autoTopUp}` + if (this.intentKey !== intentKey || !this.paymentSessionId) { + this.intentKey = intentKey + this.paymentSessionId = crypto.randomUUID() + } + + this.deps.change({ id: "busy", message: "Creating checkout session..." }) + try { + if (!this.checkoutSessionToken) { + const session = await this.client.createSession( + { + partnerId: resolvePartnerId(), + partnerName: DEFAULT_PARTNER_NAME, + returnUrl: buildPartnerReturnUrl(this.endpoints.verificationBaseUrl), + }, + this.signal, + ) + this.checkoutSessionToken = session.sessionToken + } + + const returnUrls = buildPartnerCheckoutReturnUrls(this.endpoints.payBaseUrl, this.checkoutSessionToken) + const { checkout } = await this.client.pay( + this.authToken, + this.checkoutSessionToken, + { + amountUsdMinor: parsed.usdMinor, + paymentSessionId: this.paymentSessionId, + ...returnUrls, + autoTopUp, + }, + this.signal, + ) + + this.deps.change({ id: "checkout", payUrl: checkout.payUrl }) + if (checkout.payUrl) { + await this.deps.openUrl(checkout.payUrl).catch(() => {}) + } + + const paid = await this.pollUntilPaid(this.checkoutSessionToken) + if (!paid) return + + this.deps.change({ id: "busy", message: "Issuing your API key..." }) + const exchanged = await this.client.exchange(this.authToken, this.checkoutSessionToken, this.signal) + + this.deps.change({ id: "topup-success", amountUsd: formatUsd(parsed.usdMinor), email: this.email }) + this.deps.complete({ apiKey: exchanged.apiKey, apiKeyId: exchanged.apiKeyId }) + } catch (error) { + if (this.signal.aborted) return + this.deps.change({ id: "credits", error: this.describe(error, COPY.errTopupFailed) }) + } + } + + private async pollUntilPaid(sessionToken: string): Promise { + const started = (this.deps.now ?? Date.now)() + const interval = this.deps.pollIntervalMs ?? POLL_INTERVAL_MS + const timeout = this.deps.pollTimeoutMs ?? POLL_TIMEOUT_MS + while (true) { + if ((this.deps.now ?? Date.now)() - started > timeout) { + this.deps.change({ id: "credits", error: COPY.errTopupFailed }) + return undefined + } + await sleep(interval, this.signal) + let session: PartnerCheckoutSession + try { + session = await this.client.getSession(sessionToken, this.signal) + } catch (error) { + if (this.signal.aborted) throw error + continue // transient poll errors are retried until the timeout + } + if (session.status === "paid" || session.status === "exchanging" || session.status === "exchanged") { + return session + } + if (session.status === "cancelled" || session.status === "expired" || session.status === "failed") { + // Terminal checkout failure: the session token is spent. + this.checkoutSessionToken = "" + this.deps.change({ id: "credits", error: COPY.errTopupFailed }) + return undefined + } + } + } + + private describe(error: unknown, fallback?: string): string { + if (error instanceof AimlapiApiError) { + if (error.status === 0) return "Network error. Please check your connection and try again." + return fallback ?? `AI/ML API request failed (${error.status}). Please try again.` + } + if (fallback) return fallback + return error instanceof Error && error.message ? error.message : "Something went wrong. Please try again." + } +} diff --git a/packages/opencode/src/kilocode/aimlapi/tui/setup.tsx b/packages/opencode/src/kilocode/aimlapi/tui/setup.tsx new file mode 100644 index 00000000000..e2f5961ba55 --- /dev/null +++ b/packages/opencode/src/kilocode/aimlapi/tui/setup.tsx @@ -0,0 +1,237 @@ +// kilocode_change - new file +// +// AI/ML API guided onboarding — TUI screens. +// +// Rendered when the user picks "aimlapi.com" in the provider dialog (see +// `selectProvider`). Screen sequence, copy and behavior follow the shared +// AIMLAPI onboarding spec; all protocol logic lives in ../flow.ts. + +import { TextAttributes } from "@opentui/core" +import { Match, Show, Switch, createSignal, onCleanup, onMount, type JSX } from "solid-js" +import open from "open" +import { useTheme } from "@tui/context/theme" +import { useSDK } from "@tui/context/sdk" +import { useSync } from "@tui/context/sync" +import { useBindings } from "@tui/keymap" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect } from "@tui/ui/dialog-select" +import { Link } from "@tui/ui/link" +import { DialogPrompt } from "@tui/ui/dialog-prompt" +import { AimlapiFlow, COPY, type FlowPhase, type FlowResult } from "../flow" + +export const PROVIDER_ID = "aimlapi" + +type ModelComponent = (props: { providerID?: string }) => JSX.Element + +export function selectProvider(input: { + providerID: string + replace(component: () => JSX.Element): void + model: ModelComponent +}) { + if (input.providerID !== PROVIDER_ID) return false + input.replace(() => ) + return true +} + +export function AimlapiSetup(props: { model: ModelComponent }) { + const sdk = useSDK() + const sync = useSync() + const dialog = useDialog() + const { theme } = useTheme() + const Model = props.model + + const [phase, setPhase] = createSignal({ id: "busy", message: "" }) + const [result, setResult] = createSignal() + const [autoTopUp, setAutoTopUp] = createSignal(true) + const [saving, setSaving] = createSignal(false) + + const flow = new AimlapiFlow({ + openUrl: async (url) => { + await open(url) + }, + change: setPhase, + complete: setResult, + }) + + onMount(() => { + flow.start(sync.data.provider_next.connected.includes(PROVIDER_ID)) + }) + onCleanup(() => flow.cancel()) + + /** Persist the issued/pasted key and continue to the model picker (S8). */ + const finalize = async () => { + if (saving()) return + const r = result() + if (!r) return + setSaving(true) + try { + await sdk.client.auth.set({ + providerID: PROVIDER_ID, + auth: { type: "api", key: r.apiKey }, + }) + await sdk.client.instance.dispose() + await sync.bootstrap() + dialog.replace(() => ) + } finally { + setSaving(false) + } + } + + /** S12 "Continue with your saved API key" — provider is already connected. */ + const continueSaved = () => { + dialog.replace(() => ) + } + + // Enter advances the terminal screens (S7 success / S11 ready). + useBindings(() => ({ + enabled: phase().id === "ready" || phase().id === "topup-success", + priority: 1, + bindings: [{ key: "return", desc: "submit", group: "Dialog", cmd: () => void finalize() }], + })) + + // Tab toggles auto top-up on the credits screen (S5). + useBindings(() => ({ + enabled: phase().id === "credits", + priority: 2, + bindings: [{ key: "tab", desc: "toggle auto top-up", group: "Dialog", cmd: () => setAutoTopUp((v) => !v) }], + })) + + const errorLine = (error?: string) => ( + {(message) => {message()}} + ) + + const footer = () => ( + + enter submit + + ) + + return ( + + + continueSaved() }, + { value: "new-key", title: COPY.reconfigureNewKey, onSelect: () => flow.chooseReconfigure() }, + ]} + /> + + + + flow.chooseNewUser() }, + { value: "have-key", title: COPY.choiceHaveKey, onSelect: () => flow.chooseHaveKey() }, + ]} + /> + + + + errorLine((phase() as Extract).error)} + onConfirm={(value) => void flow.submitKey(value)} + onCancel={() => dialog.clear()} + /> + + + + errorLine((phase() as Extract).error)} + onConfirm={(value) => void flow.submitEmail(value)} + onCancel={() => dialog.clear()} + /> + + + + ).email)} + placeholder={COPY.codePlaceholder} + description={() => errorLine((phase() as Extract).error)} + onConfirm={(value) => void flow.submitCode(value)} + onCancel={() => dialog.clear()} + /> + + + + ( + + + {COPY.creditsAutoTopUp} (Tab):{" "} + on{" "} + off + + {errorLine((phase() as Extract).error)} + + )} + onConfirm={(value) => void flow.submitAmount(value, autoTopUp())} + onCancel={() => dialog.clear()} + /> + + + + + + + {COPY.checkoutTitle} + + dialog.clear()}> + esc + + + + {COPY.checkoutBody} + + ).payUrl}> + {(url) => } + + + + + + + + + {COPY.topupSuccessTitle((phase() as Extract).amountUsd)} + + dialog.clear()}> + esc + + + + {COPY.topupSuccessBody((phase() as Extract).email)} + + {footer()} + + + + + + + + {COPY.readyTitle} + + dialog.clear()}> + esc + + + {footer()} + + + + + + {(phase() as Extract).message} + + + + ) +} diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx index d5fafd14cab..959dded14b7 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx @@ -9,7 +9,15 @@ import type { JSX } from "solid-js" import type { RGBA } from "@opentui/core" import type { ProviderAuthAuthorization } from "@kilocode/sdk/v2" import { KiloAutoMethod } from "@/kilocode/components/dialog-kilo-auto-method" -export { selectProvider } from "@/kilocode/anaconda-desktop/tui/setup" +import { selectProvider as selectAnacondaDesktop } from "@/kilocode/anaconda-desktop/tui/setup" +import { selectProvider as selectAimlapi } from "@/kilocode/aimlapi/tui/setup" + +/** Providers with a fully custom connect flow intercept selection here. */ +export function selectProvider(input: Parameters[0]) { + if (selectAnacondaDesktop(input)) return true + if (selectAimlapi(input)) return true + return false +} // --------------------------------------------------------------------------- // Failed-state gutter/description helpers diff --git a/packages/opencode/test/kilocode/aimlapi-flow.test.ts b/packages/opencode/test/kilocode/aimlapi-flow.test.ts new file mode 100644 index 00000000000..800d7f89394 --- /dev/null +++ b/packages/opencode/test/kilocode/aimlapi-flow.test.ts @@ -0,0 +1,311 @@ +// kilocode_change - new file +import { describe, expect, test } from "bun:test" +import { AimlapiApiError, type AimlapiClient, type PartnerCheckoutSession } from "../../src/kilocode/aimlapi/client" +import { AimlapiFlow, COPY, parseAmountUsd, validEmail, type FlowPhase, type FlowResult } from "../../src/kilocode/aimlapi/flow" + +function session(status: PartnerCheckoutSession["status"], token = "sess_1"): PartnerCheckoutSession { + return { + id: "pcs_1", + sessionToken: token, + partnerId: "part_test", + partnerName: "Kilo Code", + userId: 1, + amountUsdMinor: 2500, + status, + issuedKeyId: null, + returnUrl: null, + } +} + +type Harness = { + flow: AimlapiFlow + phases: FlowPhase[] + results: FlowResult[] + opened: string[] + last: () => FlowPhase +} + +function harness(client: Partial): Harness { + const phases: FlowPhase[] = [] + const results: FlowResult[] = [] + const opened: string[] = [] + const flow = new AimlapiFlow({ + client: client as AimlapiClient, + openUrl: async (url) => { + opened.push(url) + }, + change: (phase) => phases.push(phase), + complete: (result) => results.push(result), + pollIntervalMs: 1, + pollTimeoutMs: 500, + }) + return { flow, phases, results, opened, last: () => phases[phases.length - 1]! } +} + +describe("parseAmountUsd", () => { + test("empty and non-numeric inputs ask for an amount", () => { + expect(parseAmountUsd("")).toEqual({ ok: false, error: COPY.errAmountMissing }) + expect(parseAmountUsd("abc")).toEqual({ ok: false, error: COPY.errAmountMissing }) + expect(parseAmountUsd("-5")).toEqual({ ok: false, error: COPY.errAmountMissing }) + expect(parseAmountUsd("Infinity")).toEqual({ ok: false, error: COPY.errAmountMissing }) + }) + + test("below-minimum amounts return the $20 minimum error", () => { + expect(parseAmountUsd("2")).toEqual({ ok: false, error: COPY.errAmountMin }) + expect(parseAmountUsd("19.99")).toEqual({ ok: false, error: COPY.errAmountMin }) + }) + + test("valid amounts convert to USD minor units, $ prefix allowed", () => { + expect(parseAmountUsd("20")).toEqual({ ok: true, usdMinor: 2000 }) + expect(parseAmountUsd("$25")).toEqual({ ok: true, usdMinor: 2500 }) + expect(parseAmountUsd("25.50")).toEqual({ ok: true, usdMinor: 2550 }) + }) + + test("amounts above the backend maximum are rejected", () => { + expect(parseAmountUsd("999999")).toEqual({ ok: false, error: COPY.errTopupFailed }) + }) +}) + +describe("validEmail", () => { + test("accepts plain addresses and rejects malformed ones", () => { + expect(validEmail("user@example.com")).toBe(true) + expect(validEmail("user@sub.example.io")).toBe(true) + expect(validEmail("user@")).toBe(false) + expect(validEmail("user @example.com")).toBe(false) + expect(validEmail("user@example")).toBe(false) + }) +}) + +describe("submitKey (S3)", () => { + test("valid key completes with the pasted key", async () => { + const h = harness({ + getBalance: async () => ({ balance: 10, lowBalance: false, lowBalanceThreshold: 5 }), + }) + await h.flow.submitKey(" my-key ") + expect(h.last().id).toBe("ready") + expect(h.results).toEqual([{ apiKey: "my-key" }]) + }) + + test("invalid key shows the canonical invalid-key error", async () => { + const h = harness({ + getBalance: async () => { + throw new AimlapiApiError("GET -> 401", 401, "") + }, + }) + await h.flow.submitKey("bad") + expect(h.last()).toEqual({ id: "paste-key", error: COPY.errInvalidKey }) + expect(h.results).toHaveLength(0) + }) +}) + +describe("submitEmail (S4)", () => { + test("malformed email fails client-side without any HTTP call", async () => { + let called = false + const h = harness({ + checkAccount: async () => { + called = true + return { action: "sign-in" as const } + }, + }) + await h.flow.submitEmail("nope@") + expect(h.last()).toEqual({ id: "email", error: COPY.errEmailFormat }) + expect(called).toBe(false) + }) + + test("existing account branches to the code screen", async () => { + const sent: string[] = [] + const h = harness({ + checkAccount: async () => ({ action: "sign-in" as const }), + sendSignInCode: async (email) => { + sent.push(email) + }, + }) + await h.flow.submitEmail("User@Example.com") + expect(sent).toEqual(["user@example.com"]) + expect(h.last()).toEqual({ id: "code", email: "user@example.com" }) + }) + + test("new account registers passwordless and branches to credits", async () => { + const h = harness({ + checkAccount: async () => ({ action: "sign-up" as const }), + createPasswordlessAccount: async () => ({ token: "auth_tok", exp: 0 }), + }) + await h.flow.submitEmail("new@example.com") + expect(h.last()).toEqual({ id: "credits" }) + }) +}) + +describe("submitCode (S10)", () => { + async function begin(h: Harness) { + await h.flow.submitEmail("user@example.com") + } + const signIn = { + checkAccount: async () => ({ action: "sign-in" as const }), + sendSignInCode: async () => {}, + } + + test("non-6-digit input fails locally with the short error", async () => { + const h = harness({ ...signIn }) + await begin(h) + await h.flow.submitCode("12345") + expect(h.last()).toEqual({ id: "code", email: "user@example.com", error: COPY.errCodeWrong }) + }) + + test("wrong code (4xx) shows the canonical error", async () => { + const h = harness({ + ...signIn, + verifySignInCode: async () => { + throw new AimlapiApiError("POST -> 400", 400, '{"message":"code mismatch"}') + }, + }) + await begin(h) + await h.flow.submitCode("123456") + expect(h.last()).toEqual({ id: "code", email: "user@example.com", error: COPY.errCodeWrong }) + }) + + test("expired code maps to the expired error copy", async () => { + const h = harness({ + ...signIn, + verifySignInCode: async () => { + throw new AimlapiApiError("POST -> 400", 400, '{"message":"code expired"}') + }, + }) + await begin(h) + await h.flow.submitCode("123456") + expect(h.last()).toEqual({ id: "code", email: "user@example.com", error: COPY.errCodeExpired }) + }) + + test("valid code issues a key and completes", async () => { + const h = harness({ + ...signIn, + verifySignInCode: async () => ({ token: "auth_tok", exp: 0 }), + createKey: async () => ({ key: "issued-key", id: "key_1" }), + }) + await begin(h) + await h.flow.submitCode("123456") + expect(h.last().id).toBe("ready") + expect(h.results).toEqual([{ apiKey: "issued-key", apiKeyId: "key_1" }]) + }) +}) + +describe("submitAmount (S5 -> S6 -> S7)", () => { + const signUp = { + checkAccount: async () => ({ action: "sign-up" as const }), + createPasswordlessAccount: async () => ({ token: "auth_tok", exp: 0 }), + } + + test("happy path: session, pay, browser, poll to paid, exchange, success", async () => { + const payCalls: { paymentSessionId: string }[] = [] + let polls = 0 + const h = harness({ + ...signUp, + createSession: async () => session("pending_payment"), + pay: async (_bearer, _token, input) => { + payCalls.push({ paymentSessionId: input.paymentSessionId }) + return { checkout: { providerSessionId: "cs_1", payUrl: "https://pay.test/x" }, partnerCheckout: session("pending_payment") } + }, + getSession: async () => (++polls < 2 ? session("pending_payment") : session("paid")), + exchange: async () => ({ apiKey: "exchanged-key", apiKeyId: "key_2" }), + }) + await h.flow.submitEmail("new@example.com") + await h.flow.submitAmount("25", true) + + expect(h.opened).toEqual(["https://pay.test/x"]) + const success = h.phases.find((phase) => phase.id === "topup-success") + expect(success).toEqual({ id: "topup-success", amountUsd: "25", email: "new@example.com" }) + expect(h.results).toEqual([{ apiKey: "exchanged-key", apiKeyId: "key_2" }]) + expect(payCalls).toHaveLength(1) + }) + + test("retry after failure reuses the same paymentSessionId for the same intent", async () => { + const payCalls: string[] = [] + let failFirstPoll = true + const h = harness({ + ...signUp, + createSession: async () => session("pending_payment"), + pay: async (_bearer, _token, input) => { + payCalls.push(input.paymentSessionId) + return { checkout: { providerSessionId: "cs_1", payUrl: null }, partnerCheckout: session("pending_payment") } + }, + getSession: async () => { + if (failFirstPoll) { + failFirstPoll = false + return session("failed") + } + return session("paid") + }, + exchange: async () => ({ apiKey: "k", apiKeyId: "id" }), + }) + await h.flow.submitEmail("new@example.com") + await h.flow.submitAmount("25", true) + expect(h.last()).toEqual({ id: "credits", error: COPY.errTopupFailed }) + await h.flow.submitAmount("25", true) + expect(payCalls).toHaveLength(2) + expect(payCalls[0]).toBe(payCalls[1]) + }) + + test("changing the amount regenerates the idempotency handle", async () => { + const payCalls: string[] = [] + let phase: "fail" | "pass" = "fail" + const h = harness({ + ...signUp, + createSession: async () => session("pending_payment"), + pay: async (_bearer, _token, input) => { + payCalls.push(input.paymentSessionId) + return { checkout: { providerSessionId: "cs_1", payUrl: null }, partnerCheckout: session("pending_payment") } + }, + getSession: async () => { + if (phase === "fail") { + phase = "pass" + return session("cancelled") + } + return session("paid") + }, + exchange: async () => ({ apiKey: "k", apiKeyId: "id" }), + }) + await h.flow.submitEmail("new@example.com") + await h.flow.submitAmount("25", true) + await h.flow.submitAmount("30", true) + expect(payCalls).toHaveLength(2) + expect(payCalls[0]).not.toBe(payCalls[1]) + }) + + test("terminal checkout failure clears the session so retry starts fresh", async () => { + const sessions: string[] = [] + let created = 0 + let succeed = false + const h = harness({ + ...signUp, + createSession: async () => { + created += 1 + const token = `sess_${created}` + sessions.push(token) + return session("pending_payment", token) + }, + pay: async (_bearer, token) => { + return { checkout: { providerSessionId: "cs", payUrl: null }, partnerCheckout: session("pending_payment", token) } + }, + getSession: async () => (succeed ? session("paid") : ((succeed = true), session("expired"))), + exchange: async () => ({ apiKey: "k", apiKeyId: "id" }), + }) + await h.flow.submitEmail("new@example.com") + await h.flow.submitAmount("25", true) + await h.flow.submitAmount("25", true) + expect(created).toBe(2) + }) + + test("below-minimum amount never touches the network", async () => { + let called = false + const h = harness({ + ...signUp, + createSession: async () => { + called = true + return session("pending_payment") + }, + }) + await h.flow.submitEmail("new@example.com") + await h.flow.submitAmount("2", true) + expect(h.last()).toEqual({ id: "credits", error: COPY.errAmountMin }) + expect(called).toBe(false) + }) +}) From 73f7e434854ee27b9ccf8739987aadc4a3ec691d Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Fri, 17 Jul 2026 19:23:47 +0300 Subject: [PATCH 04/18] chore: revert incidental dependency-sync churn in package.json/bun.lock Local dev tooling rewrote dependency lists unrelated to the aimlapi change; restore both files to the upstream state. Co-Authored-By: Claude Fable 5 --- bun.lock | 2 +- packages/opencode/package.json | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/bun.lock b/bun.lock index cc3e29d3f31..44a2d204727 100644 --- a/bun.lock +++ b/bun.lock @@ -295,7 +295,7 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.11", + "version": "7.4.9", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index bee051299a2..231bb477aec 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -110,27 +110,21 @@ "@npmcli/arborist": "9.4.0", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", - "@openauthjs/openauth": "catalog:", "@opencode-ai/llm": "workspace:*", "@opencode-ai/script": "workspace:*", - "@opencode-ai/server": "workspace:*", "@opencode-ai/ui": "workspace:*", "@openrouter/ai-sdk-provider": "2.9.0", "@opentelemetry/api": "1.9.0", - "@opentelemetry/context-async-hooks": "2.6.1", - "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentui/core": "catalog:", "@opentui/keymap": "catalog:", "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", - "@pierre/diffs": "catalog:", "@secretlint/core": "10.2.2", "@secretlint/secretlint-rule-preset-recommend": "10.2.2", "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", - "@standard-schema/spec": "1.0.0", "@types/ws": "8.18.1", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", @@ -138,7 +132,6 @@ "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chardet": "2.1.1", - "chokidar": "4.0.3", "cli-sound": "1.1.3", "clipboardy": "4.0.0", "cross-spawn": "catalog:", @@ -148,7 +141,6 @@ "effect": "catalog:", "fuzzysort": "3.1.0", "gitlab-ai-provider": "6.8.0", - "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", "htmlparser2": "8.0.2", @@ -158,13 +150,11 @@ "jsonc-parser": "3.3.1", "mammoth": "1.12.0", "mime-types": "3.0.2", - "minimatch": "10.0.3", "npm-package-arg": "13.0.2", "open": "10.1.2", "opencode-gitlab-auth": "2.0.1", "opencode-poe-auth": "0.0.1", "opentui-spinner": "catalog:", - "partial-json": "0.1.7", "remeda": "catalog:", "ripgrep": "0.3.1", "semver": "^7.6.3", @@ -181,10 +171,20 @@ "web-tree-sitter": "0.25.10", "which": "6.0.1", "ws": "8.21.0", - "xdg-basedir": "5.1.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "yargs": "18.0.0", - "zod": "catalog:" + "zod": "catalog:", + "@openauthjs/openauth": "catalog:", + "@opencode-ai/server": "workspace:*", + "@opentelemetry/context-async-hooks": "2.6.1", + "@opentelemetry/exporter-trace-otlp-http": "0.214.0", + "@pierre/diffs": "catalog:", + "@standard-schema/spec": "1.0.0", + "chokidar": "4.0.3", + "glob": "13.0.5", + "minimatch": "10.0.3", + "partial-json": "0.1.7", + "xdg-basedir": "5.1.0" }, "overrides": { "drizzle-orm": "catalog:" From 8d5122666fa24412055948863ce6d72dfb1b64d2 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Fri, 17 Jul 2026 20:33:57 +0300 Subject: [PATCH 05/18] =?UTF-8?q?fix(opencode):=20fetch=20aimlapi=20models?= =?UTF-8?q?=20without=20a=20key=20=E2=80=94=20endpoint=20is=20public?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider.list drops catalog providers whose model list is empty, so requiring a key for the models fetch hid aimlapi.com from the connect picker for unauthenticated users. GET /models is public; attach the bearer only when a key is present. Co-Authored-By: Claude Fable 5 --- packages/opencode/src/provider/model-cache.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index b3ed38ac792..b4267e812c6 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -147,18 +147,15 @@ export const layer: Layer.Layer< const fetchAimlapiModels = Effect.fn("ModelCache.fetchAimlapiModels")(function* (options: Options) { const baseURL = options.baseURL ?? AIMLAPI_BASE_URL - if (!options.apiKey) { - log.debug("no API key for aimlapi, skipping model fetch") - return {} - } + // The models endpoint is public; attach the key only when present so + // the catalog renders before the provider is connected. const url = `${baseURL.replace(/\/+$/, "")}/models` - const response = yield* HttpClientRequest.get(url).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(options.apiKey), - http.execute, - Effect.timeout("10 seconds"), - ) + let request = HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson) + if (options.apiKey) { + request = request.pipe(HttpClientRequest.bearerToken(options.apiKey)) + } + const response = yield* request.pipe(http.execute, Effect.timeout("10 seconds")) if (response.status < 200 || response.status >= 300) { log.error("aimlapi model fetch failed", { status: response.status }) return {} From df47886563f1ab5281bd0244f9668ecf6ea0d2d1 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 12:38:35 +0300 Subject: [PATCH 06/18] fix(opencode): provide the overlay ModelsDev layer to server runtimes The HTTP provider-list handler resolved the core models.dev layer, so overlay-injected providers (kilo catalog, apertis, aimlapi) were missing from the connect picker until connected. Wire the kilocode overlay layer (same service tag, superset catalog) into the server and app runtimes. Co-Authored-By: Claude Fable 5 --- packages/opencode/src/effect/app-runtime.ts | 2 +- packages/opencode/src/server/routes/instance/httpapi/server.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 5df21991799..dada6e3e139 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -12,7 +12,7 @@ import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Storage } from "@/storage/storage" import { Snapshot } from "@/snapshot" import { Plugin } from "@/plugin" -import { ModelsDev } from "@opencode-ai/core/models-dev" +import { ModelsDev } from "@/provider/models" // kilocode_change - overlay layer injects kilo/apertis/aimlapi into the catalog import { ModelCache } from "@/provider/model-cache" // kilocode_change import { Provider } from "@/provider/provider" import { ProviderAuth } from "@/provider/auth" diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index c624628753e..4c54b4fbfa0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -32,7 +32,7 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectCopy } from "@opencode-ai/core/project/copy" import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { ProviderAuth } from "@/provider/auth" -import { ModelsDev } from "@opencode-ai/core/models-dev" +import { ModelsDev } from "@/provider/models" // kilocode_change - overlay layer injects kilo/apertis/aimlapi into the catalog import { ModelCache } from "@/provider/model-cache" // kilocode_change import { Provider } from "@/provider/provider" import { PtyTicket } from "@opencode-ai/core/pty/ticket" From dec721282b8727e59cd7d1e1966a135b69841ca8 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 13:17:28 +0300 Subject: [PATCH 07/18] fix(opencode): keep aimlapi setup dialog stable across phase changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog inserts replacement components through a tracked render effect that unwraps function roots, so a bare root re-created the whole component on every phase change — and the onMount start transition drove that re-creation into unbounded update-loop recursion (frozen TUI, eventual 'Maximum call stack size exceeded'). Wrap the screens in a static root and enter the flow state machine before the first render. Co-Authored-By: Claude Fable 5 --- .../src/kilocode/aimlapi/tui/setup.tsx | 252 +++++++++--------- 1 file changed, 129 insertions(+), 123 deletions(-) diff --git a/packages/opencode/src/kilocode/aimlapi/tui/setup.tsx b/packages/opencode/src/kilocode/aimlapi/tui/setup.tsx index e2f5961ba55..46411c970a7 100644 --- a/packages/opencode/src/kilocode/aimlapi/tui/setup.tsx +++ b/packages/opencode/src/kilocode/aimlapi/tui/setup.tsx @@ -7,7 +7,7 @@ // AIMLAPI onboarding spec; all protocol logic lives in ../flow.ts. import { TextAttributes } from "@opentui/core" -import { Match, Show, Switch, createSignal, onCleanup, onMount, type JSX } from "solid-js" +import { Match, Show, Switch, createSignal, onCleanup, type JSX } from "solid-js" import open from "open" import { useTheme } from "@tui/context/theme" import { useSDK } from "@tui/context/sdk" @@ -53,9 +53,10 @@ export function AimlapiSetup(props: { model: ModelComponent }) { complete: setResult, }) - onMount(() => { - flow.start(sync.data.provider_next.connected.includes(PROVIDER_ID)) - }) + // Enter the state machine before the first render (not in onMount): the + // dialog inserts this component through a tracked render effect, and a phase + // transition during the mount flush recurses opentui's update loop. + flow.start(sync.data.provider_next.connected.includes(PROVIDER_ID)) onCleanup(() => flow.cancel()) /** Persist the issued/pasted key and continue to the model picker (S8). */ @@ -106,132 +107,137 @@ export function AimlapiSetup(props: { model: ModelComponent }) { ) + // The root must stay a static element: the dialog's render effect unwraps a + // function root (what a bare compiles to) inside its tracked scope, + // so a dynamic root re-creates the whole component on every phase change. return ( - - - continueSaved() }, - { value: "new-key", title: COPY.reconfigureNewKey, onSelect: () => flow.chooseReconfigure() }, - ]} - /> - - - - flow.chooseNewUser() }, - { value: "have-key", title: COPY.choiceHaveKey, onSelect: () => flow.chooseHaveKey() }, - ]} - /> - - - - errorLine((phase() as Extract).error)} - onConfirm={(value) => void flow.submitKey(value)} - onCancel={() => dialog.clear()} - /> - - - - errorLine((phase() as Extract).error)} - onConfirm={(value) => void flow.submitEmail(value)} - onCancel={() => dialog.clear()} - /> - - - - ).email)} - placeholder={COPY.codePlaceholder} - description={() => errorLine((phase() as Extract).error)} - onConfirm={(value) => void flow.submitCode(value)} - onCancel={() => dialog.clear()} - /> - - - - ( - - - {COPY.creditsAutoTopUp} (Tab):{" "} - on{" "} - off + + + + continueSaved() }, + { value: "new-key", title: COPY.reconfigureNewKey, onSelect: () => flow.chooseReconfigure() }, + ]} + /> + + + + flow.chooseNewUser() }, + { value: "have-key", title: COPY.choiceHaveKey, onSelect: () => flow.chooseHaveKey() }, + ]} + /> + + + + errorLine((phase() as Extract).error)} + onConfirm={(value) => void flow.submitKey(value)} + onCancel={() => dialog.clear()} + /> + + + + errorLine((phase() as Extract).error)} + onConfirm={(value) => void flow.submitEmail(value)} + onCancel={() => dialog.clear()} + /> + + + + ).email)} + placeholder={COPY.codePlaceholder} + description={() => errorLine((phase() as Extract).error)} + onConfirm={(value) => void flow.submitCode(value)} + onCancel={() => dialog.clear()} + /> + + + + ( + + + {COPY.creditsAutoTopUp} (Tab):{" "} + on{" "} + off + + {errorLine((phase() as Extract).error)} + + )} + onConfirm={(value) => void flow.submitAmount(value, autoTopUp())} + onCancel={() => dialog.clear()} + /> + + + + + + + {COPY.checkoutTitle} + + dialog.clear()}> + esc - {errorLine((phase() as Extract).error)} - )} - onConfirm={(value) => void flow.submitAmount(value, autoTopUp())} - onCancel={() => dialog.clear()} - /> - - - - - - - {COPY.checkoutTitle} - - dialog.clear()}> - esc + + {COPY.checkoutBody} + ).payUrl}> + {(url) => } + - - {COPY.checkoutBody} - - ).payUrl}> - {(url) => } - - - - - - - - - {COPY.topupSuccessTitle((phase() as Extract).amountUsd)} - - dialog.clear()}> - esc + + + + + + + {COPY.topupSuccessTitle((phase() as Extract).amountUsd)} + + dialog.clear()}> + esc + + + + {COPY.topupSuccessBody((phase() as Extract).email)} + {footer()} - - {COPY.topupSuccessBody((phase() as Extract).email)} - - {footer()} - - - - - - - - {COPY.readyTitle} - - dialog.clear()}> - esc - + + + + + + + {COPY.readyTitle} + + dialog.clear()}> + esc + + + {footer()} + + + + + + {(phase() as Extract).message} - {footer()} - - - - - - {(phase() as Extract).message} - - - + + + ) } From 26cf1f612cb6fa8b6b7d250857d730fbae505ddb Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 16:47:31 +0300 Subject: [PATCH 08/18] feat(opencode): map aimlapi catalog capabilities and modalities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aimlapi /models endpoint exposes opt-in metadata sections via ?include=…; request capabilities+modalities and map them onto the model: reasoning (unlocks the host-generated reasoning-effort variants), image-input attachment, real input/output modalities, and the true output token limit. tool_call stays enabled across the board — the catalog's tools capability has false negatives that would hide working models. Co-Authored-By: Claude Fable 5 --- packages/opencode/src/provider/model-cache.ts | 54 +++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index b4267e812c6..421cfe72485 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -59,12 +59,19 @@ const AimlapiInfo = Schema.Struct({ developer: Schema.optional(Schema.String), releasedAt: Schema.optional(Schema.String), contextLength: Schema.optional(Schema.Finite), + outputMax: Schema.optional(Schema.Finite), +}) +const AimlapiModalities = Schema.Struct({ + input: Schema.optional(Schema.Array(Schema.String)), + output: Schema.optional(Schema.Array(Schema.String)), }) const AimlapiItem = Schema.Struct({ id: Schema.String, type: Schema.optional(Schema.String), info: Schema.optional(AimlapiInfo), tags: Schema.optional(Schema.Array(Schema.String)), + capabilities: Schema.optional(Schema.Array(Schema.String)), + modalities: Schema.optional(AimlapiModalities), }) const AimlapiResponse = Schema.Struct({ data: Schema.optional(Schema.Array(AimlapiItem)) }) type AimlapiItem = Schema.Schema.Type @@ -131,26 +138,43 @@ export const layer: Layer.Layer< return Object.fromEntries((json.data ?? []).map((item) => [item.id, aperture(item)])) }) - const aimlapiModel = (item: AimlapiItem): Models[string] => ({ - id: item.id, - name: item.info?.name ?? item.id, - family: item.info?.developer ?? "", - release_date: item.info?.releasedAt ?? "", - attachment: false, - reasoning: false, - temperature: true, - tool_call: true, - cost: { input: 0, output: 0 }, - limit: { context: item.info?.contextLength ?? 128000, output: 8192 }, - modalities: { input: ["text"], output: ["text"] }, - }) + type AimlapiModality = NonNullable["input"][number] + const AIMLAPI_MODALITIES: readonly string[] = ["text", "image", "audio", "video", "pdf"] + const aimlapiModalities = (values: readonly string[]): AimlapiModality[] => + values.filter((value): value is AimlapiModality => AIMLAPI_MODALITIES.includes(value)) + + const aimlapiModel = (item: AimlapiItem): Models[string] => { + const capabilities = item.capabilities ?? [] + const input = aimlapiModalities(item.modalities?.input ?? []) + const output = aimlapiModalities(item.modalities?.output ?? []) + return { + id: item.id, + name: item.info?.name ?? item.id, + family: item.info?.developer ?? "", + release_date: item.info?.releasedAt ?? "", + attachment: input.includes("image"), + // Unlocks the host-generated reasoning-effort variants (low/medium/high). + reasoning: capabilities.includes("reasoning"), + temperature: true, + // The catalog's `tools` capability has false negatives, so it cannot + // gate tool_call without hiding models from agent use. + tool_call: true, + cost: { input: 0, output: 0 }, + limit: { context: item.info?.contextLength ?? 128000, output: item.info?.outputMax ?? 8192 }, + modalities: { + input: input.length > 0 ? input : ["text"], + output: output.length > 0 ? output : ["text"], + }, + } + } const fetchAimlapiModels = Effect.fn("ModelCache.fetchAimlapiModels")(function* (options: Options) { const baseURL = options.baseURL ?? AIMLAPI_BASE_URL // The models endpoint is public; attach the key only when present so - // the catalog renders before the provider is connected. - const url = `${baseURL.replace(/\/+$/, "")}/models` + // the catalog renders before the provider is connected. Capability and + // modality metadata is opt-in per section via `include`. + const url = `${baseURL.replace(/\/+$/, "")}/models?include=capabilities,modalities` let request = HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson) if (options.apiKey) { request = request.pipe(HttpClientRequest.bearerToken(options.apiKey)) From 7afb38099a387f527f84e7b60037efb0b9241cc6 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 18:45:49 +0300 Subject: [PATCH 09/18] fix(opencode): stop the aimlapi catalog from phoning home on every startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public aimlapi catalog was fetched eagerly inside ModelsDev.get(), a hot path that runs at startup, on one-shot runs, and in tests — so every one of those blocked up to 10s on a live api.aimlapi.com request (e.g. tool-task-model timed out in CI). Mirror apertis: fetch eagerly only when the provider is configured (key via env/config/auth). Keyless, read whatever the cache holds and let the connect picker (provider.list) warm the public catalog on demand — that is the one place the pre-connect model list is actually needed, so the provider still appears there without any hot-path phone-home. --- packages/opencode/src/provider/models.ts | 14 ++++++++++++-- .../routes/instance/httpapi/handlers/provider.ts | 9 +++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 8279f6cede1..e83c68db722 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -70,10 +70,20 @@ export const layer: Layer.Layer Effect.succeed(undefined))) + const aimlConnected = !!process.env.AIMLAPI_API_KEY || !!aiml?.apiKey || aimlInfo?.type === "api" const addAimlapi = Effect.fnUntraced(function* () { if (providers.aimlapi) return - const models = yield* cache.fetch("aimlapi", aimlOpts).pipe(Effect.catch(() => Effect.succeed({}))) + const models = aimlConnected + ? yield* cache.fetch("aimlapi", aimlOpts).pipe(Effect.catch(() => Effect.succeed({}))) + : yield* cache.get("aimlapi").pipe(Effect.map((cached) => cached ?? {})) providers.aimlapi = { id: "aimlapi", name: "aimlapi.com", @@ -82,7 +92,7 @@ export const layer: Layer.Layer s.get())) // kilocode_change const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined From 325f261fdb3d7f9f5cb40e6f588ff111505a7624 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 18:47:09 +0300 Subject: [PATCH 10/18] test(opencode): update openai priority after inserting aimlapi in the order Adding aimlapi at order index 1 (second, right after kilo) shifts every following provider down one; openai's settings priority is now 4, not 3. --- packages/opencode/test/kilocode/provider-metadata.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/kilocode/provider-metadata.test.ts b/packages/opencode/test/kilocode/provider-metadata.test.ts index 82f17d05e4e..87ec4a74189 100644 --- a/packages/opencode/test/kilocode/provider-metadata.test.ts +++ b/packages/opencode/test/kilocode/provider-metadata.test.ts @@ -6,7 +6,7 @@ describe("providerMetadata", () => { expect(providerMetadata("openai")).toEqual({ noteKey: "settings.providers.note.openai", icon: "openai", - priority: 3, + priority: 4, }) }) From e18621a19cf2d9131bf638fd93de32f8041eb0f1 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 18:55:36 +0300 Subject: [PATCH 11/18] i18n(kilo): translate the aimlapi provider note into all locales settings.providers.note.aimlapi existed only in en; the locale-completeness test (kilo-vscode i18n-keys) requires every English key in all 19 other locales. Add the translated note to each. --- packages/kilo-i18n/src/ar.ts | 1 + packages/kilo-i18n/src/br.ts | 1 + packages/kilo-i18n/src/bs.ts | 1 + packages/kilo-i18n/src/da.ts | 1 + packages/kilo-i18n/src/de.ts | 1 + packages/kilo-i18n/src/es.ts | 1 + packages/kilo-i18n/src/fr.ts | 1 + packages/kilo-i18n/src/it.ts | 1 + packages/kilo-i18n/src/ja.ts | 1 + packages/kilo-i18n/src/ko.ts | 1 + packages/kilo-i18n/src/nl.ts | 1 + packages/kilo-i18n/src/no.ts | 1 + packages/kilo-i18n/src/pl.ts | 1 + packages/kilo-i18n/src/ru.ts | 1 + packages/kilo-i18n/src/th.ts | 1 + packages/kilo-i18n/src/tr.ts | 1 + packages/kilo-i18n/src/uk.ts | 1 + packages/kilo-i18n/src/zh.ts | 1 + packages/kilo-i18n/src/zht.ts | 1 + 19 files changed, 19 insertions(+) diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index 13170b89c6d..4ef415c510b 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "موصى به", "settings.providers.note.kilo": "الوصول إلى أكثر من 500 نموذج ذكاء اصطناعي", + "settings.providers.note.aimlapi": "أكثر من 1000 نموذج من جميع المزودين الرئيسيين، إعداد بنقرة واحدة", "settings.providers.note.opencode": "نماذج منتقاة تشمل Claude وGPT وGemini والمزيد", "settings.providers.note.anthropic": "وصول مباشر إلى نماذج Claude، بما في ذلك Pro وMax", "settings.providers.note.deepseek": "نماذج DeepSeek لمهام الاستدلال والبرمجة", diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index 3e91b3d765e..114f3f9a1ec 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Recomendados", "settings.providers.note.kilo": "Acesso a mais de 500 modelos de IA", + "settings.providers.note.aimlapi": "Mais de 1000 modelos de todos os principais provedores, configuração com um clique", "settings.providers.note.opencode": "Modelos selecionados, incluindo Claude, GPT, Gemini e mais", "settings.providers.note.anthropic": "Acesso direto aos modelos Claude, incluindo Pro e Max", "settings.providers.note.deepseek": "Modelos DeepSeek para tarefas de raciocínio e programação", diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index d1aece561ee..d138e375744 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Preporučeno", "settings.providers.note.kilo": "Pristup za 500+ AI modela", + "settings.providers.note.aimlapi": "Više od 1000 modela od svih velikih pružatelja, postavljanje jednim klikom", "settings.providers.note.opencode": "Odabrani modeli uključujući Claude, GPT, Gemini i još mnogo toga", "settings.providers.note.anthropic": "Direktan pristup Claude modelima, uključujući Pro i Max", "settings.providers.note.deepseek": "DeepSeek modeli za zadatke rezonovanja i programiranja", diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index 3147ec1eee1..bed15ae0832 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Anbefalede", "settings.providers.note.kilo": "Adgang til 500+ AI-modeller", + "settings.providers.note.aimlapi": "Over 1000 modeller fra alle større udbydere, opsætning med ét klik", "settings.providers.note.opencode": "Udvalgte modeller inklusive Claude, GPT, Gemini og mere", "settings.providers.note.anthropic": "Direkte adgang til Claude-modeller, inklusive Pro og Max", "settings.providers.note.deepseek": "DeepSeek-modeller til ræsonnement og kodningsopgaver", diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index 8e4fe468a5c..8354faab4c4 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Empfohlen", "settings.providers.note.kilo": "Zugriff auf 500+ KI-Modelle", + "settings.providers.note.aimlapi": "Über 1000 Modelle von allen großen Anbietern, Einrichtung mit einem Klick", "settings.providers.note.opencode": "Kuratierte Modelle, darunter Claude, GPT, Gemini und mehr", "settings.providers.note.anthropic": "Direkter Zugriff auf Claude-Modelle, einschließlich Pro und Max", "settings.providers.note.deepseek": "DeepSeek-Modelle für Denk- und Programmieraufgaben", diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index a72ba48094c..82bfc5c489b 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Recomendados", "settings.providers.note.kilo": "Acceso a más de 500 modelos de IA", + "settings.providers.note.aimlapi": "Más de 1000 modelos de todos los principales proveedores, configuración con un clic", "settings.providers.note.opencode": "Modelos seleccionados, incluidos Claude, GPT, Gemini y más", "settings.providers.note.anthropic": "Acceso directo a modelos Claude, incluidos Pro y Max", "settings.providers.note.deepseek": "Modelos DeepSeek para tareas de razonamiento y programación", diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index 6f654770827..9c677dbf40c 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Recommandés", "settings.providers.note.kilo": "Accès à plus de 500 modèles d'IA", + "settings.providers.note.aimlapi": "Plus de 1000 modèles de tous les principaux fournisseurs, configuration en un clic", "settings.providers.note.opencode": "Modèles sélectionnés, dont Claude, GPT, Gemini et plus encore", "settings.providers.note.anthropic": "Accès direct aux modèles Claude, y compris Pro et Max", "settings.providers.note.deepseek": "Modèles DeepSeek pour les tâches de raisonnement et de codage", diff --git a/packages/kilo-i18n/src/it.ts b/packages/kilo-i18n/src/it.ts index 229be83c14a..523ce5c0c34 100644 --- a/packages/kilo-i18n/src/it.ts +++ b/packages/kilo-i18n/src/it.ts @@ -16,6 +16,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Consigliati", "settings.providers.note.kilo": "Accesso a oltre 500 modelli AI", + "settings.providers.note.aimlapi": "Oltre 1000 modelli da tutti i principali provider, configurazione con un clic", "settings.providers.note.opencode": "Modelli selezionati, inclusi Claude, GPT, Gemini e altri", "settings.providers.note.anthropic": "Accesso diretto ai modelli Claude, inclusi Pro e Max", "settings.providers.note.deepseek": "Modelli DeepSeek per attività di ragionamento e programmazione", diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index c1699834b6d..f64f81b0de1 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -13,6 +13,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "おすすめ", "settings.providers.note.kilo": "500以上のAIモデルにアクセス", + "settings.providers.note.aimlapi": "主要プロバイダーの1000以上のモデル、ワンクリックで設定", "settings.providers.note.opencode": "Claude、GPT、Geminiなどの厳選モデル", "settings.providers.note.anthropic": "ProやMaxを含むClaudeモデルへ直接アクセス", "settings.providers.note.deepseek": "推論とコーディング作業向けのDeepSeekモデル", diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index a0e760dd541..72141515e7d 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -13,6 +13,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "추천", "settings.providers.note.kilo": "500개 이상의 AI 모델 이용 가능", + "settings.providers.note.aimlapi": "모든 주요 제공업체의 1000개 이상 모델, 원클릭 설정", "settings.providers.note.opencode": "Claude, GPT, Gemini 등을 포함한 엄선된 모델", "settings.providers.note.anthropic": "Pro 및 Max를 포함한 Claude 모델에 직접 액세스", "settings.providers.note.deepseek": "추론 및 코딩 작업을 위한 DeepSeek 모델", diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index bf4f116fd47..3f3a27810b6 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -16,6 +16,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Aanbevolen", "settings.providers.note.kilo": "Toegang tot 500+ AI modellen", + "settings.providers.note.aimlapi": "Meer dan 1000 modellen van alle grote aanbieders, installatie met één klik", "settings.providers.note.opencode": "Geselecteerde modellen, waaronder Claude, GPT, Gemini en meer", "settings.providers.note.anthropic": "Directe toegang tot Claude-modellen, inclusief Pro en Max", "settings.providers.note.deepseek": "DeepSeek-modellen voor redeneer- en codeertaken", diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index c28bfb16cda..ed98f57e84c 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Anbefalt", "settings.providers.note.kilo": "Tilgang til 500+ AI-modeller", + "settings.providers.note.aimlapi": "Over 1000 modeller fra alle store leverandører, oppsett med ett klikk", "settings.providers.note.opencode": "Utvalgte modeller, inkludert Claude, GPT, Gemini og mer", "settings.providers.note.anthropic": "Direkte tilgang til Claude-modeller, inkludert Pro og Max", "settings.providers.note.deepseek": "DeepSeek-modeller for resonnering og kodeoppgaver", diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 6764a61f265..ed2bdbe458f 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Polecane", "settings.providers.note.kilo": "Dostęp do ponad 500 modeli AI", + "settings.providers.note.aimlapi": "Ponad 1000 modeli od wszystkich głównych dostawców, konfiguracja jednym kliknięciem", "settings.providers.note.opencode": "Wyselekcjonowane modele, w tym Claude, GPT, Gemini i inne", "settings.providers.note.anthropic": "Bezpośredni dostęp do modeli Claude, w tym Pro i Max", "settings.providers.note.deepseek": "Modele DeepSeek do zadań rozumowania i kodowania", diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index d426d1c0f1f..a163926f51c 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Рекомендуемые", "settings.providers.note.kilo": "Доступ к 500+ моделям ИИ", + "settings.providers.note.aimlapi": "Более 1000 моделей от всех основных провайдеров, настройка в один клик", "settings.providers.note.opencode": "Подобранные модели, включая Claude, GPT, Gemini и другие", "settings.providers.note.anthropic": "Прямой доступ к моделям Claude, включая Pro и Max", "settings.providers.note.deepseek": "Модели DeepSeek для задач рассуждения и программирования", diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index 11a2f317c9a..762cd1f58f3 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "แนะนำ", "settings.providers.note.kilo": "เข้าถึงโมเดล AI มากกว่า 500 รายการ", + "settings.providers.note.aimlapi": "โมเดลกว่า 1000 รายการจากผู้ให้บริการรายใหญ่ทั้งหมด ตั้งค่าได้ในคลิกเดียว", "settings.providers.note.opencode": "โมเดลที่คัดสรร รวมถึง Claude, GPT, Gemini และอื่น ๆ", "settings.providers.note.anthropic": "เข้าถึงโมเดล Claude โดยตรง รวมถึง Pro และ Max", "settings.providers.note.deepseek": "โมเดล DeepSeek สำหรับงานใช้เหตุผลและเขียนโค้ด", diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index a787e69f62c..3304c9e5e0d 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Önerilen", "settings.providers.note.kilo": "500+ AI modeline erişim", + "settings.providers.note.aimlapi": "Tüm büyük sağlayıcılardan 1000+ model, tek tıkla kurulum", "settings.providers.note.opencode": "Claude, GPT, Gemini ve daha fazlasını içeren seçilmiş modeller", "settings.providers.note.anthropic": "Pro ve Max dahil Claude modellerine doğrudan erişim", "settings.providers.note.deepseek": "Akıl yürütme ve kodlama görevleri için DeepSeek modelleri", diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index b95a8240da7..00d5e7a6bd6 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Рекомендовані", "settings.providers.note.kilo": "Доступ до 500+ моделей ШІ", + "settings.providers.note.aimlapi": "Понад 1000 моделей від усіх основних провайдерів, налаштування в один клік", "settings.providers.note.opencode": "Добірні моделі, зокрема Claude, GPT, Gemini та інші", "settings.providers.note.anthropic": "Прямий доступ до моделей Claude, зокрема Pro і Max", "settings.providers.note.deepseek": "Моделі DeepSeek для завдань міркування та програмування", diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index 8baddc98f38..78ccc3df116 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -12,6 +12,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "推荐", "settings.providers.note.kilo": "访问 500+ AI 模型", + "settings.providers.note.aimlapi": "来自所有主流提供商的 1000+ 模型,一键设置", "settings.providers.note.opencode": "精选模型,包括 Claude、GPT、Gemini 等", "settings.providers.note.anthropic": "直接访问 Claude 模型,包括 Pro 和 Max", "settings.providers.note.deepseek": "用于推理和编码任务的 DeepSeek 模型", diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index fdb58ffcaf7..b79faae7418 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -12,6 +12,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "推薦", "settings.providers.note.kilo": "存取 500+ AI 模型", + "settings.providers.note.aimlapi": "來自所有主流供應商的 1000+ 模型,一鍵設定", "settings.providers.note.opencode": "精選模型,包括 Claude、GPT、Gemini 等", "settings.providers.note.anthropic": "直接存取 Claude 模型,包括 Pro 和 Max", "settings.providers.note.deepseek": "用於推理和程式設計工作的 DeepSeek 模型", From ffb4b980ce24461a1844e6b1991fc7fc9f25e317 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 19:07:22 +0300 Subject: [PATCH 12/18] chore(opencode): pre-PR cleanups for the aimlapi onboarding flow - submitKey: only treat a 401/403 as an invalid key; report network/timeout/ 5xx as transport errors instead of blaming the pasted key (+ test). - Drop the unreached topUpByKey client method (S12 existing-key top-up is not wired yet; re-add it when that path lands). - Use the DEFAULT_AMOUNT_USD_MINOR constant for the credits screen default instead of a hard-coded "25". - Changeset: 1000+ models, matching the picker/i18n copy. - Run prettier 3.6.2 over the touched files. --- .changeset/aimlapi-provider.md | 2 +- .../opencode/src/kilocode/aimlapi/client.ts | 32 ------------------ .../opencode/src/kilocode/aimlapi/config.ts | 6 ++-- .../opencode/src/kilocode/aimlapi/flow.ts | 11 +++++-- .../src/kilocode/aimlapi/tui/setup.tsx | 4 +-- .../test/kilocode/aimlapi-flow.test.ts | 33 +++++++++++++++++-- 6 files changed, 44 insertions(+), 44 deletions(-) diff --git a/.changeset/aimlapi-provider.md b/.changeset/aimlapi-provider.md index 832a495fcd2..f0ffe680bea 100644 --- a/.changeset/aimlapi-provider.md +++ b/.changeset/aimlapi-provider.md @@ -3,4 +3,4 @@ "kilo-code": minor --- -Add AI/ML API (aimlapi.com) provider with guided onboarding: 300+ chat models with one API key and a dynamic model list, plus an in-TUI connect flow — paste an existing key, or sign in by email (passwordless code for existing accounts; registration with checkout top-up and automatic key issuance for new ones). +Add AI/ML API (aimlapi.com) provider with guided onboarding: 1000+ models with one API key and a dynamic model list, plus an in-TUI connect flow — paste an existing key, or sign in by email (passwordless code for existing accounts; registration with checkout top-up and automatic key issuance for new ones). diff --git a/packages/opencode/src/kilocode/aimlapi/client.ts b/packages/opencode/src/kilocode/aimlapi/client.ts index dbd538c3822..a6272f8a194 100644 --- a/packages/opencode/src/kilocode/aimlapi/client.ts +++ b/packages/opencode/src/kilocode/aimlapi/client.ts @@ -248,38 +248,6 @@ export class AimlapiClient { ) } - /** Top-up for a user who already holds a key (S12 → top-up path). */ - async topUpByKey( - apiKey: string, - input: { - sessionToken: string - amountUsdMinor: number - paymentSessionId: string - successUrl?: string - cancelUrl?: string - autoTopUp?: boolean - }, - signal?: AbortSignal, - ): Promise { - const inferenceBase = this.endpoints.inferenceBaseUrl - .trim() - .replace(/\/+$/, "") - .replace(/\/v1$/i, "") - return this.request(`${inferenceBase}/v2/billing/topup`, { - method: "POST", - bearer: apiKey, - body: { - sessionToken: input.sessionToken, - amountUsdMinor: input.amountUsdMinor, - paymentSessionId: input.paymentSessionId, - ...(input.successUrl ? { successUrl: input.successUrl } : {}), - ...(input.cancelUrl ? { cancelUrl: input.cancelUrl } : {}), - ...(input.autoTopUp ? { autoTopUp: true } : {}), - }, - signal, - }) - } - async exchange(bearer: string, sessionToken: string, signal?: AbortSignal): Promise { return this.request( `${this.endpoints.appBaseUrl}/v3/partner-checkout/sessions/${encodeURIComponent(sessionToken)}/exchange`, diff --git a/packages/opencode/src/kilocode/aimlapi/config.ts b/packages/opencode/src/kilocode/aimlapi/config.ts index 231080f80e4..8cf36e2efcd 100644 --- a/packages/opencode/src/kilocode/aimlapi/config.ts +++ b/packages/opencode/src/kilocode/aimlapi/config.ts @@ -54,8 +54,7 @@ export function resolveEndpoints(): AimlapiEndpoints { appBaseUrl: process.env["AIMLAPI_APP_URL"]?.trim() || DEFAULT_ENDPOINTS.appBaseUrl, payBaseUrl: process.env["AIMLAPI_PAY_URL"]?.trim() || DEFAULT_ENDPOINTS.payBaseUrl, inferenceBaseUrl: process.env["AIMLAPI_INFERENCE_URL"]?.trim() || DEFAULT_ENDPOINTS.inferenceBaseUrl, - verificationBaseUrl: - process.env["AIMLAPI_VERIFICATION_BASE_URL"]?.trim() || DEFAULT_ENDPOINTS.verificationBaseUrl, + verificationBaseUrl: process.env["AIMLAPI_VERIFICATION_BASE_URL"]?.trim() || DEFAULT_ENDPOINTS.verificationBaseUrl, } } @@ -98,8 +97,7 @@ function safeHttpBaseUrl(value: string | undefined): string | null { if (!candidate) return null try { const url = new URL(candidate) - const loopback = - url.hostname === "localhost" || /^127(?:\.\d+){3}$/.test(url.hostname) || url.hostname === "[::1]" + const loopback = url.hostname === "localhost" || /^127(?:\.\d+){3}$/.test(url.hostname) || url.hostname === "[::1]" if ( (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) || url.username || diff --git a/packages/opencode/src/kilocode/aimlapi/flow.ts b/packages/opencode/src/kilocode/aimlapi/flow.ts index be5ee3f2938..a2329fffc4a 100644 --- a/packages/opencode/src/kilocode/aimlapi/flow.ts +++ b/packages/opencode/src/kilocode/aimlapi/flow.ts @@ -23,6 +23,9 @@ import { } from "./config" import { AimlapiApiError, AimlapiClient, type PartnerCheckoutSession } from "./client" +/** Amount pre-filled on the credits screen, derived from the shared minor default. */ +export const DEFAULT_AMOUNT_USD_DISPLAY = String(DEFAULT_AMOUNT_USD_MINOR / 100) + // Copy deck — keep verbatim with the shared spec / Figma mockups. export const COPY = { choiceTitle: "Do you have aimlapi.com key?", @@ -105,7 +108,7 @@ function formatUsd(usdMinor: number): string { } async function sleep(ms: number, signal?: AbortSignal): Promise { - if (signal?.aborted) throw (signal.reason ?? new DOMException("The operation was aborted.", "AbortError")) + if (signal?.aborted) throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError") await new Promise((resolve, reject) => { const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort) @@ -176,7 +179,11 @@ export class AimlapiFlow { await this.client.getBalance(key, this.signal) } catch (error) { if (this.signal.aborted) return - this.deps.change({ id: "paste-key", error: COPY.errInvalidKey }) + // A 401/403 is the real "bad key" signal; anything else (network, + // timeout, 5xx) is a transport/service failure and must not be blamed + // on the key the user pasted. + const invalidKey = error instanceof AimlapiApiError && (error.status === 401 || error.status === 403) + this.deps.change({ id: "paste-key", error: invalidKey ? COPY.errInvalidKey : this.describe(error) }) return } this.deps.change({ id: "ready" }) diff --git a/packages/opencode/src/kilocode/aimlapi/tui/setup.tsx b/packages/opencode/src/kilocode/aimlapi/tui/setup.tsx index 46411c970a7..a74e53bfcb4 100644 --- a/packages/opencode/src/kilocode/aimlapi/tui/setup.tsx +++ b/packages/opencode/src/kilocode/aimlapi/tui/setup.tsx @@ -17,7 +17,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { Link } from "@tui/ui/link" import { DialogPrompt } from "@tui/ui/dialog-prompt" -import { AimlapiFlow, COPY, type FlowPhase, type FlowResult } from "../flow" +import { AimlapiFlow, COPY, DEFAULT_AMOUNT_USD_DISPLAY, type FlowPhase, type FlowResult } from "../flow" export const PROVIDER_ID = "aimlapi" @@ -166,7 +166,7 @@ export function AimlapiSetup(props: { model: ModelComponent }) { ( diff --git a/packages/opencode/test/kilocode/aimlapi-flow.test.ts b/packages/opencode/test/kilocode/aimlapi-flow.test.ts index 800d7f89394..6fee9996ab0 100644 --- a/packages/opencode/test/kilocode/aimlapi-flow.test.ts +++ b/packages/opencode/test/kilocode/aimlapi-flow.test.ts @@ -1,7 +1,14 @@ // kilocode_change - new file import { describe, expect, test } from "bun:test" import { AimlapiApiError, type AimlapiClient, type PartnerCheckoutSession } from "../../src/kilocode/aimlapi/client" -import { AimlapiFlow, COPY, parseAmountUsd, validEmail, type FlowPhase, type FlowResult } from "../../src/kilocode/aimlapi/flow" +import { + AimlapiFlow, + COPY, + parseAmountUsd, + validEmail, + type FlowPhase, + type FlowResult, +} from "../../src/kilocode/aimlapi/flow" function session(status: PartnerCheckoutSession["status"], token = "sess_1"): PartnerCheckoutSession { return { @@ -96,6 +103,20 @@ describe("submitKey (S3)", () => { expect(h.last()).toEqual({ id: "paste-key", error: COPY.errInvalidKey }) expect(h.results).toHaveLength(0) }) + + test("a transport failure is not blamed on the key", async () => { + const h = harness({ + getBalance: async () => { + throw new AimlapiApiError("Network request failed", 0, "") + }, + }) + await h.flow.submitKey("maybe-fine-key") + const last = h.last() as Extract + expect(last.id).toBe("paste-key") + expect(last.error).not.toBe(COPY.errInvalidKey) + expect(last.error).toContain("Network error") + expect(h.results).toHaveLength(0) + }) }) describe("submitEmail (S4)", () => { @@ -202,7 +223,10 @@ describe("submitAmount (S5 -> S6 -> S7)", () => { createSession: async () => session("pending_payment"), pay: async (_bearer, _token, input) => { payCalls.push({ paymentSessionId: input.paymentSessionId }) - return { checkout: { providerSessionId: "cs_1", payUrl: "https://pay.test/x" }, partnerCheckout: session("pending_payment") } + return { + checkout: { providerSessionId: "cs_1", payUrl: "https://pay.test/x" }, + partnerCheckout: session("pending_payment"), + } }, getSession: async () => (++polls < 2 ? session("pending_payment") : session("paid")), exchange: async () => ({ apiKey: "exchanged-key", apiKeyId: "key_2" }), @@ -283,7 +307,10 @@ describe("submitAmount (S5 -> S6 -> S7)", () => { return session("pending_payment", token) }, pay: async (_bearer, token) => { - return { checkout: { providerSessionId: "cs", payUrl: null }, partnerCheckout: session("pending_payment", token) } + return { + checkout: { providerSessionId: "cs", payUrl: null }, + partnerCheckout: session("pending_payment", token), + } }, getSession: async () => (succeed ? session("paid") : ((succeed = true), session("expired"))), exchange: async () => ({ apiKey: "k", apiKeyId: "id" }), From deeebc32e56df381473cb419383c9019d5959052 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 20:00:34 +0300 Subject: [PATCH 13/18] feat(opencode): map aimlapi catalog pricing to per-1M model cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Models showed as "Free" because the fetcher never requested or mapped the pricing section. Request `?include=...,pricing` and translate `pricing.units` to models-dev cost (USD per 1M tokens): the unit `origin` is the discriminator (provided→input, generated→output, cached→cache_read, cache_write→cache_write), only text token charges count (audio/tool-call units are skipped), the first unit per origin wins, and price is normalized by `per`. Tiered pricing (thresholds/variants) is left to the base rate for now. Validated across the live catalog: 339/341 chat models resolve real prices, 2 are genuinely free. --- packages/opencode/src/provider/model-cache.ts | 48 ++++++++++++-- .../test/kilocode/model-cache-effect.test.ts | 63 ++++++++++++++++++- 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index 421cfe72485..5fb142ace6e 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -65,6 +65,17 @@ const AimlapiModalities = Schema.Struct({ input: Schema.optional(Schema.Array(Schema.String)), output: Schema.optional(Schema.Array(Schema.String)), }) +const AimlapiPricingUnit = Schema.Struct({ + type: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + content: Schema.optional(Schema.String), + origin: Schema.optional(Schema.String), + price: Schema.optional(Schema.Finite), + per: Schema.optional(Schema.Finite), +}) +const AimlapiPricing = Schema.Struct({ + units: Schema.optional(Schema.Array(AimlapiPricingUnit)), +}) const AimlapiItem = Schema.Struct({ id: Schema.String, type: Schema.optional(Schema.String), @@ -72,6 +83,7 @@ const AimlapiItem = Schema.Struct({ tags: Schema.optional(Schema.Array(Schema.String)), capabilities: Schema.optional(Schema.Array(Schema.String)), modalities: Schema.optional(AimlapiModalities), + pricing: Schema.optional(AimlapiPricing), }) const AimlapiResponse = Schema.Struct({ data: Schema.optional(Schema.Array(AimlapiItem)) }) type AimlapiItem = Schema.Schema.Type @@ -143,6 +155,34 @@ export const layer: Layer.Layer< const aimlapiModalities = (values: readonly string[]): AimlapiModality[] => values.filter((value): value is AimlapiModality => AIMLAPI_MODALITIES.includes(value)) + // Map the catalog `pricing` section to models-dev cost (USD per 1M tokens). + // The unit discriminator is `origin` (not `measure`); only text token + // charges carry input/output/cache prices — audio and tool-call units are + // skipped. The first unit per origin wins; tiered pricing + // (thresholds/variants) is not mapped, so the base rate is used. + const AIMLAPI_ORIGIN_TO_COST: Record = { + provided: "input", + generated: "output", + cached: "cache_read", + cache_write: "cache_write", + } + const aimlapiCost = (pricing: AimlapiItem["pricing"]): NonNullable => { + const cost: { input: number; output: number; cache_read?: number; cache_write?: number } = { + input: 0, + output: 0, + } + const seen = new Set() + for (const unit of pricing?.units ?? []) { + if (unit.type !== "charge" || unit.name !== "token" || unit.content !== "text") continue + const field = unit.origin ? AIMLAPI_ORIGIN_TO_COST[unit.origin] : undefined + if (!field || unit.price === undefined || seen.has(field)) continue + seen.add(field) + const per = unit.per && unit.per > 0 ? unit.per : 1_000_000 + cost[field] = (unit.price * 1_000_000) / per + } + return cost + } + const aimlapiModel = (item: AimlapiItem): Models[string] => { const capabilities = item.capabilities ?? [] const input = aimlapiModalities(item.modalities?.input ?? []) @@ -159,7 +199,7 @@ export const layer: Layer.Layer< // The catalog's `tools` capability has false negatives, so it cannot // gate tool_call without hiding models from agent use. tool_call: true, - cost: { input: 0, output: 0 }, + cost: aimlapiCost(item.pricing), limit: { context: item.info?.contextLength ?? 128000, output: item.info?.outputMax ?? 8192 }, modalities: { input: input.length > 0 ? input : ["text"], @@ -172,9 +212,9 @@ export const layer: Layer.Layer< const baseURL = options.baseURL ?? AIMLAPI_BASE_URL // The models endpoint is public; attach the key only when present so - // the catalog renders before the provider is connected. Capability and - // modality metadata is opt-in per section via `include`. - const url = `${baseURL.replace(/\/+$/, "")}/models?include=capabilities,modalities` + // the catalog renders before the provider is connected. Capability, + // modality and pricing metadata are opt-in per section via `include`. + const url = `${baseURL.replace(/\/+$/, "")}/models?include=capabilities,modalities,pricing` let request = HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson) if (options.apiKey) { request = request.pipe(HttpClientRequest.bearerToken(options.apiKey)) diff --git a/packages/opencode/test/kilocode/model-cache-effect.test.ts b/packages/opencode/test/kilocode/model-cache-effect.test.ts index 2a0f48f32c9..2ede358e4ee 100644 --- a/packages/opencode/test/kilocode/model-cache-effect.test.ts +++ b/packages/opencode/test/kilocode/model-cache-effect.test.ts @@ -19,7 +19,11 @@ function layer( hits: Ref.Ref, cfg = TestConfig.layer(), access = auth, - gates?: { readonly started: Deferred.Deferred; readonly wait: Deferred.Deferred; readonly count?: number }, + gates?: { + readonly started: Deferred.Deferred + readonly wait: Deferred.Deferred + readonly count?: number + }, fail?: number, ) { const http = HttpClient.make((request) => @@ -310,3 +314,60 @@ it.live("does not resolve auth or config for unsupported providers", () => expect(yield* Ref.get(hits)).toEqual([]) }), ) + +function aimlapiLayer(hits: Ref.Ref, item: unknown) { + const http = HttpClient.make((request) => + Effect.gen(function* () { + yield* Ref.update(hits, (list) => [...list, { url: request.url }]) + return HttpClientResponse.fromWeb(request, Response.json({ data: [item] })) + }), + ) + return Layer.fresh(ModelCache.layer).pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, http)), + Layer.provide(TestConfig.layer()), + Layer.provide(auth), + Layer.provide(ModelCache.kiloModelsLayer), + ) +} + +it.live("maps aimlapi pricing units to per-1M model cost", () => + Effect.gen(function* () { + const hits = yield* Ref.make([]) + const item = { + id: "vendor/chat-model", + type: "openai/chat-completions", + info: { name: "Chat Model", contextLength: 200000 }, + pricing: { + units: [ + { type: "charge", name: "token", content: "text", origin: "provided", price: 3.25, per: 1_000_000 }, + // per normalization: 0.000013 per 1 token == 13 per 1M + { type: "charge", name: "token", content: "text", origin: "generated", price: 0.000013, per: 1 }, + { type: "charge", name: "token", content: "text", origin: "cached", price: 1.625, per: 1_000_000 }, + { type: "charge", name: "token", content: "text", origin: "cache_write", price: 6, per: 1_000_000 }, + // ignored: audio input, tool-call charge, and a duplicate generated row + { type: "charge", name: "token", content: "audio", origin: "provided", price: 999, per: 1_000_000 }, + { type: "charge", name: "call", content: "structured", origin: "generated", price: 0.05, per: 1 }, + { type: "charge", name: "token", content: "text", origin: "generated", price: 99, per: 1_000_000 }, + ], + }, + } + const models = yield* ModelCache.Service.use((cache) => + cache.fetch("aimlapi", { baseURL: "https://aiml.test/v1" }), + ).pipe(Effect.provide(aimlapiLayer(hits, item))) + + expect(models["vendor/chat-model"]!.cost).toEqual({ input: 3.25, output: 13, cache_read: 1.625, cache_write: 6 }) + expect((yield* Ref.get(hits))[0]!.url).toContain("include=capabilities,modalities,pricing") + }), +) + +it.live("leaves aimlapi cost at zero when the model carries no pricing", () => + Effect.gen(function* () { + const hits = yield* Ref.make([]) + const item = { id: "vendor/free-model", type: "openai/chat-completions", info: { name: "Free Model" } } + const models = yield* ModelCache.Service.use((cache) => + cache.fetch("aimlapi", { baseURL: "https://aiml.test/v1" }), + ).pipe(Effect.provide(aimlapiLayer(hits, item))) + + expect(models["vendor/free-model"]!.cost).toEqual({ input: 0, output: 0 }) + }), +) From c2c46eff29d2d9b228c926b77be9cf33e6da8c47 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 20:14:16 +0300 Subject: [PATCH 14/18] feat(opencode): surface aimlapi model descriptions in the detail panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model-info panel reads its blurb from `options.description` (Kilo's convention — models.dev catalog entries carry none), which aimlapi models never set, so the panel showed no description. Decode `info.description` from the catalog (already in the default projection) and pass it through as `options.description`. 177/341 chat models carry a description. --- packages/opencode/src/provider/model-cache.ts | 8 +++++++- .../test/kilocode/model-cache-effect.test.ts | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index 5fb142ace6e..5f275ee0daf 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -60,6 +60,7 @@ const AimlapiInfo = Schema.Struct({ releasedAt: Schema.optional(Schema.String), contextLength: Schema.optional(Schema.Finite), outputMax: Schema.optional(Schema.Finite), + description: Schema.optional(Schema.String), }) const AimlapiModalities = Schema.Struct({ input: Schema.optional(Schema.Array(Schema.String)), @@ -187,7 +188,7 @@ export const layer: Layer.Layer< const capabilities = item.capabilities ?? [] const input = aimlapiModalities(item.modalities?.input ?? []) const output = aimlapiModalities(item.modalities?.output ?? []) - return { + const model: Models[string] & { options?: Record } = { id: item.id, name: item.info?.name ?? item.id, family: item.info?.developer ?? "", @@ -206,6 +207,11 @@ export const layer: Layer.Layer< output: output.length > 0 ? output : ["text"], }, } + // The model-detail panel reads the blurb from options.description (Kilo's + // convention — models.dev catalog entries carry none). + const description = item.info?.description?.trim() + if (description) model.options = { description } + return model } const fetchAimlapiModels = Effect.fn("ModelCache.fetchAimlapiModels")(function* (options: Options) { diff --git a/packages/opencode/test/kilocode/model-cache-effect.test.ts b/packages/opencode/test/kilocode/model-cache-effect.test.ts index 2ede358e4ee..e99adcaad67 100644 --- a/packages/opencode/test/kilocode/model-cache-effect.test.ts +++ b/packages/opencode/test/kilocode/model-cache-effect.test.ts @@ -369,5 +369,24 @@ it.live("leaves aimlapi cost at zero when the model carries no pricing", () => ).pipe(Effect.provide(aimlapiLayer(hits, item))) expect(models["vendor/free-model"]!.cost).toEqual({ input: 0, output: 0 }) + // No description in the catalog → no options blurb for the detail panel. + expect((models["vendor/free-model"] as { options?: unknown }).options).toBeUndefined() + }), +) + +it.live("exposes the catalog description via model options", () => + Effect.gen(function* () { + const hits = yield* Ref.make([]) + const item = { + id: "vendor/described", + type: "openai/chat-completions", + info: { name: "Described", description: " A capable coding model. " }, + } + const models = yield* ModelCache.Service.use((cache) => + cache.fetch("aimlapi", { baseURL: "https://aiml.test/v1" }), + ).pipe(Effect.provide(aimlapiLayer(hits, item))) + + const model = models["vendor/described"] as { options?: { description?: string } } + expect(model.options?.description).toBe("A capable coding model.") }), ) From 9ac714fd925831b1ae36aeaaffc3baca8c51d77c Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Tue, 21 Jul 2026 17:07:37 +0300 Subject: [PATCH 15/18] feat(opencode): tag every aimlapi request with attribution headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send X-AIMLAPI-Source: agent and X-AIMLAPI-Partner-ID on all traffic to aimlapi.com — inference, catalog, auth and checkout — not just sign-up, so the backend can attribute the traffic and mark agent-sourced accounts. - config: shared aimlapiAttributionHeaders() (source always; partner id when known, overridable via AIMLAPI_PARTNER_ID) - client: onboarding requests carry them via the shared request() - model-cache: catalog fetch carries them, and every model gets model.headers - provider overlay: patchModelsDevModel carries model.headers onto the resolved model so getSDK stamps them on inference calls --- .../opencode/src/kilocode/aimlapi/client.ts | 6 ++- .../opencode/src/kilocode/aimlapi/config.ts | 21 ++++++++ .../src/kilocode/provider/provider.ts | 3 ++ packages/opencode/src/provider/model-cache.ts | 15 +++++- .../test/kilocode/aimlapi-client.test.ts | 49 +++++++++++++++++++ .../test/kilocode/model-cache-effect.test.ts | 32 ++++++++++-- 6 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/kilocode/aimlapi-client.test.ts diff --git a/packages/opencode/src/kilocode/aimlapi/client.ts b/packages/opencode/src/kilocode/aimlapi/client.ts index a6272f8a194..2371d8f82f6 100644 --- a/packages/opencode/src/kilocode/aimlapi/client.ts +++ b/packages/opencode/src/kilocode/aimlapi/client.ts @@ -3,7 +3,7 @@ // AI/ML API passwordless onboarding and partner-checkout HTTP client. // Protocol mirrors the AIMLAPI integrations shipped in Zero and OpenClaude. -import type { AimlapiEndpoints } from "./config" +import { aimlapiAttributionHeaders, type AimlapiEndpoints } from "./config" export type PartnerCheckoutSessionStatus = | "pending_auth" @@ -266,7 +266,9 @@ export class AimlapiClient { }, ): Promise { const label = requestLabel(url) - const headers: Record = { Accept: "application/json" } + // Attribution headers (source: agent + partner id) belong on every call, not + // just sign-up — set here so no endpoint method can forget them. + const headers: Record = { Accept: "application/json", ...aimlapiAttributionHeaders() } if (options.body !== undefined) headers["Content-Type"] = "application/json" if (options.bearer) headers["Authorization"] = `Bearer ${options.bearer.trim()}` diff --git a/packages/opencode/src/kilocode/aimlapi/config.ts b/packages/opencode/src/kilocode/aimlapi/config.ts index 8cf36e2efcd..e54b0dab9bf 100644 --- a/packages/opencode/src/kilocode/aimlapi/config.ts +++ b/packages/opencode/src/kilocode/aimlapi/config.ts @@ -63,6 +63,27 @@ export function resolvePartnerId(explicit?: string): string { return explicit?.trim() || process.env["AIMLAPI_PARTNER_ID"]?.trim() || DEFAULT_PARTNER_ID } +/** Attribution header names (AIMLAPI backend contract). */ +export const AIMLAPI_SOURCE_HEADER = "X-AIMLAPI-Source" +export const AIMLAPI_PARTNER_HEADER = "X-AIMLAPI-Partner-ID" +/** Marks the request/sign-up as agent-originated (analytics + segmentation). */ +export const AIMLAPI_SOURCE_VALUE = "agent" + +/** + * Headers every request to aimlapi.com must carry so the traffic is tagged as + * agent-originated and attributed to the Kilo Code partner — inference, catalog, + * auth and checkout alike, not just sign-up. Set once in each shared HTTP client + * so no individual call site can forget them. `X-AIMLAPI-Source` is always sent; + * `X-AIMLAPI-Partner-ID` is added only when a partner id is known (empty default + * until the production id ships, overridable via AIMLAPI_PARTNER_ID). + */ +export function aimlapiAttributionHeaders(partnerId?: string): Record { + const headers: Record = { [AIMLAPI_SOURCE_HEADER]: AIMLAPI_SOURCE_VALUE } + const partner = resolvePartnerId(partnerId) + if (partner) headers[AIMLAPI_PARTNER_HEADER] = partner + return headers +} + /** * Build the co-branded checkout return URLs the hosted payment page redirects * to after the user pays or cancels. `sessionToken` + `partnerCheckout=1` make diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index 91aef280731..8cc650200a5 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -68,6 +68,9 @@ export function patchModelsDevModel(providerID: string, source: any) { autoRouting: source.autoRouting, ai_sdk_provider: source.ai_sdk_provider, options: source.options ?? {}, + // Carry any per-model request headers (e.g. aimlapi attribution) onto the + // resolved model so getSDK merges them into the openai-compatible client. + ...(source.headers ? { headers: source.headers } : {}), } } diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index 5f275ee0daf..9270da398ed 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -6,6 +6,7 @@ import { Config } from "../config/config" import { Auth } from "../auth" import type { Provider } from "@opencode-ai/core/models-dev" import * as Log from "@opencode-ai/core/util/log" +import { aimlapiAttributionHeaders } from "../kilocode/aimlapi/config" type Models = Provider["models"] type KiloOptions = NonNullable[0]> @@ -188,7 +189,10 @@ export const layer: Layer.Layer< const capabilities = item.capabilities ?? [] const input = aimlapiModalities(item.modalities?.input ?? []) const output = aimlapiModalities(item.modalities?.output ?? []) - const model: Models[string] & { options?: Record } = { + const model: Models[string] & { + options?: Record + headers?: Record + } = { id: item.id, name: item.info?.name ?? item.id, family: item.info?.developer ?? "", @@ -211,6 +215,10 @@ export const layer: Layer.Layer< // convention — models.dev catalog entries carry none). const description = item.info?.description?.trim() if (description) model.options = { description } + // Attribution headers ride on every inference request: getSDK merges + // model.headers into the openai-compatible client, and patchModelsDevModel + // carries them onto the resolved model. + model.headers = aimlapiAttributionHeaders() return model } @@ -221,7 +229,10 @@ export const layer: Layer.Layer< // the catalog renders before the provider is connected. Capability, // modality and pricing metadata are opt-in per section via `include`. const url = `${baseURL.replace(/\/+$/, "")}/models?include=capabilities,modalities,pricing` - let request = HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson) + let request = HttpClientRequest.get(url).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.setHeaders(aimlapiAttributionHeaders()), + ) if (options.apiKey) { request = request.pipe(HttpClientRequest.bearerToken(options.apiKey)) } diff --git a/packages/opencode/test/kilocode/aimlapi-client.test.ts b/packages/opencode/test/kilocode/aimlapi-client.test.ts new file mode 100644 index 00000000000..8901b623f41 --- /dev/null +++ b/packages/opencode/test/kilocode/aimlapi-client.test.ts @@ -0,0 +1,49 @@ +// kilocode_change - new file +import { afterEach, expect, test } from "bun:test" +import { AimlapiClient } from "../../src/kilocode/aimlapi/client" +import type { AimlapiEndpoints } from "../../src/kilocode/aimlapi/config" + +const endpoints: AimlapiEndpoints = { + authBaseUrl: "https://auth.test", + appBaseUrl: "https://app.test", + payBaseUrl: "https://pay.test", + inferenceBaseUrl: "https://api.test/v1", + verificationBaseUrl: "https://front.test/app", +} + +const realFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = realFetch + delete process.env["AIMLAPI_PARTNER_ID"] +}) + +function captureHeaders(): { headers: () => Headers } { + let captured: Headers | undefined + globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + captured = new Headers(init?.headers) + return Response.json({ action: "sign-in" }) + }) as typeof fetch + return { headers: () => captured ?? new Headers() } +} + +test("onboarding client sends the attribution headers on every request", async () => { + process.env["AIMLAPI_PARTNER_ID"] = "part_test123" + const cap = captureHeaders() + + await new AimlapiClient(endpoints).checkAccount("user@example.com") + + const headers = cap.headers() + expect(headers.get("x-aimlapi-source")).toBe("agent") + expect(headers.get("x-aimlapi-partner-id")).toBe("part_test123") +}) + +test("partner-id header is omitted when no partner id is configured", async () => { + const cap = captureHeaders() + + await new AimlapiClient(endpoints).checkAccount("user@example.com") + + const headers = cap.headers() + expect(headers.get("x-aimlapi-source")).toBe("agent") + expect(headers.has("x-aimlapi-partner-id")).toBe(false) +}) diff --git a/packages/opencode/test/kilocode/model-cache-effect.test.ts b/packages/opencode/test/kilocode/model-cache-effect.test.ts index e99adcaad67..e7d1a06410b 100644 --- a/packages/opencode/test/kilocode/model-cache-effect.test.ts +++ b/packages/opencode/test/kilocode/model-cache-effect.test.ts @@ -7,7 +7,7 @@ import { ModelCache } from "../../src/provider/model-cache" import { TestConfig } from "../fixture/config" import { pollWithTimeout, testEffect } from "../lib/effect" -type Hit = { readonly url: string } +type Hit = { readonly url: string; readonly headers: Record } const auth = Layer.mock(Auth.Service)({ get: () => Effect.succeed(undefined), @@ -28,7 +28,7 @@ function layer( ) { const http = HttpClient.make((request) => Effect.gen(function* () { - yield* Ref.update(hits, (list) => [...list, { url: request.url }]) + yield* Ref.update(hits, (list) => [...list, { url: request.url, headers: request.headers }]) const count = (yield* Ref.get(hits)).length if (gates && count === (gates.count ?? 1)) { yield* Deferred.succeed(gates.started, undefined) @@ -318,7 +318,7 @@ it.live("does not resolve auth or config for unsupported providers", () => function aimlapiLayer(hits: Ref.Ref, item: unknown) { const http = HttpClient.make((request) => Effect.gen(function* () { - yield* Ref.update(hits, (list) => [...list, { url: request.url }]) + yield* Ref.update(hits, (list) => [...list, { url: request.url, headers: request.headers }]) return HttpClientResponse.fromWeb(request, Response.json({ data: [item] })) }), ) @@ -390,3 +390,29 @@ it.live("exposes the catalog description via model options", () => expect(model.options?.description).toBe("A capable coding model.") }), ) + +it.live("tags the catalog fetch and every model with the attribution headers", () => + Effect.gen(function* () { + const prev = process.env["AIMLAPI_PARTNER_ID"] + process.env["AIMLAPI_PARTNER_ID"] = "part_test123" + try { + const hits = yield* Ref.make([]) + const item = { id: "vendor/tagged", type: "openai/chat-completions", info: { name: "Tagged" } } + const models = yield* ModelCache.Service.use((cache) => + cache.fetch("aimlapi", { baseURL: "https://aiml.test/v1" }), + ).pipe(Effect.provide(aimlapiLayer(hits, item))) + + // The public catalog request carries the attribution headers. + const headers = (yield* Ref.get(hits))[0]!.headers + expect(headers["x-aimlapi-source"]).toBe("agent") + expect(headers["x-aimlapi-partner-id"]).toBe("part_test123") + + // Every model carries them too, so getSDK stamps them on inference calls. + const model = models["vendor/tagged"] as { headers?: Record } + expect(model.headers).toEqual({ "X-AIMLAPI-Source": "agent", "X-AIMLAPI-Partner-ID": "part_test123" }) + } finally { + if (prev === undefined) delete process.env["AIMLAPI_PARTNER_ID"] + else process.env["AIMLAPI_PARTNER_ID"] = prev + } + }), +) From 2d85d6632d727907be3dfbc3e730f964ca7dba2f Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Tue, 21 Jul 2026 19:43:50 +0300 Subject: [PATCH 16/18] feat(opencode): ship the kilocode aimlapi partner id as the default Set DefaultPartnerID to the partner provisioned with the same id on both the staging and production backends, so one build attributes correctly against either (only the AIMLAPI_*_URL endpoints differ); AIMLAPI_PARTNER_ID still overrides. Update the client test to assert the compiled-in default flows. --- packages/opencode/src/kilocode/aimlapi/config.ts | 10 ++++------ packages/opencode/test/kilocode/aimlapi-client.test.ts | 8 +++++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/kilocode/aimlapi/config.ts b/packages/opencode/src/kilocode/aimlapi/config.ts index e54b0dab9bf..1399f2d7be8 100644 --- a/packages/opencode/src/kilocode/aimlapi/config.ts +++ b/packages/opencode/src/kilocode/aimlapi/config.ts @@ -29,13 +29,11 @@ const DEFAULT_ENDPOINTS: AimlapiEndpoints = { /** * Partner id (`^part_[A-Za-z0-9]{1,64}$`) — rebate attribution for Kilo Code. - * Must match an active partner registered with AI/ML API. Overridable via - * AIMLAPI_PARTNER_ID (the AIMLAPI team uses a test partner on staging). - * - * TODO(aimlapi): replace with the production Kilo Code partner id before the - * upstream PR ships. + * The same id is provisioned on both the staging and production backends, so it + * ships as the compiled-in default and one build runs against either (only the + * AIMLAPI_*_URL endpoints differ). Overridable via AIMLAPI_PARTNER_ID. */ -export const DEFAULT_PARTNER_ID = "" +export const DEFAULT_PARTNER_ID = "part_NcwZvHtWloCePPggbSypywxq" export const DEFAULT_PARTNER_NAME = "Kilo Code" /** Name attached to API keys issued through this flow. */ diff --git a/packages/opencode/test/kilocode/aimlapi-client.test.ts b/packages/opencode/test/kilocode/aimlapi-client.test.ts index 8901b623f41..feab211bdc2 100644 --- a/packages/opencode/test/kilocode/aimlapi-client.test.ts +++ b/packages/opencode/test/kilocode/aimlapi-client.test.ts @@ -1,7 +1,7 @@ // kilocode_change - new file import { afterEach, expect, test } from "bun:test" import { AimlapiClient } from "../../src/kilocode/aimlapi/client" -import type { AimlapiEndpoints } from "../../src/kilocode/aimlapi/config" +import { DEFAULT_PARTNER_ID, type AimlapiEndpoints } from "../../src/kilocode/aimlapi/config" const endpoints: AimlapiEndpoints = { authBaseUrl: "https://auth.test", @@ -38,12 +38,14 @@ test("onboarding client sends the attribution headers on every request", async ( expect(headers.get("x-aimlapi-partner-id")).toBe("part_test123") }) -test("partner-id header is omitted when no partner id is configured", async () => { +test("partner-id header carries the compiled-in default when no override is set", async () => { const cap = captureHeaders() await new AimlapiClient(endpoints).checkAccount("user@example.com") const headers = cap.headers() expect(headers.get("x-aimlapi-source")).toBe("agent") - expect(headers.has("x-aimlapi-partner-id")).toBe(false) + // The default id is provisioned on both backends, so it ships without an env override. + expect(DEFAULT_PARTNER_ID).not.toBe("") + expect(headers.get("x-aimlapi-partner-id")).toBe(DEFAULT_PARTNER_ID) }) From 8ca14fd5d4b641577d9276a448c78e882efc8204 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Wed, 22 Jul 2026 16:42:16 +0300 Subject: [PATCH 17/18] feat(opencode): hide aimlapi image-generation models served over chat Some image models (openai/gpt-5-image, google/gemini-3.1-flash-image aka Nano Banana 2, etc.) reach /v1/models as type=openai/chat-completions but reject tool use, so a coding agent errors at runtime ("No endpoints found that support tool use"). Filter them out by id in the aimlapi model fetcher (+ unit test). Temporary until the catalog marks these models. Co-Authored-By: Claude Opus 4.8 --- packages/opencode/src/provider/model-cache.ts | 13 ++++++++++++- .../test/kilocode/model-cache-effect.test.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index 9270da398ed..97776f7ef4e 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -55,6 +55,15 @@ const ApertisResponse = Schema.Struct({ data: Schema.optional(Schema.Array(Apert type ApertisItem = Schema.Schema.Type const AIMLAPI_BASE_URL = "https://api.aimlapi.com/v1" +// TEMPORARY: some image-generation models are served over the chat protocol +// (via OpenRouter) and reach /v1/models as type=openai/chat-completions, but +// reject tool use — a coding agent then errors at runtime ("No endpoints found +// that support tool use"). The catalog can't distinguish them yet (the generic +// chat DTO over-reports the `tools` capability; text-only pricing leaves +// output=[text]), so we exclude them by id. Remove once the backend re-tags +// them (playground:image) or fixes their output modality — see the repo-root +// MODELS.md § "One model, multiple endpoints" and its backlog task. +const AIMLAPI_IMAGE_ON_CHAT_ID = /-image(-|$)/i const AimlapiInfo = Schema.Struct({ name: Schema.optional(Schema.String), developer: Schema.optional(Schema.String), @@ -243,7 +252,9 @@ export const layer: Layer.Layer< } const json = yield* HttpClientResponse.schemaBodyJson(AimlapiResponse)(response) - const chat = (json.data ?? []).filter((item) => item.type === "openai/chat-completions") + const chat = (json.data ?? []).filter( + (item) => item.type === "openai/chat-completions" && !AIMLAPI_IMAGE_ON_CHAT_ID.test(item.id), + ) // Coding-tagged models rank first so the picker surfaces them on top. let recommended = 0 return Object.fromEntries( diff --git a/packages/opencode/test/kilocode/model-cache-effect.test.ts b/packages/opencode/test/kilocode/model-cache-effect.test.ts index e7d1a06410b..e8b9b33030b 100644 --- a/packages/opencode/test/kilocode/model-cache-effect.test.ts +++ b/packages/opencode/test/kilocode/model-cache-effect.test.ts @@ -374,6 +374,20 @@ it.live("leaves aimlapi cost at zero when the model carries no pricing", () => }), ) +it.live("hides image-generation models that ride the chat endpoint but reject tool use", () => + Effect.gen(function* () { + const hits = yield* Ref.make([]) + // Served as type=openai/chat-completions but tool use is unsupported, so a + // coding agent must not surface it. The only reliable signal today is the id. + const item = { id: "openai/gpt-5-image", type: "openai/chat-completions", info: { name: "GPT-5 Image" } } + const models = yield* ModelCache.Service.use((cache) => + cache.fetch("aimlapi", { baseURL: "https://aiml.test/v1" }), + ).pipe(Effect.provide(aimlapiLayer(hits, item))) + + expect(Object.keys(models)).toEqual([]) + }), +) + it.live("exposes the catalog description via model options", () => Effect.gen(function* () { const hits = yield* Ref.make([]) From cca9f283e5069265913d9f5991196372a0a2140e Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Wed, 22 Jul 2026 18:44:54 +0300 Subject: [PATCH 18/18] refactor(opencode): make the aimlapi partner id a fixed constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the AIMLAPI_PARTNER_ID env override and the resolvePartnerId indirection — the partner id is fixed for the aimlapi.com provider (same id on both backends), so it is now a plain constant used directly in the attribution headers and checkout. Updates the client/model-cache tests to assert the constant instead of an injected override. Co-Authored-By: Claude Opus 4.8 --- .../opencode/src/kilocode/aimlapi/config.ts | 25 +++++-------- .../opencode/src/kilocode/aimlapi/flow.ts | 4 +-- .../test/kilocode/aimlapi-client.test.ts | 14 +------- .../test/kilocode/model-cache-effect.test.ts | 36 ++++++++----------- 4 files changed, 27 insertions(+), 52 deletions(-) diff --git a/packages/opencode/src/kilocode/aimlapi/config.ts b/packages/opencode/src/kilocode/aimlapi/config.ts index 1399f2d7be8..1c1d77605b1 100644 --- a/packages/opencode/src/kilocode/aimlapi/config.ts +++ b/packages/opencode/src/kilocode/aimlapi/config.ts @@ -29,9 +29,9 @@ const DEFAULT_ENDPOINTS: AimlapiEndpoints = { /** * Partner id (`^part_[A-Za-z0-9]{1,64}$`) — rebate attribution for Kilo Code. - * The same id is provisioned on both the staging and production backends, so it - * ships as the compiled-in default and one build runs against either (only the - * AIMLAPI_*_URL endpoints differ). Overridable via AIMLAPI_PARTNER_ID. + * Fixed for the aimlapi.com provider and not configurable: the same id is + * provisioned on both the staging and production backends, so one build runs + * against either (only the AIMLAPI_*_URL endpoints differ). */ export const DEFAULT_PARTNER_ID = "part_NcwZvHtWloCePPggbSypywxq" export const DEFAULT_PARTNER_NAME = "Kilo Code" @@ -56,11 +56,6 @@ export function resolveEndpoints(): AimlapiEndpoints { } } -/** Resolve checkout attribution with one shared precedence. */ -export function resolvePartnerId(explicit?: string): string { - return explicit?.trim() || process.env["AIMLAPI_PARTNER_ID"]?.trim() || DEFAULT_PARTNER_ID -} - /** Attribution header names (AIMLAPI backend contract). */ export const AIMLAPI_SOURCE_HEADER = "X-AIMLAPI-Source" export const AIMLAPI_PARTNER_HEADER = "X-AIMLAPI-Partner-ID" @@ -71,15 +66,13 @@ export const AIMLAPI_SOURCE_VALUE = "agent" * Headers every request to aimlapi.com must carry so the traffic is tagged as * agent-originated and attributed to the Kilo Code partner — inference, catalog, * auth and checkout alike, not just sign-up. Set once in each shared HTTP client - * so no individual call site can forget them. `X-AIMLAPI-Source` is always sent; - * `X-AIMLAPI-Partner-ID` is added only when a partner id is known (empty default - * until the production id ships, overridable via AIMLAPI_PARTNER_ID). + * so no individual call site can forget them. */ -export function aimlapiAttributionHeaders(partnerId?: string): Record { - const headers: Record = { [AIMLAPI_SOURCE_HEADER]: AIMLAPI_SOURCE_VALUE } - const partner = resolvePartnerId(partnerId) - if (partner) headers[AIMLAPI_PARTNER_HEADER] = partner - return headers +export function aimlapiAttributionHeaders(): Record { + return { + [AIMLAPI_SOURCE_HEADER]: AIMLAPI_SOURCE_VALUE, + [AIMLAPI_PARTNER_HEADER]: DEFAULT_PARTNER_ID, + } } /** diff --git a/packages/opencode/src/kilocode/aimlapi/flow.ts b/packages/opencode/src/kilocode/aimlapi/flow.ts index a2329fffc4a..424ae6c0ad8 100644 --- a/packages/opencode/src/kilocode/aimlapi/flow.ts +++ b/packages/opencode/src/kilocode/aimlapi/flow.ts @@ -12,6 +12,7 @@ import { DEFAULT_AMOUNT_USD_MINOR, + DEFAULT_PARTNER_ID, DEFAULT_PARTNER_NAME, ISSUED_KEY_NAME, MAX_AMOUNT_USD_MINOR, @@ -19,7 +20,6 @@ import { buildPartnerCheckoutReturnUrls, buildPartnerReturnUrl, resolveEndpoints, - resolvePartnerId, } from "./config" import { AimlapiApiError, AimlapiClient, type PartnerCheckoutSession } from "./client" @@ -267,7 +267,7 @@ export class AimlapiFlow { if (!this.checkoutSessionToken) { const session = await this.client.createSession( { - partnerId: resolvePartnerId(), + partnerId: DEFAULT_PARTNER_ID, partnerName: DEFAULT_PARTNER_NAME, returnUrl: buildPartnerReturnUrl(this.endpoints.verificationBaseUrl), }, diff --git a/packages/opencode/test/kilocode/aimlapi-client.test.ts b/packages/opencode/test/kilocode/aimlapi-client.test.ts index feab211bdc2..ad54563862a 100644 --- a/packages/opencode/test/kilocode/aimlapi-client.test.ts +++ b/packages/opencode/test/kilocode/aimlapi-client.test.ts @@ -15,7 +15,6 @@ const realFetch = globalThis.fetch afterEach(() => { globalThis.fetch = realFetch - delete process.env["AIMLAPI_PARTNER_ID"] }) function captureHeaders(): { headers: () => Headers } { @@ -28,24 +27,13 @@ function captureHeaders(): { headers: () => Headers } { } test("onboarding client sends the attribution headers on every request", async () => { - process.env["AIMLAPI_PARTNER_ID"] = "part_test123" const cap = captureHeaders() await new AimlapiClient(endpoints).checkAccount("user@example.com") const headers = cap.headers() expect(headers.get("x-aimlapi-source")).toBe("agent") - expect(headers.get("x-aimlapi-partner-id")).toBe("part_test123") -}) - -test("partner-id header carries the compiled-in default when no override is set", async () => { - const cap = captureHeaders() - - await new AimlapiClient(endpoints).checkAccount("user@example.com") - - const headers = cap.headers() - expect(headers.get("x-aimlapi-source")).toBe("agent") - // The default id is provisioned on both backends, so it ships without an env override. + // Fixed compiled-in partner id, provisioned on both backends. expect(DEFAULT_PARTNER_ID).not.toBe("") expect(headers.get("x-aimlapi-partner-id")).toBe(DEFAULT_PARTNER_ID) }) diff --git a/packages/opencode/test/kilocode/model-cache-effect.test.ts b/packages/opencode/test/kilocode/model-cache-effect.test.ts index e8b9b33030b..6cf3f45ed2b 100644 --- a/packages/opencode/test/kilocode/model-cache-effect.test.ts +++ b/packages/opencode/test/kilocode/model-cache-effect.test.ts @@ -4,6 +4,7 @@ import { Deferred, Effect, Exit, Fiber, Layer, Option, Ref } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { Auth } from "../../src/auth" import { ModelCache } from "../../src/provider/model-cache" +import { DEFAULT_PARTNER_ID } from "../../src/kilocode/aimlapi/config" import { TestConfig } from "../fixture/config" import { pollWithTimeout, testEffect } from "../lib/effect" @@ -407,26 +408,19 @@ it.live("exposes the catalog description via model options", () => it.live("tags the catalog fetch and every model with the attribution headers", () => Effect.gen(function* () { - const prev = process.env["AIMLAPI_PARTNER_ID"] - process.env["AIMLAPI_PARTNER_ID"] = "part_test123" - try { - const hits = yield* Ref.make([]) - const item = { id: "vendor/tagged", type: "openai/chat-completions", info: { name: "Tagged" } } - const models = yield* ModelCache.Service.use((cache) => - cache.fetch("aimlapi", { baseURL: "https://aiml.test/v1" }), - ).pipe(Effect.provide(aimlapiLayer(hits, item))) - - // The public catalog request carries the attribution headers. - const headers = (yield* Ref.get(hits))[0]!.headers - expect(headers["x-aimlapi-source"]).toBe("agent") - expect(headers["x-aimlapi-partner-id"]).toBe("part_test123") - - // Every model carries them too, so getSDK stamps them on inference calls. - const model = models["vendor/tagged"] as { headers?: Record } - expect(model.headers).toEqual({ "X-AIMLAPI-Source": "agent", "X-AIMLAPI-Partner-ID": "part_test123" }) - } finally { - if (prev === undefined) delete process.env["AIMLAPI_PARTNER_ID"] - else process.env["AIMLAPI_PARTNER_ID"] = prev - } + const hits = yield* Ref.make([]) + const item = { id: "vendor/tagged", type: "openai/chat-completions", info: { name: "Tagged" } } + const models = yield* ModelCache.Service.use((cache) => + cache.fetch("aimlapi", { baseURL: "https://aiml.test/v1" }), + ).pipe(Effect.provide(aimlapiLayer(hits, item))) + + // The public catalog request carries the attribution headers. + const headers = (yield* Ref.get(hits))[0]!.headers + expect(headers["x-aimlapi-source"]).toBe("agent") + expect(headers["x-aimlapi-partner-id"]).toBe(DEFAULT_PARTNER_ID) + + // Every model carries them too, so getSDK stamps them on inference calls. + const model = models["vendor/tagged"] as { headers?: Record } + expect(model.headers).toEqual({ "X-AIMLAPI-Source": "agent", "X-AIMLAPI-Partner-ID": DEFAULT_PARTNER_ID }) }), )