From b598d651649dd6f288b405481c924e4357672533 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Fri, 17 Jul 2026 18:17:52 +0300 Subject: [PATCH 01/16] 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 ++ 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 +++++ 6 files changed, 130 insertions(+), 8 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/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index 6a36289765e..c20bbf344b0 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -9,6 +9,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 40b266edc99..ea03fa62b58 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -56,6 +56,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, @@ -118,8 +134,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 = {} @@ -160,12 +222,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: {} }) } @@ -186,7 +265,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 faa8b65f2c1..4f1a26b41ca 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -70,7 +70,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 } @@ -94,6 +114,7 @@ export const layer: Layer.Layer Date: Fri, 17 Jul 2026 19:22:27 +0300 Subject: [PATCH 02/16] 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 +- .../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 ++++++++++++++++ 7 files changed, 1360 insertions(+), 2 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/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 be7f285e0449192aaf4501bc18c28bfd5e44b4ad Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Fri, 17 Jul 2026 20:33:57 +0300 Subject: [PATCH 03/16] =?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 ea03fa62b58..9e5539a5257 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -150,18 +150,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 d27e9688b1a38badf5145f61aff88c27b71ab768 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 12:38:35 +0300 Subject: [PATCH 04/16] 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 911571a41fc..f24d3bb4f9c 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -13,7 +13,7 @@ import { Ripgrep } from "@opencode-ai/core/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 3f24052fdb0..187d70d01be 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -65,7 +65,7 @@ import { AppNodeBuilderV1 } from "@/effect/app-node-builder-v1" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { EventV2 } from "@opencode-ai/core/event" -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 { Npm } from "@opencode-ai/core/npm" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { ProjectV2 } from "@opencode-ai/core/project" From 155c70bc9e14ccb9cb95c8b82b8a0f7e4ec182c6 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 13:17:28 +0300 Subject: [PATCH 05/16] 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 198ea43334f14cd4ad5432cf5a5bb56cb2b8ad3b Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 16:47:31 +0300 Subject: [PATCH 06/16] 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 9e5539a5257..b396b3e779a 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -62,12 +62,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 @@ -134,26 +141,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 b61682eca956bb8267b1faa37716e40e52ef91c0 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 18:45:49 +0300 Subject: [PATCH 07/16] 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 4f1a26b41ca..f2d224473b6 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -73,10 +73,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", @@ -85,7 +95,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 71ee02e49f94d39d8e14cbc220cecbe6dfe87046 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 18:47:09 +0300 Subject: [PATCH 08/16] 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 9f91e916f6bf96c6ab75447f25fade6f8230804f Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 18:55:36 +0300 Subject: [PATCH 09/16] 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 b538f126a61..8321984e18c 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -7,6 +7,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 3117506790f..1716a2ace25 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -7,6 +7,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 49cbbea5ef5..37ac5fa118e 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -7,6 +7,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 960a5b4158f..1c7db6dd440 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -7,6 +7,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 d939be47485..c0607327255 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -7,6 +7,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 0e71b78c762..b3bb2182721 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -7,6 +7,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 ae5635dfaa8..b0f2b4357dd 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -7,6 +7,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 d1445f057f7..00db0d98cad 100644 --- a/packages/kilo-i18n/src/it.ts +++ b/packages/kilo-i18n/src/it.ts @@ -9,6 +9,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 ed30565559f..f8699755358 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -7,6 +7,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 cc7fa9c8f8c..1f1d1f00732 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -7,6 +7,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 948254ee771..3dbebc2379b 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -9,6 +9,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 cdee5f5a106..e78baca5c46 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -7,6 +7,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 ba4679a5443..a4d82d617ce 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -7,6 +7,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 4875ff7100f..00e40ff38c7 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -7,6 +7,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 12455abf348..a447e42b61c 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -7,6 +7,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 bf162698d88..4c36428ada9 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -7,6 +7,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 9c7dc984a5c..3b765a929d1 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -7,6 +7,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 ffc77d1ec03..f11ebc97cbe 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -7,6 +7,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 3774a9a495e..6470c238011 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -7,6 +7,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 5c7f67657c3178b033f4ea8003973f418009f1a2 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 19:07:22 +0300 Subject: [PATCH 10/16] 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 c72700d019f5748def2278eea771b0dae89c409e Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 20:00:34 +0300 Subject: [PATCH 11/16] 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 b396b3e779a..8307661dcca 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -68,6 +68,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), @@ -75,6 +86,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 @@ -146,6 +158,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 ?? []) @@ -162,7 +202,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"], @@ -175,9 +215,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 49df92392f27104a014048ff9adbcb72ba5cba39 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Mon, 20 Jul 2026 20:14:16 +0300 Subject: [PATCH 12/16] 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 8307661dcca..8fa13466a47 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -63,6 +63,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)), @@ -190,7 +191,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 ?? "", @@ -209,6 +210,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 57ce54a6d2e993755e05583421a14e45cb20bad6 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Tue, 21 Jul 2026 17:07:37 +0300 Subject: [PATCH 13/16] 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 21345881ee9..80bf8d514d7 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -71,6 +71,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 8fa13466a47..5f19643b4dd 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -9,6 +9,7 @@ import * as Log from "@opencode-ai/core/util/log" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change import { httpClient } from "@opencode-ai/core/effect/app-node-platform" // kilocode_change +import { aimlapiAttributionHeaders } from "../kilocode/aimlapi/config" type Models = Provider["models"] type KiloOptions = NonNullable[0]> @@ -191,7 +192,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 ?? "", @@ -214,6 +218,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 } @@ -224,7 +232,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 f76397631cdb20116ced98e162c3af4245e73b2d Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Tue, 21 Jul 2026 19:43:50 +0300 Subject: [PATCH 14/16] 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 9c0b2c997df07dd902094f080fcbd680d01cc812 Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Wed, 22 Jul 2026 16:42:16 +0300 Subject: [PATCH 15/16] 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 5f19643b4dd..2bf725a4283 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -58,6 +58,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), @@ -246,7 +255,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 11311d80bba126540908c2c7828527dbc331d93b Mon Sep 17 00:00:00 2001 From: Nikolay Grinko Date: Wed, 22 Jul 2026 18:44:54 +0300 Subject: [PATCH 16/16] 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 }) }), )