diff --git a/.changeset/aimlapi-provider.md b/.changeset/aimlapi-provider.md new file mode 100644 index 00000000000..f0ffe680bea --- /dev/null +++ b/.changeset/aimlapi-provider.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +--- + +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/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index 13170b89c6d..4ef415c510b 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "موصى به", "settings.providers.note.kilo": "الوصول إلى أكثر من 500 نموذج ذكاء اصطناعي", + "settings.providers.note.aimlapi": "أكثر من 1000 نموذج من جميع المزودين الرئيسيين، إعداد بنقرة واحدة", "settings.providers.note.opencode": "نماذج منتقاة تشمل Claude وGPT وGemini والمزيد", "settings.providers.note.anthropic": "وصول مباشر إلى نماذج Claude، بما في ذلك Pro وMax", "settings.providers.note.deepseek": "نماذج DeepSeek لمهام الاستدلال والبرمجة", diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index 3e91b3d765e..114f3f9a1ec 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Recomendados", "settings.providers.note.kilo": "Acesso a mais de 500 modelos de IA", + "settings.providers.note.aimlapi": "Mais de 1000 modelos de todos os principais provedores, configuração com um clique", "settings.providers.note.opencode": "Modelos selecionados, incluindo Claude, GPT, Gemini e mais", "settings.providers.note.anthropic": "Acesso direto aos modelos Claude, incluindo Pro e Max", "settings.providers.note.deepseek": "Modelos DeepSeek para tarefas de raciocínio e programação", diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index d1aece561ee..d138e375744 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Preporučeno", "settings.providers.note.kilo": "Pristup za 500+ AI modela", + "settings.providers.note.aimlapi": "Više od 1000 modela od svih velikih pružatelja, postavljanje jednim klikom", "settings.providers.note.opencode": "Odabrani modeli uključujući Claude, GPT, Gemini i još mnogo toga", "settings.providers.note.anthropic": "Direktan pristup Claude modelima, uključujući Pro i Max", "settings.providers.note.deepseek": "DeepSeek modeli za zadatke rezonovanja i programiranja", diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index 3147ec1eee1..bed15ae0832 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Anbefalede", "settings.providers.note.kilo": "Adgang til 500+ AI-modeller", + "settings.providers.note.aimlapi": "Over 1000 modeller fra alle større udbydere, opsætning med ét klik", "settings.providers.note.opencode": "Udvalgte modeller inklusive Claude, GPT, Gemini og mere", "settings.providers.note.anthropic": "Direkte adgang til Claude-modeller, inklusive Pro og Max", "settings.providers.note.deepseek": "DeepSeek-modeller til ræsonnement og kodningsopgaver", diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index 8e4fe468a5c..8354faab4c4 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Empfohlen", "settings.providers.note.kilo": "Zugriff auf 500+ KI-Modelle", + "settings.providers.note.aimlapi": "Über 1000 Modelle von allen großen Anbietern, Einrichtung mit einem Klick", "settings.providers.note.opencode": "Kuratierte Modelle, darunter Claude, GPT, Gemini und mehr", "settings.providers.note.anthropic": "Direkter Zugriff auf Claude-Modelle, einschließlich Pro und Max", "settings.providers.note.deepseek": "DeepSeek-Modelle für Denk- und Programmieraufgaben", diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index 9eb679e3a03..3b6d7b722ff 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -16,6 +16,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Recommended", "settings.providers.note.kilo": "Access 500+ AI models", + "settings.providers.note.aimlapi": "1000+ models from all major providers, one-click setup", "settings.providers.note.opencode": "Curated models including Claude, GPT, Gemini and more", "settings.providers.note.anthropic": "Direct access to Claude models, including Pro and Max", "settings.providers.note.deepseek": "DeepSeek models for reasoning and coding tasks", diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index a72ba48094c..82bfc5c489b 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Recomendados", "settings.providers.note.kilo": "Acceso a más de 500 modelos de IA", + "settings.providers.note.aimlapi": "Más de 1000 modelos de todos los principales proveedores, configuración con un clic", "settings.providers.note.opencode": "Modelos seleccionados, incluidos Claude, GPT, Gemini y más", "settings.providers.note.anthropic": "Acceso directo a modelos Claude, incluidos Pro y Max", "settings.providers.note.deepseek": "Modelos DeepSeek para tareas de razonamiento y programación", diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index 6f654770827..9c677dbf40c 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Recommandés", "settings.providers.note.kilo": "Accès à plus de 500 modèles d'IA", + "settings.providers.note.aimlapi": "Plus de 1000 modèles de tous les principaux fournisseurs, configuration en un clic", "settings.providers.note.opencode": "Modèles sélectionnés, dont Claude, GPT, Gemini et plus encore", "settings.providers.note.anthropic": "Accès direct aux modèles Claude, y compris Pro et Max", "settings.providers.note.deepseek": "Modèles DeepSeek pour les tâches de raisonnement et de codage", diff --git a/packages/kilo-i18n/src/it.ts b/packages/kilo-i18n/src/it.ts index 229be83c14a..523ce5c0c34 100644 --- a/packages/kilo-i18n/src/it.ts +++ b/packages/kilo-i18n/src/it.ts @@ -16,6 +16,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Consigliati", "settings.providers.note.kilo": "Accesso a oltre 500 modelli AI", + "settings.providers.note.aimlapi": "Oltre 1000 modelli da tutti i principali provider, configurazione con un clic", "settings.providers.note.opencode": "Modelli selezionati, inclusi Claude, GPT, Gemini e altri", "settings.providers.note.anthropic": "Accesso diretto ai modelli Claude, inclusi Pro e Max", "settings.providers.note.deepseek": "Modelli DeepSeek per attività di ragionamento e programmazione", diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index c1699834b6d..f64f81b0de1 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -13,6 +13,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "おすすめ", "settings.providers.note.kilo": "500以上のAIモデルにアクセス", + "settings.providers.note.aimlapi": "主要プロバイダーの1000以上のモデル、ワンクリックで設定", "settings.providers.note.opencode": "Claude、GPT、Geminiなどの厳選モデル", "settings.providers.note.anthropic": "ProやMaxを含むClaudeモデルへ直接アクセス", "settings.providers.note.deepseek": "推論とコーディング作業向けのDeepSeekモデル", diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index a0e760dd541..72141515e7d 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -13,6 +13,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "추천", "settings.providers.note.kilo": "500개 이상의 AI 모델 이용 가능", + "settings.providers.note.aimlapi": "모든 주요 제공업체의 1000개 이상 모델, 원클릭 설정", "settings.providers.note.opencode": "Claude, GPT, Gemini 등을 포함한 엄선된 모델", "settings.providers.note.anthropic": "Pro 및 Max를 포함한 Claude 모델에 직접 액세스", "settings.providers.note.deepseek": "추론 및 코딩 작업을 위한 DeepSeek 모델", diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index bf4f116fd47..3f3a27810b6 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -16,6 +16,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Aanbevolen", "settings.providers.note.kilo": "Toegang tot 500+ AI modellen", + "settings.providers.note.aimlapi": "Meer dan 1000 modellen van alle grote aanbieders, installatie met één klik", "settings.providers.note.opencode": "Geselecteerde modellen, waaronder Claude, GPT, Gemini en meer", "settings.providers.note.anthropic": "Directe toegang tot Claude-modellen, inclusief Pro en Max", "settings.providers.note.deepseek": "DeepSeek-modellen voor redeneer- en codeertaken", diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index c28bfb16cda..ed98f57e84c 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Anbefalt", "settings.providers.note.kilo": "Tilgang til 500+ AI-modeller", + "settings.providers.note.aimlapi": "Over 1000 modeller fra alle store leverandører, oppsett med ett klikk", "settings.providers.note.opencode": "Utvalgte modeller, inkludert Claude, GPT, Gemini og mer", "settings.providers.note.anthropic": "Direkte tilgang til Claude-modeller, inkludert Pro og Max", "settings.providers.note.deepseek": "DeepSeek-modeller for resonnering og kodeoppgaver", diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 6764a61f265..ed2bdbe458f 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Polecane", "settings.providers.note.kilo": "Dostęp do ponad 500 modeli AI", + "settings.providers.note.aimlapi": "Ponad 1000 modeli od wszystkich głównych dostawców, konfiguracja jednym kliknięciem", "settings.providers.note.opencode": "Wyselekcjonowane modele, w tym Claude, GPT, Gemini i inne", "settings.providers.note.anthropic": "Bezpośredni dostęp do modeli Claude, w tym Pro i Max", "settings.providers.note.deepseek": "Modele DeepSeek do zadań rozumowania i kodowania", diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index d426d1c0f1f..a163926f51c 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Рекомендуемые", "settings.providers.note.kilo": "Доступ к 500+ моделям ИИ", + "settings.providers.note.aimlapi": "Более 1000 моделей от всех основных провайдеров, настройка в один клик", "settings.providers.note.opencode": "Подобранные модели, включая Claude, GPT, Gemini и другие", "settings.providers.note.anthropic": "Прямой доступ к моделям Claude, включая Pro и Max", "settings.providers.note.deepseek": "Модели DeepSeek для задач рассуждения и программирования", diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index 11a2f317c9a..762cd1f58f3 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "แนะนำ", "settings.providers.note.kilo": "เข้าถึงโมเดล AI มากกว่า 500 รายการ", + "settings.providers.note.aimlapi": "โมเดลกว่า 1000 รายการจากผู้ให้บริการรายใหญ่ทั้งหมด ตั้งค่าได้ในคลิกเดียว", "settings.providers.note.opencode": "โมเดลที่คัดสรร รวมถึง Claude, GPT, Gemini และอื่น ๆ", "settings.providers.note.anthropic": "เข้าถึงโมเดล Claude โดยตรง รวมถึง Pro และ Max", "settings.providers.note.deepseek": "โมเดล DeepSeek สำหรับงานใช้เหตุผลและเขียนโค้ด", diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index a787e69f62c..3304c9e5e0d 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Önerilen", "settings.providers.note.kilo": "500+ AI modeline erişim", + "settings.providers.note.aimlapi": "Tüm büyük sağlayıcılardan 1000+ model, tek tıkla kurulum", "settings.providers.note.opencode": "Claude, GPT, Gemini ve daha fazlasını içeren seçilmiş modeller", "settings.providers.note.anthropic": "Pro ve Max dahil Claude modellerine doğrudan erişim", "settings.providers.note.deepseek": "Akıl yürütme ve kodlama görevleri için DeepSeek modelleri", diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index b95a8240da7..00d5e7a6bd6 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -14,6 +14,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "Рекомендовані", "settings.providers.note.kilo": "Доступ до 500+ моделей ШІ", + "settings.providers.note.aimlapi": "Понад 1000 моделей від усіх основних провайдерів, налаштування в один клік", "settings.providers.note.opencode": "Добірні моделі, зокрема Claude, GPT, Gemini та інші", "settings.providers.note.anthropic": "Прямий доступ до моделей Claude, зокрема Pro і Max", "settings.providers.note.deepseek": "Моделі DeepSeek для завдань міркування та програмування", diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index 8baddc98f38..78ccc3df116 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -12,6 +12,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "推荐", "settings.providers.note.kilo": "访问 500+ AI 模型", + "settings.providers.note.aimlapi": "来自所有主流提供商的 1000+ 模型,一键设置", "settings.providers.note.opencode": "精选模型,包括 Claude、GPT、Gemini 等", "settings.providers.note.anthropic": "直接访问 Claude 模型,包括 Pro 和 Max", "settings.providers.note.deepseek": "用于推理和编码任务的 DeepSeek 模型", diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index fdb58ffcaf7..b79faae7418 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -12,6 +12,7 @@ export const dict = { // Provider settings translations "settings.providers.group.recommended": "推薦", "settings.providers.note.kilo": "存取 500+ AI 模型", + "settings.providers.note.aimlapi": "來自所有主流供應商的 1000+ 模型,一鍵設定", "settings.providers.note.opencode": "精選模型,包括 Claude、GPT、Gemini 等", "settings.providers.note.anthropic": "直接存取 Claude 模型,包括 Pro 和 Max", "settings.providers.note.deepseek": "用於推理和程式設計工作的 DeepSeek 模型", diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 82148591799..e988f663c74 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -8,7 +8,7 @@ "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" }, - "version": "7.4.9", + "version": "7.4.11", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 5df21991799..dada6e3e139 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -12,7 +12,7 @@ import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Storage } from "@/storage/storage" import { Snapshot } from "@/snapshot" import { Plugin } from "@/plugin" -import { ModelsDev } from "@opencode-ai/core/models-dev" +import { ModelsDev } from "@/provider/models" // kilocode_change - overlay layer injects kilo/apertis/aimlapi into the catalog import { ModelCache } from "@/provider/model-cache" // kilocode_change import { Provider } from "@/provider/provider" import { ProviderAuth } from "@/provider/auth" diff --git a/packages/opencode/src/kilocode/aimlapi/client.ts b/packages/opencode/src/kilocode/aimlapi/client.ts new file mode 100644 index 00000000000..2371d8f82f6 --- /dev/null +++ b/packages/opencode/src/kilocode/aimlapi/client.ts @@ -0,0 +1,315 @@ +// 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 { aimlapiAttributionHeaders, 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, + }, + ) + } + + 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) + // 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()}` + + 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..1c1d77605b1 --- /dev/null +++ b/packages/opencode/src/kilocode/aimlapi/config.ts @@ -0,0 +1,126 @@ +// 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. + * 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" + +/** 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, + } +} + +/** 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. + */ +export function aimlapiAttributionHeaders(): Record { + return { + [AIMLAPI_SOURCE_HEADER]: AIMLAPI_SOURCE_VALUE, + [AIMLAPI_PARTNER_HEADER]: 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..424ae6c0ad8 --- /dev/null +++ b/packages/opencode/src/kilocode/aimlapi/flow.ts @@ -0,0 +1,348 @@ +// 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_ID, + DEFAULT_PARTNER_NAME, + ISSUED_KEY_NAME, + MAX_AMOUNT_USD_MINOR, + MIN_AMOUNT_USD_MINOR, + buildPartnerCheckoutReturnUrls, + buildPartnerReturnUrl, + resolveEndpoints, +} 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?", + 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 + // 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" }) + 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: DEFAULT_PARTNER_ID, + 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..a74e53bfcb4 --- /dev/null +++ b/packages/opencode/src/kilocode/aimlapi/tui/setup.tsx @@ -0,0 +1,243 @@ +// 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, 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, DEFAULT_AMOUNT_USD_DISPLAY, 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, + }) + + // 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). */ + 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 + + ) + + // 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 + + {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 342d708853c..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 @@ -47,11 +55,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 +69,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/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index 91aef280731..8cc650200a5 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -68,6 +68,9 @@ export function patchModelsDevModel(providerID: string, source: any) { autoRouting: source.autoRouting, ai_sdk_provider: source.ai_sdk_provider, options: source.options ?? {}, + // Carry any per-model request headers (e.g. aimlapi attribution) onto the + // resolved model so getSDK merges them into the openai-compatible client. + ...(source.headers ? { headers: source.headers } : {}), } } diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index 542ceab8b6e..97776f7ef4e 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -6,6 +6,7 @@ import { Config } from "../config/config" import { Auth } from "../auth" import type { Provider } from "@opencode-ai/core/models-dev" import * as Log from "@opencode-ai/core/util/log" +import { aimlapiAttributionHeaders } from "../kilocode/aimlapi/config" type Models = Provider["models"] type KiloOptions = NonNullable[0]> @@ -53,6 +54,51 @@ 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" +// 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), + 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)), + 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), + info: Schema.optional(AimlapiInfo), + 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 + export const layer: Layer.Layer< Service, never, @@ -115,8 +161,113 @@ export const layer: Layer.Layer< return Object.fromEntries((json.data ?? []).map((item) => [item.id, aperture(item)])) }) + 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)) + + // 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 ?? []) + const output = aimlapiModalities(item.modalities?.output ?? []) + const model: Models[string] & { + options?: Record + headers?: Record + } = { + 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: aimlapiCost(item.pricing), + limit: { context: item.info?.contextLength ?? 128000, output: item.info?.outputMax ?? 8192 }, + modalities: { + input: input.length > 0 ? input : ["text"], + 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 } + // 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 + } + + 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. 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, + HttpClientRequest.setHeaders(aimlapiAttributionHeaders()), + ) + 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 {} + } + + const json = yield* HttpClientResponse.schemaBodyJson(AimlapiResponse)(response) + 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( + chat.map((item) => { + const base = aimlapiModel(item) + const model = item.tags?.includes("playground:code") ? { ...base, recommendedIndex: recommended++ } : base + return [item.id, model] as const + }), + ) + }) + const authOptions = Effect.fn("ModelCache.authOptions")(function* (providerID: string) { - if (providerID !== "kilo" && providerID !== "apertis") return {} + if (providerID !== "kilo" && providerID !== "apertis" && providerID !== "aimlapi") return {} const config = yield* cfg.get() const options: Options = {} @@ -157,12 +308,29 @@ export const layer: Layer.Layer< }) } + if (providerID === "aimlapi") { + const item = config.provider?.[providerID] + if (item?.options?.apiKey) options.apiKey = item.options.apiKey + if (item?.options?.baseURL) options.baseURL = item.options.baseURL + + const info = yield* auth.get(providerID) + if (info?.type === "api") options.apiKey = info.key + if (process.env.AIMLAPI_API_KEY) options.apiKey = process.env.AIMLAPI_API_KEY + if (process.env.AIMLAPI_INFERENCE_URL) options.baseURL = process.env.AIMLAPI_INFERENCE_URL + log.debug("aimlapi auth options resolved", { + providerID, + hasKey: !!options.apiKey, + hasBaseURL: !!options.baseURL, + }) + } + return options }) const fetchModels = (providerID: string, options: Options): Effect.Effect => { if (providerID === "kilo") return kilo.fetch(options) if (providerID === "apertis") return fetchApertisModels(options).pipe(Effect.map((models) => ({ models }))) + if (providerID === "aimlapi") return fetchAimlapiModels(options).pipe(Effect.map((models) => ({ models }))) log.debug("provider not implemented", { providerID }) return Effect.succeed({ models: {} }) } @@ -183,7 +351,8 @@ export const layer: Layer.Layer< if (providerID === "kilo") { return JSON.stringify([providerID, options?.baseURL, options?.kilocodeOrganizationId, options?.kilocodeToken]) } - if (providerID === "apertis") return JSON.stringify([providerID, options?.baseURL, options?.apiKey]) + if (providerID === "apertis" || providerID === "aimlapi") + return JSON.stringify([providerID, options?.baseURL, options?.apiKey]) return providerID } diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 6a693cde481..e83c68db722 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -67,7 +67,37 @@ 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 = 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", + env: ["AIMLAPI_API_KEY"], + api: aimlURL, + npm: "@ai-sdk/openai-compatible", + models, + } + if (aimlConnected && Object.keys(models).length === 0) + yield* cache.refresh("aimlapi", aimlOpts).pipe(Effect.ignore, Effect.forkDetach) + }) + if (!allowed) { + yield* addAimlapi() yield* addApertis() return providers } @@ -90,6 +120,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 diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index c624628753e..4c54b4fbfa0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -32,7 +32,7 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectCopy } from "@opencode-ai/core/project/copy" import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { ProviderAuth } from "@/provider/auth" -import { ModelsDev } from "@opencode-ai/core/models-dev" +import { ModelsDev } from "@/provider/models" // kilocode_change - overlay layer injects kilo/apertis/aimlapi into the catalog import { ModelCache } from "@/provider/model-cache" // kilocode_change import { Provider } from "@/provider/provider" import { PtyTicket } from "@opencode-ai/core/pty/ticket" 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..ad54563862a --- /dev/null +++ b/packages/opencode/test/kilocode/aimlapi-client.test.ts @@ -0,0 +1,39 @@ +// kilocode_change - new file +import { afterEach, expect, test } from "bun:test" +import { AimlapiClient } from "../../src/kilocode/aimlapi/client" +import { DEFAULT_PARTNER_ID, 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 +}) + +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 () => { + const cap = captureHeaders() + + await new AimlapiClient(endpoints).checkAccount("user@example.com") + + const headers = cap.headers() + expect(headers.get("x-aimlapi-source")).toBe("agent") + // 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/aimlapi-flow.test.ts b/packages/opencode/test/kilocode/aimlapi-flow.test.ts new file mode 100644 index 00000000000..6fee9996ab0 --- /dev/null +++ b/packages/opencode/test/kilocode/aimlapi-flow.test.ts @@ -0,0 +1,338 @@ +// 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) + }) + + 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)", () => { + 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) + }) +}) diff --git a/packages/opencode/test/kilocode/model-cache-effect.test.ts b/packages/opencode/test/kilocode/model-cache-effect.test.ts index 2a0f48f32c9..6cf3f45ed2b 100644 --- a/packages/opencode/test/kilocode/model-cache-effect.test.ts +++ b/packages/opencode/test/kilocode/model-cache-effect.test.ts @@ -4,10 +4,11 @@ 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" -type Hit = { readonly url: string } +type Hit = { readonly url: string; readonly headers: Record } const auth = Layer.mock(Auth.Service)({ get: () => Effect.succeed(undefined), @@ -19,12 +20,16 @@ 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) => 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) @@ -310,3 +315,112 @@ 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, headers: request.headers }]) + 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 }) + // No description in the catalog → no options blurb for the detail panel. + expect((models["vendor/free-model"] as { options?: unknown }).options).toBeUndefined() + }), +) + +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([]) + 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.") + }), +) + +it.live("tags the catalog fetch and every model with the attribution headers", () => + Effect.gen(function* () { + 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 }) + }), +) 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, }) })