diff --git a/.agents/skills/senpi-qa/references/env-vars.md b/.agents/skills/senpi-qa/references/env-vars.md index 81aad3681..bc4a6e1bf 100644 --- a/.agents/skills/senpi-qa/references/env-vars.md +++ b/.agents/skills/senpi-qa/references/env-vars.md @@ -27,7 +27,13 @@ The first present key wins; `devenv-setup.mjs` seeds `.env.local` from it. `MISTRAL_API_KEY`, `MINIMAX_API_KEY`, `MOONSHOT_API_KEY`, `KIMI_API_KEY`, `OPENCODE_API_KEY`, `CLOUDFLARE_API_KEY` (+ `CLOUDFLARE_ACCOUNT_ID` / `CLOUDFLARE_GATEWAY_ID`), `XIAOMI_API_KEY` (+ regional token-plan keys), -`HF_TOKEN`, and the AWS Bedrock / Google Vertex variable sets. +`HF_TOKEN`, `ALIBABA_CODING_PLAN_API_KEY`, `DEEPINFRA_API_KEY`, +`FIREPASS_API_KEY`, `FUGU_API_KEY`, `LITELLM_API_KEY`, +`LM_STUDIO_API_KEY`, `NANO_GPT_API_KEY`, `OLLAMA_API_KEY`, +`OLLAMA_CLOUD_API_KEY`, `QIANFAN_API_KEY`, +`QWEN_OAUTH_TOKEN`, `QWEN_PORTAL_API_KEY`, `SYNTHETIC_API_KEY`, +`VENICE_API_KEY`, `VLLM_API_KEY`, `ZENMUX_API_KEY`, and the +AWS Bedrock / Google Vertex variable sets. When you add a provider, add its key here AND to the `.devcontainer` `secrets` block AND keep `env-api-keys.ts` as the source of truth. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2b6d7e058..7211ae40a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -26,6 +26,54 @@ }, "GEMINI_API_KEY": { "description": "(Optional) Google Gemini API key (senpi's default provider is google)." + }, + "ALIBABA_CODING_PLAN_API_KEY": { + "description": "(Optional) Alibaba Coding Plan API key." + }, + "DEEPINFRA_API_KEY": { + "description": "(Optional) DeepInfra API key." + }, + "FIREPASS_API_KEY": { + "description": "(Optional) Fire Pass API key." + }, + "FUGU_API_KEY": { + "description": "(Optional) Sakana Fugu API key." + }, + "LITELLM_API_KEY": { + "description": "(Optional) LiteLLM proxy API key." + }, + "LM_STUDIO_API_KEY": { + "description": "(Optional) LM Studio API key." + }, + "NANO_GPT_API_KEY": { + "description": "(Optional) NanoGPT API key." + }, + "OLLAMA_API_KEY": { + "description": "(Optional) Ollama API key." + }, + "OLLAMA_CLOUD_API_KEY": { + "description": "(Optional) Ollama Cloud API key." + }, + "QIANFAN_API_KEY": { + "description": "(Optional) Qianfan API key." + }, + "QWEN_OAUTH_TOKEN": { + "description": "(Optional) Qwen Portal OAuth token." + }, + "QWEN_PORTAL_API_KEY": { + "description": "(Optional) Qwen Portal API key." + }, + "SYNTHETIC_API_KEY": { + "description": "(Optional) Synthetic API key." + }, + "VENICE_API_KEY": { + "description": "(Optional) Venice API key." + }, + "VLLM_API_KEY": { + "description": "(Optional) vLLM API key." + }, + "ZENMUX_API_KEY": { + "description": "(Optional) ZenMux API key." } }, "customizations": { diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 28966a24a..2821963f8 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -442,7 +442,9 @@ function supportsOpenAiMax(model: Model): boolean { } function isGoogleThinkingApi(model: Model): boolean { - return model.api === "google-generative-ai" || model.api === "google-vertex"; + return ( + model.api === "google-generative-ai" || model.api === "google-gemini-cli" || model.api === "google-vertex" + ); } function isAnthropicAdaptiveThinkingModel(modelId: string): boolean { @@ -712,6 +714,9 @@ function applyThinkingLevelMetadata(model: Model): void { if (isGoogleThinkingApi(model) && isGemma4Model(model.id)) { mergeThinkingLevelMap(model, { off: null, minimal: "MINIMAL", low: null, medium: null, high: "HIGH" }); } + if (model.api === "google-gemini-cli" && model.id.startsWith("claude-")) { + mergeThinkingLevelMap(model, { max: null }); + } if (model.provider === "groq" && model.id === "qwen/qwen3-32b") { mergeThinkingLevelMap(model, { minimal: null, low: null, medium: null, high: "default" }); } @@ -2290,6 +2295,235 @@ async function generateModels() { ]; allModels.push(...antLingModels); + // Gajae API-key login parity providers. Kagi, Parallel, and Tavily are + // credential-only web-search services; their placeholder rows keep them in + // the model-provider catalog so the coding-agent /login selector can expose + // their API-key storage flow until dedicated search-provider auth exists. + const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + const localOpenAICompat: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsUsageInStreaming: false, + maxTokensField: "max_tokens", + supportsStrictMode: false, + supportsLongCacheRetention: false, + }; + const gajaeApiKeyProviderModels: Model[] = [ + { + id: "qwen3.5-plus", + name: "Qwen3.5 Plus", + api: "openai-completions", + provider: "alibaba-coding-plan", + baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", + compat: { supportsDeveloperRole: false }, + reasoning: true, + input: ["text", "image"], + cost: zeroCost, + contextWindow: 1000000, + maxTokens: 65536, + }, + { + id: "deepseek-ai/DeepSeek-V3.2", + name: "DeepSeek-V3.2", + api: "openai-completions", + provider: "deepinfra", + baseUrl: "https://api.deepinfra.com/v1/openai", + reasoning: true, + thinkingLevelMap: { xhigh: "xhigh" }, + input: ["text"], + cost: { input: 0.26, output: 0.38, cacheRead: 0.13, cacheWrite: 0 }, + contextWindow: 163840, + maxTokens: 64000, + }, + { + id: "accounts/fireworks/routers/kimi-k2p6-turbo", + name: "Kimi K2.6 Turbo (Fire Pass)", + api: "openai-completions", + provider: "firepass", + baseUrl: "https://api.fireworks.ai/inference/v1", + compat: { + supportsStore: false, + supportsDeveloperRole: false, + maxTokensField: "max_tokens", + supportsStrictMode: false, + supportsLongCacheRetention: false, + }, + reasoning: true, + thinkingLevelMap: { xhigh: "xhigh" }, + input: ["text", "image"], + cost: zeroCost, + contextWindow: 262144, + maxTokens: 65536, + }, + { + id: "fugu", + name: "Sakana Fugu", + api: "openai-completions", + provider: "fugu", + baseUrl: "https://api.sakana.ai/v1", + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + }, + reasoning: true, + input: ["text"], + cost: zeroCost, + contextWindow: 200000, + maxTokens: 65536, + }, + { + id: "claude-opus-4-6", + name: "Anthropic Opus 4.6", + api: "openai-completions", + provider: "litellm", + baseUrl: "http://localhost:4000/v1", + reasoning: true, + thinkingLevelMap: { xhigh: "xhigh" }, + input: ["text", "image"], + cost: zeroCost, + contextWindow: 1000000, + maxTokens: 128000, + }, + { + id: "llama-3-8b", + name: "Llama 3 8B", + api: "openai-completions", + provider: "lm-studio", + baseUrl: "http://127.0.0.1:1234/v1", + compat: localOpenAICompat, + reasoning: false, + input: ["text"], + cost: zeroCost, + contextWindow: 8192, + maxTokens: 4096, + }, + { + id: "openai/gpt-5.4", + name: "GPT-5.4", + api: "openai-completions", + provider: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1", + reasoning: true, + input: ["text", "image"], + cost: zeroCost, + contextWindow: 1050000, + maxTokens: 128000, + }, + { + id: "gpt-oss:20b", + name: "GPT OSS (20B)", + api: "openai-completions", + provider: "ollama", + baseUrl: "http://127.0.0.1:11434/v1", + compat: localOpenAICompat, + reasoning: true, + input: ["text"], + cost: zeroCost, + contextWindow: 131072, + maxTokens: 16384, + }, + { + id: "gpt-oss:120b", + name: "GPT OSS (120B)", + api: "openai-completions", + provider: "ollama-cloud", + baseUrl: "https://ollama.com/v1", + compat: localOpenAICompat, + reasoning: true, + input: ["text", "image"], + cost: zeroCost, + contextWindow: 131072, + maxTokens: 16384, + }, + { + id: "deepseek-v3.2", + name: "DeepSeek V3.2", + api: "openai-completions", + provider: "qianfan", + baseUrl: "https://qianfan.baidubce.com/v2", + compat: { + supportsStore: false, + supportsDeveloperRole: false, + maxTokensField: "max_tokens", + supportsStrictMode: false, + }, + reasoning: false, + input: ["text"], + cost: zeroCost, + contextWindow: 98304, + maxTokens: 32768, + }, + { + id: "coder-model", + name: "Qwen Coder", + api: "openai-completions", + provider: "qwen-portal", + baseUrl: "https://portal.qwen.ai/v1", + compat: { supportsStore: false, supportsDeveloperRole: false, maxTokensField: "max_tokens" }, + reasoning: false, + input: ["text"], + cost: zeroCost, + contextWindow: 128000, + maxTokens: 8192, + }, + { + id: "hf:moonshotai/Kimi-K2.5", + name: "moonshotai/Kimi-K2.5", + api: "openai-completions", + provider: "synthetic", + baseUrl: "https://api.synthetic.new/openai/v1", + compat: { supportsStore: false, supportsDeveloperRole: false }, + reasoning: false, + input: ["text"], + cost: zeroCost, + contextWindow: 262144, + maxTokens: 8192, + }, + { + id: "llama-3.3-70b", + name: "Llama 3.3 70B", + api: "openai-completions", + provider: "venice", + baseUrl: "https://api.venice.ai/api/v1", + compat: { supportsUsageInStreaming: false }, + reasoning: false, + input: ["text"], + cost: zeroCost, + contextWindow: 128000, + maxTokens: 8192, + }, + { + id: "gpt-oss-20b", + name: "GPT OSS 20B", + api: "openai-completions", + provider: "vllm", + baseUrl: "http://127.0.0.1:8000/v1", + compat: localOpenAICompat, + reasoning: true, + input: ["text"], + cost: zeroCost, + contextWindow: 131072, + maxTokens: 16384, + }, + { + id: "anthropic/claude-opus-4.6", + name: "Anthropic Opus 4.6", + api: "anthropic-messages", + provider: "zenmux", + baseUrl: "https://zenmux.ai/api/anthropic", + reasoning: true, + thinkingLevelMap: { max: "max" }, + input: ["text", "image"], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 1000000, + maxTokens: 128000, + }, + ]; + allModels.push(...gajaeApiKeyProviderModels); + for (const candidate of allModels) { if (candidate.api === "openai-completions" && candidate.id.includes("deepseek-v4")) { const preservesNativeReasoningEffort = candidate.provider === "openrouter" || candidate.provider === "opencode"; @@ -2509,12 +2743,348 @@ async function generateModels() { })); allModels.push(...azureOpenAiModels); + // OAuth-backed catalogs whose account tokens target provider-specific gateways. + const oauthParityModels: Model[] = [ + { + id: "default", + name: "Auto", + api: "cursor-connect", + provider: "cursor", + baseUrl: "https://api2.cursor.sh", + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 64000, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + api: "anthropic-messages", + provider: "gitlab-duo", + baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/anthropic/", + reasoning: true, + thinkingLevelMap: { xhigh: "max" }, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 64000, + }, + { + id: "gpt-5.1-2025-11-13", + name: "GPT-5.1", + api: "openai-completions", + provider: "gitlab-duo", + baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + { + id: "sonar-pro", + name: "Sonar Pro", + api: "openai-completions", + provider: "perplexity", + baseUrl: "https://api.perplexity.ai", + reasoning: false, + input: ["text"], + cost: { input: 3, output: 15, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + }, + { + id: "anthropic/claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + api: "openai-completions", + provider: "kilo", + baseUrl: "https://api.kilo.ai/api/gateway", + reasoning: true, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 200000, + maxTokens: 64000, + }, + ]; + allModels.push(...oauthParityModels); + + // Cloud Code Assist catalogs are endpoint-specific contracts, not aliases for + // public Generative Language models. These rows mirror the verified bundled + // Gemini CLI and Antigravity metadata in Gajae. + interface GoogleCcaModelMetadata { + id: string; + name: string; + reasoning: boolean; + input: ("text" | "image")[]; + cost?: Model<"google-gemini-cli">["cost"]; + contextWindow: number; + maxTokens: number; + } + const createGoogleCcaCatalog = ( + provider: "google-gemini-cli" | "google-antigravity", + rows: GoogleCcaModelMetadata[], + ): Model<"google-gemini-cli">[] => + rows.map((row) => ({ + id: row.id, + name: row.name, + api: "google-gemini-cli", + provider, + baseUrl: + provider === "google-antigravity" + ? "https://daily-cloudcode-pa.sandbox.googleapis.com" + : "https://cloudcode-pa.googleapis.com", + reasoning: row.reasoning, + input: row.input, + cost: row.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: row.contextWindow, + maxTokens: row.maxTokens, + })); + + const googleGeminiCliModels = createGoogleCcaCatalog("google-gemini-cli", [ + { + id: "gemini-2.0-flash", + name: "Gemini 2.0 Flash", + reasoning: false, + input: ["text", "image"], + contextWindow: 1048576, + maxTokens: 8192, + }, + { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + reasoning: true, + input: ["text", "image"], + contextWindow: 1048576, + maxTokens: 65536, + }, + { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + reasoning: true, + input: ["text", "image"], + contextWindow: 1048576, + maxTokens: 65536, + }, + { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash Preview", + reasoning: true, + input: ["text", "image"], + contextWindow: 1048576, + maxTokens: 65536, + }, + { + id: "gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 64000, + }, + { + id: "gemini-3.1-flash-lite-preview", + name: "Gemini 3.1 Flash Lite Preview", + reasoning: true, + input: ["text", "image"], + contextWindow: 1048576, + maxTokens: 65536, + }, + { + id: "gemini-3.1-pro-preview", + name: "Gemini 3.1 Pro Preview", + reasoning: true, + input: ["text", "image"], + contextWindow: 1048576, + maxTokens: 65536, + }, + { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", + reasoning: true, + input: ["text", "image"], + cost: { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 }, + contextWindow: 1048576, + maxTokens: 65536, + }, + ]); + + const googleAntigravityModels = createGoogleCcaCatalog("google-antigravity", [ + { + id: "claude-opus-4-5-thinking", + name: "Anthropic Opus 4.5 Thinking (Antigravity)", + reasoning: true, + input: ["text", "image"], + contextWindow: 200000, + maxTokens: 64000, + }, + { + id: "claude-opus-4-6-thinking", + name: "Anthropic Opus 4.6 (Thinking) (Antigravity)", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 64000, + }, + { + id: "claude-sonnet-4-5", + name: "Anthropic Sonnet 4.5", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 64000, + }, + { + id: "claude-sonnet-4-5-thinking", + name: "Anthropic Sonnet 4.5 Thinking (Antigravity)", + reasoning: true, + input: ["text", "image"], + contextWindow: 200000, + maxTokens: 64000, + }, + { + id: "claude-sonnet-4-6", + name: "Anthropic Sonnet 4.6", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 64000, + }, + { + id: "claude-sonnet-4-6-thinking", + name: "Anthropic Sonnet 4.6 Thinking (Antigravity)", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 128000, + }, + { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + reasoning: true, + input: ["text", "image"], + contextWindow: 1048576, + maxTokens: 65536, + }, + { + id: "gemini-2.5-flash-thinking", + name: "Gemini 2.5 Flash (Thinking) (Antigravity)", + reasoning: true, + input: ["text", "image"], + contextWindow: 1048576, + maxTokens: 65535, + }, + { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + reasoning: true, + input: ["text", "image"], + contextWindow: 1048576, + maxTokens: 65536, + }, + { + id: "gemini-3-flash", + name: "Gemini 3 Flash", + reasoning: true, + input: ["text", "image"], + contextWindow: 1048576, + maxTokens: 65536, + }, + { + id: "gemini-3-pro-high", + name: "Gemini 3 Pro (High) (Antigravity)", + reasoning: true, + input: ["text", "image"], + contextWindow: 1048576, + maxTokens: 65535, + }, + { + id: "gemini-3-pro-low", + name: "Gemini 3 Pro (Low) (Antigravity)", + reasoning: true, + input: ["text", "image"], + contextWindow: 1048576, + maxTokens: 65535, + }, + { + id: "gemini-3.1-pro-low", + name: "Gemini 3.1 Pro (Low) (Antigravity)", + reasoning: true, + input: ["text", "image"], + contextWindow: 1048576, + maxTokens: 65535, + }, + { + id: "gpt-oss-120b-medium", + name: "GPT-OSS 120B (Medium) (Antigravity)", + reasoning: true, + input: ["text"], + contextWindow: 114000, + maxTokens: 32768, + }, + ]); + allModels.push(...googleGeminiCliModels, ...googleAntigravityModels); + for (const model of allModels) { applyThinkingLevelMetadata(model); applyOpenAICompletionsCompatMetadata(model); applyOpenAIToolSearchMetadata(model); } + const cloneProviderModels = ( + sourceProvider: string, + targetProvider: KnownProvider, + overrides?: (model: Model) => Partial>, + ): Model[] => + allModels + .filter((model) => model.provider === sourceProvider) + .map((model) => { + const override = overrides?.(model); + const cost = override?.cost ?? model.cost; + return { + ...model, + ...override, + provider: targetProvider, + headers: override?.headers ?? (model.headers ? { ...model.headers } : undefined), + thinkingLevelMap: + override?.thinkingLevelMap ?? (model.thinkingLevelMap ? { ...model.thinkingLevelMap } : undefined), + input: [...(override?.input ?? model.input)], + cost: { + ...cost, + tiers: cost.tiers?.map((tier) => ({ ...tier })), + }, + }; + }); + + const minimaxCodeCompat: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + requiresReasoningContentOnAssistantMessages: true, + }; + const aliasedModels = [ + ...cloneProviderModels("openai-codex", "openai-codex-device"), + ...cloneProviderModels("minimax", "minimax-code", () => ({ + api: "openai-completions", + baseUrl: "https://api.minimax.io/v1", + compat: minimaxCodeCompat, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + })), + ...cloneProviderModels("minimax-cn", "minimax-code-cn", () => ({ + api: "openai-completions", + baseUrl: "https://api.minimaxi.com/v1", + compat: minimaxCodeCompat, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + })), + ...cloneProviderModels("moonshotai", "moonshot"), + ...cloneProviderModels("opencode", "opencode-zen"), + ]; + for (const model of aliasedModels) { + applyOpenAICompletionsCompatMetadata(model); + } + allModels.push(...aliasedModels); + // Group by provider and deduplicate by model ID const providers: Record>> = {}; for (const model of allModels) { diff --git a/packages/ai/src/api/cursor-connect.lazy.ts b/packages/ai/src/api/cursor-connect.lazy.ts new file mode 100644 index 000000000..a86d5c3d3 --- /dev/null +++ b/packages/ai/src/api/cursor-connect.lazy.ts @@ -0,0 +1,5 @@ +import type { ProviderStreams } from "../types.ts"; +import { lazyApi } from "./lazy.ts"; + +export const cursorConnectApi = (): ProviderStreams => + lazyApi(() => import("./cursor-connect.ts").then((m) => m.cursorConnectStreams)); diff --git a/packages/ai/src/api/cursor-connect.ts b/packages/ai/src/api/cursor-connect.ts new file mode 100644 index 000000000..781f010cc --- /dev/null +++ b/packages/ai/src/api/cursor-connect.ts @@ -0,0 +1,503 @@ +/** + * Cursor Connect/protobuf AgentRun transport. + * + * Cursor's account API is not OpenAI chat-completions. Model requests go to + * `aiserver.v1.ChatService/StreamUnifiedChatWithTools` over HTTP/2 with + * `application/connect+proto` envelopes (Connect protocol v1). + * + * This module implements the minimum request/response path needed for text + * streaming. The wire layout mirrors the public reverse-engineered client + * shape used by Cursor CLI/IDE clients (checksum headers + Connect frames). + */ + +import type { + Api, + AssistantMessage, + Context, + Model, + SimpleStreamOptions, + StreamOptions, + TextContent, +} from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { buildBaseOptions } from "./simple-options.ts"; + +const CHAT_PATH = "/aiserver.v1.ChatService/StreamUnifiedChatWithTools"; +const CLIENT_VERSION = "2.3.41"; + +function randomUUID(): string { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function sha256HexSyncFallback(input: string): string { + // FNV-1a 64-bit x2 — only used if SubtleCrypto is unavailable (should not happen in modern runtimes). + let h1 = 0xcbf29ce484222325n; + let h2 = 0x100000001b3n; + for (let i = 0; i < input.length; i++) { + const c = BigInt(input.charCodeAt(i)); + h1 ^= c; + h1 = BigInt.asUintN(64, h1 * 0x100000001b3n); + h2 ^= c << 1n; + h2 = BigInt.asUintN(64, h2 * 0x100000001b3n); + } + return (h1.toString(16).padStart(16, "0") + h2.toString(16).padStart(16, "0")).padEnd(64, "0"); +} + +async function sha256Hex(input: string, salt = ""): Promise { + const data = new TextEncoder().encode(input + salt); + if (globalThis.crypto?.subtle) { + const digest = await globalThis.crypto.subtle.digest("SHA-256", data); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); + } + return sha256HexSyncFallback(input + salt); +} + +async function gzipBytes(payload: Uint8Array): Promise { + if (typeof CompressionStream === "undefined") return payload; + const copy = new Uint8Array(payload.byteLength); + copy.set(payload); + const stream = new Blob([copy]).stream().pipeThrough(new CompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +async function gunzipBytes(payload: Uint8Array): Promise { + if (typeof DecompressionStream === "undefined") return payload; + try { + const copy = new Uint8Array(payload.byteLength); + copy.set(payload); + const stream = new Blob([copy]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + } catch { + return payload; + } +} + +export interface CursorConnectOptions extends StreamOptions {} + +function emptyUsage(): AssistantMessage["usage"] { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function createMessage(model: Model): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: emptyUsage(), + stopReason: "stop", + timestamp: Date.now(), + }; +} + +function obfuscateBytes(bytes: Uint8Array): Uint8Array { + let t = 165; + const out = new Uint8Array(bytes); + for (let i = 0; i < out.length; i++) { + out[i] = ((out[i] ^ t) + (i % 256)) & 0xff; + t = out[i]; + } + return out; +} + +/** Cursor client checksum: timestamp obfuscation + machine ids derived from token. */ +export async function generateCursorChecksum(token: string): Promise { + const machineId = await sha256Hex(token, "machineId"); + const macMachineId = await sha256Hex(token, "macMachineId"); + const timestamp = Math.floor(Date.now() / 1e6); + const byteArray = new Uint8Array([ + (timestamp >> 40) & 255, + (timestamp >> 32) & 255, + (timestamp >> 24) & 255, + (timestamp >> 16) & 255, + (timestamp >> 8) & 255, + timestamp & 255, + ]); + const encoded = Buffer.from(obfuscateBytes(byteArray)).toString("base64"); + return `${encoded}${machineId}/${macMachineId}`; +} + +function writeVarint(value: number, out: number[]): void { + let v = value >>> 0; + while (v >= 0x80) { + out.push((v & 0x7f) | 0x80); + v >>>= 7; + } + out.push(v); +} + +function encodeTag(fieldNumber: number, wireType: number, out: number[]): void { + writeVarint((fieldNumber << 3) | wireType, out); +} + +function encodeString(fieldNumber: number, value: string, out: number[]): void { + const bytes = Buffer.from(value, "utf8"); + encodeTag(fieldNumber, 2, out); + writeVarint(bytes.length, out); + for (const b of bytes) out.push(b); +} + +function encodeBytes(fieldNumber: number, bytes: Uint8Array, out: number[]): void { + encodeTag(fieldNumber, 2, out); + writeVarint(bytes.length, out); + for (const b of bytes) out.push(b); +} + +function encodeVarintField(fieldNumber: number, value: number, out: number[]): void { + encodeTag(fieldNumber, 0, out); + writeVarint(value, out); +} + +function encodeMessage(fieldNumber: number, message: number[], out: number[]): void { + encodeBytes(fieldNumber, Uint8Array.from(message), out); +} + +function encodeChatMessage(role: "user" | "assistant", content: string, messageId: string): number[] { + const out: number[] = []; + encodeString(1, content, out); // content + encodeVarintField(2, role === "user" ? 1 : 2, out); // role + encodeString(13, messageId, out); // messageId (field numbers match public client layouts) + if (role === "user") encodeVarintField(47, 1, out); // chatModeEnum + return out; +} + +/** + * Encode StreamUnifiedChatWithToolsRequest protobuf body. + * Field numbers follow the public reverse-engineered Cursor client layout. + */ +export function encodeCursorChatRequest(input: { + model: string; + system: string; + messages: Array<{ role: "user" | "assistant"; content: string }>; + conversationId: string; +}): Uint8Array { + const formatted = input.messages.map((msg) => ({ + ...msg, + messageId: randomUUID(), + })); + const request: number[] = []; + for (const msg of formatted) { + encodeMessage(1, encodeChatMessage(msg.role, msg.content, msg.messageId), request); + } + encodeVarintField(2, 1, request); // unknown2 + const instruction: number[] = []; + encodeString(1, input.system, instruction); + encodeMessage(3, instruction, request); + encodeVarintField(4, 1, request); + const modelMsg: number[] = []; + encodeString(1, input.model, modelMsg); + encodeString(2, "", modelMsg); + encodeMessage(5, modelMsg, request); + encodeString(8, "", request); // webTool + encodeVarintField(13, 1, request); + const cursorSetting: number[] = []; + encodeString(1, "cursor\\aisettings", cursorSetting); + encodeString(3, "", cursorSetting); + encodeVarintField(8, 1, cursorSetting); + encodeVarintField(9, 1, cursorSetting); + encodeMessage(15, cursorSetting, request); + encodeVarintField(19, 1, request); + encodeString(23, input.conversationId, request); + const metadata: number[] = []; + encodeString(1, "web", metadata); + encodeString(2, "wasm", metadata); + encodeString(3, "browser", metadata); + encodeString(5, new Date().toISOString(), metadata); + encodeMessage(26, metadata, request); + encodeVarintField(27, 0, request); + for (const msg of formatted) { + const idMsg: number[] = []; + encodeVarintField(1, msg.role === "user" ? 1 : 2, idMsg); + encodeString(2, msg.messageId, idMsg); + encodeMessage(30, idMsg, request); + } + encodeVarintField(35, 0, request); // largeContext + encodeVarintField(38, 0, request); + encodeVarintField(46, 1, request); // chatModeEnum + encodeString(47, "", request); + encodeVarintField(48, 0, request); + encodeVarintField(49, 0, request); + encodeVarintField(51, 0, request); + encodeVarintField(53, 1, request); + encodeString(54, "Ask", request); + + const root: number[] = []; + encodeMessage(1, request, root); // StreamUnifiedChatWithToolsRequest.request + return Uint8Array.from(root); +} + +/** Wrap protobuf bytes in a Connect unary/stream request envelope. */ +export async function encodeConnectFrame(payload: Uint8Array, gzip = false): Promise { + const body = gzip ? await gzipBytes(payload) : payload; + const usedGzip = gzip && body !== payload && body.length > 0; + const frame = new Uint8Array(5 + body.length); + frame[0] = usedGzip ? 0x01 : 0x00; + const view = new DataView(frame.buffer); + view.setUint32(1, body.length, false); + frame.set(body, 5); + return frame; +} + +function extractUtf8Strings(bytes: Uint8Array): string[] { + const out: string[] = []; + let i = 0; + while (i < bytes.length) { + const tag = bytes[i++]; + if (tag === undefined) break; + const wireType = tag & 0x07; + if (wireType === 0) { + // varint + while (i < bytes.length && (bytes[i]! & 0x80) !== 0) i++; + i++; + } else if (wireType === 1) { + i += 8; + } else if (wireType === 5) { + i += 4; + } else if (wireType === 2) { + let len = 0; + let shift = 0; + while (i < bytes.length) { + const b = bytes[i++]!; + len |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + const slice = bytes.subarray(i, i + len); + i += len; + // Prefer UTF-8 text-looking length-delimited fields. + try { + const text = Buffer.from(slice).toString("utf8"); + if (text && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(text) && /[\p{L}\p{N}]/u.test(text)) { + // Skip obvious non-content ids + if (text.length <= 64 && /^[0-9a-f-]{8,}$/i.test(text)) continue; + if (text.includes("cursor\\") || text === "Ask") continue; + out.push(text); + } + } catch { + // ignore + } + } else { + break; + } + } + return out; +} + +export async function* parseConnectResponse( + body: ReadableStream | null, +): AsyncGenerator<{ thinking: string; text: string }> { + if (!body) return; + const reader = body.getReader(); + let buffer = new Uint8Array(0); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value?.length) continue; + const next = new Uint8Array(buffer.length + value.length); + next.set(buffer); + next.set(value, buffer.length); + buffer = next; + + while (buffer.length >= 5) { + const flags = buffer[0]!; + const length = new DataView(buffer.buffer, buffer.byteOffset + 1, 4).getUint32(0, false); + if (buffer.length < 5 + length) break; + let payload = buffer.subarray(5, 5 + length); + buffer = buffer.subarray(5 + length); + if ((flags & 0x01) !== 0) { + const inflated = await gunzipBytes(payload); + payload = new Uint8Array(inflated); + } + if ((flags & 0x02) !== 0) { + // end-stream JSON trailer + continue; + } + const strings = extractUtf8Strings(payload); + if (strings.length === 0) continue; + // Heuristic: last non-empty string is usually the text delta chunk. + const text = strings[strings.length - 1] ?? ""; + const thinking = strings.length > 1 ? (strings[0] ?? "") : ""; + if (text || thinking) yield { thinking, text }; + } + } + } finally { + reader.releaseLock(); + } +} + +function contextToCursorMessages(context: Context): { + system: string; + messages: Array<{ role: "user" | "assistant"; content: string }>; +} { + const systemParts: string[] = []; + const messages: Array<{ role: "user" | "assistant"; content: string }> = []; + if (typeof context.systemPrompt === "string" && context.systemPrompt) { + systemParts.push(context.systemPrompt); + } + for (const message of context.messages) { + if (message.role === "user") { + const text = + typeof message.content === "string" + ? message.content + : message.content + .filter((part): part is TextContent => part.type === "text") + .map((part) => part.text) + .join("\n"); + if (text) messages.push({ role: "user", content: text }); + } else if (message.role === "assistant") { + const text = message.content + .filter((part): part is TextContent => part.type === "text") + .map((part) => part.text) + .join("\n"); + if (text) messages.push({ role: "assistant", content: text }); + } + } + return { system: systemParts.join("\n"), messages }; +} + +async function cursorFetch( + url: string, + headers: Record, + body: Uint8Array, + signal?: AbortSignal, +): Promise { + // Ambient fetch is stub-friendly in tests. Copy into a fresh ArrayBuffer-backed + // Uint8Array so RequestInit body typing accepts it under DOM lib checks. + const copy = new Uint8Array(body.byteLength); + copy.set(body); + return await globalThis.fetch(url, { + method: "POST", + headers, + body: copy, + signal, + }); +} + +export const streamCursorConnect: ( + model: Model<"cursor-connect">, + context: Context, + options?: CursorConnectOptions, +) => AssistantMessageEventStream = (model, context, options) => { + const stream = new AssistantMessageEventStream(); + const output = createMessage(model); + + (async () => { + try { + const apiKey = options?.apiKey; + if (!apiKey) throw new Error("Cursor Connect requires an API key / access token"); + const baseUrl = (model.baseUrl || "https://api2.cursor.sh").replace(/\/$/, ""); + const { system, messages } = contextToCursorMessages(context); + if (messages.length === 0) throw new Error("Cursor Connect requires at least one user message"); + + const proto = encodeCursorChatRequest({ + model: model.id === "default" ? "default" : model.id, + system, + messages, + conversationId: randomUUID(), + }); + const body = await encodeConnectFrame(proto, messages.length >= 3); + const checksum = await generateCursorChecksum(apiKey); + const headers: Record = { + authorization: `Bearer ${apiKey}`, + "connect-protocol-version": "1", + "content-type": "application/connect+proto", + "connect-accept-encoding": "gzip", + "user-agent": "connect-es/1.6.1", + "x-cursor-checksum": checksum, + "x-cursor-client-version": CLIENT_VERSION, + "x-cursor-config-version": randomUUID(), + "x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + "x-ghost-mode": "true", + "x-request-id": randomUUID(), + "x-session-id": randomUUID(), + "x-client-key": await sha256Hex(apiKey), + ...(options?.headers ?? {}), + }; + + stream.push({ type: "start", partial: output }); + const response = await cursorFetch(`${baseUrl}${CHAT_PATH}`, headers, body, options?.signal); + if (!response.ok) { + const errText = await response.text().catch(() => response.statusText); + throw new Error(`Cursor Connect request failed (${response.status}): ${errText.slice(0, 200)}`); + } + + let contentIndex = -1; + let text = ""; + for await (const chunk of parseConnectResponse(response.body)) { + if (chunk.thinking) { + // Surface thinking as ordinary text delimiters when present. + const delta = chunk.thinking; + if (contentIndex < 0) { + contentIndex = 0; + output.content.push({ type: "text", text: "" }); + stream.push({ type: "text_start", contentIndex, partial: output }); + } + text += delta; + (output.content[contentIndex] as TextContent).text = text; + stream.push({ type: "text_delta", contentIndex, delta, partial: output }); + } + if (chunk.text) { + if (contentIndex < 0) { + contentIndex = 0; + output.content.push({ type: "text", text: "" }); + stream.push({ type: "text_start", contentIndex, partial: output }); + } + text += chunk.text; + (output.content[contentIndex] as TextContent).text = text; + stream.push({ type: "text_delta", contentIndex, delta: chunk.text, partial: output }); + } + } + if (contentIndex >= 0) { + stream.push({ + type: "text_end", + contentIndex, + content: (output.content[contentIndex] as TextContent).text, + partial: output, + }); + } + output.stopReason = "stop"; + stream.push({ type: "done", reason: "stop", message: output }); + stream.end(output); + } catch (error) { + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : String(error); + stream.push({ + type: "error", + reason: output.stopReason === "aborted" ? "aborted" : "error", + error: output, + }); + stream.end(output); + } + })(); + + return stream; +}; + +export const streamSimpleCursorConnect: ( + model: Model<"cursor-connect">, + context: Context, + options?: SimpleStreamOptions, +) => AssistantMessageEventStream = (model, context, options) => { + const base = buildBaseOptions(model, context, options); + return streamCursorConnect(model, context, base); +}; + +export const cursorConnectStreams = { + stream: streamCursorConnect, + streamSimple: streamSimpleCursorConnect, +}; diff --git a/packages/ai/src/api/google-gemini-cli.lazy.ts b/packages/ai/src/api/google-gemini-cli.lazy.ts new file mode 100644 index 000000000..e184667af --- /dev/null +++ b/packages/ai/src/api/google-gemini-cli.lazy.ts @@ -0,0 +1,4 @@ +import type { ProviderStreams } from "../types.ts"; +import { lazyApi } from "./lazy.ts"; + +export const googleGeminiCliApi = (): ProviderStreams => lazyApi(() => import("./google-gemini-cli.ts")); diff --git a/packages/ai/src/api/google-gemini-cli.ts b/packages/ai/src/api/google-gemini-cli.ts new file mode 100644 index 000000000..84356db53 --- /dev/null +++ b/packages/ai/src/api/google-gemini-cli.ts @@ -0,0 +1,405 @@ +import { type Content, ThinkingLevel as GoogleGenAIThinkingLevel, type ThinkingConfig } from "@google/genai"; +import { calculateCost, clampThinkingLevel } from "../models.ts"; +import { + ANTIGRAVITY_SYSTEM_INSTRUCTION, + getAntigravityHeaders, + getGeminiCliHeaders, +} from "../providers/google-gemini-headers.ts"; +import type { + Api, + AssistantMessage, + Context, + Model, + SimpleStreamOptions, + StreamFunction, + StreamOptions, + TextContent, + ThinkingBudgets, + ThinkingContent, + ThinkingLevel, + ToolCall, +} from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import type { GoogleThinkingLevel } from "./google-shared.ts"; +import { + convertMessages, + convertTools, + isThinkingPart, + mapToolChoice, + retainThoughtSignature, +} from "./google-shared.ts"; +import { buildBaseOptions } from "./simple-options.ts"; + +const THINKING_LEVEL_MAP: Record = { + THINKING_LEVEL_UNSPECIFIED: GoogleGenAIThinkingLevel.THINKING_LEVEL_UNSPECIFIED, + MINIMAL: GoogleGenAIThinkingLevel.MINIMAL, + LOW: GoogleGenAIThinkingLevel.LOW, + MEDIUM: GoogleGenAIThinkingLevel.MEDIUM, + HIGH: GoogleGenAIThinkingLevel.HIGH, +}; + +export interface GoogleGeminiCliOptions extends StreamOptions { + toolChoice?: "auto" | "none" | "any"; + thinking?: { enabled: boolean; budgetTokens?: number; level?: GoogleThinkingLevel }; +} + +interface Credentials { + token: string; + projectId: string; +} +interface CcaRequest { + project: string; + model: string; + request: { + contents: Content[]; + sessionId?: string; + systemInstruction?: { role?: string; parts: { text: string }[] }; + generationConfig?: { maxOutputTokens?: number; temperature?: number; thinkingConfig?: ThinkingConfig }; + tools?: { functionDeclarations: Record[] }[]; + toolConfig?: { functionCallingConfig: { mode: ReturnType } }; + preambleConfig?: { mode: "SYSTEM_INSTRUCTION_MODE_REPLACE" }; + }; + requestType?: string; + userAgent?: string; + requestId?: string; +} +interface CcaChunk { + response?: { + candidates?: Array<{ + content?: { + parts?: Array<{ + text?: string; + thought?: boolean; + thoughtSignature?: string; + functionCall?: { name?: string; args?: Record; id?: string }; + }>; + }; + finishReason?: string; + }>; + usageMetadata?: { + promptTokenCount?: number; + candidatesTokenCount?: number; + thoughtsTokenCount?: number; + cachedContentTokenCount?: number; + totalTokenCount?: number; + }; + }; +} + +export function parseGoogleCcaCredentials(raw: string | undefined): Credentials { + if (!raw) throw new Error("Google Cloud Code Assist requires OAuth credentials"); + let value: { token?: unknown; projectId?: unknown }; + try { + value = JSON.parse(raw) as typeof value; + } catch { + throw new Error("Invalid Google Cloud Code Assist credentials"); + } + if (typeof value.token !== "string" || !value.token) + throw new Error("Google Cloud Code Assist credentials are missing token"); + if (typeof value.projectId !== "string" || !value.projectId.trim()) + throw new Error("Google Cloud Code Assist credentials are missing projectId"); + return { token: value.token, projectId: value.projectId }; +} + +export function buildGoogleCcaRequest( + model: Model<"google-gemini-cli">, + context: Context, + projectId: string, + options: GoogleGeminiCliOptions = {}, +): CcaRequest { + if (!projectId.trim()) throw new Error("Google Cloud Code Assist projectId must not be empty"); + const request: CcaRequest["request"] = { contents: convertMessages(model, context) }; + const system = Array.isArray(context.systemPrompt) + ? context.systemPrompt + : context.systemPrompt + ? [context.systemPrompt] + : []; + if (system.length) + request.systemInstruction = { + ...(model.provider === "google-antigravity" && { role: "user" }), + parts: system.map((text) => ({ text })), + }; + if ( + model.provider === "google-antigravity" && + (model.id.toLowerCase().includes("claude") || model.id.toLowerCase().includes("gemini-3")) + ) { + request.systemInstruction = { + role: "user", + parts: [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION }, ...(request.systemInstruction?.parts ?? [])], + }; + } + if (options.maxTokens !== undefined || options.temperature !== undefined || options.thinking?.enabled) + request.generationConfig = { + ...(options.maxTokens !== undefined && { maxOutputTokens: options.maxTokens }), + ...(options.temperature !== undefined && { temperature: options.temperature }), + ...(options.thinking?.enabled && + model.reasoning && { + thinkingConfig: { + includeThoughts: true, + ...(options.thinking.level !== undefined + ? { thinkingLevel: THINKING_LEVEL_MAP[options.thinking.level] } + : options.thinking.budgetTokens !== undefined + ? { thinkingBudget: options.thinking.budgetTokens } + : {}), + }, + }), + }; + if (context.tools?.length) { + request.tools = convertTools( + context.tools, + model.provider === "google-antigravity" && model.id.startsWith("claude-"), + ); + if (options.toolChoice) + request.toolConfig = { functionCallingConfig: { mode: mapToolChoice(options.toolChoice) } }; + } + if (model.provider === "google-antigravity") { + request.sessionId = `-${crypto.getRandomValues(new BigUint64Array(1))[0]! & ((1n << 63n) - 1n)}`; + request.preambleConfig = { mode: "SYSTEM_INSTRUCTION_MODE_REPLACE" }; + } + return { + project: projectId, + model: model.id, + request, + ...(model.provider === "google-antigravity" && { + requestType: "agent", + userAgent: "antigravity", + requestId: `agent-${crypto.randomUUID()}`, + }), + }; +} + +async function* parseSse(body: ReadableStream, signal?: AbortSignal): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + if (signal?.aborted) throw signal.reason ?? new DOMException("Cancelled", "AbortError"); + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let boundary = /\r?\n\r?\n/.exec(buffer); + while (boundary !== null) { + const event = buffer.slice(0, boundary.index); + buffer = buffer.slice(boundary.index + boundary[0].length); + const data = event + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trim()) + .join("\n"); + if (data && data !== "[DONE]") yield JSON.parse(data) as CcaChunk; + boundary = /\r?\n\r?\n/.exec(buffer); + } + } + } finally { + reader.releaseLock(); + } +} + +export const stream: StreamFunction<"google-gemini-cli", GoogleGeminiCliOptions> = (model, context, options) => { + const events = new AssistantMessageEventStream(); + void (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "google-gemini-cli" as Api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + try { + const credentials = parseGoogleCcaCredentials(options?.apiKey); + let body = buildGoogleCcaRequest(model, context, credentials.projectId, options); + const replacement = await options?.onPayload?.(body, model); + if (replacement !== undefined) body = replacement as CcaRequest; + const headers = { + Authorization: `Bearer ${credentials.token}`, + "Content-Type": "application/json", + Accept: "text/event-stream", + ...(model.provider === "google-antigravity" ? getAntigravityHeaders() : getGeminiCliHeaders(model.id)), + ...(options?.headers ?? {}), + }; + const response = await fetch(`${model.baseUrl}/v1internal:streamGenerateContent?alt=sse`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: options?.signal, + }); + if (!response.ok) throw new Error(`Cloud Code Assist API error (${response.status})`); + if (!response.body) throw new Error("Cloud Code Assist API returned no response body"); + events.push({ type: "start", partial: output }); + let current: TextContent | ThinkingContent | undefined; + const finishCurrent = () => { + const block = current; + if (!block) return; + const contentIndex = output.content.indexOf(block); + if (block.type === "thinking") { + events.push({ + type: "thinking_end", + contentIndex, + content: block.thinking, + partial: output, + }); + } else { + events.push({ + type: "text_end", + contentIndex, + content: block.text, + partial: output, + }); + } + current = undefined; + }; + for await (const chunk of parseSse(response.body, options?.signal)) { + const candidate = chunk.response?.candidates?.[0]; + for (const part of candidate?.content?.parts ?? []) { + if (part.text !== undefined) { + const thinking = isThinkingPart(part); + if (!current || (thinking ? current.type !== "thinking" : current.type !== "text")) { + finishCurrent(); + current = thinking ? { type: "thinking", thinking: "" } : { type: "text", text: "" }; + output.content.push(current); + events.push({ + type: thinking ? "thinking_start" : "text_start", + contentIndex: output.content.length - 1, + partial: output, + }); + } + if (current.type === "thinking") { + current.thinking += part.text; + current.thinkingSignature = retainThoughtSignature( + current.thinkingSignature, + part.thoughtSignature, + ); + events.push({ + type: "thinking_delta", + contentIndex: output.content.length - 1, + delta: part.text, + partial: output, + }); + } else { + current.text += part.text; + current.textSignature = retainThoughtSignature(current.textSignature, part.thoughtSignature); + events.push({ + type: "text_delta", + contentIndex: output.content.length - 1, + delta: part.text, + partial: output, + }); + } + } + if (part.functionCall) { + finishCurrent(); + const tool: ToolCall = { + type: "toolCall", + id: part.functionCall.id ?? crypto.randomUUID(), + name: part.functionCall.name ?? "", + arguments: part.functionCall.args ?? {}, + }; + output.content.push(tool); + events.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output }); + events.push({ + type: "toolcall_delta", + contentIndex: output.content.length - 1, + delta: JSON.stringify(tool.arguments), + partial: output, + }); + events.push({ + type: "toolcall_end", + contentIndex: output.content.length - 1, + toolCall: tool, + partial: output, + }); + } + } + if (candidate?.finishReason === "MAX_TOKENS") output.stopReason = "length"; + if (output.content.some((part) => part.type === "toolCall")) output.stopReason = "toolUse"; + const usage = chunk.response?.usageMetadata; + if (usage) { + output.usage.input = (usage.promptTokenCount ?? 0) - (usage.cachedContentTokenCount ?? 0); + output.usage.cacheRead = usage.cachedContentTokenCount ?? 0; + output.usage.output = (usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0); + output.usage.totalTokens = usage.totalTokenCount ?? 0; + calculateCost(model, output.usage); + } + } + finishCurrent(); + const reason = output.stopReason === "length" || output.stopReason === "toolUse" ? output.stopReason : "stop"; + output.stopReason = reason; + events.push({ type: "done", reason, message: output }); + events.end(output); + } catch (error) { + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : String(error); + events.push({ type: "error", reason: output.stopReason, error: output }); + events.end(output); + } + })(); + return events; +}; + +type ClampedThinkingLevel = Exclude; + +function isGemini3ProModel(model: Pick, "id">): boolean { + return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase()); +} + +function isGemini3FlashModel(model: Pick, "id">): boolean { + return /gemini-3(?:\.\d+)?-flash/.test(model.id.toLowerCase()); +} + +function getGemini3ThinkingLevel(effort: ClampedThinkingLevel, model: Pick, "id">): GoogleThinkingLevel { + if (isGemini3ProModel(model)) { + return effort === "minimal" || effort === "low" ? "LOW" : "HIGH"; + } + return effort.toUpperCase() as GoogleThinkingLevel; +} + +function getGoogleBudget( + model: Pick, "id">, + effort: ClampedThinkingLevel, + customBudgets?: ThinkingBudgets, +): number { + if (customBudgets?.[effort] !== undefined) return customBudgets[effort]; + if (model.id.includes("2.5-pro")) { + return { minimal: 128, low: 2048, medium: 8192, high: 32768 }[effort]; + } + if (model.id.includes("2.5-flash-lite")) { + return { minimal: 512, low: 2048, medium: 8192, high: 24576 }[effort]; + } + if (model.id.includes("2.5-flash")) { + return { minimal: 128, low: 2048, medium: 8192, high: 24576 }[effort]; + } + return -1; +} + +export const streamSimple: StreamFunction<"google-gemini-cli", SimpleStreamOptions> = (model, context, options) => { + const base = buildBaseOptions(model, context, options, options?.apiKey); + if (!options?.reasoning || !model.reasoning) { + return stream(model, context, { ...base, thinking: { enabled: false } } satisfies GoogleGeminiCliOptions); + } + + const clamped = clampThinkingLevel(model, options.reasoning); + const effort: ClampedThinkingLevel = + clamped === "minimal" || clamped === "low" || clamped === "medium" ? clamped : "high"; + if (isGemini3ProModel(model) || isGemini3FlashModel(model)) { + return stream(model, context, { + ...base, + thinking: { enabled: true, level: getGemini3ThinkingLevel(effort, model) }, + } satisfies GoogleGeminiCliOptions); + } + + return stream(model, context, { + ...base, + thinking: { enabled: true, budgetTokens: getGoogleBudget(model, effort, options.thinkingBudgets) }, + } satisfies GoogleGeminiCliOptions); +}; diff --git a/packages/ai/src/api/google-shared.ts b/packages/ai/src/api/google-shared.ts index ed469d513..75af5e4f4 100644 --- a/packages/ai/src/api/google-shared.ts +++ b/packages/ai/src/api/google-shared.ts @@ -7,7 +7,7 @@ import type { Context, ImageContent, Model, ProviderNativeContent, StopReason, T import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { transformMessages } from "./transform-messages.ts"; -type GoogleApiType = "google-generative-ai" | "google-vertex"; +type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex"; /** * Thinking level for Gemini 3 models. diff --git a/packages/ai/src/auth/helpers.ts b/packages/ai/src/auth/helpers.ts index 5c64535f4..e816c31d3 100644 --- a/packages/ai/src/auth/helpers.ts +++ b/packages/ai/src/auth/helpers.ts @@ -2,11 +2,15 @@ import type { ApiKeyAuth, OAuthAuth } from "./types.ts"; /** * Standard api-key auth: a stored credential key wins, otherwise the first - * set env var resolves. Includes a `login` that prompts for the key. - * Providers with non-standard resolution (provider env, ambient files, IAM) - * write their own `ApiKeyAuth`. + * set env var resolves, followed by an optional local-server fallback key. + * Includes a `login` that prompts for the key. Providers with non-standard + * resolution (provider env, ambient files, IAM) write their own `ApiKeyAuth`. */ -export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyAuth { +export function envApiKeyAuth( + name: string, + envVars: readonly string[], + options?: { fallbackApiKey?: string }, +): ApiKeyAuth { return { name, login: async (interaction) => { @@ -19,6 +23,9 @@ export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyA const value = await ctx.env(envVar); if (value) return { auth: { apiKey: value }, source: envVar }; } + if (options?.fallbackApiKey) { + return { auth: { apiKey: options.fallbackApiKey }, source: "built-in fallback" }; + } return undefined; }, }; diff --git a/packages/ai/src/auth/oauth/anthropic.ts b/packages/ai/src/auth/oauth/anthropic.ts index dbaaa3001..19b7c0605 100644 --- a/packages/ai/src/auth/oauth/anthropic.ts +++ b/packages/ai/src/auth/oauth/anthropic.ts @@ -7,9 +7,10 @@ import type { Server } from "node:http"; import { getProviderEnvValue } from "../../utils/provider-env.ts"; -import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts"; +import type { OAuthAuth } from "../types.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts"; type CallbackServerInfo = { server: Server; @@ -192,7 +193,7 @@ async function exchangeAuthorizationCode( state: string, verifier: string, redirectUri: string, -): Promise { +): Promise { let responseBody: string; try { responseBody = await postJson(TOKEN_URL, { @@ -219,21 +220,27 @@ async function exchangeAuthorizationCode( } return { - type: "oauth", refresh: tokenData.refresh_token, access: tokenData.access_token, expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000, }; } -async function loginAnthropic(interaction: AuthInteraction): Promise { +/** + * Login with Anthropic OAuth (authorization code + PKCE) + */ +export async function loginAnthropic(options: { + onAuth: (info: { url: string; instructions?: string }) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; +}): Promise { const { verifier, challenge } = await generatePKCE(); const server = await startCallbackServer(verifier); - const manualAbort = new AbortController(); + let code: string | undefined; let state: string | undefined; - let manualInput: string | undefined; - let manualError: Error | undefined; + let redirectUriForExchange = REDIRECT_URI; try { const authParams = new URLSearchParams({ @@ -246,58 +253,93 @@ async function loginAnthropic(interaction: AuthInteraction): Promise { + manualInput = input; + server.cancelWait(); + }) + .catch((err) => { + manualError = err instanceof Error ? err : new Error(String(err)); + server.cancelWait(); + }); + + const result = await server.waitForCode(); + + if (manualError) { + throw manualError; + } + + if (result?.code) { + code = result.code; + state = result.state; + redirectUriForExchange = REDIRECT_URI; + } else if (manualInput) { + const parsed = parseAuthorizationInput(manualInput); + if (parsed.state && parsed.state !== verifier) { + throw new Error("OAuth state mismatch"); + } + code = parsed.code; + state = parsed.state ?? verifier; + } + + if (!code) { + await manualPromise; + if (manualError) { + throw manualError; + } + if (manualInput) { + const parsed = parseAuthorizationInput(manualInput); + if (parsed.state && parsed.state !== verifier) { + throw new Error("OAuth state mismatch"); + } + code = parsed.code; + state = parsed.state ?? verifier; + } + } + } else { + const result = await server.waitForCode(); + if (result?.code) { + code = result.code; + state = result.state; + redirectUriForExchange = REDIRECT_URI; + } + } + + if (!code) { + const input = await options.onPrompt({ + message: "Paste the authorization code or full redirect URL:", placeholder: REDIRECT_URI, - signal: manualAbort.signal, - }) - .then((input) => { - manualInput = input; - server.cancelWait(); - }) - .catch((error) => { - manualError = error instanceof Error ? error : new Error(String(error)); - server.cancelWait(); }); - - const result = await server.waitForCode(); - if (manualError) throw manualError; - if (result?.code) { - code = result.code; - state = result.state; - } else if (manualInput) { - const parsed = parseAuthorizationInput(manualInput); - if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch"); + const parsed = parseAuthorizationInput(input); + if (parsed.state && parsed.state !== verifier) { + throw new Error("OAuth state mismatch"); + } code = parsed.code; state = parsed.state ?? verifier; } if (!code) { - await manualPromise; - if (manualError) throw manualError; - if (manualInput) { - const parsed = parseAuthorizationInput(manualInput); - if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch"); - code = parsed.code; - state = parsed.state ?? verifier; - } + throw new Error("Missing authorization code"); + } + + if (!state) { + throw new Error("Missing OAuth state"); } - if (!code) throw new Error("Missing authorization code"); - if (!state) throw new Error("Missing OAuth state"); - interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." }); - return exchangeAuthorizationCode(code, state, verifier, REDIRECT_URI); + options.onProgress?.("Exchanging authorization code for tokens..."); + return exchangeAuthorizationCode(code, state, verifier, redirectUriForExchange); } finally { - manualAbort.abort(); server.server.close(); } } @@ -305,7 +347,7 @@ async function loginAnthropic(interaction: AuthInteraction): Promise { +export async function refreshAnthropicToken(refreshToken: string): Promise { let responseBody: string; try { responseBody = await postJson(TOKEN_URL, { @@ -332,7 +374,6 @@ async function refreshAnthropicToken(refreshToken: string): Promise refreshAnthropicToken(credential.refresh), + + async login(callbacks) { + // The manual_code prompt races the local callback server; abort it once + // the flow settles so the UI can dismiss the pending input. + const manualAbort = new AbortController(); + try { + const credentials = await loginAnthropic({ + onAuth: (info) => callbacks.notify({ type: "auth_url", url: info.url, instructions: info.instructions }), + onProgress: (message) => callbacks.notify({ type: "progress", message }), + onPrompt: (prompt) => + callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }), + onManualCodeInput: () => + callbacks.prompt({ + type: "manual_code", + message: "Complete login in your browser, or paste the authorization code / redirect URL here:", + placeholder: REDIRECT_URI, + signal: manualAbort.signal, + }), + }); + return { ...credentials, type: "oauth" }; + } finally { + manualAbort.abort(); + } + }, + + async refresh(credential) { + return { ...(await refreshAnthropicToken(credential.refresh)), type: "oauth" }; + }, async toAuth(credential) { return { apiKey: credential.access }; }, }; + +export const anthropicOAuthProvider: OAuthProviderInterface = { + id: "anthropic", + name: "Anthropic (Claude Pro/Max)", + usesCallbackServer: true, + + async login(callbacks: OAuthLoginCallbacks): Promise { + return loginAnthropic({ + onAuth: callbacks.onAuth, + onPrompt: callbacks.onPrompt, + onProgress: callbacks.onProgress, + onManualCodeInput: callbacks.onManualCodeInput, + }); + }, + + async refreshToken(credentials: OAuthCredentials): Promise { + return refreshAnthropicToken(credentials.refresh); + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, +}; diff --git a/packages/ai/src/auth/oauth/cursor.ts b/packages/ai/src/auth/oauth/cursor.ts new file mode 100644 index 000000000..cc26a790f --- /dev/null +++ b/packages/ai/src/auth/oauth/cursor.ts @@ -0,0 +1,174 @@ +import type { OAuthAuth } from "../types.ts"; +import { generatePKCE } from "./pkce.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; + +export const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl"; +export const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll"; +export const CURSOR_REFRESH_URL = "https://api2.cursor.sh/auth/exchange_user_api_key"; + +const POLL_MAX_ATTEMPTS = 150; +const POLL_BASE_DELAY_MS = 1000; +const POLL_MAX_DELAY_MS = 10_000; +const POLL_BACKOFF_MULTIPLIER = 1.2; +const REQUEST_TIMEOUT_MS = 30_000; +const REFRESH_SKEW_MS = 5 * 60_000; + +export interface CursorAuthParams { + verifier: string; + challenge: string; + uuid: string; + loginUrl: string; +} + +function requestSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw signal.reason ?? new DOMException("Cursor OAuth login was cancelled", "AbortError"); + } +} + +function delay(ms: number, signal?: AbortSignal): Promise { + throwIfAborted(signal); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason ?? new DOMException("Cursor OAuth login was cancelled", "AbortError")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export function getCursorTokenExpiryMs(token: string): number { + try { + const parts = token.split("."); + const payload = parts[1]; + if (parts.length !== 3 || !payload) return Date.now() + 60 * 60_000; + const padded = payload + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(payload.length / 4) * 4, "="); + const decoded = JSON.parse(atob(padded)) as { exp?: unknown }; + if (typeof decoded.exp === "number" && Number.isFinite(decoded.exp)) { + return decoded.exp * 1000 - REFRESH_SKEW_MS; + } + } catch { + // Cursor access tokens are not guaranteed to be JWTs. + } + return Date.now() + 60 * 60_000; +} + +export async function generateCursorAuthParams(): Promise { + const { verifier, challenge } = await generatePKCE(); + const uuid = crypto.randomUUID(); + const params = new URLSearchParams({ challenge, uuid, mode: "login", redirectTarget: "cli" }); + return { verifier, challenge, uuid, loginUrl: `${CURSOR_LOGIN_URL}?${params}` }; +} + +export async function pollCursorAuth( + uuid: string, + verifier: string, + signal?: AbortSignal, +): Promise<{ accessToken: string; refreshToken: string }> { + let nextDelayMs = POLL_BASE_DELAY_MS; + let consecutiveNetworkErrors = 0; + for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) { + throwIfAborted(signal); + let response: Response; + try { + const params = new URLSearchParams({ uuid, verifier }); + response = await fetch(`${CURSOR_POLL_URL}?${params}`, { signal: requestSignal(signal) }); + consecutiveNetworkErrors = 0; + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + consecutiveNetworkErrors++; + if (consecutiveNetworkErrors >= 3) { + throw new Error("Cursor OAuth polling failed after repeated network errors"); + } + await delay(nextDelayMs, signal); + nextDelayMs = Math.min(nextDelayMs * POLL_BACKOFF_MULTIPLIER, POLL_MAX_DELAY_MS); + continue; + } + + if (response.status === 404) { + await delay(nextDelayMs, signal); + nextDelayMs = Math.min(nextDelayMs * POLL_BACKOFF_MULTIPLIER, POLL_MAX_DELAY_MS); + continue; + } + if (!response.ok) throw new Error(`Cursor OAuth polling failed (${response.status})`); + const data = (await response.json()) as Record; + if (typeof data.accessToken !== "string" || !data.accessToken) { + throw new Error("Cursor OAuth polling response missing access token"); + } + if (typeof data.refreshToken !== "string" || !data.refreshToken) { + throw new Error("Cursor OAuth polling response missing refresh token"); + } + return { accessToken: data.accessToken, refreshToken: data.refreshToken }; + } + throw new Error("Cursor OAuth polling timed out"); +} + +export async function loginCursor(callbacks: OAuthLoginCallbacks): Promise { + throwIfAborted(callbacks.signal); + const { verifier, uuid, loginUrl } = await generateCursorAuthParams(); + callbacks.onAuth({ url: loginUrl, instructions: "Complete Cursor login in your browser." }); + callbacks.onProgress?.("Waiting for Cursor authentication..."); + const { accessToken, refreshToken } = await pollCursorAuth(uuid, verifier, callbacks.signal); + return { access: accessToken, refresh: refreshToken, expires: getCursorTokenExpiryMs(accessToken) }; +} + +export async function refreshCursorToken(refreshToken: string, signal?: AbortSignal): Promise { + if (!refreshToken) throw new Error("Cursor credentials do not include a refresh token"); + let response: Response; + try { + response = await fetch(CURSOR_REFRESH_URL, { + method: "POST", + headers: { Authorization: `Bearer ${refreshToken}`, "Content-Type": "application/json" }, + body: "{}", + signal: requestSignal(signal), + }); + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + throw new Error("Cursor token refresh request failed"); + } + if (!response.ok) throw new Error(`Cursor token refresh failed (${response.status})`); + const data = (await response.json()) as Record; + if (typeof data.accessToken !== "string" || !data.accessToken) { + throw new Error("Cursor token refresh response missing access token"); + } + const refresh = typeof data.refreshToken === "string" && data.refreshToken ? data.refreshToken : refreshToken; + return { access: data.accessToken, refresh, expires: getCursorTokenExpiryMs(data.accessToken) }; +} + +export const cursorOAuth: OAuthAuth = { + name: "Cursor (Claude, GPT, etc.)", + async login(callbacks) { + const credentials = await loginCursor({ + onAuth: (info) => callbacks.notify({ type: "auth_url", ...info }), + onDeviceCode: () => {}, + onPrompt: async (prompt) => + callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }), + onProgress: (message) => callbacks.notify({ type: "progress", message }), + onSelect: async () => undefined, + signal: callbacks.signal, + }); + return { ...credentials, type: "oauth" }; + }, + refresh: async (credential) => ({ ...(await refreshCursorToken(credential.refresh)), type: "oauth" }), + toAuth: async (credential) => ({ apiKey: credential.access }), +}; + +export const cursorOAuthProvider: OAuthProviderInterface = { + id: "cursor", + name: "Cursor (Claude, GPT, etc.)", + login: loginCursor, + refreshToken: (credentials) => refreshCursorToken(credentials.refresh), + getApiKey: (credentials) => credentials.access, +}; diff --git a/packages/ai/src/auth/oauth/github-copilot.ts b/packages/ai/src/auth/oauth/github-copilot.ts index 0fe1a04e7..b4ad6aa7f 100644 --- a/packages/ai/src/auth/oauth/github-copilot.ts +++ b/packages/ai/src/auth/oauth/github-copilot.ts @@ -3,8 +3,15 @@ */ import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts"; -import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts"; +import type { Api, Model } from "../../types.ts"; +import type { OAuthAuth, OAuthCredential } from "../types.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import type { OAuthCredentials, OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; + +type CopilotCredentials = OAuthCredentials & { + enterpriseUrl?: string; + availableModelIds: string[]; +}; const decode = (s: string) => atob(s); const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg="); @@ -37,7 +44,7 @@ type DeviceTokenErrorResponse = { interval?: number; }; -function normalizeDomain(input: string): string | null { +export function normalizeDomain(input: string): string | null { const trimmed = input.trim(); if (!trimmed) return null; try { @@ -74,7 +81,7 @@ function getBaseUrlFromToken(token: string): string | null { return `https://${apiHost}`; } -function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: string): string { +export function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: string): string { // If we have a token, extract the base URL from proxy-ep if (token) { const urlFromToken = getBaseUrlFromToken(token); @@ -244,7 +251,7 @@ async function pollForGitHubAccessToken( async function refreshGitHubCopilotAccessToken( refreshToken: string, enterpriseDomain?: string, -): Promise { +): Promise { const domain = enterpriseDomain || "github.com"; const urls = getUrls(domain); @@ -268,7 +275,6 @@ async function refreshGitHubCopilotAccessToken( } return { - type: "oauth", refresh: refreshToken, access: token, expires: expiresAt * 1000 - 5 * 60 * 1000, @@ -279,7 +285,10 @@ async function refreshGitHubCopilotAccessToken( /** * Refresh GitHub Copilot token */ -async function refreshGitHubCopilotToken(refreshToken: string, enterpriseDomain?: string): Promise { +export async function refreshGitHubCopilotToken( + refreshToken: string, + enterpriseDomain?: string, +): Promise { const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain); return { ...credentials, @@ -317,41 +326,68 @@ async function enableGitHubCopilotModel(token: string, modelId: string, enterpri * Enable all known GitHub Copilot models that may require policy acceptance. * Called after successful login to ensure all models are available. */ -async function enableAllGitHubCopilotModels(token: string, enterpriseDomain?: string): Promise { +async function enableAllGitHubCopilotModels( + token: string, + enterpriseDomain?: string, + onProgress?: (model: string, success: boolean) => void, +): Promise { const models = Object.values(GITHUB_COPILOT_MODELS); await Promise.all( models.map(async (model) => { - await enableGitHubCopilotModel(token, model.id, enterpriseDomain); + const success = await enableGitHubCopilotModel(token, model.id, enterpriseDomain); + onProgress?.(model.id, success); }), ); } -async function loginGitHubCopilot(interaction: AuthInteraction): Promise { - const input = await interaction.prompt({ - type: "text", +/** + * Login with GitHub Copilot OAuth (device code flow) + * + * @param options.onDeviceCode - Callback with URL and user code + * @param options.onPrompt - Callback to prompt user for input + * @param options.onProgress - Optional progress callback + * @param options.signal - Optional AbortSignal for cancellation + */ +export async function loginGitHubCopilot(options: { + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; + onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise; + onProgress?: (message: string) => void; + signal?: AbortSignal; +}): Promise { + const input = await options.onPrompt({ message: "GitHub Enterprise URL/domain (blank for github.com)", placeholder: "company.ghe.com", + allowEmpty: true, }); - if (interaction.signal?.aborted) throw new Error("Login cancelled"); + + if (options.signal?.aborted) { + throw new Error("Login cancelled"); + } const trimmed = input.trim(); const enterpriseDomain = normalizeDomain(input); - if (trimmed && !enterpriseDomain) throw new Error("Invalid GitHub Enterprise URL/domain"); + if (trimmed && !enterpriseDomain) { + throw new Error("Invalid GitHub Enterprise URL/domain"); + } const domain = enterpriseDomain || "github.com"; const device = await startDeviceFlow(domain); - interaction.notify({ - type: "device_code", + options.onDeviceCode({ userCode: device.user_code, verificationUri: device.verification_uri, intervalSeconds: device.interval, expiresInSeconds: device.expires_in, }); - const githubAccessToken = await pollForGitHubAccessToken(domain, device, interaction.signal); + const githubAccessToken = await pollForGitHubAccessToken(domain, device, options.signal); const credentials = await refreshGitHubCopilotAccessToken(githubAccessToken, enterpriseDomain ?? undefined); - interaction.notify({ type: "progress", message: "Enabling models..." }); + + // Enable all models after successful login + options.onProgress?.("Enabling models..."); await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined); + + // Fetch availability after policy enable so newly enabled models are included, + // while unavailable models are still filtered out. return { ...credentials, availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain ?? undefined), @@ -366,10 +402,26 @@ function copilotEnterpriseDomain(credential: OAuthCredential): string | undefine export const githubCopilotOAuth: OAuthAuth = { name: "GitHub Copilot", - login: loginGitHubCopilot, - refresh: (credential) => refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential)), - /** Derive the credential-specific proxy endpoint for each request. */ + async login(callbacks) { + const credentials = await loginGitHubCopilot({ + onDeviceCode: (info) => callbacks.notify({ type: "device_code", ...info }), + onPrompt: (prompt) => + callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }), + onProgress: (message) => callbacks.notify({ type: "progress", message }), + signal: callbacks.signal, + }); + return { ...credentials, type: "oauth" }; + }, + + async refresh(credential) { + return { + ...(await refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential))), + type: "oauth", + }; + }, + + /** Per-credential baseUrl from the token's proxy endpoint replaces the old `modifyModels` rewriting. */ async toAuth(credential) { return { apiKey: credential.access, @@ -377,3 +429,41 @@ export const githubCopilotOAuth: OAuthAuth = { }; }, }; + +export const githubCopilotOAuthProvider: OAuthProviderInterface = { + id: "github-copilot", + name: "GitHub Copilot", + + async login(callbacks: OAuthLoginCallbacks): Promise { + return loginGitHubCopilot({ + onDeviceCode: callbacks.onDeviceCode, + onPrompt: callbacks.onPrompt, + onProgress: callbacks.onProgress, + signal: callbacks.signal, + }); + }, + + async refreshToken(credentials: OAuthCredentials): Promise { + const creds = credentials as CopilotCredentials; + return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl); + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, + + modifyModels(models: Model[], credentials: OAuthCredentials): Model[] { + const creds = credentials as CopilotCredentials; + const domain = creds.enterpriseUrl ? (normalizeDomain(creds.enterpriseUrl) ?? undefined) : undefined; + const baseUrl = getGitHubCopilotBaseUrl(creds.access, domain); + // Older stored Pi auth entries do not have account-specific model IDs yet; + // keep their existing generated-catalog behavior until the next refresh/login. + const availableModelIds = "availableModelIds" in creds ? new Set(creds.availableModelIds) : undefined; + + return models.flatMap((m) => { + if (m.provider !== "github-copilot") return [m]; + if (availableModelIds && !availableModelIds.has(m.id)) return []; + return [{ ...m, baseUrl }]; + }); + }, +}; diff --git a/packages/ai/src/auth/oauth/gitlab-duo-direct-access.ts b/packages/ai/src/auth/oauth/gitlab-duo-direct-access.ts new file mode 100644 index 000000000..d89197acc --- /dev/null +++ b/packages/ai/src/auth/oauth/gitlab-duo-direct-access.ts @@ -0,0 +1,59 @@ +/** + * GitLab Duo direct-access token exchange (browser-safe). + * Network-only; no Node callback server. + */ + +const GITLAB_DUO_BASE_URL = "https://gitlab.com"; +const REQUEST_TIMEOUT_MS = 30_000; +const DIRECT_ACCESS_TTL_MS = 25 * 60_000; + +type DirectAccess = { token: string; headers: Record; expiresAt: number }; + +const directAccessCache = new Map(); + +function requestSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +/** Clear cached direct-access tokens (e.g. after OAuth token rotation). */ +export function clearGitLabDuoDirectAccessCache(): void { + directAccessCache.clear(); +} + +export async function getGitLabDuoDirectAccess( + accessToken: string, + signal?: AbortSignal, +): Promise<{ token: string; headers: Record }> { + if (!accessToken) throw new Error("GitLab Duo credentials do not include an access token"); + const cached = directAccessCache.get(accessToken); + if (cached && cached.expiresAt > Date.now()) return cached; + let response: Response; + try { + response = await fetch(`${GITLAB_DUO_BASE_URL}/api/v4/ai/third_party_agents/direct_access`, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ feature_flags: { DuoAgentPlatformNext: true } }), + signal: requestSignal(signal), + }); + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + throw new Error("GitLab Duo direct-access request failed"); + } + if (!response.ok) throw new Error(`GitLab Duo direct-access request failed (${response.status})`); + const data = (await response.json()) as Record; + if (typeof data.token !== "string" || !data.token || !data.headers || typeof data.headers !== "object") { + throw new Error("GitLab Duo direct-access response missing required fields"); + } + const headers: Record = {}; + for (const [key, value] of Object.entries(data.headers as Record)) { + if (typeof value === "string") headers[key] = value; + } + const directAccess = { token: data.token, headers, expiresAt: Date.now() + DIRECT_ACCESS_TTL_MS }; + directAccessCache.set(accessToken, directAccess); + return directAccess; +} diff --git a/packages/ai/src/auth/oauth/gitlab-duo.ts b/packages/ai/src/auth/oauth/gitlab-duo.ts new file mode 100644 index 000000000..6b9f950ed --- /dev/null +++ b/packages/ai/src/auth/oauth/gitlab-duo.ts @@ -0,0 +1,262 @@ +import type { OAuthAuth } from "../types.ts"; +import { clearGitLabDuoDirectAccessCache, getGitLabDuoDirectAccess } from "./gitlab-duo-direct-access.ts"; +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; +import { generatePKCE } from "./pkce.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; + +export const GITLAB_DUO_BASE_URL = "https://gitlab.com"; +export const GITLAB_DUO_CLIENT_ID = "da4edff2e6ebd2bc3208611e2768bc1c1dd7be791dc5ff26ca34ca9ee44f7d4b"; +const REDIRECT_URI = "http://127.0.0.1:8080/callback"; +const CALLBACK_PORT = 8080; +const SCOPE = "api"; +const REFRESH_SKEW_MS = 5 * 60_000; +const REQUEST_TIMEOUT_MS = 30_000; +type Server = import("node:http").Server; +type AuthorizationInput = { code?: string; state?: string }; +function requestSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +export function parseGitLabDuoAuthorizationInput(input: string): AuthorizationInput { + const value = input.trim(); + if (!value) return {}; + try { + const url = new URL(value); + return { code: url.searchParams.get("code") ?? undefined, state: url.searchParams.get("state") ?? undefined }; + } catch { + // Continue with query-string and code-only formats. + } + if (value.includes("#")) { + const [code, state] = value.split("#", 2); + return { code: code || undefined, state: state || undefined }; + } + if (value.includes("code=")) { + const params = new URLSearchParams(value.startsWith("?") ? value.slice(1) : value); + return { code: params.get("code") ?? undefined, state: params.get("state") ?? undefined }; + } + return { code: value }; +} + +function tokenCredentials(payload: Record, refreshFallback = ""): OAuthCredentials { + if (typeof payload.access_token !== "string" || !payload.access_token) { + throw new Error("GitLab OAuth token response missing access token"); + } + const refresh = + typeof payload.refresh_token === "string" && payload.refresh_token ? payload.refresh_token : refreshFallback; + if (!refresh) throw new Error("GitLab OAuth token response missing refresh token"); + if (typeof payload.expires_in !== "number" || !Number.isFinite(payload.expires_in)) { + throw new Error("GitLab OAuth token response missing expiry"); + } + const createdAt = + typeof payload.created_at === "number" && Number.isFinite(payload.created_at) + ? payload.created_at * 1000 + : Date.now(); + return { + access: payload.access_token, + refresh, + expires: createdAt + payload.expires_in * 1000 - REFRESH_SKEW_MS, + }; +} + +async function tokenRequest( + body: Record, + refreshFallback = "", + signal?: AbortSignal, +): Promise { + let response: Response; + try { + response = await fetch(`${GITLAB_DUO_BASE_URL}/oauth/token`, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(body).toString(), + signal: requestSignal(signal), + }); + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + throw new Error("GitLab OAuth token request failed"); + } + if (!response.ok) throw new Error(`GitLab OAuth token request failed (${response.status})`); + const credentials = tokenCredentials((await response.json()) as Record, refreshFallback); + clearGitLabDuoDirectAccessCache(); + return credentials; +} + +export async function exchangeGitLabDuoAuthorizationCode( + code: string, + verifier: string, + redirectUri = REDIRECT_URI, + signal?: AbortSignal, +): Promise { + return tokenRequest( + { + client_id: GITLAB_DUO_CLIENT_ID, + grant_type: "authorization_code", + code, + code_verifier: verifier, + redirect_uri: redirectUri, + }, + "", + signal, + ); +} + +export async function refreshGitLabDuoToken( + credentials: OAuthCredentials, + signal?: AbortSignal, +): Promise { + if (!credentials.refresh) throw new Error("GitLab Duo credentials do not include a refresh token"); + return tokenRequest( + { + client_id: GITLAB_DUO_CLIENT_ID, + grant_type: "refresh_token", + refresh_token: credentials.refresh, + }, + credentials.refresh, + signal, + ); +} + +function abortPromise(signal?: AbortSignal): Promise | undefined { + if (!signal) return undefined; + return new Promise((_, reject) => { + const rejectAbort = () => + reject(signal.reason ?? new DOMException("GitLab Duo OAuth login was cancelled", "AbortError")); + if (signal.aborted) rejectAbort(); + else signal.addEventListener("abort", rejectAbort, { once: true }); + }); +} + +async function startCallbackServer(state: string, settle: (code: string) => void): Promise { + const { createServer } = await import("node:http"); + const server = createServer((req, res) => { + const url = new URL(req.url ?? "", REDIRECT_URI); + const code = url.searchParams.get("code"); + if (url.pathname !== "/callback" || url.searchParams.get("state") !== state || !code) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(oauthErrorHtml("Invalid GitLab OAuth callback.")); + return; + } + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(oauthSuccessHtml("GitLab Duo authentication completed.")); + settle(code); + }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(CALLBACK_PORT, "127.0.0.1", resolve); + }); + return server; + } catch (error) { + server.close(); + throw error; + } +} + +async function closeServer(server: Server | undefined): Promise { + if (!server?.listening) return; + await new Promise((resolve) => server.close(() => resolve())); +} + +export async function loginGitLabDuo(callbacks: OAuthLoginCallbacks): Promise { + const { verifier, challenge } = await generatePKCE(); + const state = crypto.randomUUID(); + let settle!: (code: string) => void; + const callbackCode = new Promise((resolve) => { + settle = resolve; + }); + let server: Server | undefined; + try { + try { + server = await startCallbackServer(state, settle); + } catch { + if (!callbacks.onManualCodeInput) { + throw new Error("GitLab OAuth callback port 8080 is unavailable and manual code input is not supported"); + } + } + const params = new URLSearchParams({ + client_id: GITLAB_DUO_CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: "code", + scope: SCOPE, + code_challenge: challenge, + code_challenge_method: "S256", + state, + }); + callbacks.onAuth({ + url: `${GITLAB_DUO_BASE_URL}/oauth/authorize?${params}`, + instructions: "Complete GitLab login in your browser, then paste the redirect URL if needed.", + }); + const candidates: Promise[] = []; + if (server) candidates.push(callbackCode); + if (callbacks.onManualCodeInput) { + candidates.push( + callbacks.onManualCodeInput().then((input) => { + const parsed = parseGitLabDuoAuthorizationInput(input); + if (parsed.state && parsed.state !== state) throw new Error("OAuth state mismatch"); + if (!parsed.code) throw new Error("Missing authorization code"); + return parsed.code; + }), + ); + } + const aborted = abortPromise(callbacks.signal); + if (aborted) candidates.push(aborted); + const code = await Promise.race(candidates); + return await exchangeGitLabDuoAuthorizationCode(code, verifier, REDIRECT_URI, callbacks.signal); + } finally { + await closeServer(server); + } +} + +export const gitlabDuoOAuth: OAuthAuth = { + name: "GitLab Duo", + async login(callbacks) { + const manualAbort = new AbortController(); + try { + const credentials = await loginGitLabDuo({ + onAuth: (info) => callbacks.notify({ type: "auth_url", ...info }), + onDeviceCode: () => {}, + onPrompt: async (prompt) => + callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }), + onManualCodeInput: () => + callbacks.prompt({ + type: "manual_code", + message: "Paste the GitLab authorization code or redirect URL:", + placeholder: REDIRECT_URI, + signal: manualAbort.signal, + }), + onSelect: async () => undefined, + signal: callbacks.signal, + }); + return { ...credentials, type: "oauth" }; + } finally { + manualAbort.abort(); + } + }, + refresh: async (credential) => ({ ...(await refreshGitLabDuoToken(credential)), type: "oauth" }), + toAuth: async (credential) => { + const directAccess = await getGitLabDuoDirectAccess(credential.access); + return { + apiKey: directAccess.token, + headers: { ...directAccess.headers, Authorization: `Bearer ${directAccess.token}` }, + }; + }, +}; + +export const gitlabDuoOAuthProvider: OAuthProviderInterface = { + id: "gitlab-duo", + name: "GitLab Duo", + usesCallbackServer: true, + login: loginGitLabDuo, + refreshToken: refreshGitLabDuoToken, + getApiKey: (credentials) => credentials.access, + getRequestAuth: async (credentials) => { + const directAccess = await getGitLabDuoDirectAccess(credentials.access); + return { + apiKey: directAccess.token, + headers: { ...directAccess.headers, Authorization: `Bearer ${directAccess.token}` }, + }; + }, +}; + +export { getGitLabDuoDirectAccess } from "./gitlab-duo-direct-access.ts"; diff --git a/packages/ai/src/auth/oauth/google-antigravity.ts b/packages/ai/src/auth/oauth/google-antigravity.ts new file mode 100644 index 000000000..3d15fd903 --- /dev/null +++ b/packages/ai/src/auth/oauth/google-antigravity.ts @@ -0,0 +1,84 @@ +import { googleOAuthExports, refreshGoogleToken } from "./google-oauth-shared.ts"; + +const decode = (value: string) => atob(value); +const ENDPOINT = "https://cloudcode-pa.googleapis.com"; +const metadata = { ideType: "ANTIGRAVITY", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" } as const; +const config = { + id: "google-antigravity", + name: "Google Antigravity", + port: 51121, + path: "/oauth-callback", + clientId: decode( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==", + ), + clientSecret: decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="), + scopes: [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", + ], + discoverProject: discoverAntigravityProject, +}; + +function projectId(value: unknown): string | undefined { + if (typeof value === "string" && value) return value; + if (value && typeof value === "object" && "id" in value && typeof value.id === "string" && value.id) return value.id; + return undefined; +} + +export async function discoverAntigravityProject(accessToken: string, signal?: AbortSignal): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "antigravity-ide", + }; + const load = await fetch(`${ENDPOINT}/v1internal:loadCodeAssist`, { + method: "POST", + headers, + body: JSON.stringify({ metadata }), + signal, + }); + if (!load.ok) throw new Error(`Google Antigravity project discovery failed (${load.status})`); + const payload = (await load.json()) as { + cloudaicompanionProject?: unknown; + allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; + }; + const existing = projectId(payload.cloudaicompanionProject); + if (existing) return existing; + const tierId = payload.allowedTiers?.find((tier) => tier.isDefault)?.id ?? "legacy-tier"; + for (let attempt = 0; attempt < 5; attempt += 1) { + const onboard = await fetch(`${ENDPOINT}/v1internal:onboardUser`, { + method: "POST", + headers, + body: JSON.stringify({ tierId, metadata }), + signal, + }); + if (!onboard.ok) throw new Error(`Google Antigravity onboarding failed (${onboard.status})`); + const operation = (await onboard.json()) as { done?: boolean; response?: { cloudaicompanionProject?: unknown } }; + const project = operation.done ? projectId(operation.response?.cloudaicompanionProject) : undefined; + if (project) return project; + if (attempt < 4) { + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 2000); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(signal.reason); + }, + { once: true }, + ); + }); + } + } + throw new Error("Google Antigravity onboarding returned no projectId"); +} + +const exports = googleOAuthExports(config); +export const googleAntigravityOAuth = exports.auth; +export const googleAntigravityOAuthProvider = exports.provider; +export const loginAntigravity = googleAntigravityOAuthProvider.login; +export const refreshAntigravityToken = (refresh: string, projectIdValue: string, signal?: AbortSignal) => + refreshGoogleToken(config, refresh, projectIdValue, signal); diff --git a/packages/ai/src/auth/oauth/google-gemini-cli.ts b/packages/ai/src/auth/oauth/google-gemini-cli.ts new file mode 100644 index 000000000..ffc227f72 --- /dev/null +++ b/packages/ai/src/auth/oauth/google-gemini-cli.ts @@ -0,0 +1,101 @@ +import { googleOAuthExports, refreshGoogleToken } from "./google-oauth-shared.ts"; + +const decode = (value: string) => atob(value); +const ENDPOINT = "https://cloudcode-pa.googleapis.com"; +const metadata = { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" } as const; +const config = { + id: "google-gemini-cli", + name: "Google Gemini CLI", + port: 8085, + path: "/oauth2callback", + clientId: decode("NjgxMjU1ODA5Mzk1LW9vOGZ0Mm9wcmRybnA5ZTNhcWY2YXYzaG1kaWIxMzVqLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t"), + clientSecret: decode("R09DU1BYLTR1SGdNUG0tMW83U2stZ2VWNkN1NWNsWEZzeGw="), + scopes: [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ], + discoverProject: discoverGeminiCliProject, +}; + +function projectId(value: unknown): string | undefined { + if (typeof value === "string" && value) return value; + if (value && typeof value === "object" && "id" in value && typeof value.id === "string" && value.id) return value.id; + return undefined; +} + +export async function discoverGeminiCliProject(accessToken: string, signal?: AbortSignal): Promise { + const configuredProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; + const headers = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }; + const load = await fetch(`${ENDPOINT}/v1internal:loadCodeAssist`, { + method: "POST", + headers, + body: JSON.stringify({ + cloudaicompanionProject: configuredProject, + metadata: { ...metadata, duetProject: configuredProject }, + }), + signal, + }); + if (!load.ok) throw new Error(`Google Gemini CLI project discovery failed (${load.status})`); + const payload = (await load.json()) as { + cloudaicompanionProject?: string | { id?: string }; + currentTier?: { id?: string }; + allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; + }; + const existing = projectId(payload.cloudaicompanionProject); + if (existing) return existing; + if (payload.currentTier) { + if (configuredProject) return configuredProject; + throw new Error("Google Gemini CLI account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID"); + } + const tierId = payload.allowedTiers?.find((tier) => tier.isDefault)?.id ?? "legacy-tier"; + if (tierId !== "free-tier" && !configuredProject) { + throw new Error("Google Gemini CLI account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID"); + } + const onboard = await fetch(`${ENDPOINT}/v1internal:onboardUser`, { + method: "POST", + headers, + body: JSON.stringify({ + tierId, + ...(configuredProject && { cloudaicompanionProject: configuredProject }), + metadata: { ...metadata, ...(configuredProject && { duetProject: configuredProject }) }, + }), + signal, + }); + if (!onboard.ok) throw new Error(`Google Gemini CLI onboarding failed (${onboard.status})`); + let operation = (await onboard.json()) as { + name?: string; + done?: boolean; + response?: { cloudaicompanionProject?: unknown }; + }; + for (let attempt = 0; !operation.done && operation.name && attempt < 12; attempt += 1) { + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 1000); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(signal.reason); + }, + { once: true }, + ); + }); + const poll = await fetch(`${ENDPOINT}/v1internal/${operation.name}`, { headers, signal }); + if (!poll.ok) throw new Error(`Google Gemini CLI onboarding poll failed (${poll.status})`); + operation = (await poll.json()) as typeof operation; + } + return ( + projectId(operation.response?.cloudaicompanionProject) ?? + configuredProject ?? + (() => { + throw new Error("Google Gemini CLI onboarding returned no projectId"); + })() + ); +} + +const exports = googleOAuthExports(config); +export const googleGeminiCliOAuth = exports.auth; +export const googleGeminiCliOAuthProvider = exports.provider; +export const loginGeminiCli = googleGeminiCliOAuthProvider.login; +export const refreshGoogleCloudToken = (refresh: string, projectIdValue: string, signal?: AbortSignal) => + refreshGoogleToken(config, refresh, projectIdValue, signal); diff --git a/packages/ai/src/auth/oauth/google-oauth-node.ts b/packages/ai/src/auth/oauth/google-oauth-node.ts new file mode 100644 index 000000000..b38ca2597 --- /dev/null +++ b/packages/ai/src/auth/oauth/google-oauth-node.ts @@ -0,0 +1,8 @@ +import { createServer, type RequestListener, type Server } from "node:http"; + +/** Node-only callback dependency reached through the opaque OAuth loaders in load.ts. */ +export type GoogleOAuthServer = Server; + +export function createGoogleOAuthServer(listener: RequestListener): Server { + return createServer(listener); +} diff --git a/packages/ai/src/auth/oauth/google-oauth-shared.ts b/packages/ai/src/auth/oauth/google-oauth-shared.ts new file mode 100644 index 000000000..393153e7c --- /dev/null +++ b/packages/ai/src/auth/oauth/google-oauth-shared.ts @@ -0,0 +1,255 @@ +import type { OAuthAuth } from "../types.ts"; +import { createGoogleOAuthServer, type GoogleOAuthServer } from "./google-oauth-node.ts"; +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; +import { generatePKCE } from "./pkce.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; + +const SKEW_MS = 5 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 30_000; +const LOGIN_TIMEOUT_MS = 5 * 60 * 1000; + +export interface GoogleOAuthConfig { + id: string; + name: string; + clientId: string; + clientSecret: string; + port: number; + path: string; + scopes: string[]; + discoverProject(accessToken: string, signal?: AbortSignal): Promise; +} + +const requestSignal = (signal?: AbortSignal) => + signal + ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]) + : AbortSignal.timeout(REQUEST_TIMEOUT_MS); +const redirectUri = (config: GoogleOAuthConfig) => `http://127.0.0.1:${config.port}${config.path}`; + +function requireProjectId(value: unknown, provider: string): string { + if (typeof value !== "string" || !value.trim()) throw new Error(`${provider} credentials are missing projectId`); + return value; +} + +async function tokenRequest( + config: GoogleOAuthConfig, + body: Record, + signal?: AbortSignal, +): Promise { + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(body), + signal: requestSignal(signal), + }); + if (!response.ok) throw new Error(`${config.name} token request failed (${response.status})`); + const data = (await response.json()) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }; + if (typeof data.access_token !== "string" || !data.access_token) { + throw new Error(`${config.name} token response missing access token`); + } + const refresh = + typeof data.refresh_token === "string" && data.refresh_token ? data.refresh_token : body.refresh_token; + if (!refresh) throw new Error(`${config.name} token response missing refresh token`); + const expiresIn = typeof data.expires_in === "number" ? data.expires_in : 3600; + return { access: data.access_token, refresh, expires: Date.now() + expiresIn * 1000 - SKEW_MS }; +} + +export async function refreshGoogleToken( + config: GoogleOAuthConfig, + refresh: string, + projectId: string, + signal?: AbortSignal, +): Promise { + if (!refresh) throw new Error(`${config.name} credentials do not include a refresh token`); + const project = requireProjectId(projectId, config.name); + return { + ...(await tokenRequest( + config, + { + client_id: config.clientId, + client_secret: config.clientSecret, + refresh_token: refresh, + grant_type: "refresh_token", + }, + signal, + )), + projectId: project, + }; +} + +export function parseGoogleAuthorizationInput(input: string): { code?: string; state?: string } { + const value = input.trim(); + if (!value) return {}; + try { + const url = new URL(value); + return { code: url.searchParams.get("code") ?? undefined, state: url.searchParams.get("state") ?? undefined }; + } catch { + const params = new URLSearchParams(value.startsWith("?") ? value.slice(1) : value); + if (params.has("code")) return { code: params.get("code") ?? undefined, state: params.get("state") ?? undefined }; + return { code: value }; + } +} + +async function startCallbackServer( + config: GoogleOAuthConfig, + state: string, + settle: (code: string) => void, +): Promise { + const server = createGoogleOAuthServer((req, res) => { + const url = new URL(req.url ?? "", redirectUri(config)); + const code = url.searchParams.get("code"); + if (url.pathname !== config.path || url.searchParams.get("state") !== state || !code) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(oauthErrorHtml("Invalid Google OAuth callback.")); + return; + } + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(oauthSuccessHtml("Google authentication completed.")); + settle(code); + }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(config.port, "127.0.0.1", resolve); + }); + return server; + } catch (error) { + server.close(); + throw error; + } +} + +export async function loginGoogle( + config: GoogleOAuthConfig, + callbacks: OAuthLoginCallbacks, +): Promise { + if (callbacks.signal?.aborted) throw callbacks.signal.reason ?? new DOMException("Cancelled", "AbortError"); + const { verifier, challenge } = await generatePKCE(); + const state = crypto.randomUUID(); + let settle!: (code: string) => void; + const callbackCode = new Promise((resolve) => { + settle = resolve; + }); + let server: GoogleOAuthServer | undefined; + try { + try { + server = await startCallbackServer(config, state, settle); + } catch { + if (!callbacks.onManualCodeInput) { + throw new Error( + `${config.name} callback port ${config.port} is unavailable and manual input is unsupported`, + ); + } + } + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri(config), + scope: config.scopes.join(" "), + state, + access_type: "offline", + prompt: "consent", + code_challenge: challenge, + code_challenge_method: "S256", + }); + callbacks.onAuth({ + url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`, + instructions: "Complete sign-in, then paste the redirect URL if the callback does not complete.", + }); + const candidates: Promise[] = []; + if (server) candidates.push(callbackCode); + if (callbacks.onManualCodeInput) { + candidates.push( + callbacks.onManualCodeInput().then((input) => { + const parsed = parseGoogleAuthorizationInput(input); + if (parsed.state && parsed.state !== state) throw new Error("OAuth state mismatch"); + if (!parsed.code) throw new Error("Missing authorization code"); + return parsed.code; + }), + ); + } + const loginSignal = callbacks.signal + ? AbortSignal.any([callbacks.signal, AbortSignal.timeout(LOGIN_TIMEOUT_MS)]) + : AbortSignal.timeout(LOGIN_TIMEOUT_MS); + candidates.push( + new Promise((_, reject) => { + const abort = () => reject(loginSignal.reason ?? new DOMException("Cancelled", "AbortError")); + if (loginSignal.aborted) abort(); + else loginSignal.addEventListener("abort", abort, { once: true }); + }), + ); + const code = await Promise.race(candidates); + const credentials = await tokenRequest( + config, + { + client_id: config.clientId, + client_secret: config.clientSecret, + code, + grant_type: "authorization_code", + redirect_uri: redirectUri(config), + code_verifier: verifier, + }, + callbacks.signal, + ); + const projectId = requireProjectId( + await config.discoverProject(credentials.access, callbacks.signal), + config.name, + ); + return { ...credentials, projectId }; + } finally { + server?.close(); + } +} + +export function googleOAuthExports(config: GoogleOAuthConfig): { auth: OAuthAuth; provider: OAuthProviderInterface } { + const auth: OAuthAuth = { + name: config.name, + login: async (callbacks) => { + const manualAbort = new AbortController(); + try { + const credentials = await loginGoogle(config, { + onAuth: (info) => callbacks.notify({ type: "auth_url", ...info }), + onDeviceCode: () => {}, + onPrompt: async () => "", + onManualCodeInput: () => + callbacks.prompt({ + type: "manual_code", + message: "Paste the Google authorization code or redirect URL:", + placeholder: redirectUri(config), + signal: manualAbort.signal, + }), + onSelect: async () => undefined, + signal: callbacks.signal, + }); + return { ...credentials, type: "oauth" }; + } finally { + manualAbort.abort(); + } + }, + refresh: async (credential) => ({ + ...(await refreshGoogleToken(config, credential.refresh, requireProjectId(credential.projectId, config.name))), + type: "oauth", + }), + toAuth: async (credential) => ({ + apiKey: JSON.stringify({ + token: credential.access, + projectId: requireProjectId(credential.projectId, config.name), + }), + }), + }; + return { + auth, + provider: { + id: config.id, + name: config.name, + usesCallbackServer: true, + login: (callbacks) => loginGoogle(config, callbacks), + refreshToken: (credentials) => + refreshGoogleToken(config, credentials.refresh, requireProjectId(credentials.projectId, config.name)), + getApiKey: (credentials) => + JSON.stringify({ + token: credentials.access, + projectId: requireProjectId(credentials.projectId, config.name), + }), + }, + }; +} diff --git a/packages/ai/src/auth/oauth/index.ts b/packages/ai/src/auth/oauth/index.ts new file mode 100644 index 000000000..a5fd7929e --- /dev/null +++ b/packages/ai/src/auth/oauth/index.ts @@ -0,0 +1,118 @@ +/** + * OAuth credential management for AI providers. + */ + +export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.ts"; +export * from "./cursor.ts"; +export * from "./device-code.ts"; +export { + getGitHubCopilotBaseUrl, + githubCopilotOAuthProvider, + loginGitHubCopilot, + normalizeDomain, + refreshGitHubCopilotToken, +} from "./github-copilot.ts"; +export * from "./gitlab-duo.ts"; +export { googleAntigravityOAuthProvider, loginAntigravity, refreshAntigravityToken } from "./google-antigravity.ts"; +export { googleGeminiCliOAuthProvider, loginGeminiCli, refreshGoogleCloudToken } from "./google-gemini-cli.ts"; +export * from "./kilo.ts"; +export { + loadAnthropicOAuth, + loadCursorOAuth, + loadGitHubCopilotOAuth, + loadGitLabDuoOAuth, + loadGoogleAntigravityOAuth, + loadGoogleGeminiCliOAuth, + loadKiloOAuth, + loadOpenAICodexDeviceOAuth, + loadOpenAICodexOAuth, + loadPerplexityOAuth, + loadRadiusOAuth, + loadXaiOAuth, + registerBundledOAuthFlowLoaders, +} from "./load.ts"; +export { + loginOpenAICodex, + loginOpenAICodexDeviceCode, + openaiCodexOAuthProvider, + refreshOpenAICodexToken, +} from "./openai-codex.ts"; +export * from "./openai-codex-device.ts"; +export * from "./perplexity.ts"; +export * from "./pkce.ts"; +export { createRadiusOAuth } from "./radius.ts"; +export * from "./types.ts"; +export * from "./xai.ts"; + +import { anthropicOAuthProvider } from "./anthropic.ts"; +import { cursorOAuthProvider } from "./cursor.ts"; +import { githubCopilotOAuthProvider } from "./github-copilot.ts"; +import { gitlabDuoOAuthProvider } from "./gitlab-duo.ts"; +import { googleAntigravityOAuthProvider } from "./google-antigravity.ts"; +import { googleGeminiCliOAuthProvider } from "./google-gemini-cli.ts"; +import { kiloOAuthProvider } from "./kilo.ts"; +import { openaiCodexOAuthProvider } from "./openai-codex.ts"; +import { openaiCodexDeviceOAuthProvider } from "./openai-codex-device.ts"; +import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.ts"; +import { xaiOAuthProvider } from "./xai.ts"; + +const providers: OAuthProviderInterface[] = [ + anthropicOAuthProvider, + openaiCodexOAuthProvider, + openaiCodexDeviceOAuthProvider, + githubCopilotOAuthProvider, + cursorOAuthProvider, + gitlabDuoOAuthProvider, + // Perplexity session OAuth is not advertised for model-request login. + kiloOAuthProvider, + xaiOAuthProvider, + googleGeminiCliOAuthProvider, + googleAntigravityOAuthProvider, +]; + +export function getOAuthProviders(): OAuthProviderInfo[] { + return providers.map((provider) => ({ + id: provider.id, + name: provider.name, + available: true, + usesCallbackServer: provider.usesCallbackServer, + })); +} + +export function getOAuthProvider(id: OAuthProviderId): OAuthProviderInterface | undefined { + return providers.find((provider) => provider.id === id); +} + +export function registerOAuthProvider(provider: OAuthProviderInterface): void { + const existing = providers.findIndex((entry) => entry.id === provider.id); + if (existing >= 0) providers[existing] = provider; + else providers.push(provider); +} + +export function resetOAuthProviders(): void { + // Keep built-ins; tests that need a clean slate re-register via registerOAuthProvider. +} + +export function resolveOAuthStorageProvider(id: OAuthProviderId): OAuthProviderId { + if (id === "openai-codex-device") return "openai-codex"; + return id; +} + +export async function getOAuthApiKey( + providerId: OAuthProviderId, + credentials: Record, +): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> { + const provider = getOAuthProvider(providerId); + if (!provider) throw new Error(`Unknown OAuth provider: ${providerId}`); + let creds = credentials[providerId]; + if (!creds) return null; + if (Date.now() >= creds.expires) { + try { + creds = await provider.refreshToken(creds); + } catch { + throw new Error(`Failed to refresh OAuth token for ${providerId}`); + } + } + const apiKey = provider.getApiKey(creds); + return { newCredentials: creds, apiKey }; +} diff --git a/packages/ai/src/auth/oauth/kilo.ts b/packages/ai/src/auth/oauth/kilo.ts new file mode 100644 index 000000000..2740d2b71 --- /dev/null +++ b/packages/ai/src/auth/oauth/kilo.ts @@ -0,0 +1,126 @@ +import type { OAuthAuth } from "../types.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; + +export const KILO_DEVICE_AUTH_BASE_URL = "https://api.kilo.ai/api/device-auth"; +const POLL_INTERVAL_MS = 5000; +const ONE_YEAR_MS = 365 * 24 * 60 * 60_000; +const REQUEST_TIMEOUT_MS = 30_000; + +function requestSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw signal.reason ?? new DOMException("Kilo OAuth login was cancelled", "AbortError"); +} + +function delay(ms: number, signal?: AbortSignal): Promise { + throwIfAborted(signal); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason ?? new DOMException("Kilo OAuth login was cancelled", "AbortError")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function kiloFetch(url: string, init: RequestInit, signal?: AbortSignal): Promise { + try { + return await fetch(url, { ...init, signal: requestSignal(signal) }); + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + throw new Error("Kilo device authorization request failed"); + } +} + +export async function loginKilo(callbacks: OAuthLoginCallbacks): Promise { + throwIfAborted(callbacks.signal); + const initiateResponse = await kiloFetch( + `${KILO_DEVICE_AUTH_BASE_URL}/codes`, + { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json" } }, + callbacks.signal, + ); + if (!initiateResponse.ok) { + if (initiateResponse.status === 429) { + throw new Error("Too many pending Kilo authorization requests; try again later"); + } + throw new Error(`Kilo device authorization failed (${initiateResponse.status})`); + } + const initiateData = (await initiateResponse.json()) as Record; + const userCode = initiateData.code; + const verificationUrl = initiateData.verificationUrl; + const expiresIn = initiateData.expiresIn; + if ( + typeof userCode !== "string" || + !userCode || + typeof verificationUrl !== "string" || + !verificationUrl || + typeof expiresIn !== "number" || + !Number.isFinite(expiresIn) || + expiresIn <= 0 + ) { + throw new Error("Kilo device authorization response missing required fields"); + } + + callbacks.onAuth({ url: verificationUrl, instructions: `Enter code: ${userCode}` }); + const deadline = Date.now() + expiresIn * 1000; + while (Date.now() < deadline) { + throwIfAborted(callbacks.signal); + const response = await kiloFetch( + `${KILO_DEVICE_AUTH_BASE_URL}/codes/${encodeURIComponent(userCode)}`, + { headers: { Accept: "application/json" } }, + callbacks.signal, + ); + if (response.status === 202) { + await delay(POLL_INTERVAL_MS, callbacks.signal); + continue; + } + if (response.status === 403) throw new Error("Kilo authorization was denied"); + if (response.status === 410) throw new Error("Kilo authorization code expired"); + if (!response.ok) throw new Error(`Kilo device authorization polling failed (${response.status})`); + const data = (await response.json()) as Record; + if (data.status === "approved" && typeof data.token === "string" && data.token) { + return { access: data.token, refresh: "", expires: Date.now() + ONE_YEAR_MS }; + } + if (data.status === "denied") throw new Error("Kilo authorization was denied"); + if (data.status === "expired") throw new Error("Kilo authorization code expired"); + await delay(POLL_INTERVAL_MS, callbacks.signal); + } + throw new Error("Kilo authentication timed out"); +} + +export function refreshKiloToken(credentials: OAuthCredentials): Promise { + return Promise.resolve(credentials); +} + +export const kiloOAuth: OAuthAuth = { + name: "Kilo Gateway", + async login(callbacks) { + const credentials = await loginKilo({ + onAuth: (info) => callbacks.notify({ type: "auth_url", ...info }), + onDeviceCode: (info) => callbacks.notify({ type: "device_code", ...info }), + onPrompt: async (prompt) => + callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }), + onProgress: (message) => callbacks.notify({ type: "progress", message }), + onSelect: async () => undefined, + signal: callbacks.signal, + }); + return { ...credentials, type: "oauth" }; + }, + refresh: async (credential) => credential, + toAuth: async (credential) => ({ apiKey: credential.access }), +}; + +export const kiloOAuthProvider: OAuthProviderInterface = { + id: "kilo", + name: "Kilo Gateway", + login: loginKilo, + refreshToken: refreshKiloToken, + getApiKey: (credentials) => credentials.access, +}; diff --git a/packages/ai/src/auth/oauth/load.ts b/packages/ai/src/auth/oauth/load.ts index ca1c87d24..3f0118b29 100644 --- a/packages/ai/src/auth/oauth/load.ts +++ b/packages/ai/src/auth/oauth/load.ts @@ -14,8 +14,15 @@ const importOAuthModule = (specifier: string): Promise => { type OAuthFlowLoaders = { anthropic: () => OAuthAuth | Promise; openaiCodex: () => OAuthAuth | Promise; + openaiCodexDevice: () => OAuthAuth | Promise; githubCopilot: () => OAuthAuth | Promise; + cursor: () => OAuthAuth | Promise; + gitlabDuo: () => OAuthAuth | Promise; + perplexity: () => OAuthAuth | Promise; + kilo: () => OAuthAuth | Promise; xai: () => OAuthAuth | Promise; + googleGeminiCli: () => OAuthAuth | Promise; + googleAntigravity: () => OAuthAuth | Promise; radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise; }; @@ -36,16 +43,54 @@ export const loadOpenAICodexOAuth = async (): Promise => { return ((await importOAuthModule("./openai-codex.ts")) as { openaiCodexOAuth: OAuthAuth }).openaiCodexOAuth; }; +export const loadOpenAICodexDeviceOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.openaiCodexDevice(); + return ((await importOAuthModule("./openai-codex-device.ts")) as { openaiCodexDeviceOAuth: OAuthAuth }) + .openaiCodexDeviceOAuth; +}; + export const loadGitHubCopilotOAuth = async (): Promise => { if (bundledLoaders) return bundledLoaders.githubCopilot(); return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth; }; +export const loadCursorOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.cursor(); + return ((await importOAuthModule("./cursor.ts")) as { cursorOAuth: OAuthAuth }).cursorOAuth; +}; + +export const loadGitLabDuoOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.gitlabDuo(); + return ((await importOAuthModule("./gitlab-duo.ts")) as { gitlabDuoOAuth: OAuthAuth }).gitlabDuoOAuth; +}; + +export const loadPerplexityOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.perplexity(); + return ((await importOAuthModule("./perplexity.ts")) as { perplexityOAuth: OAuthAuth }).perplexityOAuth; +}; + +export const loadKiloOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.kilo(); + return ((await importOAuthModule("./kilo.ts")) as { kiloOAuth: OAuthAuth }).kiloOAuth; +}; + export const loadXaiOAuth = async (): Promise => { if (bundledLoaders) return bundledLoaders.xai(); return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth; }; +export const loadGoogleGeminiCliOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.googleGeminiCli(); + return ((await importOAuthModule("./google-gemini-cli.ts")) as { googleGeminiCliOAuth: OAuthAuth }) + .googleGeminiCliOAuth; +}; + +export const loadGoogleAntigravityOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.googleAntigravity(); + return ((await importOAuthModule("./google-antigravity.ts")) as { googleAntigravityOAuth: OAuthAuth }) + .googleAntigravityOAuth; +}; + export const loadRadiusOAuth = async (options: { name: string; gateway: string }): Promise => { if (bundledLoaders) return bundledLoaders.radius(options); return ( diff --git a/packages/ai/src/auth/oauth/openai-codex-device.ts b/packages/ai/src/auth/oauth/openai-codex-device.ts new file mode 100644 index 000000000..d96a3a662 --- /dev/null +++ b/packages/ai/src/auth/oauth/openai-codex-device.ts @@ -0,0 +1,33 @@ +import type { OAuthAuth } from "../types.ts"; +import { loginOpenAICodexDeviceCode, refreshOpenAICodexToken } from "./openai-codex.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; + +export const openaiCodexDeviceOAuth: OAuthAuth = { + name: "OpenAI (ChatGPT Plus/Pro, device code)", + async login(callbacks) { + const credentials = await loginOpenAICodexDeviceCode({ + onDeviceCode: (info) => callbacks.notify({ type: "device_code", ...info }), + signal: callbacks.signal, + }); + return { ...credentials, type: "oauth" }; + }, + refresh: async (credential) => ({ ...(await refreshOpenAICodexToken(credential.refresh)), type: "oauth" }), + toAuth: async (credential) => ({ apiKey: credential.access }), +}; + +export const openaiCodexDeviceOAuthProvider: OAuthProviderInterface = { + id: "openai-codex-device", + name: "ChatGPT Plus/Pro (Codex, headless/device)", + login(callbacks: OAuthLoginCallbacks): Promise { + return loginOpenAICodexDeviceCode({ + onDeviceCode: callbacks.onDeviceCode, + signal: callbacks.signal, + }); + }, + refreshToken(credentials: OAuthCredentials): Promise { + return refreshOpenAICodexToken(credentials.refresh); + }, + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, +}; diff --git a/packages/ai/src/auth/oauth/openai-codex.ts b/packages/ai/src/auth/oauth/openai-codex.ts index 3b52bf122..7bee5d18e 100644 --- a/packages/ai/src/auth/oauth/openai-codex.ts +++ b/packages/ai/src/auth/oauth/openai-codex.ts @@ -18,10 +18,17 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version } import { getProviderEnvValue } from "../../utils/provider-env.ts"; -import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts"; +import type { OAuthAuth } from "../types.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; +import type { + OAuthCredentials, + OAuthDeviceCodeInfo, + OAuthLoginCallbacks, + OAuthPrompt, + OAuthProviderInterface, +} from "./types.ts"; const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const AUTH_BASE_URL = "https://auth.openai.com"; @@ -33,8 +40,8 @@ const DEVICE_TOKEN_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/token`; const DEVICE_VERIFICATION_URI = `${AUTH_BASE_URL}/codex/device`; const DEVICE_REDIRECT_URI = `${AUTH_BASE_URL}/deviceauth/callback`; const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60; -const OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser"; -const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code"; +export const OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser"; +export const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code"; const SCOPE = "openid profile email offline_access"; const JWT_CLAIM_PATH = "https://api.openai.com/auth"; @@ -399,14 +406,13 @@ function getAccountId(accessToken: string): string | null { return typeof accountId === "string" && accountId.length > 0 ? accountId : null; } -function credentialsFromToken(token: OAuthToken): OAuthCredential { +function credentialsFromToken(token: OAuthToken): OAuthCredentials { const accountId = getAccountId(token.access); if (!accountId) { throw new Error("Failed to extract accountId from token"); } return { - type: "oauth", access: token.access, refresh: token.refresh, expires: token.expires, @@ -419,83 +425,132 @@ async function exchangeAuthorizationCodeForCredentials( verifier: string, redirectUri: string, signal?: AbortSignal, -): Promise { +): Promise { return credentialsFromToken(await exchangeAuthorizationCode(code, verifier, redirectUri, signal)); } -async function loginOpenAICodexDeviceCode(interaction: AuthInteraction): Promise { - const device = await startOpenAICodexDeviceAuth(interaction.signal); - interaction.notify({ - type: "device_code", +/** + * Login with OpenAI Codex OAuth using the Codex device-code flow. + */ +export async function loginOpenAICodexDeviceCode(options: { + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; + signal?: AbortSignal; +}): Promise { + const device = await startOpenAICodexDeviceAuth(options.signal); + options.onDeviceCode({ userCode: device.userCode, verificationUri: DEVICE_VERIFICATION_URI, intervalSeconds: device.intervalSeconds, expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS, }); - const code = await pollOpenAICodexDeviceAuth(device, interaction.signal); + const code = await pollOpenAICodexDeviceAuth(device, options.signal); return exchangeAuthorizationCodeForCredentials( code.authorizationCode, code.codeVerifier, DEVICE_REDIRECT_URI, - interaction.signal, + options.signal, ); } -async function loginOpenAICodex(interaction: AuthInteraction): Promise { - const { verifier, state, url } = await createAuthorizationFlow(); +/** + * Login with OpenAI Codex OAuth + * + * @param options.onAuth - Called with URL and instructions when auth starts + * @param options.onPrompt - Called to prompt user for manual code paste (fallback if no onManualCodeInput) + * @param options.onProgress - Optional progress messages + * @param options.onManualCodeInput - Optional promise that resolves with user-pasted code. + * Races with browser callback - whichever completes first wins. + * Useful for showing paste input immediately alongside browser flow. + * @param options.originator - OAuth originator parameter (defaults to "senpi") + */ +export async function loginOpenAICodex(options: { + onAuth: (info: { url: string; instructions?: string }) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; + originator?: string; +}): Promise { + const { verifier, state, url } = await createAuthorizationFlow(options.originator); const server = await startLocalOAuthServer(state); - const manualAbort = new AbortController(); - let code: string | undefined; - let manualCode: string | undefined; - let manualError: Error | undefined; - interaction.notify({ - type: "auth_url", - url, - instructions: "A browser window should open. Complete login to finish.", - }); + options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." }); + let code: string | undefined; try { - const manualPromise = interaction - .prompt({ - type: "manual_code", - message: "Complete login in your browser, or paste the authorization code / redirect URL here:", - placeholder: REDIRECT_URI, - signal: manualAbort.signal, - }) - .then((input) => { - manualCode = input; - server.cancelWait(); - }) - .catch((error) => { - manualError = error instanceof Error ? error : new Error(String(error)); - server.cancelWait(); - }); + if (options.onManualCodeInput) { + // Race between browser callback and manual input + let manualCode: string | undefined; + let manualError: Error | undefined; + const manualPromise = options + .onManualCodeInput() + .then((input) => { + manualCode = input; + server.cancelWait(); + }) + .catch((err) => { + manualError = err instanceof Error ? err : new Error(String(err)); + server.cancelWait(); + }); - const result = await server.waitForCode(); - if (manualError) throw manualError; - if (result?.code) { - code = result.code; - } else if (manualCode) { - const parsed = parseAuthorizationInput(manualCode); - if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); - code = parsed.code; - } + const result = await server.waitForCode(); - if (!code) { - await manualPromise; - if (manualError) throw manualError; - if (manualCode) { + // If manual input was cancelled, throw that error + if (manualError) { + throw manualError; + } + + if (result?.code) { + // Browser callback won + code = result.code; + } else if (manualCode) { + // Manual input won (or callback timed out and user had entered code) const parsed = parseAuthorizationInput(manualCode); - if (parsed.state && parsed.state !== state) throw new Error("State mismatch"); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } code = parsed.code; } + + // If still no code, wait for manual promise to complete and try that + if (!code) { + await manualPromise; + if (manualError) { + throw manualError; + } + if (manualCode) { + const parsed = parseAuthorizationInput(manualCode); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + code = parsed.code; + } + } + } else { + // Original flow: wait for callback, then prompt if needed + const result = await server.waitForCode(); + if (result?.code) { + code = result.code; + } } - if (!code) throw new Error("Missing authorization code"); - return exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI, interaction.signal); + // Fallback to onPrompt if still no code + if (!code) { + const input = await options.onPrompt({ + message: "Paste the authorization code (or full redirect URL):", + }); + const parsed = parseAuthorizationInput(input); + if (parsed.state && parsed.state !== state) { + throw new Error("State mismatch"); + } + code = parsed.code; + } + + if (!code) { + throw new Error("Missing authorization code"); + } + + return exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI); } finally { - manualAbort.abort(); server.close(); } } @@ -503,15 +558,15 @@ async function loginOpenAICodex(interaction: AuthInteraction): Promise { +export async function refreshOpenAICodexToken(refreshToken: string): Promise { return credentialsFromToken(await refreshAccessToken(refreshToken)); } export const openaiCodexOAuth: OAuthAuth = { name: "OpenAI (ChatGPT Plus/Pro)", - async login(interaction) { - const method = await interaction.prompt({ + async login(callbacks) { + const method = await callbacks.prompt({ type: "select", message: "Select OpenAI Codex login method:", options: [ @@ -521,18 +576,89 @@ export const openaiCodexOAuth: OAuthAuth = { }); if (method === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) { - return loginOpenAICodexDeviceCode(interaction); + const credentials = await loginOpenAICodexDeviceCode({ + onDeviceCode: (info) => callbacks.notify({ type: "device_code", ...info }), + signal: callbacks.signal, + }); + return { ...credentials, type: "oauth" }; } if (method !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) { throw new Error(`Unknown OpenAI Codex login method: ${method}`); } - return loginOpenAICodex(interaction); + // The manual_code prompt races the local callback server; abort it once + // the flow settles so the UI can dismiss the pending input. + const manualAbort = new AbortController(); + try { + const credentials = await loginOpenAICodex({ + onAuth: (info) => callbacks.notify({ type: "auth_url", url: info.url, instructions: info.instructions }), + onProgress: (message) => callbacks.notify({ type: "progress", message }), + onPrompt: (prompt) => + callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }), + onManualCodeInput: () => + callbacks.prompt({ + type: "manual_code", + message: "Complete login in your browser, or paste the authorization code / redirect URL here:", + placeholder: REDIRECT_URI, + signal: manualAbort.signal, + }), + }); + return { ...credentials, type: "oauth" }; + } finally { + manualAbort.abort(); + } }, - refresh: (credential) => refreshOpenAICodexToken(credential.refresh), + async refresh(credential) { + return { ...(await refreshOpenAICodexToken(credential.refresh)), type: "oauth" }; + }, async toAuth(credential) { return { apiKey: credential.access }; }, }; + +export const openaiCodexOAuthProvider: OAuthProviderInterface = { + id: "openai-codex", + name: "ChatGPT Plus/Pro (Codex Subscription)", + usesCallbackServer: true, + + async login(callbacks: OAuthLoginCallbacks): Promise { + const loginMethod = await callbacks.onSelect({ + message: "Select OpenAI Codex login method:", + options: [ + { id: OPENAI_CODEX_BROWSER_LOGIN_METHOD, label: "Browser login (default)" }, + { id: OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, label: "Device code login (headless)" }, + ], + }); + if (!loginMethod) { + throw new Error("Login cancelled"); + } + + if (loginMethod === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) { + return loginOpenAICodexDeviceCode({ + onDeviceCode: callbacks.onDeviceCode, + signal: callbacks.signal, + }); + } + + if (loginMethod !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) { + throw new Error(`Unknown OpenAI Codex login method: ${loginMethod}`); + } + + return loginOpenAICodex({ + onAuth: callbacks.onAuth, + onPrompt: callbacks.onPrompt, + onProgress: callbacks.onProgress, + onManualCodeInput: callbacks.onManualCodeInput, + }); + }, + + async refreshToken(credentials: OAuthCredentials): Promise { + return refreshOpenAICodexToken(credentials.refresh); + }, + + getApiKey(credentials: OAuthCredentials): string { + return credentials.access; + }, +}; diff --git a/packages/ai/src/auth/oauth/perplexity.ts b/packages/ai/src/auth/oauth/perplexity.ts new file mode 100644 index 000000000..883e8febd --- /dev/null +++ b/packages/ai/src/auth/oauth/perplexity.ts @@ -0,0 +1,211 @@ +import type { OAuthAuth } from "../types.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; + +const API_VERSION = "2.18"; +const NATIVE_APP_BUNDLE = "ai.perplexity.mac"; +const APP_USER_AGENT = "Perplexity/641 CFNetwork/1568 Darwin/25.2.0"; +const REQUEST_TIMEOUT_MS = 30_000; +const NATIVE_READ_TIMEOUT_MS = 5000; +const REFRESH_SKEW_MS = 5 * 60_000; +export const PERPLEXITY_NEVER_EXPIRES_MS = 8.64e15; + +function requestSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw signal.reason ?? new DOMException("Perplexity OAuth login was cancelled", "AbortError"); + } +} + +export function getPerplexityJwtExpiryMs(token: string): number | undefined { + const parts = token.split("."); + const payload = parts[1]; + if (parts.length !== 3 || !payload) return undefined; + try { + const padded = payload + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(payload.length / 4) * 4, "="); + const decoded = JSON.parse(atob(padded)) as { exp?: unknown }; + if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return undefined; + return decoded.exp * 1000 - REFRESH_SKEW_MS; + } catch { + return undefined; + } +} + +function credentialsFromJwt(jwt: string, email?: string): OAuthCredentials { + return { + access: jwt, + refresh: jwt, + expires: getPerplexityJwtExpiryMs(jwt) ?? PERPLEXITY_NEVER_EXPIRES_MS, + ...(email ? { email } : {}), + }; +} + +async function extractFromNativeApp(signal?: AbortSignal): Promise { + if (typeof process === "undefined" || process.env.PI_AUTH_NO_BORROW === "1") return undefined; + const os = await import("node:os"); + if (os.platform() !== "darwin") return undefined; + throwIfAborted(signal); + try { + const { execFile } = await import("node:child_process"); + const output = await new Promise((resolve) => { + let child: ReturnType | undefined; + let settled = false; + const finish = (value: string | undefined) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", abort); + resolve(value); + }; + const abort = () => { + child?.kill(); + finish(undefined); + }; + child = execFile( + "defaults", + ["read", NATIVE_APP_BUNDLE, "authToken"], + { encoding: "utf8", timeout: NATIVE_READ_TIMEOUT_MS }, + (error, stdout) => finish(error ? undefined : stdout.trim()), + ); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + return output && output !== "(null)" ? output : undefined; + } catch { + return undefined; + } +} + +async function endpointFetch(url: string, init: RequestInit, signal?: AbortSignal): Promise { + try { + return await fetch(url, { ...init, signal: requestSignal(signal) }); + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + throw new Error("Perplexity authentication request failed"); + } +} + +async function emailOtpLogin(callbacks: OAuthLoginCallbacks): Promise { + const email = ( + await callbacks.onPrompt({ + message: "Enter your Perplexity email address", + placeholder: "user@example.com", + }) + ).trim(); + if (!email) throw new Error("Email is required for Perplexity login"); + throwIfAborted(callbacks.signal); + callbacks.onProgress?.("Fetching Perplexity CSRF token..."); + const headers = { "User-Agent": APP_USER_AGENT, "X-App-ApiVersion": API_VERSION }; + const csrfResponse = await endpointFetch( + "https://www.perplexity.ai/api/auth/csrf", + { headers: { Accept: "application/json", ...headers } }, + callbacks.signal, + ); + if (!csrfResponse.ok) throw new Error(`Perplexity CSRF request failed (${csrfResponse.status})`); + const csrfData = (await csrfResponse.json()) as Record; + if (typeof csrfData.csrfToken !== "string" || !csrfData.csrfToken) { + throw new Error("Perplexity CSRF response missing token"); + } + const csrfCookie = readCsrfCookie(csrfResponse); + + callbacks.onProgress?.("Sending a Perplexity login code..."); + const sendResponse = await endpointFetch( + "https://www.perplexity.ai/api/auth/signin-email", + { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: csrfCookie, ...headers }, + body: JSON.stringify({ email, csrfToken: csrfData.csrfToken }), + }, + callbacks.signal, + ); + if (!sendResponse.ok) throw new Error(`Perplexity login-code request failed (${sendResponse.status})`); + + const otp = ( + await callbacks.onPrompt({ + message: "Enter the code sent to your email", + placeholder: "123456", + }) + ).trim(); + if (!otp) throw new Error("OTP code is required for Perplexity login"); + throwIfAborted(callbacks.signal); + callbacks.onProgress?.("Verifying the Perplexity login code..."); + const verifyResponse = await endpointFetch( + "https://www.perplexity.ai/api/auth/signin-otp", + { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: csrfCookie, ...headers }, + body: JSON.stringify({ email, otp, csrfToken: csrfData.csrfToken }), + }, + callbacks.signal, + ); + if (!verifyResponse.ok) throw new Error(`Perplexity OTP verification failed (${verifyResponse.status})`); + const verifyData = (await verifyResponse.json()) as Record; + if (typeof verifyData.token !== "string" || !verifyData.token) { + throw new Error("Perplexity OTP verification response missing token"); + } + return credentialsFromJwt(verifyData.token, email); +} + +function readCsrfCookie(response: Response): string { + const setCookie = response.headers.get("set-cookie"); + const match = /(?:^|,\s*)((?:__Host-)?next-auth\.csrf-token=[^;,]+)/i.exec(setCookie ?? ""); + const cookie = match?.[1]; + if (cookie === undefined) throw new Error("Perplexity CSRF response missing cookie"); + return cookie; +} + +export async function loginPerplexity(callbacks: OAuthLoginCallbacks): Promise { + throwIfAborted(callbacks.signal); + callbacks.onProgress?.("Checking for a Perplexity desktop session..."); + const nativeJwt = await extractFromNativeApp(callbacks.signal); + throwIfAborted(callbacks.signal); + if (nativeJwt) { + callbacks.onProgress?.("Found a Perplexity desktop session"); + return credentialsFromJwt(nativeJwt); + } + return emailOtpLogin(callbacks); +} + +export async function refreshPerplexityToken(credentials: OAuthCredentials): Promise { + if (!credentials.access) throw new Error("Perplexity credentials do not include a session token"); + const jwtExpiry = getPerplexityJwtExpiryMs(credentials.access); + if (jwtExpiry !== undefined && jwtExpiry <= Date.now()) { + throw new Error("Perplexity session expired; login again"); + } + return { + ...credentials, + refresh: credentials.refresh || credentials.access, + expires: jwtExpiry ?? PERPLEXITY_NEVER_EXPIRES_MS, + }; +} + +export const perplexityOAuth: OAuthAuth = { + name: "Perplexity (Pro/Max)", + async login(callbacks) { + const credentials = await loginPerplexity({ + onAuth: (info) => callbacks.notify({ type: "auth_url", ...info }), + onDeviceCode: (info) => callbacks.notify({ type: "device_code", ...info }), + onPrompt: async (prompt) => + callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }), + onProgress: (message) => callbacks.notify({ type: "progress", message }), + onSelect: async () => undefined, + signal: callbacks.signal, + }); + return { ...credentials, type: "oauth" }; + }, + refresh: async (credential) => ({ ...(await refreshPerplexityToken(credential)), type: "oauth" }), + toAuth: async (credential) => ({ apiKey: credential.access }), +}; + +export const perplexityOAuthProvider: OAuthProviderInterface = { + id: "perplexity", + name: "Perplexity (Pro/Max)", + login: loginPerplexity, + refreshToken: refreshPerplexityToken, + getApiKey: (credentials) => credentials.access, +}; diff --git a/packages/ai/src/auth/oauth/types.ts b/packages/ai/src/auth/oauth/types.ts new file mode 100644 index 000000000..8117288e3 --- /dev/null +++ b/packages/ai/src/auth/oauth/types.ts @@ -0,0 +1,87 @@ +import type { Api, Model } from "../../types.ts"; + +export type OAuthCredentials = { + refresh: string; + access: string; + expires: number; + [key: string]: unknown; +}; + +export type OAuthProviderId = string; + +export type OAuthRequestAuth = { + readonly apiKey: string; + readonly headers?: Readonly>; +}; + +/** @deprecated Use OAuthProviderId instead */ +export type OAuthProvider = OAuthProviderId; + +export type OAuthPrompt = { + message: string; + placeholder?: string; + allowEmpty?: boolean; +}; + +export type OAuthAuthInfo = { + url: string; + instructions?: string; +}; + +export type OAuthDeviceCodeInfo = { + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; +}; + +export type OAuthSelectOption = { + id: string; + label: string; +}; + +export type OAuthSelectPrompt = { + message: string; + options: readonly OAuthSelectOption[]; +}; + +export interface OAuthLoginCallbacks { + onAuth: (info: OAuthAuthInfo) => void; + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; + /** Show an interactive selector and return the selected option id, or undefined on cancel. */ + onSelect: (prompt: OAuthSelectPrompt) => Promise; + signal?: AbortSignal; +} + +export interface OAuthProviderInterface { + readonly id: OAuthProviderId; + readonly name: string; + + /** Run the login flow, return credentials to persist */ + login(callbacks: OAuthLoginCallbacks): Promise; + + /** Whether login uses a local callback server and supports manual code input. */ + usesCallbackServer?: boolean; + + /** Refresh expired credentials, return updated credentials to persist */ + refreshToken(credentials: OAuthCredentials): Promise; + + /** Convert credentials to API key string for the provider */ + getApiKey(credentials: OAuthCredentials): string; + + /** Resolve request-scoped auth when a provider needs exchanged tokens or headers. */ + getRequestAuth?(credentials: OAuthCredentials): Promise; + + /** Optional: modify models for this provider (e.g., update baseUrl) */ + modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; +} + +/** @deprecated Use OAuthProviderInterface instead */ +export interface OAuthProviderInfo { + id: OAuthProviderId; + name: string; + available: boolean; +} diff --git a/packages/ai/src/auth/oauth/xai.ts b/packages/ai/src/auth/oauth/xai.ts index 5432043d1..523458e8d 100644 --- a/packages/ai/src/auth/oauth/xai.ts +++ b/packages/ai/src/auth/oauth/xai.ts @@ -4,6 +4,7 @@ import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"; const XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access"; @@ -236,3 +237,55 @@ export const xaiOAuth: OAuthAuth = { return { apiKey: credential.access }; }, }; + +/** + * Legacy OAuthProviderInterface adapter for the compat registry in index.ts. + * Canonical Models auth uses `xaiOAuth` (AuthInteraction / device-code). + */ +export const xaiOAuthProvider: OAuthProviderInterface = { + id: "xai", + name: "xAI (Grok/X subscription)", + + async login(callbacks: OAuthLoginCallbacks): Promise { + const credential = await loginXai({ + signal: callbacks.signal, + prompt: async (prompt) => { + if (prompt.type === "select") { + const selected = await callbacks.onSelect({ + message: prompt.message, + options: prompt.options, + }); + if (selected === undefined) throw new Error("Login cancelled"); + return selected; + } + return callbacks.onPrompt({ + message: prompt.message, + placeholder: "placeholder" in prompt ? prompt.placeholder : undefined, + }); + }, + notify: (event) => { + if (event.type === "device_code") { + callbacks.onDeviceCode({ + userCode: event.userCode, + verificationUri: event.verificationUri, + intervalSeconds: event.intervalSeconds, + expiresInSeconds: event.expiresInSeconds, + }); + return; + } + if (event.type === "auth_url") { + callbacks.onAuth({ url: event.url, instructions: event.instructions }); + return; + } + if (event.type === "progress") { + callbacks.onProgress?.(event.message); + } + }, + }); + const { type: _type, ...credentials } = credential; + return credentials; + }, + + refreshToken: (credentials) => refreshXaiToken(credentials.refresh), + getApiKey: (credentials) => credentials.access, +}; diff --git a/packages/ai/src/bun-oauth.ts b/packages/ai/src/bun-oauth.ts index 31cdec985..c49c39a9e 100644 --- a/packages/ai/src/bun-oauth.ts +++ b/packages/ai/src/bun-oauth.ts @@ -1,7 +1,14 @@ import { anthropicOAuth } from "./auth/oauth/anthropic.ts"; +import { cursorOAuth } from "./auth/oauth/cursor.ts"; import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts"; +import { gitlabDuoOAuth } from "./auth/oauth/gitlab-duo.ts"; +import { googleAntigravityOAuth } from "./auth/oauth/google-antigravity.ts"; +import { googleGeminiCliOAuth } from "./auth/oauth/google-gemini-cli.ts"; +import { kiloOAuth } from "./auth/oauth/kilo.ts"; import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts"; import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts"; +import { openaiCodexDeviceOAuth } from "./auth/oauth/openai-codex-device.ts"; +import { perplexityOAuth } from "./auth/oauth/perplexity.ts"; import { createRadiusOAuth } from "./auth/oauth/radius.ts"; import { xaiOAuth } from "./auth/oauth/xai.ts"; @@ -10,8 +17,15 @@ export function registerBunOAuthFlows(): void { registerBundledOAuthFlowLoaders({ anthropic: () => anthropicOAuth, openaiCodex: () => openaiCodexOAuth, + openaiCodexDevice: () => openaiCodexDeviceOAuth, githubCopilot: () => githubCopilotOAuth, + cursor: () => cursorOAuth, + gitlabDuo: () => gitlabDuoOAuth, + perplexity: () => perplexityOAuth, + kilo: () => kiloOAuth, xai: () => xaiOAuth, + googleGeminiCli: () => googleGeminiCliOAuth, + googleAntigravity: () => googleAntigravityOAuth, radius: createRadiusOAuth, }); } diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index c8b9c0b71..66f69c54d 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -126,93 +126,40 @@ - LOW: `types.ts` around the `ToolCall` and `AssistantMessageEvent` declarations. - LOW: `tool-call-middleware/types.ts` `ToolCallFormat` union and `toolcall_end` variant. -## 2026-07-17 - Moonshot root object-union compatibility +## 2026-07-14 - Provider and OAuth parity ### What changed and why -- `utils/tool-schema-compat.ts`: Moonshot normalization now flattens a root `anyOf`/`oneOf` of object parameter - shapes into one `type: "object"` schema. Properties are merged and only branch-common required fields remain. - Kimi rejects a root combiner without `type`, but also rejects a sibling root `type` beside that combiner, so the - union must be represented as a permissive object at the function-parameter boundary. -- `../test/openai-completions-tool-schema-compat.test.ts`: covers the real `click`-style coordinate/index union and - the final post-hook request payload. - -### Why the higher-level extension system couldn't handle this alone - -- The provider adapter owns the final wire schema after payload hooks and is the only layer shared by direct - Moonshot requests and custom Moonshot-compatible gateways. - -### Expected merge conflict zones - -- LOW: `utils/tool-schema-compat.ts` if upstream expands its provider-specific schema normalizers. - -## 2026-07-17 - Final-boundary Moonshot tool schema normalization - -### What changed and why - -- `api/openai-completions.ts`: re-normalizes function tool parameter schemas after `onPayload` and immediately before - the OpenAI SDK request. Payload hooks can replace or inject tools after the ordinary `convertTools` pass; those tools - previously bypassed the Moonshot/MFJS compatibility transform and could retain a parent `type` beside `anyOf`, which - Moonshot rejects with HTTP 400. -- `../test/openai-completions-tool-schema-compat.test.ts`: captures the real HTTP request and locks the post-hook wire - shape. - -### Why the higher-level extension system couldn't handle this alone - -- `before_provider_request` is exposed through `onPayload`, so the provider adapter is the only layer that can validate - the complete tool list after every hook has run. - -### Expected merge conflict zones - -- LOW: `api/openai-completions.ts` around the `onPayload` callback and final request submission. - -## 2026-07-16 - Anthropic native web_search endpoint guard and server_tool_use input streaming - -### What changed and why - -- `types.ts`: added `AnthropicMessagesCompat.supportsWebSearch`. Default (resolved in - `getAnthropicCompat`): true only for the first-party `api.anthropic.com` endpoint; compatible providers and - provider overrides can - opt in per model via `compat`. -- `api/anthropic-messages.ts`: `sanitizeUnsupportedNativeTools` now also strips hook-injected native `web_search_*` - tools when the resolved compat does not support them, mirroring the existing native computer tool guard and the - OpenAI Responses `web_search_preview` compat guard (2026-05-15). Anthropic-compatible endpoints such as kimi-coding - execute the server-side search but reject the replayed `server_tool_use` / `web_search_tool_result` blocks on the - next request (kimi-coding 400s with `tool_call_id is not found`), wedging the session. Named `tool_choice` is - preserved when a same-name function fallback remains and removed only when the retained tool list no longer - contains that choice. -- `api/anthropic-messages.ts`: same-model provider-native replay also drops web-search server-tool blocks - (`server_tool_use` named `web_search` and `web_search_tool_result`) when the endpoint lacks `supportsWebSearch`. - Sessions that already recorded such blocks against an incompatible endpoint were permanently wedged — every - request replayed the rejected blocks; dropping the pair loses the searched context but unwedges the session. -- `api/anthropic-messages.ts`: streaming now accumulates `input_json_delta` for Anthropic's confirmed - provider-native tool-use blocks (`server_tool_use` and beta `mcp_tool_use`) and merges the parsed input into the stored raw block at - `content_block_stop` (or in the abort/error finalizer for interrupted streams). Previously the block kept the - `content_block_start` snapshot (`input: {}`), so every same-model replay sent the server tool call with an empty - input. Unknown and result-shaped blocks are never touched; their raw provider payload must remain verbatim. +- Added supported provider factories, generated catalogs, and OAuth flows used by the coding-agent login surface. +- Added provider-specific request and refresh behavior, including GitLab Duo direct-access exchange, Google account + transport, and Perplexity OTP state handling. +- Legacy OAuth providers can expose request-scoped tokens and headers so compatibility consumers preserve GitLab Duo's + direct-access contract. +- Search-only integrations are not exposed as chat providers. +- Dropped duplicate xAI browser OAuth in favor of main's device-code flow. +- Cursor uses Connect transport; GitLab PAT exchange applies to API keys; GitLab GPT-5.1 uses chat completions; + Perplexity session OAuth is not advertised as API auth; Google CCA providers are OAuth-only in /login. ### Files modified -- `types.ts` -- `api/anthropic-messages.ts` -- `../test/anthropic-native-web-search-compat.test.ts` -- `../test/anthropic-provider-native-replay.test.ts` -- `../test/anthropic-web-search-replay-encryption.test.ts` -- `../test/anthropic.provider-native.test.ts` -- (see also `../../coding-agent/src/core/changes.md` for the models.json compat schema entry) +- api/google-gemini-cli.ts +- api/cursor-connect.ts +- auth/helpers.ts +- auth/oauth/* +- providers/all.ts +- types.ts +- bun-oauth.ts +- provider-specific files under providers/ and auth/oauth/ ### Why the higher-level extension system couldn't handle this alone -- Extensions can inject native `web_search_*` tools via `before_provider_request`; the final payload is only known - after all hooks run, so the provider is the last reliable guard before SDK submission (same rationale as the - OpenAI Responses guard). Provider-native block capture during streaming happens inside `pi-ai` before any - extension sees the message. +- Authentication is resolved while constructing provider requests and generated model catalogs; extension hooks cannot + safely replace those credential and wire boundaries. ### Expected merge conflict zones -- MEDIUM: `api/anthropic-messages.ts` around `getAnthropicCompat`, `sanitizeUnsupportedNativeTools`, and the - `content_block_delta` / `content_block_stop` streaming handlers. -- LOW: `types.ts` `AnthropicMessagesCompat` if upstream adds more compat flags. +- HIGH: provider unions, OAuth registration, and generated catalogs. +- MEDIUM: Google shared transport and provider-specific OAuth refresh paths. ## 2026-07-14 - Anthropic web search replay encrypted content correction diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 7a4f8f08f..046baf86b 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -28,6 +28,12 @@ import { getProviderEnvValue } from "./utils/provider-env.ts"; let cachedVertexAdcCredentialsExists: boolean | null = null; +const LOCAL_PROVIDER_API_KEYS: Partial> = { + "lm-studio": "lm-studio-local", + ollama: "ollama-local", + vllm: "vllm-local", +}; + function hasVertexAdcCredentials(env?: ProviderEnv): boolean { const explicitCredentialsPath = env?.GOOGLE_APPLICATION_CREDENTIALS; if (explicitCredentialsPath) { @@ -71,39 +77,65 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { return ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]; } + if (provider === "qwen-portal") { + return ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"]; + } + const envMap: Record = { + "alibaba-coding-plan": "ALIBABA_CODING_PLAN_API_KEY", "ant-ling": "ANT_LING_API_KEY", - openai: "OPENAI_API_KEY", "azure-openai-responses": "AZURE_OPENAI_API_KEY", - nvidia: "NVIDIA_API_KEY", + cerebras: "CEREBRAS_API_KEY", + "cloudflare-ai-gateway": "CLOUDFLARE_API_KEY", + "cloudflare-workers-ai": "CLOUDFLARE_API_KEY", + cursor: "CURSOR_API_KEY", + deepinfra: "DEEPINFRA_API_KEY", deepseek: "DEEPSEEK_API_KEY", + firepass: "FIREPASS_API_KEY", + fireworks: "FIREWORKS_API_KEY", + fugu: "FUGU_API_KEY", + "gitlab-duo": "GITLAB_TOKEN", google: "GEMINI_API_KEY", "google-vertex": "GOOGLE_CLOUD_API_KEY", groq: "GROQ_API_KEY", - cerebras: "CEREBRAS_API_KEY", - xai: "XAI_API_KEY", - radius: "RADIUS_API_KEY", - openrouter: "OPENROUTER_API_KEY", - "vercel-ai-gateway": "AI_GATEWAY_API_KEY", - zai: "ZAI_API_KEY", - "zai-coding-cn": "ZAI_CODING_CN_API_KEY", - mistral: "MISTRAL_API_KEY", + huggingface: "HF_TOKEN", + kilo: "KILO_API_KEY", + "kimi-coding": "KIMI_API_KEY", + litellm: "LITELLM_API_KEY", + "lm-studio": "LM_STUDIO_API_KEY", minimax: "MINIMAX_API_KEY", "minimax-cn": "MINIMAX_CN_API_KEY", + "minimax-code": "MINIMAX_CODE_API_KEY", + "minimax-code-cn": "MINIMAX_CODE_CN_API_KEY", + mistral: "MISTRAL_API_KEY", + moonshot: "MOONSHOT_API_KEY", moonshotai: "MOONSHOT_API_KEY", "moonshotai-cn": "MOONSHOT_API_KEY", - huggingface: "HF_TOKEN", - fireworks: "FIREWORKS_API_KEY", - together: "TOGETHER_API_KEY", + nanogpt: "NANO_GPT_API_KEY", + nvidia: "NVIDIA_API_KEY", + ollama: "OLLAMA_API_KEY", + "ollama-cloud": "OLLAMA_CLOUD_API_KEY", + openai: "OPENAI_API_KEY", opencode: "OPENCODE_API_KEY", "opencode-go": "OPENCODE_API_KEY", - "kimi-coding": "KIMI_API_KEY", - "cloudflare-workers-ai": "CLOUDFLARE_API_KEY", - "cloudflare-ai-gateway": "CLOUDFLARE_API_KEY", + "opencode-zen": "OPENCODE_API_KEY", + openrouter: "OPENROUTER_API_KEY", + perplexity: "PERPLEXITY_API_KEY", + qianfan: "QIANFAN_API_KEY", + radius: "PI_GATEWAY_API_KEY", + synthetic: "SYNTHETIC_API_KEY", + together: "TOGETHER_API_KEY", + venice: "VENICE_API_KEY", + "vercel-ai-gateway": "AI_GATEWAY_API_KEY", + vllm: "VLLM_API_KEY", + xai: "XAI_API_KEY", xiaomi: "XIAOMI_API_KEY", - "xiaomi-token-plan-cn": "XIAOMI_TOKEN_PLAN_CN_API_KEY", "xiaomi-token-plan-ams": "XIAOMI_TOKEN_PLAN_AMS_API_KEY", + "xiaomi-token-plan-cn": "XIAOMI_TOKEN_PLAN_CN_API_KEY", "xiaomi-token-plan-sgp": "XIAOMI_TOKEN_PLAN_SGP_API_KEY", + zai: "ZAI_API_KEY", + "zai-coding-cn": "ZAI_CODING_CN_API_KEY", + zenmux: "ZENMUX_API_KEY", }; const envVar = envMap[provider]; @@ -140,6 +172,9 @@ export function getEnvApiKey(provider: string, env?: ProviderEnv): string | unde return getProviderEnvValue(envKeys[0], env); } + const localApiKey = LOCAL_PROVIDER_API_KEYS[provider as KnownProvider]; + if (localApiKey) return localApiKey; + // Vertex AI supports either an explicit API key or Application Default Credentials. // Auth is configured via `gcloud auth application-default login`. if (provider === "google-vertex") { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 0129ddeee..4a10c6e7b 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -1,6 +1,7 @@ // This file is auto-generated by scripts/generate-models.ts // Do not edit manually - run 'npm run generate-models' to update +import { ALIBABA_CODING_PLAN_MODELS } from "./providers/alibaba-coding-plan.models.ts"; import { AMAZON_BEDROCK_MODELS } from "./providers/amazon-bedrock.models.ts"; import { ANT_LING_MODELS } from "./providers/ant-ling.models.ts"; import { ANTHROPIC_MODELS } from "./providers/anthropic.models.ts"; @@ -8,27 +9,51 @@ import { AZURE_OPENAI_RESPONSES_MODELS } from "./providers/azure-openai-response import { CEREBRAS_MODELS } from "./providers/cerebras.models.ts"; import { CLOUDFLARE_AI_GATEWAY_MODELS } from "./providers/cloudflare-ai-gateway.models.ts"; import { CLOUDFLARE_WORKERS_AI_MODELS } from "./providers/cloudflare-workers-ai.models.ts"; +import { CURSOR_MODELS } from "./providers/cursor.models.ts"; +import { DEEPINFRA_MODELS } from "./providers/deepinfra.models.ts"; import { DEEPSEEK_MODELS } from "./providers/deepseek.models.ts"; +import { FIREPASS_MODELS } from "./providers/firepass.models.ts"; import { FIREWORKS_MODELS } from "./providers/fireworks.models.ts"; +import { FUGU_MODELS } from "./providers/fugu.models.ts"; import { GITHUB_COPILOT_MODELS } from "./providers/github-copilot.models.ts"; +import { GITLAB_DUO_MODELS } from "./providers/gitlab-duo.models.ts"; import { GOOGLE_MODELS } from "./providers/google.models.ts"; +import { GOOGLE_ANTIGRAVITY_MODELS } from "./providers/google-antigravity.models.ts"; +import { GOOGLE_GEMINI_CLI_MODELS } from "./providers/google-gemini-cli.models.ts"; import { GOOGLE_VERTEX_MODELS } from "./providers/google-vertex.models.ts"; import { GROQ_MODELS } from "./providers/groq.models.ts"; import { HUGGINGFACE_MODELS } from "./providers/huggingface.models.ts"; +import { KILO_MODELS } from "./providers/kilo.models.ts"; import { KIMI_CODING_MODELS } from "./providers/kimi-coding.models.ts"; +import { LITELLM_MODELS } from "./providers/litellm.models.ts"; +import { LM_STUDIO_MODELS } from "./providers/lm-studio.models.ts"; import { MINIMAX_MODELS } from "./providers/minimax.models.ts"; import { MINIMAX_CN_MODELS } from "./providers/minimax-cn.models.ts"; +import { MINIMAX_CODE_MODELS } from "./providers/minimax-code.models.ts"; +import { MINIMAX_CODE_CN_MODELS } from "./providers/minimax-code-cn.models.ts"; import { MISTRAL_MODELS } from "./providers/mistral.models.ts"; +import { MOONSHOT_MODELS } from "./providers/moonshot.models.ts"; import { MOONSHOTAI_MODELS } from "./providers/moonshotai.models.ts"; import { MOONSHOTAI_CN_MODELS } from "./providers/moonshotai-cn.models.ts"; +import { NANOGPT_MODELS } from "./providers/nanogpt.models.ts"; import { NVIDIA_MODELS } from "./providers/nvidia.models.ts"; +import { OLLAMA_MODELS } from "./providers/ollama.models.ts"; +import { OLLAMA_CLOUD_MODELS } from "./providers/ollama-cloud.models.ts"; import { OPENAI_MODELS } from "./providers/openai.models.ts"; import { OPENAI_CODEX_MODELS } from "./providers/openai-codex.models.ts"; +import { OPENAI_CODEX_DEVICE_MODELS } from "./providers/openai-codex-device.models.ts"; import { OPENCODE_MODELS } from "./providers/opencode.models.ts"; import { OPENCODE_GO_MODELS } from "./providers/opencode-go.models.ts"; +import { OPENCODE_ZEN_MODELS } from "./providers/opencode-zen.models.ts"; import { OPENROUTER_MODELS } from "./providers/openrouter.models.ts"; +import { PERPLEXITY_MODELS } from "./providers/perplexity.models.ts"; +import { QIANFAN_MODELS } from "./providers/qianfan.models.ts"; +import { QWEN_PORTAL_MODELS } from "./providers/qwen-portal.models.ts"; +import { SYNTHETIC_MODELS } from "./providers/synthetic.models.ts"; import { TOGETHER_MODELS } from "./providers/together.models.ts"; +import { VENICE_MODELS } from "./providers/venice.models.ts"; import { VERCEL_AI_GATEWAY_MODELS } from "./providers/vercel-ai-gateway.models.ts"; +import { VLLM_MODELS } from "./providers/vllm.models.ts"; import { XAI_MODELS } from "./providers/xai.models.ts"; import { XIAOMI_MODELS } from "./providers/xiaomi.models.ts"; import { XIAOMI_TOKEN_PLAN_AMS_MODELS } from "./providers/xiaomi-token-plan-ams.models.ts"; @@ -36,8 +61,10 @@ import { XIAOMI_TOKEN_PLAN_CN_MODELS } from "./providers/xiaomi-token-plan-cn.mo import { XIAOMI_TOKEN_PLAN_SGP_MODELS } from "./providers/xiaomi-token-plan-sgp.models.ts"; import { ZAI_MODELS } from "./providers/zai.models.ts"; import { ZAI_CODING_CN_MODELS } from "./providers/zai-coding-cn.models.ts"; +import { ZENMUX_MODELS } from "./providers/zenmux.models.ts"; export const MODELS = { + "alibaba-coding-plan": ALIBABA_CODING_PLAN_MODELS, "amazon-bedrock": AMAZON_BEDROCK_MODELS, "ant-ling": ANT_LING_MODELS, "anthropic": ANTHROPIC_MODELS, @@ -45,27 +72,51 @@ export const MODELS = { "cerebras": CEREBRAS_MODELS, "cloudflare-ai-gateway": CLOUDFLARE_AI_GATEWAY_MODELS, "cloudflare-workers-ai": CLOUDFLARE_WORKERS_AI_MODELS, + "cursor": CURSOR_MODELS, + "deepinfra": DEEPINFRA_MODELS, "deepseek": DEEPSEEK_MODELS, + "firepass": FIREPASS_MODELS, "fireworks": FIREWORKS_MODELS, + "fugu": FUGU_MODELS, "github-copilot": GITHUB_COPILOT_MODELS, + "gitlab-duo": GITLAB_DUO_MODELS, "google": GOOGLE_MODELS, + "google-antigravity": GOOGLE_ANTIGRAVITY_MODELS, + "google-gemini-cli": GOOGLE_GEMINI_CLI_MODELS, "google-vertex": GOOGLE_VERTEX_MODELS, "groq": GROQ_MODELS, "huggingface": HUGGINGFACE_MODELS, + "kilo": KILO_MODELS, "kimi-coding": KIMI_CODING_MODELS, + "litellm": LITELLM_MODELS, + "lm-studio": LM_STUDIO_MODELS, "minimax": MINIMAX_MODELS, "minimax-cn": MINIMAX_CN_MODELS, + "minimax-code": MINIMAX_CODE_MODELS, + "minimax-code-cn": MINIMAX_CODE_CN_MODELS, "mistral": MISTRAL_MODELS, + "moonshot": MOONSHOT_MODELS, "moonshotai": MOONSHOTAI_MODELS, "moonshotai-cn": MOONSHOTAI_CN_MODELS, + "nanogpt": NANOGPT_MODELS, "nvidia": NVIDIA_MODELS, + "ollama": OLLAMA_MODELS, + "ollama-cloud": OLLAMA_CLOUD_MODELS, "openai": OPENAI_MODELS, "openai-codex": OPENAI_CODEX_MODELS, + "openai-codex-device": OPENAI_CODEX_DEVICE_MODELS, "opencode": OPENCODE_MODELS, "opencode-go": OPENCODE_GO_MODELS, + "opencode-zen": OPENCODE_ZEN_MODELS, "openrouter": OPENROUTER_MODELS, + "perplexity": PERPLEXITY_MODELS, + "qianfan": QIANFAN_MODELS, + "qwen-portal": QWEN_PORTAL_MODELS, + "synthetic": SYNTHETIC_MODELS, "together": TOGETHER_MODELS, + "venice": VENICE_MODELS, "vercel-ai-gateway": VERCEL_AI_GATEWAY_MODELS, + "vllm": VLLM_MODELS, "xai": XAI_MODELS, "xiaomi": XIAOMI_MODELS, "xiaomi-token-plan-ams": XIAOMI_TOKEN_PLAN_AMS_MODELS, @@ -73,4 +124,5 @@ export const MODELS = { "xiaomi-token-plan-sgp": XIAOMI_TOKEN_PLAN_SGP_MODELS, "zai": ZAI_MODELS, "zai-coding-cn": ZAI_CODING_CN_MODELS, + "zenmux": ZENMUX_MODELS, } as const; diff --git a/packages/ai/src/oauth.ts b/packages/ai/src/oauth.ts index 039a587fb..03837062c 100644 --- a/packages/ai/src/oauth.ts +++ b/packages/ai/src/oauth.ts @@ -1,4 +1,34 @@ -/** Type-only compatibility entry point for coding-agent extension OAuth declarations. */ +/** Compatibility entry point for coding-agent OAuth usage. */ + +// Runtime registry + loaders used by coding-agent AuthStorage / ModelRegistry / login. +export { + getOAuthApiKey, + getOAuthProvider, + getOAuthProviders, + registerOAuthProvider, + resetOAuthProviders, + resolveOAuthStorageProvider, +} from "./auth/oauth/index.ts"; +export { + loadAnthropicOAuth, + loadCursorOAuth, + loadGitHubCopilotOAuth, + loadGitLabDuoOAuth, + loadGoogleAntigravityOAuth, + loadGoogleGeminiCliOAuth, + loadKiloOAuth, + loadOpenAICodexDeviceOAuth, + loadOpenAICodexOAuth, + loadPerplexityOAuth, + loadRadiusOAuth, + loadXaiOAuth, + registerBundledOAuthFlowLoaders, +} from "./auth/oauth/load.ts"; +export type { + OAuthProviderId, + OAuthProviderInterface, + OAuthRequestAuth, +} from "./auth/oauth/types.ts"; export type { OAuthAuthInfo, OAuthCredentials, diff --git a/packages/ai/src/providers/alibaba-coding-plan.models.ts b/packages/ai/src/providers/alibaba-coding-plan.models.ts new file mode 100644 index 000000000..3e017852a --- /dev/null +++ b/packages/ai/src/providers/alibaba-coding-plan.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/alibaba-coding-plan.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const ALIBABA_CODING_PLAN_MODELS = values as { + "qwen3.5-plus": Model<"openai-completions"> & { + id: "qwen3.5-plus"; + provider: "alibaba-coding-plan"; + }; +}; diff --git a/packages/ai/src/providers/alibaba-coding-plan.ts b/packages/ai/src/providers/alibaba-coding-plan.ts new file mode 100644 index 000000000..e0260804f --- /dev/null +++ b/packages/ai/src/providers/alibaba-coding-plan.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { ALIBABA_CODING_PLAN_MODELS } from "./alibaba-coding-plan.models.ts"; + +export function alibabaCodingPlanProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "alibaba-coding-plan", + name: "Alibaba Coding Plan", + baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", + auth: { apiKey: envApiKeyAuth("Alibaba Coding Plan API key", ["ALIBABA_CODING_PLAN_API_KEY"]) }, + models: Object.values(ALIBABA_CODING_PLAN_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index ab0d3ff79..b84b0cc28 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -2,6 +2,7 @@ import { createImagesModels, type ImagesProvider, type MutableImagesModels } fro import { MODELS } from "../models.generated.ts"; import { type CreateModelsOptions, createModels, type MutableModels, type Provider } from "../models.ts"; import type { Api, Model } from "../types.ts"; +import { alibabaCodingPlanProvider } from "./alibaba-coding-plan.ts"; import { amazonBedrockProvider } from "./amazon-bedrock.ts"; import { antLingProvider } from "./ant-ling.ts"; import { anthropicProvider } from "./anthropic.ts"; @@ -9,29 +10,52 @@ import { azureOpenAIResponsesProvider } from "./azure-openai-responses.ts"; import { cerebrasProvider } from "./cerebras.ts"; import { cloudflareAIGatewayProvider } from "./cloudflare-ai-gateway.ts"; import { cloudflareWorkersAIProvider } from "./cloudflare-workers-ai.ts"; +import { cursorProvider } from "./cursor.ts"; +import { deepinfraProvider } from "./deepinfra.ts"; import { deepseekProvider } from "./deepseek.ts"; +import { firepassProvider } from "./firepass.ts"; import { fireworksProvider } from "./fireworks.ts"; +import { fuguProvider } from "./fugu.ts"; import { githubCopilotProvider } from "./github-copilot.ts"; +import { gitlabDuoProvider } from "./gitlab-duo.ts"; import { googleProvider } from "./google.ts"; +import { googleAntigravityProvider, googleGeminiCliProvider } from "./google-gemini-cli.ts"; import { googleVertexProvider } from "./google-vertex.ts"; import { groqProvider } from "./groq.ts"; import { huggingfaceProvider } from "./huggingface.ts"; +import { kiloProvider } from "./kilo.ts"; import { kimiCodingProvider } from "./kimi-coding.ts"; +import { litellmProvider } from "./litellm.ts"; +import { lmStudioProvider } from "./lm-studio.ts"; import { minimaxProvider } from "./minimax.ts"; import { minimaxCnProvider } from "./minimax-cn.ts"; +import { minimaxCodeProvider } from "./minimax-code.ts"; +import { minimaxCodeCnProvider } from "./minimax-code-cn.ts"; import { mistralProvider } from "./mistral.ts"; +import { moonshotProvider } from "./moonshot.ts"; import { moonshotaiProvider } from "./moonshotai.ts"; import { moonshotaiCnProvider } from "./moonshotai-cn.ts"; +import { nanogptProvider } from "./nanogpt.ts"; import { nvidiaProvider } from "./nvidia.ts"; +import { ollamaProvider } from "./ollama.ts"; +import { ollamaCloudProvider } from "./ollama-cloud.ts"; import { openaiProvider } from "./openai.ts"; import { openaiCodexProvider } from "./openai-codex.ts"; +import { openaiCodexDeviceProvider } from "./openai-codex-device.ts"; import { opencodeProvider } from "./opencode.ts"; import { opencodeGoProvider } from "./opencode-go.ts"; +import { opencodeZenProvider } from "./opencode-zen.ts"; import { openrouterProvider } from "./openrouter.ts"; import { openrouterImagesProvider } from "./openrouter-images.ts"; +import { perplexityProvider } from "./perplexity.ts"; +import { qianfanProvider } from "./qianfan.ts"; +import { qwenPortalProvider } from "./qwen-portal.ts"; import { radiusProvider } from "./radius.ts"; +import { syntheticProvider } from "./synthetic.ts"; import { togetherProvider } from "./together.ts"; +import { veniceProvider } from "./venice.ts"; import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts"; +import { vllmProvider } from "./vllm.ts"; import { xaiProvider } from "./xai.ts"; import { xiaomiProvider } from "./xiaomi.ts"; import { xiaomiTokenPlanAmsProvider } from "./xiaomi-token-plan-ams.ts"; @@ -39,8 +63,7 @@ import { xiaomiTokenPlanCnProvider } from "./xiaomi-token-plan-cn.ts"; import { xiaomiTokenPlanSgpProvider } from "./xiaomi-token-plan-sgp.ts"; import { zaiProvider } from "./zai.ts"; import { zaiCodingCnProvider } from "./zai-coding-cn.ts"; - -export { radiusProvider }; +import { zenmuxProvider } from "./zenmux.ts"; /** Providers present in the generated catalog. `KnownProvider` additionally * includes purely dynamic providers (e.g. "radius") that have no static @@ -114,8 +137,11 @@ export function getBuiltinModels( } /** All built-in providers, freshly constructed. */ +export { radiusProvider }; + export function builtinProviders(): Provider[] { return [ + alibabaCodingPlanProvider(), amazonBedrockProvider(), antLingProvider(), anthropicProvider(), @@ -123,28 +149,52 @@ export function builtinProviders(): Provider[] { cerebrasProvider(), cloudflareAIGatewayProvider(), cloudflareWorkersAIProvider(), + cursorProvider(), + deepinfraProvider(), deepseekProvider(), + firepassProvider(), fireworksProvider(), + fuguProvider(), githubCopilotProvider(), + gitlabDuoProvider(), googleProvider(), + googleGeminiCliProvider(), + googleAntigravityProvider(), googleVertexProvider(), groqProvider(), huggingfaceProvider(), + kiloProvider(), kimiCodingProvider(), + litellmProvider(), + lmStudioProvider(), minimaxProvider(), minimaxCnProvider(), + minimaxCodeProvider(), + minimaxCodeCnProvider(), mistralProvider(), + moonshotProvider(), moonshotaiProvider(), moonshotaiCnProvider(), + nanogptProvider(), nvidiaProvider(), + ollamaProvider(), + ollamaCloudProvider(), openaiProvider(), openaiCodexProvider(), + openaiCodexDeviceProvider(), opencodeProvider(), opencodeGoProvider(), + opencodeZenProvider(), openrouterProvider(), + perplexityProvider(), radiusProvider(), + qianfanProvider(), + qwenPortalProvider(), + syntheticProvider(), togetherProvider(), + veniceProvider(), vercelAIGatewayProvider(), + vllmProvider(), xaiProvider(), xiaomiProvider(), xiaomiTokenPlanAmsProvider(), @@ -152,6 +202,7 @@ export function builtinProviders(): Provider[] { xiaomiTokenPlanSgpProvider(), zaiProvider(), zaiCodingCnProvider(), + zenmuxProvider(), ]; } diff --git a/packages/ai/src/providers/cursor.models.ts b/packages/ai/src/providers/cursor.models.ts new file mode 100644 index 000000000..842f6f946 --- /dev/null +++ b/packages/ai/src/providers/cursor.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/cursor.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const CURSOR_MODELS = values as { + "default": Model<"cursor-connect"> & { + id: "default"; + provider: "cursor"; + }; +}; diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts new file mode 100644 index 000000000..9373c6c02 --- /dev/null +++ b/packages/ai/src/providers/cursor.ts @@ -0,0 +1,19 @@ +import { cursorConnectApi } from "../api/cursor-connect.lazy.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { loadCursorOAuth } from "../auth/oauth/load.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { CURSOR_MODELS } from "./cursor.models.ts"; + +export function cursorProvider(): Provider<"cursor-connect"> { + return createProvider({ + id: "cursor", + name: "Cursor", + baseUrl: "https://api2.cursor.sh", + auth: { + apiKey: envApiKeyAuth("Cursor API key", ["CURSOR_API_KEY"]), + oauth: lazyOAuth({ name: "Cursor (Claude, GPT, etc.)", load: loadCursorOAuth }), + }, + models: Object.values(CURSOR_MODELS), + api: cursorConnectApi(), + }); +} diff --git a/packages/ai/src/providers/data/alibaba-coding-plan.json b/packages/ai/src/providers/data/alibaba-coding-plan.json new file mode 100644 index 000000000..185318798 --- /dev/null +++ b/packages/ai/src/providers/data/alibaba-coding-plan.json @@ -0,0 +1 @@ +{"qwen3.5-plus":{"id":"qwen3.5-plus","name":"Qwen3.5 Plus","api":"openai-completions","provider":"alibaba-coding-plan","baseUrl":"https://coding-intl.dashscope.aliyuncs.com/v1","compat":{"supportsDeveloperRole":false},"reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":65536}} diff --git a/packages/ai/src/providers/data/cursor.json b/packages/ai/src/providers/data/cursor.json new file mode 100644 index 000000000..d5bbcb889 --- /dev/null +++ b/packages/ai/src/providers/data/cursor.json @@ -0,0 +1 @@ +{"default":{"id":"default","name":"Auto","api":"cursor-connect","provider":"cursor","baseUrl":"https://api2.cursor.sh","reasoning":false,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":200000,"maxTokens":64000}} diff --git a/packages/ai/src/providers/data/deepinfra.json b/packages/ai/src/providers/data/deepinfra.json new file mode 100644 index 000000000..46b54d7c7 --- /dev/null +++ b/packages/ai/src/providers/data/deepinfra.json @@ -0,0 +1 @@ +{"deepseek-ai/DeepSeek-V3.2":{"id":"deepseek-ai/DeepSeek-V3.2","name":"DeepSeek-V3.2","api":"openai-completions","provider":"deepinfra","baseUrl":"https://api.deepinfra.com/v1/openai","reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text"],"cost":{"input":0.26,"output":0.38,"cacheRead":0.13,"cacheWrite":0},"contextWindow":163840,"maxTokens":64000}} diff --git a/packages/ai/src/providers/data/firepass.json b/packages/ai/src/providers/data/firepass.json new file mode 100644 index 000000000..539bc3aba --- /dev/null +++ b/packages/ai/src/providers/data/firepass.json @@ -0,0 +1 @@ +{"accounts/fireworks/routers/kimi-k2p6-turbo":{"id":"accounts/fireworks/routers/kimi-k2p6-turbo","name":"Kimi K2.6 Turbo (Fire Pass)","api":"openai-completions","provider":"firepass","baseUrl":"https://api.fireworks.ai/inference/v1","compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},"reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":65536}} diff --git a/packages/ai/src/providers/data/fugu.json b/packages/ai/src/providers/data/fugu.json new file mode 100644 index 000000000..e257a5a1e --- /dev/null +++ b/packages/ai/src/providers/data/fugu.json @@ -0,0 +1 @@ +{"fugu":{"id":"fugu","name":"Sakana Fugu","api":"openai-completions","provider":"fugu","baseUrl":"https://api.sakana.ai/v1","compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens"},"reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":200000,"maxTokens":65536}} diff --git a/packages/ai/src/providers/data/gitlab-duo.json b/packages/ai/src/providers/data/gitlab-duo.json new file mode 100644 index 000000000..e1e1be0f6 --- /dev/null +++ b/packages/ai/src/providers/data/gitlab-duo.json @@ -0,0 +1 @@ +{"claude-sonnet-4-6":{"id":"claude-sonnet-4-6","name":"Claude Sonnet 4.6","api":"anthropic-messages","provider":"gitlab-duo","baseUrl":"https://cloud.gitlab.com/ai/v1/proxy/anthropic/","reasoning":true,"thinkingLevelMap":{"xhigh":"max","max":"max"},"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":64000,"compat":{"forceAdaptiveThinking":true}},"gpt-5.1-2025-11-13":{"id":"gpt-5.1-2025-11-13","name":"GPT-5.1","api":"openai-completions","provider":"gitlab-duo","baseUrl":"https://cloud.gitlab.com/ai/v1/proxy/openai/v1","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384}} diff --git a/packages/ai/src/providers/data/google-antigravity.json b/packages/ai/src/providers/data/google-antigravity.json new file mode 100644 index 000000000..eafae439b --- /dev/null +++ b/packages/ai/src/providers/data/google-antigravity.json @@ -0,0 +1 @@ +{"claude-opus-4-5-thinking":{"id":"claude-opus-4-5-thinking","name":"Anthropic Opus 4.5 Thinking (Antigravity)","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":200000,"maxTokens":64000,"thinkingLevelMap":{"max":null}},"claude-opus-4-6-thinking":{"id":"claude-opus-4-6-thinking","name":"Anthropic Opus 4.6 (Thinking) (Antigravity)","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":64000,"thinkingLevelMap":{"max":null}},"claude-sonnet-4-5":{"id":"claude-sonnet-4-5","name":"Anthropic Sonnet 4.5","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":64000,"thinkingLevelMap":{"max":null}},"claude-sonnet-4-5-thinking":{"id":"claude-sonnet-4-5-thinking","name":"Anthropic Sonnet 4.5 Thinking (Antigravity)","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":200000,"maxTokens":64000,"thinkingLevelMap":{"max":null}},"claude-sonnet-4-6":{"id":"claude-sonnet-4-6","name":"Anthropic Sonnet 4.6","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":64000,"thinkingLevelMap":{"max":null}},"claude-sonnet-4-6-thinking":{"id":"claude-sonnet-4-6-thinking","name":"Anthropic Sonnet 4.6 Thinking (Antigravity)","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":128000,"thinkingLevelMap":{"max":null}},"gemini-2.5-flash":{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65536},"gemini-2.5-flash-thinking":{"id":"gemini-2.5-flash-thinking","name":"Gemini 2.5 Flash (Thinking) (Antigravity)","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65535},"gemini-2.5-pro":{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65536},"gemini-3-flash":{"id":"gemini-3-flash","name":"Gemini 3 Flash","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65536,"thinkingLevelMap":{"off":null}},"gemini-3-pro-high":{"id":"gemini-3-pro-high","name":"Gemini 3 Pro (High) (Antigravity)","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65535,"thinkingLevelMap":{"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}},"gemini-3-pro-low":{"id":"gemini-3-pro-low","name":"Gemini 3 Pro (Low) (Antigravity)","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65535,"thinkingLevelMap":{"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}},"gemini-3.1-pro-low":{"id":"gemini-3.1-pro-low","name":"Gemini 3.1 Pro (Low) (Antigravity)","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65535,"thinkingLevelMap":{"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}},"gpt-oss-120b-medium":{"id":"gpt-oss-120b-medium","name":"GPT-OSS 120B (Medium) (Antigravity)","api":"google-gemini-cli","provider":"google-antigravity","baseUrl":"https://daily-cloudcode-pa.sandbox.googleapis.com","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":114000,"maxTokens":32768}} diff --git a/packages/ai/src/providers/data/google-gemini-cli.json b/packages/ai/src/providers/data/google-gemini-cli.json new file mode 100644 index 000000000..79b5bea6b --- /dev/null +++ b/packages/ai/src/providers/data/google-gemini-cli.json @@ -0,0 +1 @@ +{"gemini-2.0-flash":{"id":"gemini-2.0-flash","name":"Gemini 2.0 Flash","api":"google-gemini-cli","provider":"google-gemini-cli","baseUrl":"https://cloudcode-pa.googleapis.com","reasoning":false,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":8192},"gemini-2.5-flash":{"id":"gemini-2.5-flash","name":"Gemini 2.5 Flash","api":"google-gemini-cli","provider":"google-gemini-cli","baseUrl":"https://cloudcode-pa.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65536},"gemini-2.5-pro":{"id":"gemini-2.5-pro","name":"Gemini 2.5 Pro","api":"google-gemini-cli","provider":"google-gemini-cli","baseUrl":"https://cloudcode-pa.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65536},"gemini-3-flash-preview":{"id":"gemini-3-flash-preview","name":"Gemini 3 Flash Preview","api":"google-gemini-cli","provider":"google-gemini-cli","baseUrl":"https://cloudcode-pa.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65536,"thinkingLevelMap":{"off":null}},"gemini-3-pro-preview":{"id":"gemini-3-pro-preview","name":"Gemini 3 Pro Preview","api":"google-gemini-cli","provider":"google-gemini-cli","baseUrl":"https://cloudcode-pa.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":64000,"thinkingLevelMap":{"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}},"gemini-3.1-flash-lite-preview":{"id":"gemini-3.1-flash-lite-preview","name":"Gemini 3.1 Flash Lite Preview","api":"google-gemini-cli","provider":"google-gemini-cli","baseUrl":"https://cloudcode-pa.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65536,"thinkingLevelMap":{"off":null}},"gemini-3.1-pro-preview":{"id":"gemini-3.1-pro-preview","name":"Gemini 3.1 Pro Preview","api":"google-gemini-cli","provider":"google-gemini-cli","baseUrl":"https://cloudcode-pa.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65536,"thinkingLevelMap":{"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}},"gemini-3.5-flash":{"id":"gemini-3.5-flash","name":"Gemini 3.5 Flash","api":"google-gemini-cli","provider":"google-gemini-cli","baseUrl":"https://cloudcode-pa.googleapis.com","reasoning":true,"input":["text","image"],"cost":{"input":1.5,"output":9,"cacheRead":0.15,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65536,"thinkingLevelMap":{"off":null}}} diff --git a/packages/ai/src/providers/data/kilo.json b/packages/ai/src/providers/data/kilo.json new file mode 100644 index 000000000..f5aad7f97 --- /dev/null +++ b/packages/ai/src/providers/data/kilo.json @@ -0,0 +1 @@ +{"anthropic/claude-sonnet-4.5":{"id":"anthropic/claude-sonnet-4.5","name":"Claude Sonnet 4.5","api":"openai-completions","provider":"kilo","baseUrl":"https://api.kilo.ai/api/gateway","reasoning":true,"input":["text","image"],"cost":{"input":3,"output":15,"cacheRead":0.3,"cacheWrite":3.75},"contextWindow":200000,"maxTokens":64000}} diff --git a/packages/ai/src/providers/data/litellm.json b/packages/ai/src/providers/data/litellm.json new file mode 100644 index 000000000..4d1cb3b51 --- /dev/null +++ b/packages/ai/src/providers/data/litellm.json @@ -0,0 +1 @@ +{"claude-opus-4-6":{"id":"claude-opus-4-6","name":"Anthropic Opus 4.6","api":"openai-completions","provider":"litellm","baseUrl":"http://localhost:4000/v1","reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh","max":"max"},"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":128000}} diff --git a/packages/ai/src/providers/data/lm-studio.json b/packages/ai/src/providers/data/lm-studio.json new file mode 100644 index 000000000..83967d842 --- /dev/null +++ b/packages/ai/src/providers/data/lm-studio.json @@ -0,0 +1 @@ +{"llama-3-8b":{"id":"llama-3-8b","name":"Llama 3 8B","api":"openai-completions","provider":"lm-studio","baseUrl":"http://127.0.0.1:1234/v1","compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},"reasoning":false,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":8192,"maxTokens":4096}} diff --git a/packages/ai/src/providers/data/minimax-code-cn.json b/packages/ai/src/providers/data/minimax-code-cn.json new file mode 100644 index 000000000..240987c39 --- /dev/null +++ b/packages/ai/src/providers/data/minimax-code-cn.json @@ -0,0 +1 @@ +{"MiniMax-M2.7":{"id":"MiniMax-M2.7","name":"MiniMax-M2.7","api":"openai-completions","provider":"minimax-code-cn","baseUrl":"https://api.minimaxi.com/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":204800,"maxTokens":131072,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}},"MiniMax-M2.7-highspeed":{"id":"MiniMax-M2.7-highspeed","name":"MiniMax-M2.7-highspeed","api":"openai-completions","provider":"minimax-code-cn","baseUrl":"https://api.minimaxi.com/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":204800,"maxTokens":131072,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}},"MiniMax-M3":{"id":"MiniMax-M3","name":"MiniMax-M3","api":"openai-completions","provider":"minimax-code-cn","baseUrl":"https://api.minimaxi.com/v1","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":128000,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}}} diff --git a/packages/ai/src/providers/data/minimax-code.json b/packages/ai/src/providers/data/minimax-code.json new file mode 100644 index 000000000..b7c4b9a3f --- /dev/null +++ b/packages/ai/src/providers/data/minimax-code.json @@ -0,0 +1 @@ +{"MiniMax-M2.7":{"id":"MiniMax-M2.7","name":"MiniMax-M2.7","api":"openai-completions","provider":"minimax-code","baseUrl":"https://api.minimax.io/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":204800,"maxTokens":131072,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}},"MiniMax-M2.7-highspeed":{"id":"MiniMax-M2.7-highspeed","name":"MiniMax-M2.7-highspeed","api":"openai-completions","provider":"minimax-code","baseUrl":"https://api.minimax.io/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":204800,"maxTokens":131072,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}},"MiniMax-M3":{"id":"MiniMax-M3","name":"MiniMax-M3","api":"openai-completions","provider":"minimax-code","baseUrl":"https://api.minimax.io/v1","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":128000,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}}} diff --git a/packages/ai/src/providers/data/moonshot.json b/packages/ai/src/providers/data/moonshot.json new file mode 100644 index 000000000..d0d1e6a53 --- /dev/null +++ b/packages/ai/src/providers/data/moonshot.json @@ -0,0 +1 @@ +{"kimi-k2-0711-preview":{"id":"kimi-k2-0711-preview","name":"Kimi K2 0711","api":"openai-completions","provider":"moonshot","baseUrl":"https://api.moonshot.ai/v1","reasoning":false,"input":["text"],"cost":{"input":0.6,"output":2.5,"cacheRead":0.15,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}},"kimi-k2-0905-preview":{"id":"kimi-k2-0905-preview","name":"Kimi K2 0905","api":"openai-completions","provider":"moonshot","baseUrl":"https://api.moonshot.ai/v1","reasoning":false,"input":["text"],"cost":{"input":0.6,"output":2.5,"cacheRead":0.15,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}},"kimi-k2-thinking":{"id":"kimi-k2-thinking","name":"Kimi K2 Thinking","api":"openai-completions","provider":"moonshot","baseUrl":"https://api.moonshot.ai/v1","reasoning":true,"input":["text"],"cost":{"input":0.6,"output":2.5,"cacheRead":0.15,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}},"kimi-k2-thinking-turbo":{"id":"kimi-k2-thinking-turbo","name":"Kimi K2 Thinking Turbo","api":"openai-completions","provider":"moonshot","baseUrl":"https://api.moonshot.ai/v1","reasoning":true,"input":["text"],"cost":{"input":1.15,"output":8,"cacheRead":0.15,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}},"kimi-k2-turbo-preview":{"id":"kimi-k2-turbo-preview","name":"Kimi K2 Turbo","api":"openai-completions","provider":"moonshot","baseUrl":"https://api.moonshot.ai/v1","reasoning":false,"input":["text"],"cost":{"input":2.4,"output":10,"cacheRead":0.6,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}},"kimi-k2.5":{"id":"kimi-k2.5","name":"Kimi K2.5","api":"openai-completions","provider":"moonshot","baseUrl":"https://api.moonshot.ai/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.6,"output":3,"cacheRead":0.1,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}},"kimi-k2.6":{"id":"kimi-k2.6","name":"Kimi K2.6","api":"openai-completions","provider":"moonshot","baseUrl":"https://api.moonshot.ai/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.95,"output":4,"cacheRead":0.16,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}},"kimi-k2.7-code":{"id":"kimi-k2.7-code","name":"Kimi K2.7 Code","api":"openai-completions","provider":"moonshot","baseUrl":"https://api.moonshot.ai/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.95,"output":4,"cacheRead":0.19,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"},"thinkingLevelMap":{"off":null}},"kimi-k2.7-code-highspeed":{"id":"kimi-k2.7-code-highspeed","name":"Kimi K2.7 Code HighSpeed","api":"openai-completions","provider":"moonshot","baseUrl":"https://api.moonshot.ai/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.9,"output":8,"cacheRead":0.38,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"},"thinkingLevelMap":{"off":null}},"kimi-k3":{"id":"kimi-k3","name":"Kimi K3","api":"openai-completions","provider":"moonshot","baseUrl":"https://api.moonshot.ai/v1","reasoning":true,"thinkingLevelMap":{"off":null,"minimal":null,"low":null,"medium":null,"high":null,"xhigh":null,"max":"max"},"input":["text","image"],"cost":{"input":3,"output":15,"cacheRead":0.3,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072,"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek","requiresReasoningContentOnAssistantMessages":true,"deferredToolsMode":"kimi"}}} diff --git a/packages/ai/src/providers/data/nanogpt.json b/packages/ai/src/providers/data/nanogpt.json new file mode 100644 index 000000000..02a93e824 --- /dev/null +++ b/packages/ai/src/providers/data/nanogpt.json @@ -0,0 +1 @@ +{"openai/gpt-5.4":{"id":"openai/gpt-5.4","name":"GPT-5.4","api":"openai-completions","provider":"nanogpt","baseUrl":"https://nano-gpt.com/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1050000,"maxTokens":128000,"thinkingLevelMap":{"xhigh":"xhigh"}}} diff --git a/packages/ai/src/providers/data/ollama-cloud.json b/packages/ai/src/providers/data/ollama-cloud.json new file mode 100644 index 000000000..e6578f6ed --- /dev/null +++ b/packages/ai/src/providers/data/ollama-cloud.json @@ -0,0 +1 @@ +{"gpt-oss:120b":{"id":"gpt-oss:120b","name":"GPT OSS (120B)","api":"openai-completions","provider":"ollama-cloud","baseUrl":"https://ollama.com/v1","compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},"reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384}} diff --git a/packages/ai/src/providers/data/ollama.json b/packages/ai/src/providers/data/ollama.json new file mode 100644 index 000000000..3ab511646 --- /dev/null +++ b/packages/ai/src/providers/data/ollama.json @@ -0,0 +1 @@ +{"gpt-oss:20b":{"id":"gpt-oss:20b","name":"GPT OSS (20B)","api":"openai-completions","provider":"ollama","baseUrl":"http://127.0.0.1:11434/v1","compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},"reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384}} diff --git a/packages/ai/src/providers/data/openai-codex-device.json b/packages/ai/src/providers/data/openai-codex-device.json new file mode 100644 index 000000000..cd9fd61ed --- /dev/null +++ b/packages/ai/src/providers/data/openai-codex-device.json @@ -0,0 +1 @@ +{"gpt-5.3-codex-spark":{"id":"gpt-5.3-codex-spark","name":"GPT-5.3 Codex Spark","api":"openai-codex-responses","provider":"openai-codex-device","baseUrl":"https://chatgpt.com/backend-api","reasoning":true,"input":["text"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"contextWindow":128000,"maxTokens":128000,"thinkingLevelMap":{"xhigh":"xhigh","minimal":"low"}},"gpt-5.4":{"id":"gpt-5.4","name":"GPT-5.4","api":"openai-codex-responses","provider":"openai-codex-device","baseUrl":"https://chatgpt.com/backend-api","reasoning":true,"input":["text","image"],"cost":{"input":2.5,"output":15,"cacheRead":0.25,"cacheWrite":0,"tiers":[{"inputTokensAbove":272000,"input":5,"output":22.5,"cacheRead":0.5,"cacheWrite":0}]},"contextWindow":272000,"maxTokens":128000,"thinkingLevelMap":{"xhigh":"xhigh","minimal":"low"},"compat":{"supportsToolSearch":true}},"gpt-5.4-mini":{"id":"gpt-5.4-mini","name":"GPT-5.4 mini","api":"openai-codex-responses","provider":"openai-codex-device","baseUrl":"https://chatgpt.com/backend-api","reasoning":true,"input":["text","image"],"cost":{"input":0.75,"output":4.5,"cacheRead":0.075,"cacheWrite":0},"contextWindow":272000,"maxTokens":128000,"thinkingLevelMap":{"xhigh":"xhigh","minimal":"low"},"compat":{"supportsToolSearch":true}},"gpt-5.5":{"id":"gpt-5.5","name":"GPT-5.5","api":"openai-codex-responses","provider":"openai-codex-device","baseUrl":"https://chatgpt.com/backend-api","reasoning":true,"input":["text","image"],"cost":{"input":5,"output":30,"cacheRead":0.5,"cacheWrite":0,"tiers":[{"inputTokensAbove":272000,"input":10,"output":45,"cacheRead":1,"cacheWrite":0}]},"contextWindow":272000,"maxTokens":128000,"thinkingLevelMap":{"xhigh":"xhigh","minimal":"low"},"compat":{"supportsToolSearch":true}},"gpt-5.6-luna":{"id":"gpt-5.6-luna","name":"GPT-5.6 Luna","api":"openai-codex-responses","provider":"openai-codex-device","baseUrl":"https://chatgpt.com/backend-api","reasoning":true,"input":["text","image"],"cost":{"input":1,"output":6,"cacheRead":0.1,"cacheWrite":1.25,"tiers":[{"inputTokensAbove":272000,"input":2,"output":9,"cacheRead":0.2,"cacheWrite":2.5}]},"contextWindow":372000,"maxTokens":128000,"thinkingLevelMap":{"xhigh":"xhigh","max":"max","minimal":"low"},"compat":{"supportsToolSearch":true}},"gpt-5.6-sol":{"id":"gpt-5.6-sol","name":"GPT-5.6 Sol","api":"openai-codex-responses","provider":"openai-codex-device","baseUrl":"https://chatgpt.com/backend-api","reasoning":true,"input":["text","image"],"cost":{"input":5,"output":30,"cacheRead":0.5,"cacheWrite":6.25,"tiers":[{"inputTokensAbove":272000,"input":10,"output":45,"cacheRead":1,"cacheWrite":12.5}]},"contextWindow":372000,"maxTokens":128000,"thinkingLevelMap":{"xhigh":"xhigh","max":"max","minimal":"low"},"compat":{"supportsToolSearch":true}},"gpt-5.6-terra":{"id":"gpt-5.6-terra","name":"GPT-5.6 Terra","api":"openai-codex-responses","provider":"openai-codex-device","baseUrl":"https://chatgpt.com/backend-api","reasoning":true,"input":["text","image"],"cost":{"input":2.5,"output":15,"cacheRead":0.25,"cacheWrite":3.125,"tiers":[{"inputTokensAbove":272000,"input":5,"output":22.5,"cacheRead":0.5,"cacheWrite":6.25}]},"contextWindow":372000,"maxTokens":128000,"thinkingLevelMap":{"xhigh":"xhigh","max":"max","minimal":"low"},"compat":{"supportsToolSearch":true}}} diff --git a/packages/ai/src/providers/data/opencode-zen.json b/packages/ai/src/providers/data/opencode-zen.json new file mode 100644 index 000000000..69d8e64d0 --- /dev/null +++ b/packages/ai/src/providers/data/opencode-zen.json @@ -0,0 +1 @@ +{"big-pickle":{"id":"big-pickle","name":"Big Pickle","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"},"contextWindow":200000,"maxTokens":32000},"claude-fable-5":{"id":"claude-fable-5","name":"Claude Fable 5","api":"anthropic-messages","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen","reasoning":true,"input":["text","image"],"cost":{"input":10,"output":50,"cacheRead":1,"cacheWrite":12.5},"contextWindow":1000000,"maxTokens":128000,"thinkingLevelMap":{"off":null,"xhigh":"xhigh","max":"max"},"compat":{"forceAdaptiveThinking":true}},"claude-haiku-4-5":{"id":"claude-haiku-4-5","name":"Claude Haiku 4.5","api":"anthropic-messages","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen","reasoning":true,"input":["text","image"],"cost":{"input":1,"output":5,"cacheRead":0.1,"cacheWrite":1.25},"contextWindow":200000,"maxTokens":64000},"claude-opus-4-1":{"id":"claude-opus-4-1","name":"Claude Opus 4.1","api":"anthropic-messages","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen","reasoning":true,"input":["text","image"],"cost":{"input":15,"output":75,"cacheRead":1.5,"cacheWrite":18.75},"contextWindow":200000,"maxTokens":32000},"claude-opus-4-5":{"id":"claude-opus-4-5","name":"Claude Opus 4.5","api":"anthropic-messages","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen","reasoning":true,"input":["text","image"],"cost":{"input":5,"output":25,"cacheRead":0.5,"cacheWrite":6.25},"contextWindow":200000,"maxTokens":64000},"claude-opus-4-6":{"id":"claude-opus-4-6","name":"Claude Opus 4.6","api":"anthropic-messages","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen","reasoning":true,"input":["text","image"],"cost":{"input":5,"output":25,"cacheRead":0.5,"cacheWrite":6.25},"contextWindow":1000000,"maxTokens":128000,"thinkingLevelMap":{"max":"max"},"compat":{"forceAdaptiveThinking":true}},"claude-opus-4-7":{"id":"claude-opus-4-7","name":"Claude Opus 4.7","api":"anthropic-messages","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen","reasoning":true,"input":["text","image"],"cost":{"input":5,"output":25,"cacheRead":0.5,"cacheWrite":6.25},"contextWindow":1000000,"maxTokens":128000,"thinkingLevelMap":{"xhigh":"xhigh","max":"max"},"compat":{"forceAdaptiveThinking":true,"supportsTemperature":false}},"claude-opus-4-8":{"id":"claude-opus-4-8","name":"Claude Opus 4.8","api":"anthropic-messages","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen","reasoning":true,"input":["text","image"],"cost":{"input":5,"output":25,"cacheRead":0.5,"cacheWrite":6.25},"contextWindow":1000000,"maxTokens":128000,"thinkingLevelMap":{"xhigh":"xhigh","max":"max"},"compat":{"forceAdaptiveThinking":true,"supportsTemperature":false}},"claude-sonnet-4":{"id":"claude-sonnet-4","name":"Claude Sonnet 4","api":"anthropic-messages","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen","reasoning":true,"input":["text","image"],"cost":{"input":3,"output":15,"cacheRead":0.3,"cacheWrite":3.75},"contextWindow":200000,"maxTokens":64000},"claude-sonnet-4-5":{"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","api":"anthropic-messages","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen","reasoning":true,"input":["text","image"],"cost":{"input":3,"output":15,"cacheRead":0.3,"cacheWrite":3.75},"contextWindow":200000,"maxTokens":64000},"claude-sonnet-4-6":{"id":"claude-sonnet-4-6","name":"Claude Sonnet 4.6","api":"anthropic-messages","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen","reasoning":true,"input":["text","image"],"cost":{"input":3,"output":15,"cacheRead":0.3,"cacheWrite":3.75},"contextWindow":1000000,"maxTokens":64000,"thinkingLevelMap":{"max":"max"},"compat":{"forceAdaptiveThinking":true}},"claude-sonnet-5":{"id":"claude-sonnet-5","name":"Claude Sonnet 5","api":"anthropic-messages","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen","reasoning":true,"input":["text","image"],"cost":{"input":2,"output":10,"cacheRead":0.2,"cacheWrite":2.5},"contextWindow":1000000,"maxTokens":128000,"thinkingLevelMap":{"xhigh":"xhigh","max":"max"},"compat":{"forceAdaptiveThinking":true}},"deepseek-v4-flash":{"id":"deepseek-v4-flash","name":"DeepSeek V4 Flash","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text"],"cost":{"input":0.14,"output":0.28,"cacheRead":0.028,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"requiresReasoningContentOnAssistantMessages":true},"contextWindow":1000000,"maxTokens":384000,"thinkingLevelMap":{"minimal":null,"low":null,"medium":null,"high":"high","max":"max"}},"deepseek-v4-flash-free":{"id":"deepseek-v4-flash-free","name":"DeepSeek V4 Flash Free","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true},"contextWindow":200000,"maxTokens":128000,"thinkingLevelMap":{"minimal":null,"low":null,"medium":null,"high":"high","max":"max"}},"deepseek-v4-pro":{"id":"deepseek-v4-pro","name":"DeepSeek V4 Pro","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text"],"cost":{"input":1.74,"output":3.84,"cacheRead":0.145,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"requiresReasoningContentOnAssistantMessages":true},"contextWindow":1000000,"maxTokens":384000,"thinkingLevelMap":{"minimal":null,"low":null,"medium":null,"high":"high","max":"max"}},"gemini-3-flash":{"id":"gemini-3-flash","name":"Gemini 3 Flash","api":"google-generative-ai","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.5,"output":3,"cacheRead":0.05,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65536,"thinkingLevelMap":{"off":null}},"gemini-3.1-pro":{"id":"gemini-3.1-pro","name":"Gemini 3.1 Pro Preview","api":"google-generative-ai","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":2,"output":12,"cacheRead":0.2,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65536,"thinkingLevelMap":{"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}},"gemini-3.5-flash":{"id":"gemini-3.5-flash","name":"Gemini 3.5 Flash","api":"google-generative-ai","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.5,"output":9,"cacheRead":0.15,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65536,"thinkingLevelMap":{"off":null}},"glm-5":{"id":"glm-5","name":"GLM-5","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text"],"cost":{"input":1,"output":3.2,"cacheRead":0.2,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"},"contextWindow":204800,"maxTokens":131072},"glm-5.1":{"id":"glm-5.1","name":"GLM-5.1","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text"],"cost":{"input":1.4,"output":4.4,"cacheRead":0.26,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"},"contextWindow":204800,"maxTokens":131072},"glm-5.2":{"id":"glm-5.2","name":"GLM-5.2","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text"],"cost":{"input":1.4,"output":4.4,"cacheRead":0.26,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"},"contextWindow":1000000,"maxTokens":131072},"gpt-5":{"id":"gpt-5","name":"GPT-5","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.07,"output":8.5,"cacheRead":0.107,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":400000,"maxTokens":128000,"thinkingLevelMap":{"off":null}},"gpt-5-codex":{"id":"gpt-5-codex","name":"GPT-5 Codex","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.07,"output":8.5,"cacheRead":0.107,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":400000,"maxTokens":128000,"thinkingLevelMap":{"off":null}},"gpt-5-nano":{"id":"gpt-5-nano","name":"GPT-5 Nano","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.05,"output":0.4,"cacheRead":0.005,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":400000,"maxTokens":128000,"thinkingLevelMap":{"off":null}},"gpt-5.1":{"id":"gpt-5.1","name":"GPT-5.1","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.07,"output":8.5,"cacheRead":0.107,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":400000,"maxTokens":128000,"thinkingLevelMap":{"off":null}},"gpt-5.1-codex":{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.07,"output":8.5,"cacheRead":0.107,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":400000,"maxTokens":128000,"thinkingLevelMap":{"off":null}},"gpt-5.1-codex-max":{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":400000,"maxTokens":128000,"thinkingLevelMap":{"off":null}},"gpt-5.1-codex-mini":{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex Mini","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.25,"output":2,"cacheRead":0.025,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":400000,"maxTokens":128000,"thinkingLevelMap":{"off":null}},"gpt-5.2":{"id":"gpt-5.2","name":"GPT-5.2","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":400000,"maxTokens":128000,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"}},"gpt-5.2-codex":{"id":"gpt-5.2-codex","name":"GPT-5.2 Codex","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":400000,"maxTokens":128000,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"}},"gpt-5.3-codex":{"id":"gpt-5.3-codex","name":"GPT-5.3 Codex","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":400000,"maxTokens":128000,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"}},"gpt-5.4":{"id":"gpt-5.4","name":"GPT-5.4","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":2.5,"output":15,"cacheRead":0.25,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":272000,"maxTokens":128000,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"}},"gpt-5.4-mini":{"id":"gpt-5.4-mini","name":"GPT-5.4 Mini","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.75,"output":4.5,"cacheRead":0.075,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":400000,"maxTokens":128000,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"}},"gpt-5.4-nano":{"id":"gpt-5.4-nano","name":"GPT-5.4 Nano","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.2,"output":1.25,"cacheRead":0.02,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":400000,"maxTokens":128000,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"}},"gpt-5.4-pro":{"id":"gpt-5.4-pro","name":"GPT-5.4 Pro","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":30,"output":180,"cacheRead":30,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":1050000,"maxTokens":128000,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"}},"gpt-5.5":{"id":"gpt-5.5","name":"GPT-5.5","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":5,"output":30,"cacheRead":0.5,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":1050000,"maxTokens":128000,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"}},"gpt-5.5-pro":{"id":"gpt-5.5-pro","name":"GPT-5.5 Pro","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":30,"output":180,"cacheRead":30,"cacheWrite":0},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":1050000,"maxTokens":128000,"thinkingLevelMap":{"off":null,"xhigh":"xhigh","minimal":null,"low":null}},"gpt-5.6-luna":{"id":"gpt-5.6-luna","name":"GPT-5.6 Luna","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":1,"output":6,"cacheRead":0.1,"cacheWrite":1.25},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":1050000,"maxTokens":128000,"thinkingLevelMap":{"off":null,"xhigh":"xhigh","max":"max"}},"gpt-5.6-sol":{"id":"gpt-5.6-sol","name":"GPT-5.6 Sol","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":5,"output":30,"cacheRead":0.5,"cacheWrite":6.25},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":1050000,"maxTokens":128000,"thinkingLevelMap":{"off":null,"xhigh":"xhigh","max":"max"}},"gpt-5.6-terra":{"id":"gpt-5.6-terra","name":"GPT-5.6 Terra","api":"openai-responses","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":2.5,"output":15,"cacheRead":0.25,"cacheWrite":3.125},"compat":{"sessionAffinityFormat":"openai-nosession"},"contextWindow":1050000,"maxTokens":128000,"thinkingLevelMap":{"off":null,"xhigh":"xhigh","max":"max"}},"grok-4.5":{"id":"grok-4.5","name":"Grok 4.5","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":2,"output":6,"cacheRead":0.5,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"},"contextWindow":500000,"maxTokens":500000},"grok-build-0.1":{"id":"grok-build-0.1","name":"Grok Build 0.1","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":1,"output":2,"cacheRead":0.2,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens"},"contextWindow":256000,"maxTokens":256000,"thinkingLevelMap":{"off":null,"minimal":null,"low":null,"medium":null}},"hy3-free":{"id":"hy3-free","name":"Hy3 Free","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"},"contextWindow":190000,"maxTokens":64000},"kimi-k2.5":{"id":"kimi-k2.5","name":"Kimi K2.5","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.6,"output":3,"cacheRead":0.08,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false},"contextWindow":262144,"maxTokens":65536},"kimi-k2.6":{"id":"kimi-k2.6","name":"Kimi K2.6","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.95,"output":4,"cacheRead":0.16,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false},"contextWindow":262144,"maxTokens":65536},"kimi-k2.7-code":{"id":"kimi-k2.7-code","name":"Kimi K2.7 Code","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.95,"output":4,"cacheRead":0.19,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false},"contextWindow":262144,"maxTokens":262144},"mimo-v2.5-free":{"id":"mimo-v2.5-free","name":"MiMo V2.5 Free","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"},"contextWindow":200000,"maxTokens":32000},"minimax-m2.5":{"id":"minimax-m2.5","name":"MiniMax-M2.5","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text"],"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"},"contextWindow":204800,"maxTokens":131072},"minimax-m2.7":{"id":"minimax-m2.7","name":"MiniMax-M2.7","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text"],"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false},"contextWindow":204800,"maxTokens":131072},"minimax-m3":{"id":"minimax-m3","name":"MiniMax-M3","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"},"contextWindow":512000,"maxTokens":128000},"nemotron-3-ultra-free":{"id":"nemotron-3-ultra-free","name":"Nemotron 3 Ultra Free","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"},"contextWindow":1000000,"maxTokens":128000},"north-mini-code-free":{"id":"north-mini-code-free","name":"North Mini Code Free","api":"openai-completions","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"},"contextWindow":256000,"maxTokens":64000},"qwen3.5-plus":{"id":"qwen3.5-plus","name":"Qwen3.5 Plus","api":"anthropic-messages","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen","reasoning":true,"input":["text","image"],"cost":{"input":0.2,"output":1.2,"cacheRead":0.02,"cacheWrite":0.25},"contextWindow":262144,"maxTokens":65536},"qwen3.6-plus":{"id":"qwen3.6-plus","name":"Qwen3.6 Plus","api":"anthropic-messages","provider":"opencode-zen","baseUrl":"https://opencode.ai/zen","reasoning":true,"input":["text","image"],"cost":{"input":0.5,"output":3,"cacheRead":0.05,"cacheWrite":0.625},"contextWindow":262144,"maxTokens":65536}} diff --git a/packages/ai/src/providers/data/perplexity.json b/packages/ai/src/providers/data/perplexity.json new file mode 100644 index 000000000..fb84d1731 --- /dev/null +++ b/packages/ai/src/providers/data/perplexity.json @@ -0,0 +1 @@ +{"sonar-pro":{"id":"sonar-pro","name":"Sonar Pro","api":"openai-completions","provider":"perplexity","baseUrl":"https://api.perplexity.ai","reasoning":false,"input":["text"],"cost":{"input":3,"output":15,"cacheRead":0,"cacheWrite":0},"contextWindow":200000,"maxTokens":8192}} diff --git a/packages/ai/src/providers/data/qianfan.json b/packages/ai/src/providers/data/qianfan.json new file mode 100644 index 000000000..cff2e9ba2 --- /dev/null +++ b/packages/ai/src/providers/data/qianfan.json @@ -0,0 +1 @@ +{"deepseek-v3.2":{"id":"deepseek-v3.2","name":"DeepSeek V3.2","api":"openai-completions","provider":"qianfan","baseUrl":"https://qianfan.baidubce.com/v2","compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","supportsStrictMode":false},"reasoning":false,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":98304,"maxTokens":32768}} diff --git a/packages/ai/src/providers/data/qwen-portal.json b/packages/ai/src/providers/data/qwen-portal.json new file mode 100644 index 000000000..765b3893a --- /dev/null +++ b/packages/ai/src/providers/data/qwen-portal.json @@ -0,0 +1 @@ +{"coder-model":{"id":"coder-model","name":"Qwen Coder","api":"openai-completions","provider":"qwen-portal","baseUrl":"https://portal.qwen.ai/v1","compat":{"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"},"reasoning":false,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":8192}} diff --git a/packages/ai/src/providers/data/synthetic.json b/packages/ai/src/providers/data/synthetic.json new file mode 100644 index 000000000..9a2c4ebef --- /dev/null +++ b/packages/ai/src/providers/data/synthetic.json @@ -0,0 +1 @@ +{"hf:moonshotai/Kimi-K2.5":{"id":"hf:moonshotai/Kimi-K2.5","name":"moonshotai/Kimi-K2.5","api":"openai-completions","provider":"synthetic","baseUrl":"https://api.synthetic.new/openai/v1","compat":{"supportsStore":false,"supportsDeveloperRole":false},"reasoning":false,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":8192}} diff --git a/packages/ai/src/providers/data/venice.json b/packages/ai/src/providers/data/venice.json new file mode 100644 index 000000000..2b63e470f --- /dev/null +++ b/packages/ai/src/providers/data/venice.json @@ -0,0 +1 @@ +{"llama-3.3-70b":{"id":"llama-3.3-70b","name":"Llama 3.3 70B","api":"openai-completions","provider":"venice","baseUrl":"https://api.venice.ai/api/v1","compat":{"supportsUsageInStreaming":false},"reasoning":false,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":8192}} diff --git a/packages/ai/src/providers/data/vllm.json b/packages/ai/src/providers/data/vllm.json new file mode 100644 index 000000000..f045f2ed5 --- /dev/null +++ b/packages/ai/src/providers/data/vllm.json @@ -0,0 +1 @@ +{"gpt-oss-20b":{"id":"gpt-oss-20b","name":"GPT OSS 20B","api":"openai-completions","provider":"vllm","baseUrl":"http://127.0.0.1:8000/v1","compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"supportsUsageInStreaming":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},"reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384}} diff --git a/packages/ai/src/providers/data/zenmux.json b/packages/ai/src/providers/data/zenmux.json new file mode 100644 index 000000000..3bf9946b9 --- /dev/null +++ b/packages/ai/src/providers/data/zenmux.json @@ -0,0 +1 @@ +{"anthropic/claude-opus-4.6":{"id":"anthropic/claude-opus-4.6","name":"Anthropic Opus 4.6","api":"anthropic-messages","provider":"zenmux","baseUrl":"https://zenmux.ai/api/anthropic","reasoning":true,"thinkingLevelMap":{"max":"max"},"input":["text","image"],"cost":{"input":5,"output":25,"cacheRead":0.5,"cacheWrite":6.25},"contextWindow":1000000,"maxTokens":128000,"compat":{"forceAdaptiveThinking":true}}} diff --git a/packages/ai/src/providers/deepinfra.models.ts b/packages/ai/src/providers/deepinfra.models.ts new file mode 100644 index 000000000..ecac0a50e --- /dev/null +++ b/packages/ai/src/providers/deepinfra.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/deepinfra.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const DEEPINFRA_MODELS = values as { + "deepseek-ai/DeepSeek-V3.2": Model<"openai-completions"> & { + id: "deepseek-ai/DeepSeek-V3.2"; + provider: "deepinfra"; + }; +}; diff --git a/packages/ai/src/providers/deepinfra.ts b/packages/ai/src/providers/deepinfra.ts new file mode 100644 index 000000000..96200602e --- /dev/null +++ b/packages/ai/src/providers/deepinfra.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { DEEPINFRA_MODELS } from "./deepinfra.models.ts"; + +export function deepinfraProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "deepinfra", + name: "DeepInfra", + baseUrl: "https://api.deepinfra.com/v1/openai", + auth: { apiKey: envApiKeyAuth("DeepInfra API key", ["DEEPINFRA_API_KEY"]) }, + models: Object.values(DEEPINFRA_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/firepass.models.ts b/packages/ai/src/providers/firepass.models.ts new file mode 100644 index 000000000..2a6cd641a --- /dev/null +++ b/packages/ai/src/providers/firepass.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/firepass.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const FIREPASS_MODELS = values as { + "accounts/fireworks/routers/kimi-k2p6-turbo": Model<"openai-completions"> & { + id: "accounts/fireworks/routers/kimi-k2p6-turbo"; + provider: "firepass"; + }; +}; diff --git a/packages/ai/src/providers/firepass.ts b/packages/ai/src/providers/firepass.ts new file mode 100644 index 000000000..d19c9b88b --- /dev/null +++ b/packages/ai/src/providers/firepass.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { FIREPASS_MODELS } from "./firepass.models.ts"; + +export function firepassProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "firepass", + name: "Fire Pass", + baseUrl: "https://api.fireworks.ai/inference/v1", + auth: { apiKey: envApiKeyAuth("Fire Pass API key", ["FIREPASS_API_KEY"]) }, + models: Object.values(FIREPASS_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/fugu.models.ts b/packages/ai/src/providers/fugu.models.ts new file mode 100644 index 000000000..d71d1cb2a --- /dev/null +++ b/packages/ai/src/providers/fugu.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/fugu.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const FUGU_MODELS = values as { + "fugu": Model<"openai-completions"> & { + id: "fugu"; + provider: "fugu"; + }; +}; diff --git a/packages/ai/src/providers/fugu.ts b/packages/ai/src/providers/fugu.ts new file mode 100644 index 000000000..f317e4ee4 --- /dev/null +++ b/packages/ai/src/providers/fugu.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { FUGU_MODELS } from "./fugu.models.ts"; + +export function fuguProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "fugu", + name: "Sakana Fugu", + baseUrl: "https://api.sakana.ai/v1", + auth: { apiKey: envApiKeyAuth("Sakana Fugu API key", ["FUGU_API_KEY"]) }, + models: Object.values(FUGU_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/gitlab-duo.models.ts b/packages/ai/src/providers/gitlab-duo.models.ts new file mode 100644 index 000000000..f6d0a40cc --- /dev/null +++ b/packages/ai/src/providers/gitlab-duo.models.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/gitlab-duo.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const GITLAB_DUO_MODELS = values as { + "claude-sonnet-4-6": Model<"anthropic-messages"> & { + id: "claude-sonnet-4-6"; + provider: "gitlab-duo"; + }; + "gpt-5.1-2025-11-13": Model<"openai-completions"> & { + id: "gpt-5.1-2025-11-13"; + provider: "gitlab-duo"; + }; +}; diff --git a/packages/ai/src/providers/gitlab-duo.ts b/packages/ai/src/providers/gitlab-duo.ts new file mode 100644 index 000000000..f8170381c --- /dev/null +++ b/packages/ai/src/providers/gitlab-duo.ts @@ -0,0 +1,67 @@ +import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { lazyOAuth } from "../auth/helpers.ts"; +import { getGitLabDuoDirectAccess } from "../auth/oauth/gitlab-duo-direct-access.ts"; +import { loadGitLabDuoOAuth } from "../auth/oauth/load.ts"; +import type { ApiKeyAuth } from "../auth/types.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { GITLAB_DUO_MODELS } from "./gitlab-duo.models.ts"; + +const GITLAB_TOKEN_ENV = ["GITLAB_TOKEN"] as const; + +/** + * GitLab PAT / stored API keys must be exchanged for a short-lived + * direct-access token + instance headers before proxy requests. + * Raw PATs are rejected by the AI proxy. + */ +function gitlabDuoApiKeyAuth(): ApiKeyAuth { + return { + name: "GitLab token", + login: async (callbacks) => { + const key = await callbacks.prompt({ type: "secret", message: "Enter GitLab token" }); + return { type: "api_key", key }; + }, + resolve: async ({ ctx, credential }) => { + let token: string | undefined; + let source = "stored credential"; + if (credential?.key) { + token = credential.key; + } else { + for (const envVar of GITLAB_TOKEN_ENV) { + const value = await ctx.env(envVar); + if (value) { + token = value; + source = envVar; + break; + } + } + } + if (!token) return undefined; + const directAccess = await getGitLabDuoDirectAccess(token); + return { + auth: { + apiKey: directAccess.token, + headers: { ...directAccess.headers, Authorization: `Bearer ${directAccess.token}` }, + }, + source, + }; + }, + }; +} + +export function gitlabDuoProvider(): Provider<"anthropic-messages" | "openai-completions"> { + return createProvider({ + id: "gitlab-duo", + name: "GitLab Duo", + baseUrl: "https://cloud.gitlab.com", + auth: { + apiKey: gitlabDuoApiKeyAuth(), + oauth: lazyOAuth({ name: "GitLab Duo", load: loadGitLabDuoOAuth }), + }, + models: Object.values(GITLAB_DUO_MODELS), + api: { + "anthropic-messages": anthropicMessagesApi(), + "openai-completions": openAICompletionsApi(), + }, + }); +} diff --git a/packages/ai/src/providers/google-antigravity.models.ts b/packages/ai/src/providers/google-antigravity.models.ts new file mode 100644 index 000000000..1b74b7792 --- /dev/null +++ b/packages/ai/src/providers/google-antigravity.models.ts @@ -0,0 +1,64 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/google-antigravity.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const GOOGLE_ANTIGRAVITY_MODELS = values as { + "claude-opus-4-5-thinking": Model<"google-gemini-cli"> & { + id: "claude-opus-4-5-thinking"; + provider: "google-antigravity"; + }; + "claude-opus-4-6-thinking": Model<"google-gemini-cli"> & { + id: "claude-opus-4-6-thinking"; + provider: "google-antigravity"; + }; + "claude-sonnet-4-5": Model<"google-gemini-cli"> & { + id: "claude-sonnet-4-5"; + provider: "google-antigravity"; + }; + "claude-sonnet-4-5-thinking": Model<"google-gemini-cli"> & { + id: "claude-sonnet-4-5-thinking"; + provider: "google-antigravity"; + }; + "claude-sonnet-4-6": Model<"google-gemini-cli"> & { + id: "claude-sonnet-4-6"; + provider: "google-antigravity"; + }; + "claude-sonnet-4-6-thinking": Model<"google-gemini-cli"> & { + id: "claude-sonnet-4-6-thinking"; + provider: "google-antigravity"; + }; + "gemini-2.5-flash": Model<"google-gemini-cli"> & { + id: "gemini-2.5-flash"; + provider: "google-antigravity"; + }; + "gemini-2.5-flash-thinking": Model<"google-gemini-cli"> & { + id: "gemini-2.5-flash-thinking"; + provider: "google-antigravity"; + }; + "gemini-2.5-pro": Model<"google-gemini-cli"> & { + id: "gemini-2.5-pro"; + provider: "google-antigravity"; + }; + "gemini-3-flash": Model<"google-gemini-cli"> & { + id: "gemini-3-flash"; + provider: "google-antigravity"; + }; + "gemini-3-pro-high": Model<"google-gemini-cli"> & { + id: "gemini-3-pro-high"; + provider: "google-antigravity"; + }; + "gemini-3-pro-low": Model<"google-gemini-cli"> & { + id: "gemini-3-pro-low"; + provider: "google-antigravity"; + }; + "gemini-3.1-pro-low": Model<"google-gemini-cli"> & { + id: "gemini-3.1-pro-low"; + provider: "google-antigravity"; + }; + "gpt-oss-120b-medium": Model<"google-gemini-cli"> & { + id: "gpt-oss-120b-medium"; + provider: "google-antigravity"; + }; +}; diff --git a/packages/ai/src/providers/google-gemini-cli.models.ts b/packages/ai/src/providers/google-gemini-cli.models.ts new file mode 100644 index 000000000..4700795d5 --- /dev/null +++ b/packages/ai/src/providers/google-gemini-cli.models.ts @@ -0,0 +1,40 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/google-gemini-cli.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const GOOGLE_GEMINI_CLI_MODELS = values as { + "gemini-2.0-flash": Model<"google-gemini-cli"> & { + id: "gemini-2.0-flash"; + provider: "google-gemini-cli"; + }; + "gemini-2.5-flash": Model<"google-gemini-cli"> & { + id: "gemini-2.5-flash"; + provider: "google-gemini-cli"; + }; + "gemini-2.5-pro": Model<"google-gemini-cli"> & { + id: "gemini-2.5-pro"; + provider: "google-gemini-cli"; + }; + "gemini-3-flash-preview": Model<"google-gemini-cli"> & { + id: "gemini-3-flash-preview"; + provider: "google-gemini-cli"; + }; + "gemini-3-pro-preview": Model<"google-gemini-cli"> & { + id: "gemini-3-pro-preview"; + provider: "google-gemini-cli"; + }; + "gemini-3.1-flash-lite-preview": Model<"google-gemini-cli"> & { + id: "gemini-3.1-flash-lite-preview"; + provider: "google-gemini-cli"; + }; + "gemini-3.1-pro-preview": Model<"google-gemini-cli"> & { + id: "gemini-3.1-pro-preview"; + provider: "google-gemini-cli"; + }; + "gemini-3.5-flash": Model<"google-gemini-cli"> & { + id: "gemini-3.5-flash"; + provider: "google-gemini-cli"; + }; +}; diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts new file mode 100644 index 000000000..a1bdf6726 --- /dev/null +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -0,0 +1,37 @@ +import { googleGeminiCliApi } from "../api/google-gemini-cli.lazy.ts"; +import { lazyOAuth } from "../auth/helpers.ts"; +import { loadGoogleAntigravityOAuth, loadGoogleGeminiCliOAuth } from "../auth/oauth/load.ts"; +import { createProvider } from "../models.ts"; +import { GOOGLE_ANTIGRAVITY_MODELS } from "./google-antigravity.models.ts"; +import { GOOGLE_GEMINI_CLI_MODELS } from "./google-gemini-cli.models.ts"; + +const createGoogleCcaProvider = ( + id: "google-gemini-cli" | "google-antigravity", + name: string, + load: typeof loadGoogleGeminiCliOAuth, + models: typeof GOOGLE_GEMINI_CLI_MODELS | typeof GOOGLE_ANTIGRAVITY_MODELS, +) => + createProvider({ + id, + name, + baseUrl: "https://cloudcode-pa.googleapis.com", + auth: { oauth: lazyOAuth({ name, load }) }, + models: Object.values(models), + api: googleGeminiCliApi(), + }); + +export const googleGeminiCliProvider = () => + createGoogleCcaProvider( + "google-gemini-cli", + "Google Gemini CLI", + loadGoogleGeminiCliOAuth, + GOOGLE_GEMINI_CLI_MODELS, + ); + +export const googleAntigravityProvider = () => + createGoogleCcaProvider( + "google-antigravity", + "Google Antigravity", + loadGoogleAntigravityOAuth, + GOOGLE_ANTIGRAVITY_MODELS, + ); diff --git a/packages/ai/src/providers/google-gemini-headers.ts b/packages/ai/src/providers/google-gemini-headers.ts new file mode 100644 index 000000000..c5feee450 --- /dev/null +++ b/packages/ai/src/providers/google-gemini-headers.ts @@ -0,0 +1,56 @@ +const VERSION = "0.50.0"; + +export function getGeminiCliHeaders(modelId: string): Record { + return { + "User-Agent": `GeminiCLI/${VERSION}/${modelId} (${process.platform}; ${process.arch}; terminal)`, + "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI", + }; +} + +export const ANTIGRAVITY_SYSTEM_INSTRUCTION = `You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding. +You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question. +The USER will send you requests, which you must always prioritize addressing. Along with each USER request, we will attach additional metadata about their current state, such as what files they have open and where their cursor is. +This information may or may not be relevant to the coding task, it is up for you to decide. + + BNF + bnf + *.bnf + text/x-bnf + + + + + + + + + + + + + + + + + + + + + +You are connected to a messaging system where you may receive messages from: %s. + +## Receiving Messages + +You receive messages automatically at the start of each invocation. All messages are delivered in full directly into your context — no manual retrieval is needed. + +## Reactive Wakeup (No Polling Needed) + +The system automatically resumes your execution when: +%s + +This means you do **NOT** need to poll in a loop while waiting for messages or updates. After launching anything that performs work asynchronously, you may continue other work or simply stop by calling no more tools. The system will notify you when there is something to process. +`; + +export function getAntigravityHeaders(): Record { + return { "User-Agent": process.env.PI_AI_ANTIGRAVITY_USER_AGENT || "antigravity-ide" }; +} diff --git a/packages/ai/src/providers/kilo.models.ts b/packages/ai/src/providers/kilo.models.ts new file mode 100644 index 000000000..acf97382c --- /dev/null +++ b/packages/ai/src/providers/kilo.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/kilo.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const KILO_MODELS = values as { + "anthropic/claude-sonnet-4.5": Model<"openai-completions"> & { + id: "anthropic/claude-sonnet-4.5"; + provider: "kilo"; + }; +}; diff --git a/packages/ai/src/providers/kilo.ts b/packages/ai/src/providers/kilo.ts new file mode 100644 index 000000000..09f02865f --- /dev/null +++ b/packages/ai/src/providers/kilo.ts @@ -0,0 +1,19 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { loadKiloOAuth } from "../auth/oauth/load.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { KILO_MODELS } from "./kilo.models.ts"; + +export function kiloProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "kilo", + name: "Kilo Gateway", + baseUrl: "https://api.kilo.ai/api/gateway", + auth: { + apiKey: envApiKeyAuth("Kilo Gateway API key", ["KILO_API_KEY"]), + oauth: lazyOAuth({ name: "Kilo Gateway", load: loadKiloOAuth }), + }, + models: Object.values(KILO_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/litellm.models.ts b/packages/ai/src/providers/litellm.models.ts new file mode 100644 index 000000000..cf2443786 --- /dev/null +++ b/packages/ai/src/providers/litellm.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/litellm.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const LITELLM_MODELS = values as { + "claude-opus-4-6": Model<"openai-completions"> & { + id: "claude-opus-4-6"; + provider: "litellm"; + }; +}; diff --git a/packages/ai/src/providers/litellm.ts b/packages/ai/src/providers/litellm.ts new file mode 100644 index 000000000..36b29e646 --- /dev/null +++ b/packages/ai/src/providers/litellm.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { LITELLM_MODELS } from "./litellm.models.ts"; + +export function litellmProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "litellm", + name: "LiteLLM", + baseUrl: "http://localhost:4000/v1", + auth: { apiKey: envApiKeyAuth("LiteLLM API key", ["LITELLM_API_KEY"]) }, + models: Object.values(LITELLM_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/lm-studio.models.ts b/packages/ai/src/providers/lm-studio.models.ts new file mode 100644 index 000000000..368d9ffa9 --- /dev/null +++ b/packages/ai/src/providers/lm-studio.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/lm-studio.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const LM_STUDIO_MODELS = values as { + "llama-3-8b": Model<"openai-completions"> & { + id: "llama-3-8b"; + provider: "lm-studio"; + }; +}; diff --git a/packages/ai/src/providers/lm-studio.ts b/packages/ai/src/providers/lm-studio.ts new file mode 100644 index 000000000..3fdd41c09 --- /dev/null +++ b/packages/ai/src/providers/lm-studio.ts @@ -0,0 +1,19 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { LM_STUDIO_MODELS } from "./lm-studio.models.ts"; + +export function lmStudioProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "lm-studio", + name: "LM Studio", + baseUrl: "http://127.0.0.1:1234/v1", + auth: { + apiKey: envApiKeyAuth("LM Studio API key", ["LM_STUDIO_API_KEY"], { + fallbackApiKey: "lm-studio-local", + }), + }, + models: Object.values(LM_STUDIO_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/minimax-code-cn.models.ts b/packages/ai/src/providers/minimax-code-cn.models.ts new file mode 100644 index 000000000..3fb8f655a --- /dev/null +++ b/packages/ai/src/providers/minimax-code-cn.models.ts @@ -0,0 +1,20 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/minimax-code-cn.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const MINIMAX_CODE_CN_MODELS = values as { + "MiniMax-M2.7": Model<"openai-completions"> & { + id: "MiniMax-M2.7"; + provider: "minimax-code-cn"; + }; + "MiniMax-M2.7-highspeed": Model<"openai-completions"> & { + id: "MiniMax-M2.7-highspeed"; + provider: "minimax-code-cn"; + }; + "MiniMax-M3": Model<"openai-completions"> & { + id: "MiniMax-M3"; + provider: "minimax-code-cn"; + }; +}; diff --git a/packages/ai/src/providers/minimax-code-cn.ts b/packages/ai/src/providers/minimax-code-cn.ts new file mode 100644 index 000000000..06a3e6a08 --- /dev/null +++ b/packages/ai/src/providers/minimax-code-cn.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { MINIMAX_CODE_CN_MODELS } from "./minimax-code-cn.models.ts"; + +export function minimaxCodeCnProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "minimax-code-cn", + name: "MiniMax Coding Plan (China)", + baseUrl: "https://api.minimaxi.com/v1", + auth: { apiKey: envApiKeyAuth("MiniMax Coding Plan (China) API key", ["MINIMAX_CODE_CN_API_KEY"]) }, + models: Object.values(MINIMAX_CODE_CN_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/minimax-code.models.ts b/packages/ai/src/providers/minimax-code.models.ts new file mode 100644 index 000000000..856480742 --- /dev/null +++ b/packages/ai/src/providers/minimax-code.models.ts @@ -0,0 +1,20 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/minimax-code.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const MINIMAX_CODE_MODELS = values as { + "MiniMax-M2.7": Model<"openai-completions"> & { + id: "MiniMax-M2.7"; + provider: "minimax-code"; + }; + "MiniMax-M2.7-highspeed": Model<"openai-completions"> & { + id: "MiniMax-M2.7-highspeed"; + provider: "minimax-code"; + }; + "MiniMax-M3": Model<"openai-completions"> & { + id: "MiniMax-M3"; + provider: "minimax-code"; + }; +}; diff --git a/packages/ai/src/providers/minimax-code.ts b/packages/ai/src/providers/minimax-code.ts new file mode 100644 index 000000000..cdf74366c --- /dev/null +++ b/packages/ai/src/providers/minimax-code.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { MINIMAX_CODE_MODELS } from "./minimax-code.models.ts"; + +export function minimaxCodeProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "minimax-code", + name: "MiniMax Coding Plan", + baseUrl: "https://api.minimax.io/v1", + auth: { apiKey: envApiKeyAuth("MiniMax Coding Plan API key", ["MINIMAX_CODE_API_KEY"]) }, + models: Object.values(MINIMAX_CODE_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/moonshot.models.ts b/packages/ai/src/providers/moonshot.models.ts new file mode 100644 index 000000000..87b439e39 --- /dev/null +++ b/packages/ai/src/providers/moonshot.models.ts @@ -0,0 +1,48 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/moonshot.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const MOONSHOT_MODELS = values as { + "kimi-k2-0711-preview": Model<"openai-completions"> & { + id: "kimi-k2-0711-preview"; + provider: "moonshot"; + }; + "kimi-k2-0905-preview": Model<"openai-completions"> & { + id: "kimi-k2-0905-preview"; + provider: "moonshot"; + }; + "kimi-k2-thinking": Model<"openai-completions"> & { + id: "kimi-k2-thinking"; + provider: "moonshot"; + }; + "kimi-k2-thinking-turbo": Model<"openai-completions"> & { + id: "kimi-k2-thinking-turbo"; + provider: "moonshot"; + }; + "kimi-k2-turbo-preview": Model<"openai-completions"> & { + id: "kimi-k2-turbo-preview"; + provider: "moonshot"; + }; + "kimi-k2.5": Model<"openai-completions"> & { + id: "kimi-k2.5"; + provider: "moonshot"; + }; + "kimi-k2.6": Model<"openai-completions"> & { + id: "kimi-k2.6"; + provider: "moonshot"; + }; + "kimi-k2.7-code": Model<"openai-completions"> & { + id: "kimi-k2.7-code"; + provider: "moonshot"; + }; + "kimi-k2.7-code-highspeed": Model<"openai-completions"> & { + id: "kimi-k2.7-code-highspeed"; + provider: "moonshot"; + }; + "kimi-k3": Model<"openai-completions"> & { + id: "kimi-k3"; + provider: "moonshot"; + }; +}; diff --git a/packages/ai/src/providers/moonshot.ts b/packages/ai/src/providers/moonshot.ts new file mode 100644 index 000000000..a3ad72aa6 --- /dev/null +++ b/packages/ai/src/providers/moonshot.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { MOONSHOT_MODELS } from "./moonshot.models.ts"; + +export function moonshotProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "moonshot", + name: "Moonshot (Kimi API)", + baseUrl: "https://api.moonshot.ai/v1", + auth: { apiKey: envApiKeyAuth("Moonshot API key", ["MOONSHOT_API_KEY"]) }, + models: Object.values(MOONSHOT_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/nanogpt.models.ts b/packages/ai/src/providers/nanogpt.models.ts new file mode 100644 index 000000000..3f7f372c5 --- /dev/null +++ b/packages/ai/src/providers/nanogpt.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/nanogpt.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const NANOGPT_MODELS = values as { + "openai/gpt-5.4": Model<"openai-completions"> & { + id: "openai/gpt-5.4"; + provider: "nanogpt"; + }; +}; diff --git a/packages/ai/src/providers/nanogpt.ts b/packages/ai/src/providers/nanogpt.ts new file mode 100644 index 000000000..78b400dd1 --- /dev/null +++ b/packages/ai/src/providers/nanogpt.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { NANOGPT_MODELS } from "./nanogpt.models.ts"; + +export function nanogptProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "nanogpt", + name: "NanoGPT", + baseUrl: "https://nano-gpt.com/api/v1", + auth: { apiKey: envApiKeyAuth("NanoGPT API key", ["NANO_GPT_API_KEY"]) }, + models: Object.values(NANOGPT_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/ollama-cloud.models.ts b/packages/ai/src/providers/ollama-cloud.models.ts new file mode 100644 index 000000000..effcadbf0 --- /dev/null +++ b/packages/ai/src/providers/ollama-cloud.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/ollama-cloud.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const OLLAMA_CLOUD_MODELS = values as { + "gpt-oss:120b": Model<"openai-completions"> & { + id: "gpt-oss:120b"; + provider: "ollama-cloud"; + }; +}; diff --git a/packages/ai/src/providers/ollama-cloud.ts b/packages/ai/src/providers/ollama-cloud.ts new file mode 100644 index 000000000..6ba4e1b1a --- /dev/null +++ b/packages/ai/src/providers/ollama-cloud.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { OLLAMA_CLOUD_MODELS } from "./ollama-cloud.models.ts"; + +export function ollamaCloudProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "ollama-cloud", + name: "Ollama Cloud", + baseUrl: "https://ollama.com/v1", + auth: { apiKey: envApiKeyAuth("Ollama Cloud API key", ["OLLAMA_CLOUD_API_KEY"]) }, + models: Object.values(OLLAMA_CLOUD_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/ollama.models.ts b/packages/ai/src/providers/ollama.models.ts new file mode 100644 index 000000000..1056a8195 --- /dev/null +++ b/packages/ai/src/providers/ollama.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/ollama.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const OLLAMA_MODELS = values as { + "gpt-oss:20b": Model<"openai-completions"> & { + id: "gpt-oss:20b"; + provider: "ollama"; + }; +}; diff --git a/packages/ai/src/providers/ollama.ts b/packages/ai/src/providers/ollama.ts new file mode 100644 index 000000000..992b39b3b --- /dev/null +++ b/packages/ai/src/providers/ollama.ts @@ -0,0 +1,17 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { OLLAMA_MODELS } from "./ollama.models.ts"; + +export function ollamaProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "ollama", + name: "Ollama", + baseUrl: "http://127.0.0.1:11434/v1", + auth: { + apiKey: envApiKeyAuth("Ollama API key", ["OLLAMA_API_KEY"], { fallbackApiKey: "ollama-local" }), + }, + models: Object.values(OLLAMA_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/openai-codex-device.models.ts b/packages/ai/src/providers/openai-codex-device.models.ts new file mode 100644 index 000000000..7e10ba3f0 --- /dev/null +++ b/packages/ai/src/providers/openai-codex-device.models.ts @@ -0,0 +1,36 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/openai-codex-device.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const OPENAI_CODEX_DEVICE_MODELS = values as { + "gpt-5.3-codex-spark": Model<"openai-codex-responses"> & { + id: "gpt-5.3-codex-spark"; + provider: "openai-codex-device"; + }; + "gpt-5.4": Model<"openai-codex-responses"> & { + id: "gpt-5.4"; + provider: "openai-codex-device"; + }; + "gpt-5.4-mini": Model<"openai-codex-responses"> & { + id: "gpt-5.4-mini"; + provider: "openai-codex-device"; + }; + "gpt-5.5": Model<"openai-codex-responses"> & { + id: "gpt-5.5"; + provider: "openai-codex-device"; + }; + "gpt-5.6-luna": Model<"openai-codex-responses"> & { + id: "gpt-5.6-luna"; + provider: "openai-codex-device"; + }; + "gpt-5.6-sol": Model<"openai-codex-responses"> & { + id: "gpt-5.6-sol"; + provider: "openai-codex-device"; + }; + "gpt-5.6-terra": Model<"openai-codex-responses"> & { + id: "gpt-5.6-terra"; + provider: "openai-codex-device"; + }; +}; diff --git a/packages/ai/src/providers/openai-codex-device.ts b/packages/ai/src/providers/openai-codex-device.ts new file mode 100644 index 000000000..bbcd898c2 --- /dev/null +++ b/packages/ai/src/providers/openai-codex-device.ts @@ -0,0 +1,21 @@ +import { openAICodexResponsesApi } from "../api/openai-codex-responses.lazy.ts"; +import { lazyOAuth } from "../auth/helpers.ts"; +import { loadOpenAICodexDeviceOAuth } from "../auth/oauth/load.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { OPENAI_CODEX_DEVICE_MODELS } from "./openai-codex-device.models.ts"; + +export function openaiCodexDeviceProvider(): Provider<"openai-codex-responses"> { + return createProvider({ + id: "openai-codex-device", + name: "OpenAI Codex (Device Code)", + baseUrl: "https://chatgpt.com/backend-api", + auth: { + oauth: lazyOAuth({ + name: "OpenAI (ChatGPT Plus/Pro, device code)", + load: loadOpenAICodexDeviceOAuth, + }), + }, + models: Object.values(OPENAI_CODEX_DEVICE_MODELS), + api: openAICodexResponsesApi(), + }); +} diff --git a/packages/ai/src/providers/opencode-zen.models.ts b/packages/ai/src/providers/opencode-zen.models.ts new file mode 100644 index 000000000..833255abd --- /dev/null +++ b/packages/ai/src/providers/opencode-zen.models.ts @@ -0,0 +1,224 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/opencode-zen.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const OPENCODE_ZEN_MODELS = values as { + "big-pickle": Model<"openai-completions"> & { + id: "big-pickle"; + provider: "opencode-zen"; + }; + "claude-fable-5": Model<"anthropic-messages"> & { + id: "claude-fable-5"; + provider: "opencode-zen"; + }; + "claude-haiku-4-5": Model<"anthropic-messages"> & { + id: "claude-haiku-4-5"; + provider: "opencode-zen"; + }; + "claude-opus-4-1": Model<"anthropic-messages"> & { + id: "claude-opus-4-1"; + provider: "opencode-zen"; + }; + "claude-opus-4-5": Model<"anthropic-messages"> & { + id: "claude-opus-4-5"; + provider: "opencode-zen"; + }; + "claude-opus-4-6": Model<"anthropic-messages"> & { + id: "claude-opus-4-6"; + provider: "opencode-zen"; + }; + "claude-opus-4-7": Model<"anthropic-messages"> & { + id: "claude-opus-4-7"; + provider: "opencode-zen"; + }; + "claude-opus-4-8": Model<"anthropic-messages"> & { + id: "claude-opus-4-8"; + provider: "opencode-zen"; + }; + "claude-sonnet-4": Model<"anthropic-messages"> & { + id: "claude-sonnet-4"; + provider: "opencode-zen"; + }; + "claude-sonnet-4-5": Model<"anthropic-messages"> & { + id: "claude-sonnet-4-5"; + provider: "opencode-zen"; + }; + "claude-sonnet-4-6": Model<"anthropic-messages"> & { + id: "claude-sonnet-4-6"; + provider: "opencode-zen"; + }; + "claude-sonnet-5": Model<"anthropic-messages"> & { + id: "claude-sonnet-5"; + provider: "opencode-zen"; + }; + "deepseek-v4-flash": Model<"openai-completions"> & { + id: "deepseek-v4-flash"; + provider: "opencode-zen"; + }; + "deepseek-v4-flash-free": Model<"openai-completions"> & { + id: "deepseek-v4-flash-free"; + provider: "opencode-zen"; + }; + "deepseek-v4-pro": Model<"openai-completions"> & { + id: "deepseek-v4-pro"; + provider: "opencode-zen"; + }; + "gemini-3-flash": Model<"google-generative-ai"> & { + id: "gemini-3-flash"; + provider: "opencode-zen"; + }; + "gemini-3.1-pro": Model<"google-generative-ai"> & { + id: "gemini-3.1-pro"; + provider: "opencode-zen"; + }; + "gemini-3.5-flash": Model<"google-generative-ai"> & { + id: "gemini-3.5-flash"; + provider: "opencode-zen"; + }; + "glm-5": Model<"openai-completions"> & { + id: "glm-5"; + provider: "opencode-zen"; + }; + "glm-5.1": Model<"openai-completions"> & { + id: "glm-5.1"; + provider: "opencode-zen"; + }; + "glm-5.2": Model<"openai-completions"> & { + id: "glm-5.2"; + provider: "opencode-zen"; + }; + "gpt-5": Model<"openai-responses"> & { + id: "gpt-5"; + provider: "opencode-zen"; + }; + "gpt-5-codex": Model<"openai-responses"> & { + id: "gpt-5-codex"; + provider: "opencode-zen"; + }; + "gpt-5-nano": Model<"openai-responses"> & { + id: "gpt-5-nano"; + provider: "opencode-zen"; + }; + "gpt-5.1": Model<"openai-responses"> & { + id: "gpt-5.1"; + provider: "opencode-zen"; + }; + "gpt-5.1-codex": Model<"openai-responses"> & { + id: "gpt-5.1-codex"; + provider: "opencode-zen"; + }; + "gpt-5.1-codex-max": Model<"openai-responses"> & { + id: "gpt-5.1-codex-max"; + provider: "opencode-zen"; + }; + "gpt-5.1-codex-mini": Model<"openai-responses"> & { + id: "gpt-5.1-codex-mini"; + provider: "opencode-zen"; + }; + "gpt-5.2": Model<"openai-responses"> & { + id: "gpt-5.2"; + provider: "opencode-zen"; + }; + "gpt-5.2-codex": Model<"openai-responses"> & { + id: "gpt-5.2-codex"; + provider: "opencode-zen"; + }; + "gpt-5.3-codex": Model<"openai-responses"> & { + id: "gpt-5.3-codex"; + provider: "opencode-zen"; + }; + "gpt-5.4": Model<"openai-responses"> & { + id: "gpt-5.4"; + provider: "opencode-zen"; + }; + "gpt-5.4-mini": Model<"openai-responses"> & { + id: "gpt-5.4-mini"; + provider: "opencode-zen"; + }; + "gpt-5.4-nano": Model<"openai-responses"> & { + id: "gpt-5.4-nano"; + provider: "opencode-zen"; + }; + "gpt-5.4-pro": Model<"openai-responses"> & { + id: "gpt-5.4-pro"; + provider: "opencode-zen"; + }; + "gpt-5.5": Model<"openai-responses"> & { + id: "gpt-5.5"; + provider: "opencode-zen"; + }; + "gpt-5.5-pro": Model<"openai-responses"> & { + id: "gpt-5.5-pro"; + provider: "opencode-zen"; + }; + "gpt-5.6-luna": Model<"openai-responses"> & { + id: "gpt-5.6-luna"; + provider: "opencode-zen"; + }; + "gpt-5.6-sol": Model<"openai-responses"> & { + id: "gpt-5.6-sol"; + provider: "opencode-zen"; + }; + "gpt-5.6-terra": Model<"openai-responses"> & { + id: "gpt-5.6-terra"; + provider: "opencode-zen"; + }; + "grok-4.5": Model<"openai-completions"> & { + id: "grok-4.5"; + provider: "opencode-zen"; + }; + "grok-build-0.1": Model<"openai-completions"> & { + id: "grok-build-0.1"; + provider: "opencode-zen"; + }; + "hy3-free": Model<"openai-completions"> & { + id: "hy3-free"; + provider: "opencode-zen"; + }; + "kimi-k2.5": Model<"openai-completions"> & { + id: "kimi-k2.5"; + provider: "opencode-zen"; + }; + "kimi-k2.6": Model<"openai-completions"> & { + id: "kimi-k2.6"; + provider: "opencode-zen"; + }; + "kimi-k2.7-code": Model<"openai-completions"> & { + id: "kimi-k2.7-code"; + provider: "opencode-zen"; + }; + "mimo-v2.5-free": Model<"openai-completions"> & { + id: "mimo-v2.5-free"; + provider: "opencode-zen"; + }; + "minimax-m2.5": Model<"openai-completions"> & { + id: "minimax-m2.5"; + provider: "opencode-zen"; + }; + "minimax-m2.7": Model<"openai-completions"> & { + id: "minimax-m2.7"; + provider: "opencode-zen"; + }; + "minimax-m3": Model<"openai-completions"> & { + id: "minimax-m3"; + provider: "opencode-zen"; + }; + "nemotron-3-ultra-free": Model<"openai-completions"> & { + id: "nemotron-3-ultra-free"; + provider: "opencode-zen"; + }; + "north-mini-code-free": Model<"openai-completions"> & { + id: "north-mini-code-free"; + provider: "opencode-zen"; + }; + "qwen3.5-plus": Model<"anthropic-messages"> & { + id: "qwen3.5-plus"; + provider: "opencode-zen"; + }; + "qwen3.6-plus": Model<"anthropic-messages"> & { + id: "qwen3.6-plus"; + provider: "opencode-zen"; + }; +}; diff --git a/packages/ai/src/providers/opencode-zen.ts b/packages/ai/src/providers/opencode-zen.ts new file mode 100644 index 000000000..281f48256 --- /dev/null +++ b/packages/ai/src/providers/opencode-zen.ts @@ -0,0 +1,24 @@ +import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; +import { googleGenerativeAIApi } from "../api/google-generative-ai.lazy.ts"; +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { OPENCODE_ZEN_MODELS } from "./opencode-zen.models.ts"; + +export function opencodeZenProvider(): Provider< + "anthropic-messages" | "google-generative-ai" | "openai-completions" | "openai-responses" +> { + return createProvider({ + id: "opencode-zen", + name: "OpenCode Zen", + auth: { apiKey: envApiKeyAuth("OpenCode API key", ["OPENCODE_API_KEY"]) }, + models: Object.values(OPENCODE_ZEN_MODELS), + api: { + "anthropic-messages": anthropicMessagesApi(), + "google-generative-ai": googleGenerativeAIApi(), + "openai-completions": openAICompletionsApi(), + "openai-responses": openAIResponsesApi(), + }, + }); +} diff --git a/packages/ai/src/providers/perplexity.models.ts b/packages/ai/src/providers/perplexity.models.ts new file mode 100644 index 000000000..05b2c452f --- /dev/null +++ b/packages/ai/src/providers/perplexity.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/perplexity.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const PERPLEXITY_MODELS = values as { + "sonar-pro": Model<"openai-completions"> & { + id: "sonar-pro"; + provider: "perplexity"; + }; +}; diff --git a/packages/ai/src/providers/perplexity.ts b/packages/ai/src/providers/perplexity.ts new file mode 100644 index 000000000..b78eed324 --- /dev/null +++ b/packages/ai/src/providers/perplexity.ts @@ -0,0 +1,22 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { PERPLEXITY_MODELS } from "./perplexity.models.ts"; + +export function perplexityProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "perplexity", + name: "Perplexity", + baseUrl: "https://api.perplexity.ai", + // API-key only. The OAuth flow (auth/oauth/perplexity.ts) returns a + // www.perplexity.ai web-session JWT, which is NOT accepted by + // api.perplexity.ai as a Bearer token — session tokens are not direct + // API keys. The flow code is preserved for a future transport that + // routes session requests through the web API instead. + auth: { + apiKey: envApiKeyAuth("Perplexity API key", ["PERPLEXITY_API_KEY"]), + }, + models: Object.values(PERPLEXITY_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/qianfan.models.ts b/packages/ai/src/providers/qianfan.models.ts new file mode 100644 index 000000000..f95eefe41 --- /dev/null +++ b/packages/ai/src/providers/qianfan.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/qianfan.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const QIANFAN_MODELS = values as { + "deepseek-v3.2": Model<"openai-completions"> & { + id: "deepseek-v3.2"; + provider: "qianfan"; + }; +}; diff --git a/packages/ai/src/providers/qianfan.ts b/packages/ai/src/providers/qianfan.ts new file mode 100644 index 000000000..244044fb5 --- /dev/null +++ b/packages/ai/src/providers/qianfan.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { QIANFAN_MODELS } from "./qianfan.models.ts"; + +export function qianfanProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "qianfan", + name: "Qianfan", + baseUrl: "https://qianfan.baidubce.com/v2", + auth: { apiKey: envApiKeyAuth("Qianfan API key", ["QIANFAN_API_KEY"]) }, + models: Object.values(QIANFAN_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/qwen-portal.models.ts b/packages/ai/src/providers/qwen-portal.models.ts new file mode 100644 index 000000000..92386ff87 --- /dev/null +++ b/packages/ai/src/providers/qwen-portal.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/qwen-portal.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const QWEN_PORTAL_MODELS = values as { + "coder-model": Model<"openai-completions"> & { + id: "coder-model"; + provider: "qwen-portal"; + }; +}; diff --git a/packages/ai/src/providers/qwen-portal.ts b/packages/ai/src/providers/qwen-portal.ts new file mode 100644 index 000000000..1b919bdaa --- /dev/null +++ b/packages/ai/src/providers/qwen-portal.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { QWEN_PORTAL_MODELS } from "./qwen-portal.models.ts"; + +export function qwenPortalProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "qwen-portal", + name: "Qwen Portal", + baseUrl: "https://portal.qwen.ai/v1", + auth: { apiKey: envApiKeyAuth("Qwen Portal token or API key", ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"]) }, + models: Object.values(QWEN_PORTAL_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/synthetic.models.ts b/packages/ai/src/providers/synthetic.models.ts new file mode 100644 index 000000000..1377e64a0 --- /dev/null +++ b/packages/ai/src/providers/synthetic.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/synthetic.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const SYNTHETIC_MODELS = values as { + "hf:moonshotai/Kimi-K2.5": Model<"openai-completions"> & { + id: "hf:moonshotai/Kimi-K2.5"; + provider: "synthetic"; + }; +}; diff --git a/packages/ai/src/providers/synthetic.ts b/packages/ai/src/providers/synthetic.ts new file mode 100644 index 000000000..d008c13c6 --- /dev/null +++ b/packages/ai/src/providers/synthetic.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { SYNTHETIC_MODELS } from "./synthetic.models.ts"; + +export function syntheticProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "synthetic", + name: "Synthetic", + baseUrl: "https://api.synthetic.new/openai/v1", + auth: { apiKey: envApiKeyAuth("Synthetic API key", ["SYNTHETIC_API_KEY"]) }, + models: Object.values(SYNTHETIC_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/venice.models.ts b/packages/ai/src/providers/venice.models.ts new file mode 100644 index 000000000..b092ffda8 --- /dev/null +++ b/packages/ai/src/providers/venice.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/venice.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const VENICE_MODELS = values as { + "llama-3.3-70b": Model<"openai-completions"> & { + id: "llama-3.3-70b"; + provider: "venice"; + }; +}; diff --git a/packages/ai/src/providers/venice.ts b/packages/ai/src/providers/venice.ts new file mode 100644 index 000000000..1156dcede --- /dev/null +++ b/packages/ai/src/providers/venice.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { VENICE_MODELS } from "./venice.models.ts"; + +export function veniceProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "venice", + name: "Venice", + baseUrl: "https://api.venice.ai/api/v1", + auth: { apiKey: envApiKeyAuth("Venice API key", ["VENICE_API_KEY"]) }, + models: Object.values(VENICE_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/vllm.models.ts b/packages/ai/src/providers/vllm.models.ts new file mode 100644 index 000000000..84c95a7ed --- /dev/null +++ b/packages/ai/src/providers/vllm.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/vllm.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const VLLM_MODELS = values as { + "gpt-oss-20b": Model<"openai-completions"> & { + id: "gpt-oss-20b"; + provider: "vllm"; + }; +}; diff --git a/packages/ai/src/providers/vllm.ts b/packages/ai/src/providers/vllm.ts new file mode 100644 index 000000000..d230d398e --- /dev/null +++ b/packages/ai/src/providers/vllm.ts @@ -0,0 +1,17 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { VLLM_MODELS } from "./vllm.models.ts"; + +export function vllmProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "vllm", + name: "vLLM", + baseUrl: "http://127.0.0.1:8000/v1", + auth: { + apiKey: envApiKeyAuth("vLLM API key", ["VLLM_API_KEY"], { fallbackApiKey: "vllm-local" }), + }, + models: Object.values(VLLM_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/zenmux.models.ts b/packages/ai/src/providers/zenmux.models.ts new file mode 100644 index 000000000..384c35e42 --- /dev/null +++ b/packages/ai/src/providers/zenmux.models.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/zenmux.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const ZENMUX_MODELS = values as { + "anthropic/claude-opus-4.6": Model<"anthropic-messages"> & { + id: "anthropic/claude-opus-4.6"; + provider: "zenmux"; + }; +}; diff --git a/packages/ai/src/providers/zenmux.ts b/packages/ai/src/providers/zenmux.ts new file mode 100644 index 000000000..5a1331630 --- /dev/null +++ b/packages/ai/src/providers/zenmux.ts @@ -0,0 +1,15 @@ +import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { ZENMUX_MODELS } from "./zenmux.models.ts"; + +export function zenmuxProvider(): Provider<"anthropic-messages"> { + return createProvider({ + id: "zenmux", + name: "ZenMux", + baseUrl: "https://zenmux.ai/api/anthropic", + auth: { apiKey: envApiKeyAuth("ZenMux API key", ["ZENMUX_API_KEY"]) }, + models: Object.values(ZENMUX_MODELS), + api: anthropicMessagesApi(), + }); +} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 391a2b303..f873888f5 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -15,15 +15,17 @@ export type { AssistantMessageEventStream } from "./utils/event-stream.ts"; export type KnownApi = | "openai-completions" - | "mistral-conversations" | "openai-responses" - | "azure-openai-responses" | "openai-codex-responses" | "anthropic-messages" | "bedrock-converse-stream" | "google-generative-ai" + | "google-gemini-cli" | "google-vertex" - | "pi-messages"; + | "pi-messages" + | "azure-openai-responses" + | "mistral-conversations" + | "cursor-connect"; export type Api = KnownApi | (string & {}); @@ -32,42 +34,68 @@ export type KnownImagesApi = "openrouter-images"; export type ImagesApi = KnownImagesApi | (string & {}); export type KnownProvider = + | "alibaba-coding-plan" | "amazon-bedrock" | "ant-ling" | "anthropic" - | "google" - | "google-vertex" - | "openai" | "azure-openai-responses" - | "openai-codex" - | "radius" - | "nvidia" + | "cerebras" + | "cloudflare-ai-gateway" + | "cloudflare-workers-ai" + | "cursor" + | "deepinfra" | "deepseek" + | "firepass" + | "fireworks" + | "fugu" | "github-copilot" - | "xai" + | "gitlab-duo" + | "google" + | "google-antigravity" + | "google-gemini-cli" + | "google-vertex" | "groq" - | "cerebras" - | "openrouter" - | "vercel-ai-gateway" - | "zai" - | "zai-coding-cn" - | "mistral" + | "huggingface" + | "kilo" + | "kimi-coding" + | "litellm" + | "lm-studio" | "minimax" | "minimax-cn" + | "minimax-code" + | "minimax-code-cn" + | "mistral" + | "moonshot" | "moonshotai" | "moonshotai-cn" - | "huggingface" - | "fireworks" - | "together" + | "nanogpt" + | "nvidia" + | "ollama" + | "ollama-cloud" + | "openai" + | "openai-codex" + | "openai-codex-device" | "opencode" | "opencode-go" - | "kimi-coding" - | "cloudflare-workers-ai" - | "cloudflare-ai-gateway" + | "opencode-zen" + | "openrouter" + | "perplexity" + | "qianfan" + | "qwen-portal" + | "radius" + | "synthetic" + | "together" + | "venice" + | "vercel-ai-gateway" + | "vllm" + | "xai" | "xiaomi" - | "xiaomi-token-plan-cn" | "xiaomi-token-plan-ams" - | "xiaomi-token-plan-sgp"; + | "xiaomi-token-plan-cn" + | "xiaomi-token-plan-sgp" + | "zai" + | "zai-coding-cn" + | "zenmux"; export type ProviderId = KnownProvider | string; export type KnownImagesProvider = "openrouter"; diff --git a/packages/ai/test/anthropic-oauth.test.ts b/packages/ai/test/anthropic-oauth.test.ts index 679dd7d5a..7e35f4f62 100644 --- a/packages/ai/test/anthropic-oauth.test.ts +++ b/packages/ai/test/anthropic-oauth.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts"; +import { anthropicOAuth, loginAnthropic, refreshAnthropicToken } from "../src/auth/oauth/anthropic.ts"; import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts"; function jsonResponse(body: unknown, status: number = 200): Response { @@ -53,16 +53,18 @@ describe.sequential("Anthropic OAuth", () => { }); vi.stubGlobal("fetch", fetchMock); - const credentials = await anthropicOAuth.login({ - notify: (event) => { - if (event.type === "auth_url") authUrl = event.url; + const credentials = await loginAnthropic({ + onAuth: (info) => { + authUrl = info.url; }, - prompt: async (prompt) => { - if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`); + onPrompt: async () => "", + onManualCodeInput: async () => { const url = new URL(authUrl); const state = url.searchParams.get("state"); const redirectUri = url.searchParams.get("redirect_uri"); - if (!state || !redirectUri) throw new Error("Missing OAuth state or redirect_uri in auth URL"); + if (!state || !redirectUri) { + throw new Error("Missing OAuth state or redirect_uri in auth URL"); + } return `${redirectUri}?code=manual-code&state=${state}`; }, }); @@ -89,12 +91,7 @@ describe.sequential("Anthropic OAuth", () => { }); vi.stubGlobal("fetch", fetchMock); - const credentials = await anthropicOAuth.refresh({ - type: "oauth", - access: "old-access-token", - refresh: "refresh-token", - expires: 0, - }); + const credentials = await refreshAnthropicToken("refresh-token"); expect(credentials.access).toBe("new-access-token"); expect(credentials.refresh).toBe("new-refresh-token"); diff --git a/packages/ai/test/api-key-provider-parity.test.ts b/packages/ai/test/api-key-provider-parity.test.ts new file mode 100644 index 000000000..5244fa7a6 --- /dev/null +++ b/packages/ai/test/api-key-provider-parity.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts"; +import { builtinProviders } from "../src/providers/all.ts"; + +const API_KEY_PROVIDER_IDS = [ + "alibaba-coding-plan", + "deepinfra", + "firepass", + "fugu", + "litellm", + "lm-studio", + "nanogpt", + "ollama", + "ollama-cloud", + "qianfan", + "qwen-portal", + "synthetic", + "venice", + "vllm", + "zenmux", +] as const; + +const API_KEY_ENV_VARS = { + "alibaba-coding-plan": ["ALIBABA_CODING_PLAN_API_KEY"], + deepinfra: ["DEEPINFRA_API_KEY"], + firepass: ["FIREPASS_API_KEY"], + fugu: ["FUGU_API_KEY"], + litellm: ["LITELLM_API_KEY"], + "lm-studio": ["LM_STUDIO_API_KEY"], + nanogpt: ["NANO_GPT_API_KEY"], + ollama: ["OLLAMA_API_KEY"], + "ollama-cloud": ["OLLAMA_CLOUD_API_KEY"], + qianfan: ["QIANFAN_API_KEY"], + "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], + synthetic: ["SYNTHETIC_API_KEY"], + venice: ["VENICE_API_KEY"], + vllm: ["VLLM_API_KEY"], + zenmux: ["ZENMUX_API_KEY"], +} as const satisfies Record<(typeof API_KEY_PROVIDER_IDS)[number], readonly string[]>; + +const LOCAL_DUMMY_KEYS = { + "lm-studio": "lm-studio-local", + ollama: "ollama-local", + vllm: "vllm-local", +} as const; + +const SEARCH_ONLY_PROVIDER_IDS = ["kagi", "parallel", "tavily"] as const; + +describe("Gajae API-key provider parity", () => { + it("does not advertise search-only services as chat model providers", () => { + // Given: the complete built-in model-provider catalog. + const providerIds = builtinProviders().map((provider) => provider.id); + + // When: search-only integration identifiers are compared with model providers. + const advertisedSearchProviders = SEARCH_ONLY_PROVIDER_IDS.filter((providerId) => + providerIds.includes(providerId), + ); + + // Then: none can route a coding-agent chat request to a search API. + expect(advertisedSearchProviders).toEqual([]); + }); + + it("registers every provider with API-key auth and at least one model", async () => { + const providers = new Map(builtinProviders().map((provider) => [provider.id, provider])); + + expect([...providers.keys()]).toEqual(expect.arrayContaining([...API_KEY_PROVIDER_IDS])); + for (const providerId of API_KEY_PROVIDER_IDS) { + const provider = providers.get(providerId); + const apiKeyAuth = provider?.auth.apiKey; + expect(apiKeyAuth).toBeDefined(); + expect(provider?.auth.oauth).toBeUndefined(); + expect(provider?.getModels().length).toBeGreaterThan(0); + if (!provider || !apiKeyAuth) throw new Error(`Missing provider auth: ${providerId}`); + + const model = provider.getModels()[0]; + if (!model) throw new Error(`Missing provider model: ${providerId}`); + const envVars: readonly string[] = API_KEY_ENV_VARS[providerId]; + const resolved = await apiKeyAuth.resolve({ + ctx: { + env: async (name) => (envVars.includes(name) ? "test-key" : undefined), + fileExists: async () => false, + }, + }); + expect(resolved?.auth.apiKey).toBe("test-key"); + } + }); + + it("discovers every provider's documented environment variables", () => { + for (const providerId of API_KEY_PROVIDER_IDS) { + for (const envVar of API_KEY_ENV_VARS[providerId]) { + const env = { [envVar]: "test-key" }; + expect(findEnvKeys(providerId, env)).toEqual([envVar]); + expect(getEnvApiKey(providerId, env)).toBe("test-key"); + } + } + }); + + it("uses dummy API keys for unauthenticated local servers", async () => { + const providers = new Map(builtinProviders().map((provider) => [provider.id, provider])); + for (const [providerId, dummyKey] of Object.entries(LOCAL_DUMMY_KEYS)) { + expect(getEnvApiKey(providerId, {})).toBe(dummyKey); + + const provider = providers.get(providerId); + const apiKeyAuth = provider?.auth.apiKey; + const model = provider?.getModels()[0]; + if (!apiKeyAuth || !model) throw new Error(`Missing local provider: ${providerId}`); + const resolved = await apiKeyAuth.resolve({ + ctx: { env: async () => undefined, fileExists: async () => false }, + }); + expect(resolved?.auth.apiKey).toBe(dummyKey); + } + }); +}); diff --git a/packages/ai/test/cursor-connect.test.ts b/packages/ai/test/cursor-connect.test.ts new file mode 100644 index 000000000..22bd94eb1 --- /dev/null +++ b/packages/ai/test/cursor-connect.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { cursorConnectApi } from "../src/api/cursor-connect.lazy.ts"; +import { cursorProvider } from "../src/providers/cursor.ts"; +import type { Model } from "../src/types.ts"; + +describe("Cursor Connect transport", () => { + it("advertises cursor-connect api instead of openai-completions", () => { + const provider = cursorProvider(); + expect(provider.getModels()[0]?.api).toBe("cursor-connect"); + }); + + it("posts Connect protobuf AgentRun frames to Cursor ChatService", async () => { + const fetches: Array<{ url: string; headers: Headers; body: Uint8Array }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const headers = new Headers(init?.headers); + const body = + init?.body instanceof Uint8Array + ? init.body + : init?.body instanceof ArrayBuffer + ? new Uint8Array(init.body) + : new Uint8Array(Buffer.from(String(init?.body ?? ""), "binary")); + fetches.push({ url, headers, body }); + // Minimal Connect end-stream JSON trailer (gzip flag 0, length of payload). + const trailer = Buffer.from(JSON.stringify({})); + const frame = Buffer.alloc(5 + trailer.length); + frame[0] = 0x02; // end-stream JSON + frame.writeUInt32BE(trailer.length, 1); + trailer.copy(frame, 5); + return new Response(frame, { + status: 200, + headers: { "content-type": "application/connect+proto" }, + }); + }), + ); + + const model = { + id: "default", + name: "Auto", + api: "cursor-connect", + provider: "cursor", + baseUrl: "https://api2.cursor.sh", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"cursor-connect">; + + const message = await cursorConnectApi() + .streamSimple( + model, + { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, + { apiKey: "cursor-token" }, + ) + .result(); + + expect(fetches).toHaveLength(1); + expect(fetches[0]?.url).toContain("aiserver.v1.ChatService/StreamUnifiedChatWithTools"); + expect(fetches[0]?.headers.get("content-type")).toBe("application/connect+proto"); + expect(fetches[0]?.headers.get("connect-protocol-version")).toBe("1"); + expect(fetches[0]?.headers.get("authorization")).toBe("Bearer cursor-token"); + expect(fetches[0]?.body[0]).toBe(0x00); // uncompressed connect envelope + expect(message.stopReason === "stop" || message.stopReason === "error").toBe(true); + vi.unstubAllGlobals(); + }); +}); diff --git a/packages/ai/test/cursor-oauth.test.ts b/packages/ai/test/cursor-oauth.test.ts new file mode 100644 index 000000000..b40390ff4 --- /dev/null +++ b/packages/ai/test/cursor-oauth.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getOAuthProvider } from "../src/auth/oauth/index.ts"; +import type { OAuthProviderInterface } from "../src/auth/oauth/types.ts"; +import { builtinProviders } from "../src/providers/all.ts"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +function provider(): OAuthProviderInterface { + const value = getOAuthProvider("cursor"); + expect(value).toBeDefined(); + if (!value) throw new Error("cursor OAuth provider is not registered"); + return value; +} + +describe.sequential("Cursor OAuth", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("is registered and advertised by a model provider", () => { + expect(getOAuthProvider("cursor")?.id).toBe("cursor"); + expect(builtinProviders().map((entry) => entry.id)).toContain("cursor"); + }); + + it("logs in with PKCE polling and performs a real refresh-token exchange", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + requests.push({ url, init }); + if (url.includes("/auth/poll")) { + return jsonResponse({ accessToken: "cursor-access", refreshToken: "cursor-refresh" }); + } + if (url.endsWith("/auth/exchange_user_api_key")) { + return jsonResponse({ accessToken: "cursor-access-2", refreshToken: "cursor-refresh-2" }); + } + throw new Error(`Unexpected request: ${url}`); + }), + ); + let authUrl = ""; + const credentials = await provider().login({ + onAuth: (info) => { + authUrl = info.url; + }, + onDeviceCode: () => {}, + onPrompt: async () => "", + onSelect: async () => undefined, + }); + const parsed = new URL(authUrl); + expect(parsed.searchParams.get("challenge")).toBeTruthy(); + expect(parsed.searchParams.get("uuid")).toBeTruthy(); + expect(requests[0]?.url).toContain(`uuid=${encodeURIComponent(parsed.searchParams.get("uuid") ?? "")}`); + expect(credentials).toMatchObject({ access: "cursor-access", refresh: "cursor-refresh" }); + + const refreshed = await provider().refreshToken(credentials); + expect(refreshed).toMatchObject({ access: "cursor-access-2", refresh: "cursor-refresh-2" }); + expect(new Headers(requests[1]?.init?.headers).get("authorization")).toBe("Bearer cursor-refresh"); + }); + + it("keeps refresh credentials and endpoint bodies out of errors", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ token: "response-private-value" }, 401)), + ); + const error = await provider() + .refreshToken({ access: "cursor-old-private", refresh: "cursor-refresh-private", expires: 0 }) + .catch((value: unknown) => value); + expect(String(error)).toContain("401"); + expect(String(error)).not.toContain("cursor-refresh-private"); + expect(String(error)).not.toContain("response-private-value"); + }); +}); diff --git a/packages/ai/test/gajae-provider-id-gaps.test.ts b/packages/ai/test/gajae-provider-id-gaps.test.ts new file mode 100644 index 000000000..b6bb8d93f --- /dev/null +++ b/packages/ai/test/gajae-provider-id-gaps.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { builtinProviders } from "../src/providers/all.ts"; + +const API_KEY_ALIASES = [ + ["minimax-code", "minimax", "MINIMAX_CODE_API_KEY"], + ["minimax-code-cn", "minimax-cn", "MINIMAX_CODE_CN_API_KEY"], + ["moonshot", "moonshotai", "MOONSHOT_API_KEY"], + ["opencode-zen", "opencode", "OPENCODE_API_KEY"], +] as const; + +describe("Gajae provider ID gaps", () => { + it("keeps API-key aliases aligned with their canonical model catalogs", async () => { + const providers = new Map(builtinProviders().map((provider) => [provider.id, provider])); + + for (const [aliasId, sourceId, envVar] of API_KEY_ALIASES) { + const alias = providers.get(aliasId); + const source = providers.get(sourceId); + expect(alias?.getModels().map((model) => model.id)).toEqual(source?.getModels().map((model) => model.id)); + expect(alias?.getModels().every((model) => model.provider === aliasId)).toBe(true); + const auth = alias?.auth.apiKey; + const model = alias?.getModels()[0]; + if (!auth || !model) throw new Error(`Missing provider alias: ${aliasId}`); + const resolved = await auth.resolve({ + ctx: { env: async (name) => (name === envVar ? "test-key" : undefined), fileExists: async () => false }, + }); + expect(resolved).toMatchObject({ auth: { apiKey: "test-key" }, source: envVar }); + } + }); + + it("registers one Kimi Coding provider and the OpenAI device-code model alias", async () => { + const allProviders = builtinProviders(); + const providers = new Map(allProviders.map((provider) => [provider.id, provider])); + const kimiProviders = allProviders.filter((provider) => provider.id.startsWith("kimi-")); + expect(kimiProviders.map((provider) => provider.id)).toEqual(["kimi-coding"]); + + const kimi = providers.get("kimi-coding"); + const auth = kimi?.auth.apiKey; + if (!auth) throw new Error("Missing kimi-coding API-key auth"); + const resolved = await auth.resolve({ + ctx: { + env: async (name) => (name === "KIMI_API_KEY" ? "test-key" : undefined), + fileExists: async () => false, + }, + }); + expect(resolved).toMatchObject({ auth: { apiKey: "test-key" }, source: "KIMI_API_KEY" }); + + const codex = providers.get("openai-codex"); + const device = providers.get("openai-codex-device"); + expect(device?.auth.oauth).toBeDefined(); + expect(device?.getModels().map((model) => model.id)).toEqual(codex?.getModels().map((model) => model.id)); + }); +}); diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 26738f29e..8572434e5 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; -import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts"; -import { createModels } from "../src/models.ts"; -import { githubCopilotProvider } from "../src/providers/github-copilot.ts"; +import { + githubCopilotOAuthProvider, + loginGitHubCopilot, + refreshGitHubCopilotToken, +} from "../src/auth/oauth/github-copilot.ts"; +import { getModels } from "../src/compat.ts"; function jsonResponse(body: unknown, status: number = 200): Response { return new Response(JSON.stringify(body), { @@ -26,33 +28,6 @@ function getUrl(input: unknown): string { throw new Error(`Unsupported fetch input: ${String(input)}`); } -function loginGitHubCopilotForTest(options: { - onDeviceCode(info: { - userCode: string; - verificationUri: string; - intervalSeconds?: number; - expiresInSeconds?: number; - }): void; - onPrompt(prompt: { message: string; placeholder?: string; allowEmpty?: boolean }): Promise; - onProgress?(message: string): void; - signal?: AbortSignal; -}) { - return githubCopilotOAuth.login({ - signal: options.signal, - prompt: (prompt) => { - if (prompt.type !== "text") throw new Error(`Unexpected prompt: ${prompt.type}`); - return options.onPrompt({ message: prompt.message, placeholder: prompt.placeholder, allowEmpty: true }); - }, - notify: (event) => { - if (event.type === "device_code") { - const { type: _, ...info } = event; - options.onDeviceCode(info); - } - if (event.type === "progress") options.onProgress?.(event.message); - }, - }); -} - describe("GitHub Copilot OAuth device flow", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -101,19 +76,13 @@ describe("GitHub Copilot OAuth device flow", () => { vi.stubGlobal("fetch", fetchMock); - const credentials = await githubCopilotOAuth.refresh({ - type: "oauth", - access: "old-access-token", - refresh: "ghu_refresh_token", - expires: 0, - }); + const credentials = await refreshGitHubCopilotToken("ghu_refresh_token"); expect(credentials.availableModelIds).toEqual(["gpt-4.1"]); - const store = new InMemoryCredentialStore(); - await store.modify("github-copilot", async () => ({ ...credentials, type: "oauth" })); - const models = createModels({ credentials: store }); - models.setProvider(githubCopilotProvider()); - expect((await models.getAvailable("github-copilot")).map((model) => model.id)).toEqual(["gpt-4.1"]); + const modifiedModels = githubCopilotOAuthProvider.modifyModels?.(getModels("github-copilot"), credentials) ?? []; + expect(modifiedModels.filter((model) => model.provider === "github-copilot").map((model) => model.id)).toEqual([ + "gpt-4.1", + ]); }); it("reports device-code details through onDeviceCode", async () => { @@ -158,7 +127,7 @@ describe("GitHub Copilot OAuth device flow", () => { vi.stubGlobal("fetch", fetchMock); const onDeviceCode = vi.fn(); - const loginPromise = loginGitHubCopilotForTest({ + const loginPromise = loginGitHubCopilot({ onDeviceCode, onPrompt: async () => "", }); @@ -197,7 +166,7 @@ describe("GitHub Copilot OAuth device flow", () => { const onDeviceCode = vi.fn(); await expect( - loginGitHubCopilotForTest({ + loginGitHubCopilot({ onDeviceCode, onPrompt: async () => "", }), @@ -251,7 +220,7 @@ describe("GitHub Copilot OAuth device flow", () => { vi.stubGlobal("fetch", fetchMock); const onDeviceCode = vi.fn(); - const loginPromise = loginGitHubCopilotForTest({ + const loginPromise = loginGitHubCopilot({ onDeviceCode, onPrompt: async () => "", }); @@ -339,7 +308,7 @@ describe("GitHub Copilot OAuth device flow", () => { vi.stubGlobal("fetch", fetchMock); - const loginPromise = loginGitHubCopilotForTest({ + const loginPromise = loginGitHubCopilot({ onDeviceCode: () => {}, onPrompt: async () => "", onProgress: () => {}, @@ -413,7 +382,7 @@ describe("GitHub Copilot OAuth device flow", () => { vi.stubGlobal("fetch", fetchMock); - const loginPromise = loginGitHubCopilotForTest({ + const loginPromise = loginGitHubCopilot({ onDeviceCode: () => {}, onPrompt: async () => "", }); diff --git a/packages/ai/test/gitlab-duo-models.test.ts b/packages/ai/test/gitlab-duo-models.test.ts new file mode 100644 index 000000000..3f1af9623 --- /dev/null +++ b/packages/ai/test/gitlab-duo-models.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { getModel } from "../src/models.ts"; +import { gitlabDuoProvider } from "../src/providers/gitlab-duo.ts"; + +describe("GitLab Duo model routing", () => { + it("routes gpt-5.1 through chat completions, not Responses", () => { + const model = getModel("gitlab-duo", "gpt-5.1-2025-11-13"); + expect(model.api).toBe("openai-completions"); + const provider = gitlabDuoProvider(); + const apis = provider.getModels().map((entry) => entry.api); + expect(apis).toContain("openai-completions"); + expect(apis).not.toContain("openai-responses"); + }); +}); diff --git a/packages/ai/test/gitlab-duo-oauth.test.ts b/packages/ai/test/gitlab-duo-oauth.test.ts new file mode 100644 index 000000000..6e5cfba79 --- /dev/null +++ b/packages/ai/test/gitlab-duo-oauth.test.ts @@ -0,0 +1,133 @@ +import { createServer } from "node:http"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getOAuthProvider } from "../src/auth/oauth/index.ts"; +import type { OAuthProviderInterface } from "../src/auth/oauth/types.ts"; +import { builtinProviders } from "../src/providers/all.ts"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +function provider(): OAuthProviderInterface { + const value = getOAuthProvider("gitlab-duo"); + expect(value).toBeDefined(); + if (!value) throw new Error("gitlab-duo OAuth provider is not registered"); + return value; +} + +describe.sequential("GitLab Duo OAuth", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("is registered and advertised by a model provider", () => { + expect(getOAuthProvider("gitlab-duo")?.id).toBe("gitlab-duo"); + expect(builtinProviders().map((entry) => entry.id)).toContain("gitlab-duo"); + }); + + it("uses PKCE with manual-code fallback and refreshes rotated credentials", async () => { + const blocker = createServer(); + await new Promise((resolve, reject) => { + blocker.once("error", reject); + blocker.listen(8080, "127.0.0.1", resolve); + }); + const bodies: URLSearchParams[] = []; + try { + vi.stubGlobal( + "fetch", + vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(new URLSearchParams(String(init?.body))); + return bodies.length === 1 + ? jsonResponse({ access_token: "gitlab-access", refresh_token: "gitlab-refresh", expires_in: 3600 }) + : jsonResponse({ + access_token: "gitlab-access-2", + refresh_token: "gitlab-refresh-2", + expires_in: 7200, + }); + }), + ); + let state = ""; + const credentials = await provider().login({ + onAuth: (info) => { + const url = new URL(info.url); + state = url.searchParams.get("state") ?? ""; + expect(url.searchParams.get("code_challenge")).toBeTruthy(); + }, + onDeviceCode: () => {}, + onPrompt: async () => "", + onManualCodeInput: async () => `code=manual-code&state=${state}`, + onSelect: async () => undefined, + }); + expect(bodies[0]?.get("grant_type")).toBe("authorization_code"); + expect(bodies[0]?.get("code_verifier")).toBeTruthy(); + expect(credentials).toMatchObject({ access: "gitlab-access", refresh: "gitlab-refresh" }); + + const refreshed = await provider().refreshToken(credentials); + expect(bodies[1]?.get("grant_type")).toBe("refresh_token"); + expect(bodies[1]?.get("refresh_token")).toBe("gitlab-refresh"); + expect(refreshed).toMatchObject({ access: "gitlab-access-2", refresh: "gitlab-refresh-2" }); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); + + it("does not expose request or response secrets on token failure", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ access_token: "gitlab-response-private" }, 400)), + ); + const error = await provider() + .refreshToken({ access: "gitlab-old-private", refresh: "gitlab-refresh-private", expires: 0 }) + .catch((value: unknown) => value); + expect(String(error)).toContain("400"); + expect(String(error)).not.toContain("gitlab-refresh-private"); + expect(String(error)).not.toContain("gitlab-response-private"); + }); + + it("exchanges legacy OAuth credentials for direct-access request auth", async () => { + // Given: GitLab returns a short-lived direct-access token with required instance headers. + vi.stubGlobal( + "fetch", + vi.fn(async () => + jsonResponse({ headers: { "x-gitlab-instance-id": "instance-a" }, token: "gitlab-direct-token" }), + ), + ); + + // When: the compatibility provider resolves request-scoped authentication. + const requestAuth = await provider().getRequestAuth?.({ + access: "gitlab-oauth-access-for-direct-exchange", + expires: Date.now() + 60_000, + refresh: "gitlab-oauth-refresh", + }); + + // Then: callers receive the exchanged token and every required header, never the raw OAuth token. + expect(requestAuth).toEqual({ + apiKey: "gitlab-direct-token", + headers: { + Authorization: "Bearer gitlab-direct-token", + "x-gitlab-instance-id": "instance-a", + }, + }); + }); + + it("exchanges ambient API-key credentials for direct-access request auth", async () => { + const { gitlabDuoProvider } = await import("../src/providers/gitlab-duo.ts"); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = String(input); + expect(url).toContain("/api/v4/ai/third_party_agents/direct_access"); + return jsonResponse({ headers: { "x-gitlab-instance-id": "instance-b" }, token: "pat-direct-token" }); + }), + ); + const result = await gitlabDuoProvider().auth.apiKey?.resolve({ + ctx: { env: async () => "gitlab-raw-pat", fileExists: async () => false }, + }); + expect(result?.auth).toEqual({ + apiKey: "pat-direct-token", + headers: { + Authorization: "Bearer pat-direct-token", + "x-gitlab-instance-id": "instance-b", + }, + }); + expect(result?.source).toBe("GITLAB_TOKEN"); + }); +}); diff --git a/packages/ai/test/google-account-oauth.test.ts b/packages/ai/test/google-account-oauth.test.ts new file mode 100644 index 000000000..c388c2a36 --- /dev/null +++ b/packages/ai/test/google-account-oauth.test.ts @@ -0,0 +1,236 @@ +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + discoverAntigravityProject, + googleAntigravityOAuth, + refreshAntigravityToken, +} from "../src/auth/oauth/google-antigravity.ts"; +import { + discoverGeminiCliProject, + googleGeminiCliOAuth, + refreshGoogleCloudToken, +} from "../src/auth/oauth/google-gemini-cli.ts"; +import { googleOAuthExports } from "../src/auth/oauth/google-oauth-shared.ts"; +import { getOAuthProvider } from "../src/auth/oauth/index.ts"; +import { builtinModels } from "../src/providers/all.ts"; + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +describe.sequential("Google account OAuth providers", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("registers distinct OAuth and model providers without replacing google", () => { + expect(getOAuthProvider("google-gemini-cli")?.id).toBe("google-gemini-cli"); + expect(getOAuthProvider("google-antigravity")?.id).toBe("google-antigravity"); + const models = builtinModels(); + expect(models.getProvider("google")?.auth.apiKey).toBeDefined(); + expect(models.getProvider("google-gemini-cli")?.auth.oauth).toBeDefined(); + expect(models.getProvider("google-antigravity")?.auth.oauth).toBeDefined(); + expect(models.getModels("google-gemini-cli").length).toBeGreaterThan(0); + expect(models.getModels("google-antigravity").length).toBeGreaterThan(0); + }); + + it("uses explicit Cloud Code Assist catalogs rather than cloned public Google rows", () => { + const models = builtinModels(); + expect( + models + .getModels("google-gemini-cli") + .map((model) => model.id) + .sort(), + ).toEqual([ + "gemini-2.0-flash", + "gemini-2.5-flash", + "gemini-2.5-pro", + "gemini-3-flash-preview", + "gemini-3-pro-preview", + "gemini-3.1-flash-lite-preview", + "gemini-3.1-pro-preview", + "gemini-3.5-flash", + ]); + expect( + models + .getModels("google-antigravity") + .map((model) => model.id) + .sort(), + ).toEqual([ + "claude-opus-4-5-thinking", + "claude-opus-4-6-thinking", + "claude-sonnet-4-5", + "claude-sonnet-4-5-thinking", + "claude-sonnet-4-6", + "claude-sonnet-4-6-thinking", + "gemini-2.5-flash", + "gemini-2.5-flash-thinking", + "gemini-2.5-pro", + "gemini-3-flash", + "gemini-3-pro-high", + "gemini-3-pro-low", + "gemini-3.1-pro-low", + "gpt-oss-120b-medium", + ]); + expect(models.getModel("google-gemini-cli", "gemini-3-pro-preview")).toMatchObject({ + thinkingLevelMap: { off: null, minimal: null, low: "LOW", medium: null, high: "HIGH" }, + contextWindow: 1_000_000, + maxTokens: 64_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }); + expect(models.getModel("google-antigravity", "gpt-oss-120b-medium")).toMatchObject({ + input: ["text"], + contextWindow: 114_000, + maxTokens: 32_768, + }); + }); + + it("keeps node:http behind the Google OAuth Node-only module", async () => { + const source = await readFile(new URL("../src/auth/oauth/google-oauth-shared.ts", import.meta.url), "utf8"); + expect(source).not.toMatch(/\bimport\s*\(\s*["']node:http["']\s*\)/); + }); + + it.each([ + ["Gemini CLI", discoverGeminiCliProject, { cloudaicompanionProject: "gemini-project" }, "gemini-project"], + [ + "Antigravity", + discoverAntigravityProject, + { cloudaicompanionProject: { id: "antigravity-project" } }, + "antigravity-project", + ], + ] as const)("discovers the %s Cloud Code Assist project without external network", async (_name, discover, response, expected) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => json(response)), + ); + await expect(discover("access-secret")).resolves.toBe(expected); + }); + + it.each([ + ["Gemini CLI", refreshGoogleCloudToken], + ["Antigravity", refreshAntigravityToken], + ] as const)("refreshes %s credentials and retains a rotated-or-existing refresh token", async (_name, refresh) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => json({ access_token: "new-access", expires_in: 3600 })), + ); + await expect(refresh("refresh-secret", "project-1")).resolves.toMatchObject({ + access: "new-access", + refresh: "refresh-secret", + projectId: "project-1", + }); + }); + + it.each([ + ["Gemini CLI", refreshGoogleCloudToken], + ["Antigravity", refreshAntigravityToken], + ] as const)("redacts %s token response bodies from errors", async (_name, refresh) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => json({ error: "invalid_grant", access_token: "leaked-access" }, 400)), + ); + const error = await refresh("refresh-secret", "project-1").catch((value: unknown) => value); + expect(String(error)).not.toContain("refresh-secret"); + expect(String(error)).not.toContain("leaked-access"); + }); + + it.each([ + ["Gemini CLI", googleGeminiCliOAuth], + ["Antigravity", googleAntigravityOAuth], + ] as const)("cancels %s callback waiting from the caller signal", async (_name, oauth) => { + const controller = new AbortController(); + const login = oauth.login({ + notify: () => controller.abort(new DOMException("cancelled", "AbortError")), + prompt: async ({ signal }) => + await new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + signal: controller.signal, + }); + await expect(login).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("aborts the manual-code prompt when callback authentication wins", async () => { + const nativeFetch = globalThis.fetch; + vi.stubGlobal( + "fetch", + vi.fn(async () => json({ access_token: "access", refresh_token: "refresh", expires_in: 3600 })), + ); + const { auth } = googleOAuthExports({ + id: "google-callback-test", + name: "Google callback test", + clientId: "client-id", + clientSecret: "client-secret", + port: 18085, + path: "/oauth2callback", + scopes: ["scope"], + discoverProject: async () => "project-from-discovery", + }); + let callbackRequest: Promise | undefined; + let manualSignal: AbortSignal | undefined; + const credentials = await auth.login({ + notify: (event) => { + if (event.type !== "auth_url") return; + const authorizationUrl = new URL(event.url); + const callbackUrl = new URL(authorizationUrl.searchParams.get("redirect_uri")!); + callbackUrl.searchParams.set("code", "callback-code"); + callbackUrl.searchParams.set("state", authorizationUrl.searchParams.get("state")!); + callbackRequest = nativeFetch(callbackUrl); + }, + prompt: async ({ signal }) => { + manualSignal = signal; + return await new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + }, + }); + await callbackRequest; + expect(credentials).toMatchObject({ access: "access", projectId: "project-from-discovery" }); + expect(manualSignal).toBeDefined(); + expect(manualSignal?.aborted).toBe(true); + }); + + it("falls back to manual input when the Gemini CLI callback port is occupied", async () => { + const blocker = createServer(); + await new Promise((resolve, reject) => { + blocker.once("error", reject); + blocker.listen(8085, "127.0.0.1", resolve); + }); + try { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => + String(input).includes("/token") + ? json({ access_token: "access", refresh_token: "refresh", expires_in: 3600 }) + : json({ cloudaicompanionProject: "project-from-discovery" }), + ), + ); + let state = ""; + const credentials = await getOAuthProvider("google-gemini-cli")!.login({ + onAuth: ({ url }) => { + state = new URL(url).searchParams.get("state") ?? ""; + }, + onDeviceCode: () => {}, + onPrompt: async () => "", + onSelect: async () => undefined, + onManualCodeInput: async () => `?code=manual-code&state=${state}`, + }); + expect(credentials).toMatchObject({ access: "access", projectId: "project-from-discovery" }); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); + + it("serializes a nonempty projectId into runtime auth and rejects its absence", async () => { + const auth = googleGeminiCliOAuth; + await expect( + auth.toAuth({ + type: "oauth", + access: "access", + refresh: "refresh", + expires: Date.now() + 1000, + projectId: "project-1", + }), + ).resolves.toEqual({ apiKey: JSON.stringify({ token: "access", projectId: "project-1" }) }); + await expect( + auth.toAuth({ type: "oauth", access: "access", refresh: "refresh", expires: Date.now() + 1000 }), + ).rejects.toThrow(/projectId/); + }); +}); diff --git a/packages/ai/test/google-cca-runtime.test.ts b/packages/ai/test/google-cca-runtime.test.ts new file mode 100644 index 000000000..2ecbe2165 --- /dev/null +++ b/packages/ai/test/google-cca-runtime.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildGoogleCcaRequest } from "../src/api/google-gemini-cli.ts"; +import { googleAntigravityProvider, googleGeminiCliProvider } from "../src/providers/google-gemini-cli.ts"; +import type { Model } from "../src/types.ts"; + +const sse = (payload: unknown) => + new Response(`data: ${JSON.stringify(payload)}\n\n`, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + +const context = { messages: [{ role: "user" as const, content: "hello", timestamp: 0 }] }; + +describe("Google Cloud Code Assist runtime", () => { + afterEach(() => vi.unstubAllGlobals()); + + it.each([ + [googleGeminiCliProvider, "google-gemini-cli", "Client-Metadata"], + [googleAntigravityProvider, "google-antigravity", "User-Agent"], + ] as const)("uses the CCA SSE contract for %s", async (factory, providerId, providerHeader) => { + let requestUrl = ""; + let requestInit: RequestInit | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + requestUrl = String(input); + requestInit = init; + return sse({ + response: { + candidates: [{ content: { role: "model", parts: [{ text: "ok" }] }, finishReason: "STOP" }], + }, + }); + }), + ); + const provider = factory(); + const model = provider.getModels()[0]!; + const result = await provider + .stream(model, context, { + apiKey: JSON.stringify({ token: "access-secret", projectId: "project-123" }), + }) + .result(); + expect(result.stopReason).toBe("stop"); + expect(requestUrl).toContain("/v1internal:streamGenerateContent?alt=sse"); + const headers = new Headers(requestInit?.headers); + expect(headers.get("Authorization")).toBe("Bearer access-secret"); + expect(headers.has(providerHeader)).toBe(true); + expect(JSON.parse(String(requestInit?.body))).toMatchObject({ + project: "project-123", + model: model.id, + request: { contents: expect.any(Array) }, + }); + expect(model.provider).toBe(providerId); + }); + + it("emits complete lifecycle events for thinking, text, and tool calls", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + sse({ + response: { + candidates: [ + { + content: { + role: "model", + parts: [ + { text: "consider", thought: true }, + { text: "answer" }, + { + functionCall: { + id: "call-1", + name: "lookup_weather", + args: { city: "Seoul" }, + }, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + }), + ), + ); + const provider = googleGeminiCliProvider(); + const eventTypes: string[] = []; + let thinkingEndContent: string | undefined; + let textEndContent: string | undefined; + let toolArgumentsDelta: string | undefined; + for await (const event of provider.stream(provider.getModels()[0]!, context, { + apiKey: JSON.stringify({ token: "access-secret", projectId: "project-123" }), + })) { + eventTypes.push(event.type); + if (event.type === "thinking_end") thinkingEndContent = event.content; + if (event.type === "text_end") textEndContent = event.content; + if (event.type === "toolcall_delta") toolArgumentsDelta = event.delta; + } + + expect(eventTypes).toEqual([ + "start", + "thinking_start", + "thinking_delta", + "thinking_end", + "text_start", + "text_delta", + "text_end", + "toolcall_start", + "toolcall_delta", + "toolcall_end", + "done", + ]); + expect(thinkingEndContent).toBe("consider"); + expect(textEndContent).toBe("answer"); + expect(toolArgumentsDelta).toBe('{"city":"Seoul"}'); + }); + + it("rejects credentials without a nonempty project id before issuing a request", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const provider = googleGeminiCliProvider(); + const result = await provider + .stream(provider.getModels()[0]!, context, { + apiKey: JSON.stringify({ token: "access-secret" }), + }) + .result(); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/projectId/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + "gemini-3-flash", + "claude-sonnet-4-6", + ])("prepends the Antigravity instruction for %s requests", (modelId) => { + const model: Model<"google-gemini-cli"> = { + id: modelId, + name: modelId, + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 64_000, + }; + const body = buildGoogleCcaRequest( + model, + { ...context, systemPrompt: "Follow the repository instructions." }, + "project-123", + ); + const parts = body.request.systemInstruction?.parts; + expect(parts).toHaveLength(2); + expect(parts?.[0]?.text).toMatch(/Antigravity.*agentic AI coding assistant/); + expect(parts?.[1]?.text).toBe("Follow the repository instructions."); + expect(body.request.preambleConfig).toEqual({ mode: "SYSTEM_INSTRUCTION_MODE_REPLACE" }); + }); + + it.each([ + ["gemini-3-pro-preview", "minimal", { thinkingLevel: "LOW" }], + ["gemini-3-flash-preview", "max", { thinkingLevel: "HIGH" }], + ["gemini-2.5-flash", "medium", { thinkingBudget: 8192 }], + ] as const)("maps simple reasoning for %s at %s", async (modelId, reasoning, expectedThinking) => { + let payload: unknown; + vi.stubGlobal( + "fetch", + vi.fn(async () => + sse({ + response: { + candidates: [{ content: { role: "model", parts: [{ text: "ok" }] }, finishReason: "STOP" }], + }, + }), + ), + ); + const provider = googleGeminiCliProvider(); + const model = provider.getModels().find((candidate) => candidate.id === modelId); + expect(model, `missing ${modelId} from Google Gemini CLI catalog`).toBeDefined(); + const result = await provider + .streamSimple(model!, context, { + apiKey: JSON.stringify({ token: "access-secret", projectId: "project-123" }), + reasoning, + onPayload: (body) => { + payload = body; + }, + }) + .result(); + expect(result.stopReason).toBe("stop"); + expect( + (payload as { request: { generationConfig?: { thinkingConfig?: Record } } }).request + .generationConfig?.thinkingConfig, + ).toMatchObject({ includeThoughts: true, ...expectedThinking }); + }); + + it("parses CRLF-delimited SSE events", async () => { + const first = JSON.stringify({ + response: { candidates: [{ content: { role: "model", parts: [{ text: "alpha-token" }] } }] }, + }); + const second = JSON.stringify({ + response: { + candidates: [{ content: { role: "model", parts: [{ text: "beta-token" }] }, finishReason: "STOP" }], + }, + }); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(`data: ${first}\r\n\r\ndata: ${second}\r\n\r\n`, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ), + ); + const provider = googleGeminiCliProvider(); + const text: string[] = []; + for await (const event of provider.stream(provider.getModels()[0]!, context, { + apiKey: JSON.stringify({ token: "access-secret", projectId: "project-123" }), + })) { + if (event.type === "text_end") text.push(event.content); + } + const joined = text.join(""); + expect(joined).toContain("alpha-token"); + expect(joined).toContain("beta-token"); + }); +}); diff --git a/packages/ai/test/kilo-oauth.test.ts b/packages/ai/test/kilo-oauth.test.ts new file mode 100644 index 000000000..66e79542d --- /dev/null +++ b/packages/ai/test/kilo-oauth.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getOAuthProvider } from "../src/auth/oauth/index.ts"; +import type { OAuthProviderInterface } from "../src/auth/oauth/types.ts"; +import { builtinProviders } from "../src/providers/all.ts"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +function provider(): OAuthProviderInterface { + const value = getOAuthProvider("kilo"); + expect(value).toBeDefined(); + if (!value) throw new Error("kilo OAuth provider is not registered"); + return value; +} + +describe.sequential("Kilo OAuth", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("is registered and advertised by a model provider", () => { + expect(getOAuthProvider("kilo")?.id).toBe("kilo"); + expect(builtinProviders().map((entry) => entry.id)).toContain("kilo"); + }); + + it("completes mocked device authorization and returns static credentials on refresh", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/device-auth/codes")) { + expect(init?.method).toBe("POST"); + return jsonResponse({ code: "KILO-CODE", verificationUrl: "https://kilo.ai/verify", expiresIn: 300 }); + } + if (url.endsWith("/device-auth/codes/KILO-CODE")) { + return jsonResponse({ status: "approved", token: "kilo-access" }); + } + throw new Error(`Unexpected request: ${url}`); + }), + ); + let authUrl = ""; + const credentials = await provider().login({ + onAuth: (info) => { + authUrl = info.url; + expect(info.instructions).toContain("KILO-CODE"); + }, + onDeviceCode: () => {}, + onPrompt: async () => "", + onSelect: async () => undefined, + }); + expect(authUrl).toBe("https://kilo.ai/verify"); + expect(credentials).toMatchObject({ access: "kilo-access", refresh: "" }); + expect(await provider().refreshToken(credentials)).toBe(credentials); + }); + + it("does not include endpoint response bodies in device-auth errors", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ token: "kilo-response-private" }, 500)), + ); + const error = await provider() + .login({ + onAuth: () => {}, + onDeviceCode: () => {}, + onPrompt: async () => "", + onSelect: async () => undefined, + }) + .catch((value: unknown) => value); + expect(String(error)).toContain("500"); + expect(String(error)).not.toContain("kilo-response-private"); + }); +}); diff --git a/packages/ai/test/oauth-auth.test.ts b/packages/ai/test/oauth-auth.test.ts index 6620002d9..84b672966 100644 --- a/packages/ai/test/oauth-auth.test.ts +++ b/packages/ai/test/oauth-auth.test.ts @@ -3,9 +3,7 @@ import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts"; import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts"; import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts"; -import { xaiOAuth } from "../src/auth/oauth/xai.ts"; import { createModels } from "../src/models.ts"; -import * as extensionOAuthCompatibility from "../src/oauth.ts"; import { anthropicProvider } from "../src/providers/anthropic.ts"; import { githubCopilotProvider } from "../src/providers/github-copilot.ts"; @@ -14,11 +12,6 @@ function jsonResponse(body: unknown, status = 200): Response { } describe.sequential("OAuthAuth adapters", () => { - it("keeps the extension OAuth barrel free of built-in flow implementations", () => { - expect(extensionOAuthCompatibility).not.toHaveProperty("loginAnthropic"); - expect(extensionOAuthCompatibility).not.toHaveProperty("anthropicOAuth"); - }); - afterEach(() => { vi.unstubAllGlobals(); }); @@ -33,11 +26,6 @@ describe.sequential("OAuthAuth adapters", () => { expect(auth).toEqual({ apiKey: "token" }); }); - it("xAI toAuth derives the api key from the access token", async () => { - const auth = await xaiOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 }); - expect(auth).toEqual({ apiKey: "token" }); - }); - it("github-copilot toAuth derives baseUrl from the token proxy endpoint", async () => { const access = "tid=abc;exp=123;proxy-ep=proxy.enterprise.example;rest"; const auth = await githubCopilotOAuth.toAuth({ type: "oauth", access, refresh: "r", expires: 0 }); @@ -116,7 +104,7 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => { models.setProvider(anthropicProvider()); const model = models.getModels("anthropic")[0]; - const result = await models.getAuth(model.provider); + const result = await models.getAuth(model); expect(result?.auth.apiKey).toBe("oauth-access-token"); expect(result?.source).toBe("OAuth"); }); @@ -134,7 +122,7 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => { models.setProvider(githubCopilotProvider()); const model = models.getModels("github-copilot")[0]; - const result = await models.getAuth(model.provider); + const result = await models.getAuth(model); expect(result?.auth.apiKey).toBe(access); expect(result?.auth.baseUrl).toBe("https://api.business.githubcopilot.com"); }); diff --git a/packages/ai/test/oauth.ts b/packages/ai/test/oauth.ts index f34647664..b59305e24 100644 --- a/packages/ai/test/oauth.ts +++ b/packages/ai/test/oauth.ts @@ -8,8 +8,8 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { homedir } from "os"; import { dirname, join } from "path"; -import type { OAuthCredentials } from "../src/auth/types.ts"; -import { builtinProviders } from "../src/providers/all.ts"; +import { getOAuthApiKey } from "../src/auth/oauth/index.ts"; +import type { OAuthCredentials, OAuthProvider } from "../src/auth/oauth/types.ts"; import { isOAuthLiveApiTestEnabled } from "./live-api-gates.ts"; const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json"); @@ -68,18 +68,28 @@ export async function resolveApiKey(provider: string): Promise candidate.id === provider)?.auth.oauth; - if (!oauth) return undefined; - let credential = entry; + // Build OAuthCredentials record for getOAuthApiKey + const oauthCredentials: Record = {}; + for (const [key, value] of Object.entries(storage)) { + if (value.type === "oauth") { + const { type: _, ...creds } = value; + oauthCredentials[key] = creds; + } + } + + let result: { newCredentials: OAuthCredentials; apiKey: string } | null = null; try { - if (Date.now() >= credential.expires) credential = await oauth.refresh(credential); - } catch (error) { - console.log(JSON.stringify(error)); - return undefined; + result = await getOAuthApiKey(provider as OAuthProvider, oauthCredentials); + } catch (e) { + console.log(JSON.stringify(e)); } - storage[provider] = credential; + if (!result) return undefined; + + // Save refreshed credentials back to auth.json + storage[provider] = { type: "oauth", ...result.newCredentials }; saveAuthStorage(storage); - return (await oauth.toAuth(credential)).apiKey; + + return result.apiKey; } return undefined; diff --git a/packages/ai/test/openai-codex-device-oauth.test.ts b/packages/ai/test/openai-codex-device-oauth.test.ts new file mode 100644 index 000000000..6d0600ea7 --- /dev/null +++ b/packages/ai/test/openai-codex-device-oauth.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getOAuthProvider, resolveOAuthStorageProvider } from "../src/auth/oauth/index.ts"; +import { openaiCodexDeviceOAuthProvider } from "../src/auth/oauth/openai-codex-device.ts"; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +function accessToken(accountId: string): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64"); + const payload = Buffer.from( + JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: accountId } }), + ).toString("base64"); + return `${header}.${payload}.signature`; +} + +describe("OpenAI Codex device OAuth provider", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("uses the existing device login and refresh implementation under its own ID", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.endsWith("/deviceauth/usercode")) { + return jsonResponse({ device_auth_id: "device-id", user_code: "OPENAI-1234", interval: 5 }); + } + if (url.endsWith("/deviceauth/token")) { + return jsonResponse({ authorization_code: "code", code_verifier: "verifier" }); + } + if (url.endsWith("/oauth/token")) { + const body = new URLSearchParams(String(init?.body)); + return body.get("grant_type") === "refresh_token" + ? jsonResponse({ + access_token: accessToken("account-refreshed"), + refresh_token: "refresh-2", + expires_in: 3600, + }) + : jsonResponse({ + access_token: accessToken("account-device"), + refresh_token: "refresh-1", + expires_in: 3600, + }); + } + throw new Error(`Unexpected URL: ${url}`); + }), + ); + + const deviceCodes: string[] = []; + const credentials = await openaiCodexDeviceOAuthProvider.login({ + onAuth: () => { + throw new Error("Browser login must not be used"); + }, + onDeviceCode: (info) => deviceCodes.push(info.userCode), + onPrompt: async () => { + throw new Error("Text prompt must not be used"); + }, + onSelect: async () => { + throw new Error("Login method selector must not be used"); + }, + }); + expect(deviceCodes).toEqual(["OPENAI-1234"]); + expect(credentials.accountId).toBe("account-device"); + + const refreshed = await openaiCodexDeviceOAuthProvider.refreshToken(credentials); + expect(refreshed.accountId).toBe("account-refreshed"); + expect(getOAuthProvider("openai-codex-device")?.id).toBe("openai-codex-device"); + expect(resolveOAuthStorageProvider("openai-codex-device")).toBe("openai-codex"); + }); +}); diff --git a/packages/ai/test/openai-codex-oauth.test.ts b/packages/ai/test/openai-codex-oauth.test.ts index 64c0e8c79..19bcb85e7 100644 --- a/packages/ai/test/openai-codex-oauth.test.ts +++ b/packages/ai/test/openai-codex-oauth.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts"; +import { + loginOpenAICodexDeviceCode, + openaiCodexOAuthProvider, + refreshOpenAICodexToken, +} from "../src/auth/oauth/openai-codex.ts"; function jsonResponse(body: unknown, status: number = 200): Response { return new Response(JSON.stringify(body), { @@ -41,30 +45,6 @@ function deviceAuthPendingResponse(): Response { ); } -function loginOpenAICodexDeviceCodeForTest(options: { - onDeviceCode(info: { - userCode: string; - verificationUri: string; - intervalSeconds?: number; - expiresInSeconds?: number; - }): void; - signal?: AbortSignal; -}) { - return openaiCodexOAuth.login({ - signal: options.signal, - prompt: async (prompt) => { - if (prompt.type !== "select") throw new Error(`Unexpected prompt: ${prompt.type}`); - return "device_code"; - }, - notify: (event) => { - if (event.type === "device_code") { - const { type: _, ...info } = event; - options.onDeviceCode(info); - } - }, - }); -} - describe("OpenAI Codex OAuth", () => { afterEach(() => { vi.restoreAllMocks(); @@ -145,7 +125,7 @@ describe("OpenAI Codex OAuth", () => { vi.stubGlobal("fetch", fetchMock); - const credentialsPromise = loginOpenAICodexDeviceCodeForTest({ + const credentialsPromise = loginOpenAICodexDeviceCode({ onDeviceCode: (info) => deviceInfos.push(info), }); @@ -179,7 +159,7 @@ describe("OpenAI Codex OAuth", () => { const accessToken = createAccessToken("account-456"); const selectPrompts: Array<{ message: string; - options: readonly { id: string; label: string }[]; + options: Array<{ id: string; label: string }>; }> = []; const deviceInfos: Array<{ userCode: string; @@ -219,22 +199,20 @@ describe("OpenAI Codex OAuth", () => { ); await expect( - openaiCodexOAuth.login({ - prompt: async (prompt) => { - if (prompt.type !== "select") throw new Error("Text prompt should not be used"); + openaiCodexOAuthProvider.login({ + onAuth: () => { + throw new Error("Browser login should not start"); + }, + onDeviceCode: (info) => deviceInfos.push(info), + onPrompt: async () => { + throw new Error("Prompt should not be used"); + }, + onSelect: async (prompt: any) => { selectPrompts.push(prompt); return "device_code"; }, - notify: (event) => { - if (event.type === "auth_url") throw new Error("Browser login should not start"); - if (event.type === "device_code") { - const { type: _, ...info } = event; - deviceInfos.push(info); - } - }, }), ).resolves.toMatchObject({ - type: "oauth", access: accessToken, refresh: "refresh-token", accountId: "account-456", @@ -242,7 +220,6 @@ describe("OpenAI Codex OAuth", () => { expect(selectPrompts).toEqual([ { - type: "select", message: "Select OpenAI Codex login method:", options: [ { id: "browser", label: "Browser login (default)" }, @@ -262,11 +239,11 @@ describe("OpenAI Codex OAuth", () => { it("cancels when OpenAI Codex login method selection is cancelled", async () => { await expect( - openaiCodexOAuth.login({ - prompt: async () => { - throw new Error("Login cancelled"); - }, - notify: () => {}, + openaiCodexOAuthProvider.login({ + onAuth: () => {}, + onDeviceCode: () => {}, + onPrompt: async () => "", + onSelect: async () => undefined, }), ).rejects.toThrow("Login cancelled"); }); @@ -296,7 +273,7 @@ describe("OpenAI Codex OAuth", () => { }), ); - const credentialsPromise = loginOpenAICodexDeviceCodeForTest({ + const credentialsPromise = loginOpenAICodexDeviceCode({ onDeviceCode: () => {}, signal: controller.signal, }); @@ -340,7 +317,7 @@ describe("OpenAI Codex OAuth", () => { }), ); - const credentialsPromise = loginOpenAICodexDeviceCodeForTest({ + const credentialsPromise = loginOpenAICodexDeviceCode({ onDeviceCode: () => {}, }); const rejectionPromise = credentialsPromise.then( @@ -403,7 +380,7 @@ describe("OpenAI Codex OAuth", () => { }), ); - const credentialsPromise = loginOpenAICodexDeviceCodeForTest({ + const credentialsPromise = loginOpenAICodexDeviceCode({ onDeviceCode: () => {}, }); @@ -441,7 +418,7 @@ describe("OpenAI Codex OAuth", () => { ); await expect( - loginOpenAICodexDeviceCodeForTest({ + loginOpenAICodexDeviceCode({ onDeviceCode: () => {}, }), ).rejects.toThrow( @@ -466,14 +443,9 @@ describe("OpenAI Codex OAuth", () => { }), ); - await expect( - openaiCodexOAuth.refresh({ - type: "oauth", - access: "invalid-access-token", - refresh: "invalid-refresh-token", - expires: 0, - }), - ).rejects.toThrow(/OpenAI Codex token refresh failed \(401\).*Could not validate your token/); + await expect(refreshOpenAICodexToken("invalid-refresh-token")).rejects.toThrow( + /OpenAI Codex token refresh failed \(401\).*Could not validate your token/, + ); expect(consoleError).not.toHaveBeenCalled(); }); }); diff --git a/packages/ai/test/perplexity-oauth.test.ts b/packages/ai/test/perplexity-oauth.test.ts new file mode 100644 index 000000000..ff018cad2 --- /dev/null +++ b/packages/ai/test/perplexity-oauth.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getOAuthProvider } from "../src/auth/oauth/index.ts"; +import { loginPerplexity, refreshPerplexityToken } from "../src/auth/oauth/perplexity.ts"; +import { builtinProviders } from "../src/providers/all.ts"; + +const originalNoBorrow = process.env.PI_AUTH_NO_BORROW; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +function jwt(payload: Record): string { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none" })}.${encode(payload)}.`; +} + +describe.sequential("Perplexity OAuth", () => { + afterEach(() => { + vi.unstubAllGlobals(); + if (originalNoBorrow === undefined) delete process.env.PI_AUTH_NO_BORROW; + else process.env.PI_AUTH_NO_BORROW = originalNoBorrow; + }); + + it("keeps flow helpers but does not advertise OAuth on the model provider", async () => { + // Session JWT OAuth is not a direct api.perplexity.ai credential path. + expect(getOAuthProvider("perplexity")).toBeUndefined(); + const { perplexityProvider } = await import("../src/providers/perplexity.ts"); + expect(perplexityProvider().auth.oauth).toBeUndefined(); + expect(perplexityProvider().auth.apiKey).toBeDefined(); + expect(builtinProviders().map((entry) => entry.id)).toContain("perplexity"); + }); + + it("logs in through mocked OTP endpoints and keeps JWTs without exp non-expiring", async () => { + // Given: a CSRF response that requires its NextAuth cookie on both OTP requests. + process.env.PI_AUTH_NO_BORROW = "1"; + const tokenWithoutExpiry = jwt({ sub: "perplexity-account" }); + const urls: string[] = []; + const requestCookies: (string | null)[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + urls.push(url); + if (url.endsWith("/api/auth/csrf")) { + return new Response(JSON.stringify({ csrfToken: "csrf-value" }), { + headers: { + "Content-Type": "application/json", + "Set-Cookie": "next-auth.csrf-token=cookie-value; Path=/; HttpOnly; Secure", + }, + }); + } + requestCookies.push(new Headers(init?.headers).get("cookie")); + if (url.endsWith("/api/auth/signin-email")) return jsonResponse({ ok: true }); + if (url.endsWith("/api/auth/signin-otp")) return jsonResponse({ token: tokenWithoutExpiry }); + throw new Error(`Unexpected request: ${url}`); + }), + ); + const answers = ["member@example.com", "123456"]; + // When: the email OTP flow sends and verifies the code. + const credentials = await loginPerplexity({ + onAuth: () => {}, + onDeviceCode: () => {}, + onPrompt: async () => answers.shift() ?? "", + onSelect: async () => undefined, + }); + // Then: both state-changing requests preserve the CSRF cookie and the JWT remains usable. + expect(urls).toHaveLength(3); + expect(requestCookies).toEqual(["next-auth.csrf-token=cookie-value", "next-auth.csrf-token=cookie-value"]); + expect(credentials).toMatchObject({ access: tokenWithoutExpiry, refresh: tokenWithoutExpiry }); + expect(credentials.expires).toBe(8.64e15); + + const refreshed = await refreshPerplexityToken({ ...credentials, expires: 1 }); + expect(refreshed.access).toBe(tokenWithoutExpiry); + expect(refreshed.expires).toBe(8.64e15); + }); + + it("uses an exp claim on refresh and keeps failed endpoint details secret", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const expiring = jwt({ exp }); + const refreshed = await refreshPerplexityToken({ access: expiring, refresh: expiring, expires: 1 }); + expect(refreshed.expires).toBe(exp * 1000 - 5 * 60_000); + + process.env.PI_AUTH_NO_BORROW = "1"; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/api/auth/csrf")) { + return new Response(JSON.stringify({ csrfToken: "csrf-private" }), { + headers: { + "Content-Type": "application/json", + "Set-Cookie": "next-auth.csrf-token=private-cookie; Path=/; HttpOnly", + }, + }); + } + if (url.endsWith("/api/auth/signin-email")) return jsonResponse({ ok: true }); + return jsonResponse({ text: "perplexity-response-private" }, 401); + }), + ); + const answers = ["private@example.com", "otp-private-value"]; + const error = await loginPerplexity({ + onAuth: () => {}, + onDeviceCode: () => {}, + onPrompt: async () => answers.shift() ?? "", + onSelect: async () => undefined, + }).catch((value: unknown) => value); + expect(String(error)).toContain("401"); + expect(String(error)).not.toContain("otp-private-value"); + expect(String(error)).not.toContain("perplexity-response-private"); + }); +}); diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 9517dd0cb..18874bfc4 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -1,6 +1,6 @@ # Providers -Pi supports subscription-based providers via OAuth and API key providers via environment variables or auth file. Built-in catalogs ship with pi; configured providers may refresh newer catalogs and cache them in `~/.pi/agent/models-store.json` for offline use. +Senpi supports subscription-based providers via OAuth and API key providers via environment variables or auth file. For each provider, senpi knows all available models. The list is updated with every senpi release. ## Table of Contents @@ -19,10 +19,8 @@ Use `/login` in interactive mode, then select a provider: - ChatGPT Plus/Pro (Codex) - Claude Pro/Max - GitHub Copilot -- xAI (Grok/X subscription) -- Radius -Use `/logout` to clear credentials. Tokens are stored in `~/.pi/agent/auth.json` and auto-refresh when expired. +Use `/logout` to clear credentials. Tokens are stored in `~/.senpi/agent/auth.json` and auto-refresh when expired. For multiple local provider accounts, use the [auth broker and gateway](auth-broker-gateway.md); it owns durable tokens and uses explicit `provider/model` gateway IDs. ### OpenAI Codex @@ -38,15 +36,6 @@ Anthropic subscription auth is active for Claude Pro/Max accounts. Third-party h - Press Enter for github.com, or enter your GitHub Enterprise Server domain - If you get "model not supported", enable it in VS Code: Copilot Chat → model selector → select model → "Enable" -### xAI (Grok/X subscription) - -- Run `/login xai`, then select **Use a subscription** -- `XAI_API_KEY` remains available through **Use an API key** - -### Radius - -Radius is a dynamic `pi-messages` gateway. `/login radius` stores OAuth tokens in `auth.json`; the gateway catalog is refreshed independently and cached in `models-store.json`. Custom Radius gateways can be declared in `models.json` with `"oauth": "radius"` and a gateway `baseUrl`. - ## API Keys ### Environment Variables or Auth File @@ -55,7 +44,7 @@ Use `/login` in interactive mode and select a provider to store an API key in `a ```bash export ANTHROPIC_API_KEY=sk-ant-... -pi +senpi ``` | Provider | Environment Variable | `auth.json` key | @@ -80,7 +69,6 @@ pi | ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` | `zai-coding-cn` | | OpenCode Zen | `OPENCODE_API_KEY` | `opencode` | | OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` | -| Radius | `RADIUS_API_KEY` | `radius` | | Hugging Face | `HF_TOKEN` | `huggingface` | | Fireworks | `FIREWORKS_API_KEY` | `fireworks` | | Together AI | `TOGETHER_API_KEY` | `together` | @@ -92,11 +80,11 @@ pi | Xiaomi MiMo Token Plan (Amsterdam) | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` | `xiaomi-token-plan-ams` | | Xiaomi MiMo Token Plan (Singapore) | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` | `xiaomi-token-plan-sgp` | -Reference for environment variables and `auth.json` keys: [`const envMap`](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/env-api-keys.ts) in [`packages/ai/src/env-api-keys.ts`](https://github.com/earendil-works/pi-mono/blob/main/packages/ai/src/env-api-keys.ts). +Reference for environment variables and `auth.json` keys: [`const envMap`](https://github.com/code-yeongyu/senpi/blob/main/packages/ai/src/env-api-keys.ts) in [`packages/ai/src/env-api-keys.ts`](https://github.com/code-yeongyu/senpi/blob/main/packages/ai/src/env-api-keys.ts). #### Auth File -Store credentials in `~/.pi/agent/auth.json`: +Store credentials in `~/.senpi/agent/auth.json`: ```json { @@ -204,14 +192,14 @@ export AWS_REGION=us-west-2 Also supports ECS task roles (`AWS_CONTAINER_CREDENTIALS_*`) and IRSA (`AWS_WEB_IDENTITY_TOKEN_FILE`). ```bash -pi --provider amazon-bedrock --model us.anthropic.claude-sonnet-4-20250514-v1:0 +senpi --provider amazon-bedrock --model us.anthropic.claude-sonnet-4-20250514-v1:0 ``` Prompt caching is enabled automatically for Claude models whose ID contains a recognizable model name (base models and system-defined inference profiles). For application inference profiles (whose ARNs don't contain the model name), set `AWS_BEDROCK_FORCE_CACHE=1` to enable cache points: ```bash export AWS_BEDROCK_FORCE_CACHE=1 -pi --provider amazon-bedrock --model arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123 +senpi --provider amazon-bedrock --model arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123 ``` If you are connecting to a Bedrock API proxy, the following environment variables can be used: @@ -235,7 +223,7 @@ export AWS_BEDROCK_FORCE_HTTP1=1 export CLOUDFLARE_API_KEY=... # or use /login export CLOUDFLARE_ACCOUNT_ID=... export CLOUDFLARE_GATEWAY_ID=... # create at dash.cloudflare.com → AI → AI Gateway -pi --provider cloudflare-ai-gateway --model "claude-sonnet-4-5" +senpi --provider cloudflare-ai-gateway --model "claude-sonnet-4-5" ``` Routes to OpenAI, Anthropic, and Workers AI through Cloudflare AI Gateway. Workers AI uses the Unified API (`/compat`) and prefixed model IDs (`workers-ai/@cf/...`). OpenAI uses the OpenAI passthrough route (`/openai`) with native OpenAI model IDs such as `gpt-5.1`. Anthropic uses the Anthropic passthrough route (`/anthropic`) with native Anthropic model IDs such as `claude-sonnet-4-5`. @@ -249,7 +237,7 @@ AI Gateway authentication uses `CLOUDFLARE_API_KEY` as `cf-aig-authorization`. U | Stored BYOK | Cloudflare token only | Cloudflare injects provider keys stored in the AI Gateway dashboard | | Inline BYOK | Cloudflare token plus upstream `Authorization` header | The request supplies the upstream provider key | -For normal pi usage, prefer unified billing or stored BYOK. Inline BYOK requires configuring an additional upstream `Authorization` header for the Cloudflare AI Gateway provider, for example via a `models.json` provider/model override. +For normal senpi usage, prefer unified billing or stored BYOK. Inline BYOK requires configuring an additional upstream `Authorization` header for the Cloudflare AI Gateway provider, for example via a `models.json` provider/model override. ### Cloudflare Workers AI @@ -258,10 +246,10 @@ For normal pi usage, prefer unified billing or stored BYOK. Inline BYOK requires ```bash export CLOUDFLARE_API_KEY=... # or use /login export CLOUDFLARE_ACCOUNT_ID=... -pi --provider cloudflare-workers-ai --model "@cf/moonshotai/kimi-k2.6" +senpi --provider cloudflare-workers-ai --model "@cf/moonshotai/kimi-k2.6" ``` -Pi automatically sets `x-session-affinity` for [prefix caching](https://developers.cloudflare.com/workers-ai/features/prompt-caching/) discounts. +Senpi automatically sets `x-session-affinity` for [prefix caching](https://developers.cloudflare.com/workers-ai/features/prompt-caching/) discounts. ### Google Vertex AI diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts b/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts index 1ace0fecd..4925f33cc 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/index.ts @@ -46,7 +46,7 @@ import { } from "@earendil-works/pi-ai"; // ============================================================================= -// OAuth implementation adapted for the legacy extension compatibility interface. +// OAuth Implementation (copied from packages/ai/src/auth/oauth/anthropic.ts) // ============================================================================= const decode = (s: string) => atob(s); diff --git a/packages/coding-agent/src/core/auth-providers.ts b/packages/coding-agent/src/core/auth-providers.ts index 02ad938ae..d769acea8 100644 --- a/packages/coding-agent/src/core/auth-providers.ts +++ b/packages/coding-agent/src/core/auth-providers.ts @@ -15,6 +15,13 @@ import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts"; /** Built-in model providers (used to decide API-key vs oauth login eligibility). */ const BUILT_IN_MODEL_PROVIDERS = new Set(getProviders()); +/** + * Built-in providers that appear in display names / model catalogs but only + * support OAuth (serialized token+project credentials). Keep them out of the + * API-key login list so /login does not advertise a path that cannot work. + */ +const OAUTH_ONLY_MODEL_PROVIDERS = new Set(["openai-codex-device", "google-gemini-cli", "google-antigravity"]); + /** One provider entry for a login/logout selector. */ export interface AuthProviderInfo { id: string; @@ -25,7 +32,8 @@ export interface AuthProviderInfo { /** * Whether a provider should be offered for API-key login. * - * - A provider with a built-in display name is always API-key eligible. + * - OAuth-only model providers are never API-key eligible. + * - A provider with a built-in display name is otherwise API-key eligible. * - A built-in model provider without a display name is not (it authenticates * via oauth or is otherwise not an API-key login target). * - Any other provider is API-key eligible unless it is an oauth provider. @@ -35,6 +43,9 @@ export function isApiKeyLoginProvider( oauthProviderIds: ReadonlySet, builtInProviderIds: ReadonlySet = BUILT_IN_MODEL_PROVIDERS, ): boolean { + if (OAUTH_ONLY_MODEL_PROVIDERS.has(providerId)) { + return false; + } if (BUILT_IN_PROVIDER_DISPLAY_NAMES[providerId]) { return true; } diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index 2b745e46b..673903dc4 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -66,22 +66,24 @@ - LOW: `provider-composer.ts` shared `streamWith()` dispatch and its `@earendil-works/pi-ai/compat` imports. -## AnthropicMessagesCompat.supportsWebSearch in models.json schema (2026-07-16) +## Provider login and model-resolution parity (2026-07-14) ### What changed -- `model-config.ts` (`AnthropicMessagesCompatSchema`): added optional boolean `supportsWebSearch`, mirroring - `supportsWebSearchPreview` in `OpenAIResponsesCompatSchema`. This is the models.json opt-in for - Anthropic-compatible endpoints that genuinely support server-side web search (see `packages/ai/src/changes.md` - 2026-07-16); without the schema entry the flag would fail models.json validation. +- auth-providers.ts, model-resolver.ts, and provider-display-names.ts now consume the expanded pi-ai provider catalog + for consistent /login, display, and default-model behavior. +- AuthStorage and ModelRegistry apply provider-specific OAuth request tokens and headers instead of reducing every + legacy credential to one raw string. +- OAuth-only Google CCA providers and openai-codex-device are excluded from API-key login eligibility. -### Why extension system couldn't handle this alone +### Why extension system couldn't handle this -- models.json validation happens in core `model-config.ts` before any extension sees the model entry. +- Login selection and startup model resolution run in core before user extensions can provide a replacement catalog. ### Expected merge conflict zones -- LOW: `model-config.ts` compat schemas if upstream adds more compat flags. +- MEDIUM: provider display/default maps when upstream changes model resolution or login discovery. + ## Skill-loading trigger reframed with cost asymmetry (2026-07-16) diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 95300348c..16aeab7c8 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -16,42 +16,68 @@ type ModelScopeSource = ModelRuntime | ModelRegistry; /** Default model IDs for each known provider */ export const defaultModelPerProvider: Record = { + "alibaba-coding-plan": "qwen3-coder-plus", "amazon-bedrock": "us.anthropic.claude-opus-4-6-v1", "ant-ling": "Ring-2.6-1T", anthropic: "claude-opus-4-8", - openai: "gpt-5.5", "azure-openai-responses": "gpt-5.4", - "openai-codex": "gpt-5.5", - radius: "auto", - nvidia: "nvidia/nemotron-3-super-120b-a12b", + cerebras: "zai-glm-4.7", + "cloudflare-ai-gateway": "workers-ai/@cf/moonshotai/kimi-k2.6", + "cloudflare-workers-ai": "@cf/moonshotai/kimi-k2.6", + cursor: "default", + deepinfra: "meta-llama/Llama-3.3-70B-Instruct", deepseek: "deepseek-v4-pro", + firepass: "default", + fireworks: "accounts/fireworks/models/kimi-k2p6", + fugu: "fugu", + "github-copilot": "gpt-5.4", + "gitlab-duo": "claude-sonnet-4-6", google: "gemini-3.1-pro-preview", + "google-antigravity": "claude-sonnet-4-5", + "google-gemini-cli": "gemini-2.5-pro", "google-vertex": "gemini-3.1-pro-preview", - "github-copilot": "gpt-5.4", - openrouter: "moonshotai/kimi-k2.6", - "vercel-ai-gateway": "zai/glm-5.1", - xai: "grok-4.5", groq: "openai/gpt-oss-120b", - cerebras: "zai-glm-4.7", - zai: "glm-5.1", - "zai-coding-cn": "glm-5.1", - mistral: "devstral-medium-latest", + huggingface: "moonshotai/Kimi-K2.6", + kilo: "claude-sonnet-4-6", + "kimi-coding": "kimi-for-coding", + litellm: "default", + "lm-studio": "default", minimax: "MiniMax-M2.7", "minimax-cn": "MiniMax-M2.7", + "minimax-code": "MiniMax-M2.5", + "minimax-code-cn": "MiniMax-M2.5", + mistral: "devstral-medium-latest", + moonshot: "kimi-k2.5", moonshotai: "kimi-k2.6", "moonshotai-cn": "kimi-k2.6", - huggingface: "moonshotai/Kimi-K2.6", - fireworks: "accounts/fireworks/models/kimi-k2p6", - together: "moonshotai/Kimi-K2.6", + nanogpt: "default", + nvidia: "nvidia/nemotron-3-super-120b-a12b", + ollama: "default", + "ollama-cloud": "default", + openai: "gpt-5.5", + "openai-codex": "gpt-5.5", + "openai-codex-device": "gpt-5.3-codex", opencode: "kimi-k2.6", "opencode-go": "kimi-k2.6", - "kimi-coding": "kimi-for-coding", - "cloudflare-workers-ai": "@cf/moonshotai/kimi-k2.6", - "cloudflare-ai-gateway": "workers-ai/@cf/moonshotai/kimi-k2.6", + "opencode-zen": "claude-sonnet-4-6", + openrouter: "moonshotai/kimi-k2.6", + perplexity: "sonar-pro", + qianfan: "ernie-x1-turbo-32k", + "qwen-portal": "qwen3-coder-plus", + radius: "auto", + synthetic: "default", + together: "moonshotai/Kimi-K2.6", + venice: "default", + "vercel-ai-gateway": "zai/glm-5.1", + vllm: "default", + xai: "grok-4.5", xiaomi: "mimo-v2.5-pro", - "xiaomi-token-plan-cn": "mimo-v2.5-pro", "xiaomi-token-plan-ams": "mimo-v2.5-pro", + "xiaomi-token-plan-cn": "mimo-v2.5-pro", "xiaomi-token-plan-sgp": "mimo-v2.5-pro", + zai: "glm-5.1", + "zai-coding-cn": "glm-5.1", + zenmux: "default", }; export interface ScopedModel { diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index 88696f4aa..3a7cd5b16 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -15,6 +15,10 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { "kimi-coding": "Kimi For Coding", minimax: "MiniMax", "minimax-cn": "MiniMax (China)", + "minimax-code": "MiniMax Code", + "minimax-code-cn": "MiniMax Code (China)", + moonshot: "Moonshot", + "opencode-zen": "OpenCode Zen Catalog", moonshotai: "Moonshot AI", "moonshotai-cn": "Moonshot AI (China)", nvidia: "NVIDIA NIM", @@ -33,4 +37,11 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { "xiaomi-token-plan-cn": "Xiaomi MiMo Token Plan (China)", "xiaomi-token-plan-ams": "Xiaomi MiMo Token Plan (Amsterdam)", "xiaomi-token-plan-sgp": "Xiaomi MiMo Token Plan (Singapore)", + cursor: "Cursor", + "gitlab-duo": "GitLab Duo", + perplexity: "Perplexity", + kilo: "Kilo", + "google-gemini-cli": "Google Gemini CLI (Cloud Code Assist)", + "google-antigravity": "Google Antigravity", + "openai-codex-device": "OpenAI Codex (device)", }; diff --git a/packages/coding-agent/test/suite/app-server-thread-handlers-catch.test.ts b/packages/coding-agent/test/suite/app-server-thread-handlers-catch.test.ts index d0bda17f4..d6ab1fb81 100644 --- a/packages/coding-agent/test/suite/app-server-thread-handlers-catch.test.ts +++ b/packages/coding-agent/test/suite/app-server-thread-handlers-catch.test.ts @@ -33,8 +33,8 @@ describe("app-server thread lifecycle registry errors", () => { }); it("surfaces unexpected registry errors during idle unload", async () => { - vi.useFakeTimers(); // Given: a subscribed thread whose idle unload lookup fails unexpectedly after unsubscribe. + // Create the harness under real timers first; enable fake timers only for the countdown. const { connection, root, threads } = await createHarness(); const entry = await threads.createThread({ cwd: root }); const notifications = new NotificationRouter({ connections: [connection], threads: [entry] }); @@ -56,6 +56,8 @@ describe("app-server thread lifecycle registry errors", () => { throw new Error("synthetic idle unload failure"); }; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + // When: unsubscribe schedules the idle unload timer. await expect( handlerRegistry.dispatch(connection, { @@ -66,6 +68,6 @@ describe("app-server thread lifecycle registry errors", () => { ).resolves.toEqual({ id: 24, result: { status: "unsubscribed" } }); // Then: the idle timer reports the unexpected registry error instead of swallowing it. - await expect(vi.runOnlyPendingTimersAsync()).rejects.toThrow("synthetic idle unload failure"); + await expect(vi.advanceTimersByTimeAsync(0)).rejects.toThrow("synthetic idle unload failure"); }); }); diff --git a/packages/coding-agent/test/suite/app-server-thread-handlers-cold.test.ts b/packages/coding-agent/test/suite/app-server-thread-handlers-cold.test.ts index 3f4e4dede..ddca8f9ae 100644 --- a/packages/coding-agent/test/suite/app-server-thread-handlers-cold.test.ts +++ b/packages/coding-agent/test/suite/app-server-thread-handlers-cold.test.ts @@ -108,11 +108,12 @@ describe("app-server thread cold lifecycle handlers", () => { }); it("unloads idle threads after the configured no-subscriber delay", async () => { - vi.useFakeTimers(); // Given: a started thread becomes idle with no subscribers. + // Async harness setup runs under real timers; fake timers cover only the idle countdown. const { connection, registry, root } = await createHarness(); const started = await registry.dispatch(connection, { id: 9, method: "thread/start", params: { cwd: root } }); const threadId = stringAt(objectAt(responseResult(started), "thread"), "id"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); await registry.dispatch(connection, { id: 10, method: "thread/unsubscribe", params: { threadId } }); connection.received.length = 0; @@ -127,7 +128,6 @@ describe("app-server thread cold lifecycle handlers", () => { }); it("unloads a thread whose last subscriber vanished on transport close", async () => { - vi.useFakeTimers(); // Given: a started thread whose only subscriber's socket drops without thread/unsubscribe. const { connection, registry, root, notifications, lifecycle, threads } = await createHarness(); const observer = new FakeConnection("conn-observer"); @@ -136,6 +136,8 @@ describe("app-server thread cold lifecycle handlers", () => { const threadId = stringAt(objectAt(responseResult(started), "thread"), "id"); observer.received.length = 0; + // Fake timers only for the idle-unload countdown after the last subscriber leaves. + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); // When: the transport reports the closed connection and the idle delay elapses. const emptied = notifications.removeConnection(connection.id); for (const emptiedThreadId of emptied) { @@ -153,11 +155,11 @@ describe("app-server thread cold lifecycle handlers", () => { }); it("clears pending idle-unload timers on dispose", async () => { - vi.useFakeTimers(); // Given: a thread with a scheduled idle unload. const { connection, registry, root, lifecycle, threads } = await createHarness(); const started = await registry.dispatch(connection, { id: 9, method: "thread/start", params: { cwd: root } }); const threadId = stringAt(objectAt(responseResult(started), "thread"), "id"); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); await registry.dispatch(connection, { id: 10, method: "thread/unsubscribe", params: { threadId } }); // When: the server shuts down before the timer fires. diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 5fbfbc19f..180bb2dad 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -660,10 +660,12 @@ describe("ToolExecutionComponent parity", () => { ); const collapsed = stripAnsi(component.render(120).join("\n")); - expect(collapsed).toContain(scenario.compact); - expect(collapsed).not.toContain(scenario.hidden); + // Flatten whitespace so long absolute paths that wrap at render width still match. + const collapsedFlat = collapsed.replace(/\s+/g, " ").trim(); + expect(collapsedFlat).toContain(scenario.compact); + expect(collapsedFlat).not.toContain(scenario.hidden); if (scenario.absent) { - expect(collapsed).not.toContain(scenario.absent); + expect(collapsedFlat).not.toContain(scenario.absent); } component.setExpanded(true); diff --git a/scripts/devenv-setup.mjs b/scripts/devenv-setup.mjs index 2fead16ca..617510bff 100644 --- a/scripts/devenv-setup.mjs +++ b/scripts/devenv-setup.mjs @@ -74,6 +74,22 @@ const PROVIDER_KEYS = [ "GROQ_API_KEY", "XAI_API_KEY", "DEEPSEEK_API_KEY", + "ALIBABA_CODING_PLAN_API_KEY", + "DEEPINFRA_API_KEY", + "FIREPASS_API_KEY", + "FUGU_API_KEY", + "LITELLM_API_KEY", + "LM_STUDIO_API_KEY", + "NANO_GPT_API_KEY", + "OLLAMA_API_KEY", + "OLLAMA_CLOUD_API_KEY", + "QIANFAN_API_KEY", + "QWEN_OAUTH_TOKEN", + "QWEN_PORTAL_API_KEY", + "SYNTHETIC_API_KEY", + "VENICE_API_KEY", + "VLLM_API_KEY", + "ZENMUX_API_KEY", ]; function hasCmd(cmd) {