From b61169f7bc635585dc34ce730b0609fb92b5f9eb Mon Sep 17 00:00:00 2001 From: islee Date: Sun, 12 Jul 2026 22:19:07 +0900 Subject: [PATCH 01/11] feat(ai): add provider and OAuth parity for /login Add 18 API-key providers (alibaba-coding-plan, cursor, deepinfra, firepass, fugu, gitlab-duo, glm-zcode, kagi, kilo, kimi-code, litellm, lm-studio, minimax-code(-cn), moonshot, nanogpt, ollama(-cloud), openai-codex-device, parallel, perplexity, qianfan, qwen-portal, synthetic, tavily, venice, vllm, zenmux) plus OAuth flows for cursor, gitlab-duo, glm-zcode, google-antigravity, google-gemini-cli, kilo, kimi-code, openai-codex-device, perplexity, xai, and the remaining Gajae provider-ID gaps for full /login parity. The coding-agent provider wiring (model-resolver defaults, provider-display-names, auth-providers oauth-only set) travels here because model-resolver is exhaustive over the ai KnownProvider union. Source of truth for keys remains env-api-keys.ts; parity is enforced by test/api-key-provider-parity.test.ts. --- packages/ai/scripts/generate-models.ts | 724 ++++++++++++- packages/ai/src/api/google-gemini-cli.lazy.ts | 4 + packages/ai/src/api/google-gemini-cli.ts | 405 ++++++++ packages/ai/src/api/google-shared.ts | 2 +- packages/ai/src/auth/helpers.ts | 15 +- packages/ai/src/env-api-keys.ts | 72 +- packages/ai/src/models.generated.ts | 62 ++ .../providers/alibaba-coding-plan.models.ts | 25 + .../ai/src/providers/alibaba-coding-plan.ts | 15 + packages/ai/src/providers/all.ts | 61 ++ packages/ai/src/providers/cursor.models.ts | 19 + packages/ai/src/providers/cursor.ts | 19 + packages/ai/src/providers/deepinfra.models.ts | 25 + packages/ai/src/providers/deepinfra.ts | 15 + packages/ai/src/providers/firepass.models.ts | 26 + packages/ai/src/providers/firepass.ts | 15 + packages/ai/src/providers/fugu.models.ts | 25 + packages/ai/src/providers/fugu.ts | 15 + .../ai/src/providers/gitlab-duo.models.ts | 34 + packages/ai/src/providers/gitlab-duo.ts | 23 + packages/ai/src/providers/glm-zcode.models.ts | 28 + packages/ai/src/providers/glm-zcode.ts | 19 + .../providers/google-antigravity.models.ts | 255 +++++ .../src/providers/google-gemini-cli.models.ts | 148 +++ .../ai/src/providers/google-gemini-cli.ts | 37 + .../ai/src/providers/google-gemini-headers.ts | 56 + packages/ai/src/providers/kagi.models.ts | 25 + packages/ai/src/providers/kagi.ts | 15 + packages/ai/src/providers/kilo.models.ts | 19 + packages/ai/src/providers/kilo.ts | 19 + packages/ai/src/providers/kimi-code.models.ts | 103 ++ packages/ai/src/providers/kimi-code.ts | 19 + packages/ai/src/providers/litellm.models.ts | 25 + packages/ai/src/providers/litellm.ts | 15 + packages/ai/src/providers/lm-studio.models.ts | 25 + packages/ai/src/providers/lm-studio.ts | 19 + .../src/providers/minimax-code-cn.models.ts | 61 ++ packages/ai/src/providers/minimax-code-cn.ts | 15 + .../ai/src/providers/minimax-code.models.ts | 61 ++ packages/ai/src/providers/minimax-code.ts | 15 + packages/ai/src/providers/moonshot.models.ts | 171 +++ packages/ai/src/providers/moonshot.ts | 15 + packages/ai/src/providers/nanogpt.models.ts | 25 + packages/ai/src/providers/nanogpt.ts | 15 + .../ai/src/providers/ollama-cloud.models.ts | 25 + packages/ai/src/providers/ollama-cloud.ts | 15 + packages/ai/src/providers/ollama.models.ts | 25 + packages/ai/src/providers/ollama.ts | 17 + .../providers/openai-codex-device.models.ts | 144 +++ .../ai/src/providers/openai-codex-device.ts | 21 + .../ai/src/providers/opencode-zen.models.ts | 982 ++++++++++++++++++ packages/ai/src/providers/opencode-zen.ts | 24 + packages/ai/src/providers/parallel.models.ts | 25 + packages/ai/src/providers/parallel.ts | 15 + .../ai/src/providers/perplexity.models.ts | 19 + packages/ai/src/providers/perplexity.ts | 19 + packages/ai/src/providers/qianfan.models.ts | 25 + packages/ai/src/providers/qianfan.ts | 15 + .../ai/src/providers/qwen-portal.models.ts | 25 + packages/ai/src/providers/qwen-portal.ts | 15 + packages/ai/src/providers/synthetic.models.ts | 25 + packages/ai/src/providers/synthetic.ts | 15 + packages/ai/src/providers/tavily.models.ts | 25 + packages/ai/src/providers/tavily.ts | 15 + packages/ai/src/providers/venice.models.ts | 25 + packages/ai/src/providers/venice.ts | 15 + packages/ai/src/providers/vllm.models.ts | 25 + packages/ai/src/providers/vllm.ts | 17 + packages/ai/src/providers/xai.ts | 8 +- packages/ai/src/providers/zenmux.models.ts | 26 + packages/ai/src/providers/zenmux.ts | 15 + packages/ai/src/types.ts | 74 +- packages/ai/src/utils/oauth/cursor.ts | 174 ++++ packages/ai/src/utils/oauth/gitlab-duo.ts | 295 ++++++ packages/ai/src/utils/oauth/glm-zcode.ts | 498 +++++++++ .../ai/src/utils/oauth/google-antigravity.ts | 84 ++ .../ai/src/utils/oauth/google-gemini-cli.ts | 101 ++ .../ai/src/utils/oauth/google-oauth-node.ts | 8 + .../ai/src/utils/oauth/google-oauth-shared.ts | 255 +++++ packages/ai/src/utils/oauth/index.ts | 44 +- packages/ai/src/utils/oauth/kilo.ts | 126 +++ packages/ai/src/utils/oauth/kimi-code.ts | 305 ++++++ packages/ai/src/utils/oauth/load.ts | 32 + .../ai/src/utils/oauth/openai-codex-device.ts | 33 + packages/ai/src/utils/oauth/perplexity.ts | 202 ++++ packages/ai/src/utils/oauth/xai.ts | 251 +++++ .../ai/test/api-key-provider-parity.test.ts | 106 ++ packages/ai/test/cursor-oauth.test.ts | 73 ++ .../ai/test/gajae-provider-id-gaps.test.ts | 48 + packages/ai/test/gitlab-duo-oauth.test.ts | 84 ++ packages/ai/test/glm-zcode-oauth.test.ts | 105 ++ packages/ai/test/google-account-oauth.test.ts | 236 +++++ packages/ai/test/google-cca-runtime.test.ts | 192 ++++ packages/ai/test/kilo-oauth.test.ts | 71 ++ packages/ai/test/kimi-code-oauth.test.ts | 151 +++ .../ai/test/openai-codex-device-oauth.test.ts | 70 ++ packages/ai/test/perplexity-oauth.test.ts | 96 ++ packages/ai/test/xai-oauth.test.ts | 212 ++++ packages/coding-agent/docs/providers.md | 2 +- .../coding-agent/src/core/auth-providers.ts | 4 + .../coding-agent/src/core/model-resolver.ts | 67 +- .../src/core/provider-display-names.ts | 43 +- 102 files changed, 8400 insertions(+), 70 deletions(-) create mode 100644 packages/ai/src/api/google-gemini-cli.lazy.ts create mode 100644 packages/ai/src/api/google-gemini-cli.ts create mode 100644 packages/ai/src/providers/alibaba-coding-plan.models.ts create mode 100644 packages/ai/src/providers/alibaba-coding-plan.ts create mode 100644 packages/ai/src/providers/cursor.models.ts create mode 100644 packages/ai/src/providers/cursor.ts create mode 100644 packages/ai/src/providers/deepinfra.models.ts create mode 100644 packages/ai/src/providers/deepinfra.ts create mode 100644 packages/ai/src/providers/firepass.models.ts create mode 100644 packages/ai/src/providers/firepass.ts create mode 100644 packages/ai/src/providers/fugu.models.ts create mode 100644 packages/ai/src/providers/fugu.ts create mode 100644 packages/ai/src/providers/gitlab-duo.models.ts create mode 100644 packages/ai/src/providers/gitlab-duo.ts create mode 100644 packages/ai/src/providers/glm-zcode.models.ts create mode 100644 packages/ai/src/providers/glm-zcode.ts create mode 100644 packages/ai/src/providers/google-antigravity.models.ts create mode 100644 packages/ai/src/providers/google-gemini-cli.models.ts create mode 100644 packages/ai/src/providers/google-gemini-cli.ts create mode 100644 packages/ai/src/providers/google-gemini-headers.ts create mode 100644 packages/ai/src/providers/kagi.models.ts create mode 100644 packages/ai/src/providers/kagi.ts create mode 100644 packages/ai/src/providers/kilo.models.ts create mode 100644 packages/ai/src/providers/kilo.ts create mode 100644 packages/ai/src/providers/kimi-code.models.ts create mode 100644 packages/ai/src/providers/kimi-code.ts create mode 100644 packages/ai/src/providers/litellm.models.ts create mode 100644 packages/ai/src/providers/litellm.ts create mode 100644 packages/ai/src/providers/lm-studio.models.ts create mode 100644 packages/ai/src/providers/lm-studio.ts create mode 100644 packages/ai/src/providers/minimax-code-cn.models.ts create mode 100644 packages/ai/src/providers/minimax-code-cn.ts create mode 100644 packages/ai/src/providers/minimax-code.models.ts create mode 100644 packages/ai/src/providers/minimax-code.ts create mode 100644 packages/ai/src/providers/moonshot.models.ts create mode 100644 packages/ai/src/providers/moonshot.ts create mode 100644 packages/ai/src/providers/nanogpt.models.ts create mode 100644 packages/ai/src/providers/nanogpt.ts create mode 100644 packages/ai/src/providers/ollama-cloud.models.ts create mode 100644 packages/ai/src/providers/ollama-cloud.ts create mode 100644 packages/ai/src/providers/ollama.models.ts create mode 100644 packages/ai/src/providers/ollama.ts create mode 100644 packages/ai/src/providers/openai-codex-device.models.ts create mode 100644 packages/ai/src/providers/openai-codex-device.ts create mode 100644 packages/ai/src/providers/opencode-zen.models.ts create mode 100644 packages/ai/src/providers/opencode-zen.ts create mode 100644 packages/ai/src/providers/parallel.models.ts create mode 100644 packages/ai/src/providers/parallel.ts create mode 100644 packages/ai/src/providers/perplexity.models.ts create mode 100644 packages/ai/src/providers/perplexity.ts create mode 100644 packages/ai/src/providers/qianfan.models.ts create mode 100644 packages/ai/src/providers/qianfan.ts create mode 100644 packages/ai/src/providers/qwen-portal.models.ts create mode 100644 packages/ai/src/providers/qwen-portal.ts create mode 100644 packages/ai/src/providers/synthetic.models.ts create mode 100644 packages/ai/src/providers/synthetic.ts create mode 100644 packages/ai/src/providers/tavily.models.ts create mode 100644 packages/ai/src/providers/tavily.ts create mode 100644 packages/ai/src/providers/venice.models.ts create mode 100644 packages/ai/src/providers/venice.ts create mode 100644 packages/ai/src/providers/vllm.models.ts create mode 100644 packages/ai/src/providers/vllm.ts create mode 100644 packages/ai/src/providers/zenmux.models.ts create mode 100644 packages/ai/src/providers/zenmux.ts create mode 100644 packages/ai/src/utils/oauth/cursor.ts create mode 100644 packages/ai/src/utils/oauth/gitlab-duo.ts create mode 100644 packages/ai/src/utils/oauth/glm-zcode.ts create mode 100644 packages/ai/src/utils/oauth/google-antigravity.ts create mode 100644 packages/ai/src/utils/oauth/google-gemini-cli.ts create mode 100644 packages/ai/src/utils/oauth/google-oauth-node.ts create mode 100644 packages/ai/src/utils/oauth/google-oauth-shared.ts create mode 100644 packages/ai/src/utils/oauth/kilo.ts create mode 100644 packages/ai/src/utils/oauth/kimi-code.ts create mode 100644 packages/ai/src/utils/oauth/openai-codex-device.ts create mode 100644 packages/ai/src/utils/oauth/perplexity.ts create mode 100644 packages/ai/src/utils/oauth/xai.ts create mode 100644 packages/ai/test/api-key-provider-parity.test.ts create mode 100644 packages/ai/test/cursor-oauth.test.ts create mode 100644 packages/ai/test/gajae-provider-id-gaps.test.ts create mode 100644 packages/ai/test/gitlab-duo-oauth.test.ts create mode 100644 packages/ai/test/glm-zcode-oauth.test.ts create mode 100644 packages/ai/test/google-account-oauth.test.ts create mode 100644 packages/ai/test/google-cca-runtime.test.ts create mode 100644 packages/ai/test/kilo-oauth.test.ts create mode 100644 packages/ai/test/kimi-code-oauth.test.ts create mode 100644 packages/ai/test/openai-codex-device-oauth.test.ts create mode 100644 packages/ai/test/perplexity-oauth.test.ts create mode 100644 packages/ai/test/xai-oauth.test.ts diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 93920bf8b..7cd184692 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -75,6 +75,20 @@ const KIMI_STATIC_HEADERS = { "User-Agent": "KimiCLI/1.5", } as const; +const KIMI_CODE_STATIC_HEADERS = { + "User-Agent": "KimiCLI/1.5", + "X-Msh-Platform": "kimi_cli", +} as const; + +const KIMI_CODE_COMPAT: OpenAICompletionsCompat = { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + maxTokensField: "max_tokens", + supportsStrictMode: false, + thinkingFormat: "zai", +}; + const TOGETHER_BASE_URL = "https://api.together.ai/v1"; const TOGETHER_BASE_COMPAT: OpenAICompletionsCompat = { supportsStore: false, @@ -332,7 +346,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 { @@ -598,6 +614,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" }); } @@ -2112,6 +2131,274 @@ 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: "search", + name: "Kagi Search (credential placeholder)", + api: "openai-completions", + provider: "kagi", + baseUrl: "https://kagi.com/api/v0", + compat: localOpenAICompat, + reasoning: false, + input: ["text"], + cost: zeroCost, + contextWindow: 8192, + maxTokens: 1024, + }, + { + 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: "search", + name: "Parallel Search (credential placeholder)", + api: "openai-completions", + provider: "parallel", + baseUrl: "https://api.parallel.ai/v1beta", + compat: localOpenAICompat, + reasoning: false, + input: ["text"], + cost: zeroCost, + contextWindow: 8192, + maxTokens: 1024, + }, + { + 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: "search", + name: "Tavily Search (credential placeholder)", + api: "openai-completions", + provider: "tavily", + baseUrl: "https://api.tavily.com", + compat: localOpenAICompat, + reasoning: false, + input: ["text"], + cost: zeroCost, + contextWindow: 8192, + maxTokens: 1024, + }, + { + 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"; @@ -2381,12 +2668,447 @@ async function generateModels() { })); allModels.push(...azureOpenAiModels); + // OAuth-backed catalogs whose account tokens target provider-specific gateways. + const oauthParityModels: Model[] = [ + { + id: "default", + name: "Auto", + api: "openai-completions", + 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-responses", + 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, + }, + { + id: "glm-5.2", + name: "GLM-5.2 (ZCode)", + api: "anthropic-messages", + provider: "glm-zcode", + baseUrl: "https://api.z.ai/api/anthropic", + headers: { + "User-Agent": "ZCode/3.1.2", + "X-Title": "Z Code@electron", + "HTTP-Referer": "https://zcode.z.ai", + "X-ZCode-Agent": "glm", + "X-ZCode-App-Version": "3.1.2", + "X-Release-Channel": "production", + }, + reasoning: true, + thinkingLevelMap: { minimal: null, low: "high", medium: "high", high: "high", max: "max" }, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 131072, + }, + ]; + 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); + + // Kimi Code OAuth uses Kimi's coding endpoint rather than the API-key-only + // kimi-coding provider. Keep the bundled set explicit because the endpoint's + // unauthenticated model catalog is not available during generation. + const kimiCodeModels: Model<"openai-completions">[] = [ + { + id: "kimi-for-coding", + name: "Kimi For Coding", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: KIMI_CODE_STATIC_HEADERS, + compat: KIMI_CODE_COMPAT, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 32000, + }, + { + id: "kimi-k2", + name: "Kimi K2", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: KIMI_CODE_STATIC_HEADERS, + compat: KIMI_CODE_COMPAT, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 262144, + }, + { + id: "kimi-k2-turbo-preview", + name: "Kimi K2 Turbo Preview", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: KIMI_CODE_STATIC_HEADERS, + compat: KIMI_CODE_COMPAT, + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 32000, + }, + { + id: "kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: KIMI_CODE_STATIC_HEADERS, + compat: KIMI_CODE_COMPAT, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 65536, + }, + { + id: "kimi-k2.7-code", + name: "Kimi K2.7 Code", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: KIMI_CODE_STATIC_HEADERS, + compat: KIMI_CODE_COMPAT, + reasoning: true, + thinkingLevelMap: { off: null }, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 65536, + }, + ]; + allModels.push(...kimiCodeModels); + 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/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..2928ba43d --- /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 = buffer.indexOf("\n\n"); + while (boundary >= 0) { + const event = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + 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 = buffer.indexOf("\n\n"); + } + } + } 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 d9a34ad27..ac60e9582 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 (callbacks) => { @@ -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/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 7bd955e1f..923cf345f 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,38 +77,69 @@ 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", + "glm-zcode": "GLM_ZCODE_API_KEY", google: "GEMINI_API_KEY", "google-vertex": "GOOGLE_CLOUD_API_KEY", groq: "GROQ_API_KEY", - cerebras: "CEREBRAS_API_KEY", - xai: "XAI_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", + kagi: "KAGI_API_KEY", + kilo: "KILO_API_KEY", + "kimi-code": "KIMI_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", + parallel: "PARALLEL_API_KEY", + perplexity: "PERPLEXITY_API_KEY", + qianfan: "QIANFAN_API_KEY", + synthetic: "SYNTHETIC_API_KEY", + tavily: "TAVILY_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]; @@ -139,6 +176,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..6539ab104 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,56 @@ 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 { GLM_ZCODE_MODELS } from "./providers/glm-zcode.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 { KAGI_MODELS } from "./providers/kagi.models.ts"; +import { KILO_MODELS } from "./providers/kilo.models.ts"; +import { KIMI_CODE_MODELS } from "./providers/kimi-code.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 { PARALLEL_MODELS } from "./providers/parallel.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 { TAVILY_MODELS } from "./providers/tavily.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 +66,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 +77,56 @@ 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, + "glm-zcode": GLM_ZCODE_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, + "kagi": KAGI_MODELS, + "kilo": KILO_MODELS, + "kimi-code": KIMI_CODE_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, + "parallel": PARALLEL_MODELS, + "perplexity": PERPLEXITY_MODELS, + "qianfan": QIANFAN_MODELS, + "qwen-portal": QWEN_PORTAL_MODELS, + "synthetic": SYNTHETIC_MODELS, + "tavily": TAVILY_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 +134,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/providers/alibaba-coding-plan.models.ts b/packages/ai/src/providers/alibaba-coding-plan.models.ts new file mode 100644 index 000000000..11e5f4cfa --- /dev/null +++ b/packages/ai/src/providers/alibaba-coding-plan.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const ALIBABA_CODING_PLAN_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; 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 0836b4a6a..ec3942654 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, KnownProvider, 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,28 +10,56 @@ 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 { glmZcodeProvider } from "./glm-zcode.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 { kagiProvider } from "./kagi.ts"; +import { kiloProvider } from "./kilo.ts"; +import { kimiCodeProvider } from "./kimi-code.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 { parallelProvider } from "./parallel.ts"; +import { perplexityProvider } from "./perplexity.ts"; +import { qianfanProvider } from "./qianfan.ts"; +import { qwenPortalProvider } from "./qwen-portal.ts"; +import { syntheticProvider } from "./synthetic.ts"; +import { tavilyProvider } from "./tavily.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"; @@ -38,6 +67,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"; +import { zenmuxProvider } from "./zenmux.ts"; type BuiltinModelApi< TProvider extends KnownProvider, @@ -108,6 +138,7 @@ export function getBuiltinModels( /** All built-in providers, freshly constructed. */ export function builtinProviders(): Provider[] { return [ + alibabaCodingPlanProvider(), amazonBedrockProvider(), antLingProvider(), anthropicProvider(), @@ -115,27 +146,56 @@ export function builtinProviders(): Provider[] { cerebrasProvider(), cloudflareAIGatewayProvider(), cloudflareWorkersAIProvider(), + cursorProvider(), + deepinfraProvider(), deepseekProvider(), + firepassProvider(), fireworksProvider(), + fuguProvider(), githubCopilotProvider(), + gitlabDuoProvider(), + glmZcodeProvider(), googleProvider(), + googleGeminiCliProvider(), + googleAntigravityProvider(), googleVertexProvider(), groqProvider(), huggingfaceProvider(), + kagiProvider(), + kiloProvider(), + kimiCodeProvider(), kimiCodingProvider(), + litellmProvider(), + lmStudioProvider(), minimaxProvider(), minimaxCnProvider(), + minimaxCodeProvider(), + minimaxCodeCnProvider(), mistralProvider(), + moonshotProvider(), moonshotaiProvider(), moonshotaiCnProvider(), + nanogptProvider(), nvidiaProvider(), + ollamaProvider(), + ollamaCloudProvider(), openaiProvider(), openaiCodexProvider(), + openaiCodexDeviceProvider(), opencodeProvider(), opencodeGoProvider(), + opencodeZenProvider(), openrouterProvider(), + parallelProvider(), + perplexityProvider(), + qianfanProvider(), + qwenPortalProvider(), + syntheticProvider(), + tavilyProvider(), togetherProvider(), + veniceProvider(), vercelAIGatewayProvider(), + vllmProvider(), xaiProvider(), xiaomiProvider(), xiaomiTokenPlanAmsProvider(), @@ -143,6 +203,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..464e8bd63 --- /dev/null +++ b/packages/ai/src/providers/cursor.models.ts @@ -0,0 +1,19 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const CURSOR_MODELS = { + default: { + id: "default", + name: "Auto", + api: "openai-completions", + 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, + } satisfies Model<"openai-completions">, +} as const; diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts new file mode 100644 index 000000000..847022caf --- /dev/null +++ b/packages/ai/src/providers/cursor.ts @@ -0,0 +1,19 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { loadCursorOAuth } from "../utils/oauth/load.ts"; +import { CURSOR_MODELS } from "./cursor.models.ts"; + +export function cursorProvider(): Provider<"openai-completions"> { + 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: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/deepinfra.models.ts b/packages/ai/src/providers/deepinfra.models.ts new file mode 100644 index 000000000..181069c83 --- /dev/null +++ b/packages/ai/src/providers/deepinfra.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const DEEPINFRA_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; 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..44d1643e9 --- /dev/null +++ b/packages/ai/src/providers/firepass.models.ts @@ -0,0 +1,26 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const FIREPASS_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; 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..e6bec5322 --- /dev/null +++ b/packages/ai/src/providers/fugu.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const FUGU_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; 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..a0ea9a2fe --- /dev/null +++ b/packages/ai/src/providers/gitlab-duo.models.ts @@ -0,0 +1,34 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const GITLAB_DUO_MODELS = { + "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/", + compat: { forceAdaptiveThinking: true }, + reasoning: true, + thinkingLevelMap: { xhigh: "max", max: "max" }, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "gpt-5.1-2025-11-13": { + id: "gpt-5.1-2025-11-13", + name: "GPT-5.1", + api: "openai-responses", + provider: "gitlab-duo", + baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", + reasoning: true, + thinkingLevelMap: { off: null }, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"openai-responses">, +} as const; diff --git a/packages/ai/src/providers/gitlab-duo.ts b/packages/ai/src/providers/gitlab-duo.ts new file mode 100644 index 000000000..948ea40a8 --- /dev/null +++ b/packages/ai/src/providers/gitlab-duo.ts @@ -0,0 +1,23 @@ +import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; +import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { loadGitLabDuoOAuth } from "../utils/oauth/load.ts"; +import { GITLAB_DUO_MODELS } from "./gitlab-duo.models.ts"; + +export function gitlabDuoProvider(): Provider<"anthropic-messages" | "openai-responses"> { + return createProvider({ + id: "gitlab-duo", + name: "GitLab Duo", + baseUrl: "https://cloud.gitlab.com", + auth: { + apiKey: envApiKeyAuth("GitLab token", ["GITLAB_TOKEN"]), + oauth: lazyOAuth({ name: "GitLab Duo", load: loadGitLabDuoOAuth }), + }, + models: Object.values(GITLAB_DUO_MODELS), + api: { + "anthropic-messages": anthropicMessagesApi(), + "openai-responses": openAIResponsesApi(), + }, + }); +} diff --git a/packages/ai/src/providers/glm-zcode.models.ts b/packages/ai/src/providers/glm-zcode.models.ts new file mode 100644 index 000000000..e46407f68 --- /dev/null +++ b/packages/ai/src/providers/glm-zcode.models.ts @@ -0,0 +1,28 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const GLM_ZCODE_MODELS = { + "glm-5.2": { + id: "glm-5.2", + name: "GLM-5.2 (ZCode)", + api: "anthropic-messages", + provider: "glm-zcode", + baseUrl: "https://api.z.ai/api/anthropic", + headers: { + "User-Agent": "ZCode/3.1.2", + "X-Title": "Z Code@electron", + "HTTP-Referer": "https://zcode.z.ai", + "X-ZCode-Agent": "glm", + "X-ZCode-App-Version": "3.1.2", + "X-Release-Channel": "production", + }, + reasoning: true, + thinkingLevelMap: { minimal: null, low: "high", medium: "high", high: "high", max: "max" }, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, +} as const; diff --git a/packages/ai/src/providers/glm-zcode.ts b/packages/ai/src/providers/glm-zcode.ts new file mode 100644 index 000000000..11535db5b --- /dev/null +++ b/packages/ai/src/providers/glm-zcode.ts @@ -0,0 +1,19 @@ +import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { loadGlmZcodeOAuth } from "../utils/oauth/load.ts"; +import { GLM_ZCODE_MODELS } from "./glm-zcode.models.ts"; + +export function glmZcodeProvider(): Provider<"anthropic-messages"> { + return createProvider({ + id: "glm-zcode", + name: "GLM ZCode (unofficial, opt-in)", + baseUrl: "https://api.z.ai/api/anthropic", + auth: { + apiKey: envApiKeyAuth("GLM ZCode API key", ["GLM_ZCODE_API_KEY"]), + oauth: lazyOAuth({ name: "GLM ZCode OAuth (unofficial, opt-in)", load: loadGlmZcodeOAuth }), + }, + models: Object.values(GLM_ZCODE_MODELS), + api: anthropicMessagesApi(), + }); +} 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..d92ccb5c0 --- /dev/null +++ b/packages/ai/src/providers/google-antigravity.models.ts @@ -0,0 +1,255 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const GOOGLE_ANTIGRAVITY_MODELS = { + "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, + thinkingLevelMap: {"max":null}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"max":null}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"max":null}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"max":null}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 64000, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"max":null}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"max":null}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"google-gemini-cli">, + "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, + } satisfies Model<"google-gemini-cli">, + "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, + } satisfies Model<"google-gemini-cli">, + "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, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65535, + } satisfies Model<"google-gemini-cli">, + "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, + } satisfies Model<"google-gemini-cli">, +} as const; 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..d8f38d530 --- /dev/null +++ b/packages/ai/src/providers/google-gemini-cli.models.ts @@ -0,0 +1,148 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const GOOGLE_GEMINI_CLI_MODELS = { + "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, + } satisfies Model<"google-gemini-cli">, + "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, + } satisfies Model<"google-gemini-cli">, + "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, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-gemini-cli">, + "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, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-gemini-cli">, +} as const; 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..63403c048 --- /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 { createProvider, type Provider } from "../models.ts"; +import { loadGoogleAntigravityOAuth, loadGoogleGeminiCliOAuth } from "../utils/oauth/load.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, +): Provider<"google-gemini-cli"> => + 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/kagi.models.ts b/packages/ai/src/providers/kagi.models.ts new file mode 100644 index 000000000..d879bd44f --- /dev/null +++ b/packages/ai/src/providers/kagi.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const KAGI_MODELS = { + "search": { + id: "search", + name: "Kagi Search (credential placeholder)", + api: "openai-completions", + provider: "kagi", + baseUrl: "https://kagi.com/api/v0", + 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: 1024, + } satisfies Model<"openai-completions">, +} as const; diff --git a/packages/ai/src/providers/kagi.ts b/packages/ai/src/providers/kagi.ts new file mode 100644 index 000000000..a95670caf --- /dev/null +++ b/packages/ai/src/providers/kagi.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 { KAGI_MODELS } from "./kagi.models.ts"; + +export function kagiProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "kagi", + name: "Kagi", + baseUrl: "https://kagi.com/api/v0", + auth: { apiKey: envApiKeyAuth("Kagi API key", ["KAGI_API_KEY"]) }, + models: Object.values(KAGI_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/kilo.models.ts b/packages/ai/src/providers/kilo.models.ts new file mode 100644 index 000000000..9a60381ce --- /dev/null +++ b/packages/ai/src/providers/kilo.models.ts @@ -0,0 +1,19 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const KILO_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; diff --git a/packages/ai/src/providers/kilo.ts b/packages/ai/src/providers/kilo.ts new file mode 100644 index 000000000..ea5d5781e --- /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 { createProvider, type Provider } from "../models.ts"; +import { loadKiloOAuth } from "../utils/oauth/load.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/kimi-code.models.ts b/packages/ai/src/providers/kimi-code.models.ts new file mode 100644 index 000000000..05c89e5d1 --- /dev/null +++ b/packages/ai/src/providers/kimi-code.models.ts @@ -0,0 +1,103 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const KIMI_CODE_MODELS = { + "kimi-for-coding": { + id: "kimi-for-coding", + name: "Kimi For Coding", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: {"User-Agent":"KimiCLI/1.5","X-Msh-Platform":"kimi_cli"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"zai"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "kimi-k2": { + id: "kimi-k2", + name: "Kimi K2", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: {"User-Agent":"KimiCLI/1.5","X-Msh-Platform":"kimi_cli"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"zai"}, + reasoning: false, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2-turbo-preview": { + id: "kimi-k2-turbo-preview", + name: "Kimi K2 Turbo Preview", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: {"User-Agent":"KimiCLI/1.5","X-Msh-Platform":"kimi_cli"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"zai"}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "kimi-k2.5": { + id: "kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: {"User-Agent":"KimiCLI/1.5","X-Msh-Platform":"kimi_cli"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"zai"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "kimi-k2.7-code": { + id: "kimi-k2.7-code", + name: "Kimi K2.7 Code", + api: "openai-completions", + provider: "kimi-code", + baseUrl: "https://api.kimi.com/coding/v1", + headers: {"User-Agent":"KimiCLI/1.5","X-Msh-Platform":"kimi_cli"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"zai"}, + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, +} as const; diff --git a/packages/ai/src/providers/kimi-code.ts b/packages/ai/src/providers/kimi-code.ts new file mode 100644 index 000000000..8134e954c --- /dev/null +++ b/packages/ai/src/providers/kimi-code.ts @@ -0,0 +1,19 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { loadKimiCodeOAuth } from "../utils/oauth/load.ts"; +import { KIMI_CODE_MODELS } from "./kimi-code.models.ts"; + +export function kimiCodeProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "kimi-code", + name: "Kimi Code", + baseUrl: "https://api.kimi.com/coding/v1", + auth: { + apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]), + oauth: lazyOAuth({ name: "Kimi Code", load: loadKimiCodeOAuth }), + }, + models: Object.values(KIMI_CODE_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..afcac2596 --- /dev/null +++ b/packages/ai/src/providers/litellm.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const LITELLM_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; 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..a06cda977 --- /dev/null +++ b/packages/ai/src/providers/lm-studio.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const LM_STUDIO_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; 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..df42863f7 --- /dev/null +++ b/packages/ai/src/providers/minimax-code-cn.models.ts @@ -0,0 +1,61 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const MINIMAX_CODE_CN_MODELS = { + "MiniMax-M2.7": { + id: "MiniMax-M2.7", + name: "MiniMax-M2.7", + api: "openai-completions", + provider: "minimax-code-cn", + baseUrl: "https://api.minimaxi.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "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", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "MiniMax-M3": { + id: "MiniMax-M3", + name: "MiniMax-M3", + api: "openai-completions", + provider: "minimax-code-cn", + baseUrl: "https://api.minimaxi.com/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, +} as const; 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..43ab6634d --- /dev/null +++ b/packages/ai/src/providers/minimax-code.models.ts @@ -0,0 +1,61 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const MINIMAX_CODE_MODELS = { + "MiniMax-M2.7": { + id: "MiniMax-M2.7", + name: "MiniMax-M2.7", + api: "openai-completions", + provider: "minimax-code", + baseUrl: "https://api.minimax.io/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "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", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "MiniMax-M3": { + id: "MiniMax-M3", + name: "MiniMax-M3", + api: "openai-completions", + provider: "minimax-code", + baseUrl: "https://api.minimax.io/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, +} as const; 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..e683b1cea --- /dev/null +++ b/packages/ai/src/providers/moonshot.models.ts @@ -0,0 +1,171 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const MOONSHOT_MODELS = { + "kimi-k2-0711-preview": { + id: "kimi-k2-0711-preview", + name: "Kimi K2 0711", + api: "openai-completions", + provider: "moonshot", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: false, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, + "kimi-k2-0905-preview": { + id: "kimi-k2-0905-preview", + name: "Kimi K2 0905", + api: "openai-completions", + provider: "moonshot", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: false, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2-thinking": { + id: "kimi-k2-thinking", + name: "Kimi K2 Thinking", + api: "openai-completions", + provider: "moonshot", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 0.6, + output: 2.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "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", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text"], + cost: { + input: 1.15, + output: 8, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2-turbo-preview": { + id: "kimi-k2-turbo-preview", + name: "Kimi K2 Turbo", + api: "openai-completions", + provider: "moonshot", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: false, + input: ["text"], + cost: { + input: 2.4, + output: 10, + cacheRead: 0.6, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2.5": { + id: "kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "moonshot", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0.1, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "kimi-k2.6": { + id: "kimi-k2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: "moonshot", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "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", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.19, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "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", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.9, + output: 8, + cacheRead: 0.38, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, +} as const; 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..3e6d721a4 --- /dev/null +++ b/packages/ai/src/providers/nanogpt.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const NANOGPT_MODELS = { + "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, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, +} as const; 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..45c3d8110 --- /dev/null +++ b/packages/ai/src/providers/ollama-cloud.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const OLLAMA_CLOUD_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; 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..e3c9878c9 --- /dev/null +++ b/packages/ai/src/providers/ollama.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const OLLAMA_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; 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..87df9d164 --- /dev/null +++ b/packages/ai/src/providers/openai-codex-device.models.ts @@ -0,0 +1,144 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const OPENAI_CODEX_DEVICE_MODELS = { + "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, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, + input: ["text"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-codex-responses">, + "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", + compat: {"supportsToolSearch":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, + 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, + } satisfies Model<"openai-codex-responses">, + "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", + compat: {"supportsToolSearch":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, + input: ["text", "image"], + cost: { + input: 0.75, + output: 4.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"openai-codex-responses">, + "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", + compat: {"supportsToolSearch":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":"low"}, + 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, + } satisfies Model<"openai-codex-responses">, + "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", + compat: {"supportsToolSearch":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","max":"max","minimal":"low"}, + 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, + } satisfies Model<"openai-codex-responses">, + "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", + compat: {"supportsToolSearch":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","max":"max","minimal":"low"}, + 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, + } satisfies Model<"openai-codex-responses">, + "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", + compat: {"supportsToolSearch":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","max":"max","minimal":"low"}, + 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, + } satisfies Model<"openai-codex-responses">, +} as const; 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..b4ec0e4f9 --- /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 { createProvider, type Provider } from "../models.ts"; +import { loadOpenAICodexDeviceOAuth } from "../utils/oauth/load.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..082cb810a --- /dev/null +++ b/packages/ai/src/providers/opencode-zen.models.ts @@ -0,0 +1,982 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const OPENCODE_ZEN_MODELS = { + "big-pickle": { + id: "big-pickle", + name: "Big Pickle", + api: "openai-completions", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "claude-fable-5": { + id: "claude-fable-5", + name: "Claude Fable 5", + api: "anthropic-messages", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "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, + } satisfies Model<"anthropic-messages">, + "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, + } satisfies Model<"anthropic-messages">, + "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, + } satisfies Model<"anthropic-messages">, + "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", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"max":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "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", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "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", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "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, + } satisfies Model<"anthropic-messages">, + "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, + } satisfies Model<"anthropic-messages">, + "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", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"max":"max"}, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, + contextWindow: 1000000, + maxTokens: 64000, + } satisfies Model<"anthropic-messages">, + "claude-sonnet-5": { + id: "claude-sonnet-5", + name: "Claude Sonnet 5", + api: "anthropic-messages", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "deepseek-v4-flash": { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + api: "openai-completions", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"requiresReasoningContentOnAssistantMessages":true}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","max":"max"}, + input: ["text"], + cost: { + input: 0.14, + output: 0.28, + cacheRead: 0.028, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "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", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","requiresReasoningContentOnAssistantMessages":true}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","max":"max"}, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "deepseek-v4-pro": { + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", + api: "openai-completions", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"requiresReasoningContentOnAssistantMessages":true}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","max":"max"}, + input: ["text"], + cost: { + input: 1.74, + output: 3.84, + cacheRead: 0.145, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "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, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "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, + thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "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, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "glm-5": { + id: "glm-5", + name: "GLM-5", + api: "openai-completions", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text"], + cost: { + input: 1, + output: 3.2, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5.1": { + id: "glm-5.1", + name: "GLM-5.1", + api: "openai-completions", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5.2": { + id: "glm-5.2", + name: "GLM-5.2", + api: "openai-completions", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "gpt-5": { + id: "gpt-5", + name: "GPT-5", + api: "openai-responses", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.07, + output: 8.5, + cacheRead: 0.107, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.07, + output: 8.5, + cacheRead: 0.107, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.05, + output: 0.4, + cacheRead: 0.005, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.07, + output: 8.5, + cacheRead: 0.107, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.07, + output: 8.5, + cacheRead: 0.107, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 10, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.75, + output: 14, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 2.5, + output: 15, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 272000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0.75, + output: 4.5, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 0.2, + output: 1.25, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 180, + cacheRead: 30, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"low":null}, + input: ["text", "image"], + cost: { + input: 30, + output: 180, + cacheRead: 30, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 1, + output: 6, + cacheRead: 0.1, + cacheWrite: 1.25, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "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, + thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 2.5, + output: 15, + cacheRead: 0.25, + cacheWrite: 3.125, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "grok-4.5": { + id: "grok-4.5", + name: "Grok 4.5", + api: "openai-completions", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 500000, + maxTokens: 500000, + } satisfies Model<"openai-completions">, + "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", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null}, + input: ["text", "image"], + cost: { + input: 1, + output: 2, + cacheRead: 0.2, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "hy3-free": { + id: "hy3-free", + name: "Hy3 Free", + api: "openai-completions", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 190000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "kimi-k2.5": { + id: "kimi-k2.5", + name: "Kimi K2.5", + api: "openai-completions", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.6, + output: 3, + cacheRead: 0.08, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "kimi-k2.6": { + id: "kimi-k2.6", + name: "Kimi K2.6", + api: "openai-completions", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"thinkingFormat":"deepseek","supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.16, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "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", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.19, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "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", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 32000, + } satisfies Model<"openai-completions">, + "minimax-m2.5": { + id: "minimax-m2.5", + name: "MiniMax-M2.5", + api: "openai-completions", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "minimax-m2.7": { + id: "minimax-m2.7", + name: "MiniMax-M2.7", + api: "openai-completions", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "minimax-m3": { + id: "minimax-m3", + name: "MiniMax-M3", + api: "openai-completions", + provider: "opencode-zen", + baseUrl: "https://opencode.ai/zen/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 512000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "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", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "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", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 64000, + } satisfies Model<"openai-completions">, + "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, + } satisfies Model<"anthropic-messages">, + "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, + } satisfies Model<"anthropic-messages">, +} as const; 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/parallel.models.ts b/packages/ai/src/providers/parallel.models.ts new file mode 100644 index 000000000..f2b0316ac --- /dev/null +++ b/packages/ai/src/providers/parallel.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const PARALLEL_MODELS = { + "search": { + id: "search", + name: "Parallel Search (credential placeholder)", + api: "openai-completions", + provider: "parallel", + baseUrl: "https://api.parallel.ai/v1beta", + 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: 1024, + } satisfies Model<"openai-completions">, +} as const; diff --git a/packages/ai/src/providers/parallel.ts b/packages/ai/src/providers/parallel.ts new file mode 100644 index 000000000..00ac72d90 --- /dev/null +++ b/packages/ai/src/providers/parallel.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 { PARALLEL_MODELS } from "./parallel.models.ts"; + +export function parallelProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "parallel", + name: "Parallel", + baseUrl: "https://api.parallel.ai/v1beta", + auth: { apiKey: envApiKeyAuth("Parallel API key", ["PARALLEL_API_KEY"]) }, + models: Object.values(PARALLEL_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/perplexity.models.ts b/packages/ai/src/providers/perplexity.models.ts new file mode 100644 index 000000000..bc6af4a02 --- /dev/null +++ b/packages/ai/src/providers/perplexity.models.ts @@ -0,0 +1,19 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const PERPLEXITY_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; diff --git a/packages/ai/src/providers/perplexity.ts b/packages/ai/src/providers/perplexity.ts new file mode 100644 index 000000000..84bcaaacb --- /dev/null +++ b/packages/ai/src/providers/perplexity.ts @@ -0,0 +1,19 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { loadPerplexityOAuth } from "../utils/oauth/load.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", + auth: { + apiKey: envApiKeyAuth("Perplexity API key", ["PERPLEXITY_API_KEY"]), + oauth: lazyOAuth({ name: "Perplexity (Pro/Max)", load: loadPerplexityOAuth }), + }, + 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..a3ae6409d --- /dev/null +++ b/packages/ai/src/providers/qianfan.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const QIANFAN_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; 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..4dee5b4b5 --- /dev/null +++ b/packages/ai/src/providers/qwen-portal.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const QWEN_PORTAL_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; 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..92220f7ca --- /dev/null +++ b/packages/ai/src/providers/synthetic.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const SYNTHETIC_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; 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/tavily.models.ts b/packages/ai/src/providers/tavily.models.ts new file mode 100644 index 000000000..5ce9f9ed4 --- /dev/null +++ b/packages/ai/src/providers/tavily.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const TAVILY_MODELS = { + "search": { + id: "search", + name: "Tavily Search (credential placeholder)", + api: "openai-completions", + provider: "tavily", + baseUrl: "https://api.tavily.com", + 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: 1024, + } satisfies Model<"openai-completions">, +} as const; diff --git a/packages/ai/src/providers/tavily.ts b/packages/ai/src/providers/tavily.ts new file mode 100644 index 000000000..24fa9f29b --- /dev/null +++ b/packages/ai/src/providers/tavily.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 { TAVILY_MODELS } from "./tavily.models.ts"; + +export function tavilyProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "tavily", + name: "Tavily", + baseUrl: "https://api.tavily.com", + auth: { apiKey: envApiKeyAuth("Tavily API key", ["TAVILY_API_KEY"]) }, + models: Object.values(TAVILY_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..3e1cf9383 --- /dev/null +++ b/packages/ai/src/providers/venice.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const VENICE_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; 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..68cca7060 --- /dev/null +++ b/packages/ai/src/providers/vllm.models.ts @@ -0,0 +1,25 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const VLLM_MODELS = { + "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, + } satisfies Model<"openai-completions">, +} as const; 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/xai.ts b/packages/ai/src/providers/xai.ts index 3373fbf59..122334aed 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -1,6 +1,7 @@ import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; -import { envApiKeyAuth } from "../auth/helpers.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; import { createProvider, type Provider } from "../models.ts"; +import { loadXaiOAuth } from "../utils/oauth/load.ts"; import { XAI_MODELS } from "./xai.models.ts"; export function xaiProvider(): Provider<"openai-completions"> { @@ -8,7 +9,10 @@ export function xaiProvider(): Provider<"openai-completions"> { id: "xai", name: "xAI", baseUrl: "https://api.x.ai/v1", - auth: { apiKey: envApiKeyAuth("xAI API key", ["XAI_API_KEY"]) }, + auth: { + apiKey: envApiKeyAuth("xAI API key", ["XAI_API_KEY"]), + oauth: lazyOAuth({ name: "xAI (Grok account)", load: loadXaiOAuth }), + }, models: Object.values(XAI_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..f8492227f --- /dev/null +++ b/packages/ai/src/providers/zenmux.models.ts @@ -0,0 +1,26 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import type { Model } from "../types.ts"; + +export const ZENMUX_MODELS = { + "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", + compat: {"forceAdaptiveThinking":true}, + reasoning: true, + thinkingLevelMap: {"max":"max"}, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, +} as const; 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 39d6ce2ad..e0cbd1f6c 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1,6 +1,7 @@ import type { AnthropicOptions } from "./api/anthropic-messages.ts"; import type { AzureOpenAIResponsesOptions } from "./api/azure-openai-responses.ts"; import type { BedrockOptions } from "./api/bedrock-converse-stream.ts"; +import type { GoogleGeminiCliOptions } from "./api/google-gemini-cli.ts"; import type { GoogleOptions } from "./api/google-generative-ai.ts"; import type { GoogleVertexOptions } from "./api/google-vertex.ts"; import type { MistralOptions } from "./api/mistral-conversations.ts"; @@ -21,6 +22,7 @@ export type KnownApi = | "anthropic-messages" | "bedrock-converse-stream" | "google-generative-ai" + | "google-gemini-cli" | "google-vertex"; export type Api = KnownApi | (string & {}); @@ -30,41 +32,72 @@ 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" - | "nvidia" + | "cerebras" + | "cloudflare-ai-gateway" + | "cloudflare-workers-ai" + | "cursor" + | "deepinfra" | "deepseek" + | "firepass" + | "fireworks" + | "fugu" | "github-copilot" - | "xai" + | "gitlab-duo" + | "glm-zcode" + | "google" + | "google-antigravity" + | "google-gemini-cli" + | "google-vertex" | "groq" - | "cerebras" - | "openrouter" - | "vercel-ai-gateway" - | "zai" - | "zai-coding-cn" - | "mistral" + | "huggingface" + | "kagi" + | "kilo" + | "kimi-code" + | "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" + | "parallel" + | "perplexity" + | "qianfan" + | "qwen-portal" + | "synthetic" + | "tavily" + | "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"; @@ -206,6 +239,7 @@ export interface ApiOptionsMap { "openai-codex-responses": OpenAICodexResponsesOptions; "azure-openai-responses": AzureOpenAIResponsesOptions; "google-generative-ai": GoogleOptions; + "google-gemini-cli": GoogleGeminiCliOptions; "google-vertex": GoogleVertexOptions; "mistral-conversations": MistralOptions; "bedrock-converse-stream": BedrockOptions; diff --git a/packages/ai/src/utils/oauth/cursor.ts b/packages/ai/src/utils/oauth/cursor.ts new file mode 100644 index 000000000..b4646787d --- /dev/null +++ b/packages/ai/src/utils/oauth/cursor.ts @@ -0,0 +1,174 @@ +import type { OAuthAuth } from "../../auth/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/utils/oauth/gitlab-duo.ts b/packages/ai/src/utils/oauth/gitlab-duo.ts new file mode 100644 index 000000000..6629275f9 --- /dev/null +++ b/packages/ai/src/utils/oauth/gitlab-duo.ts @@ -0,0 +1,295 @@ +import type { OAuthAuth } from "../../auth/types.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; +const DIRECT_ACCESS_TTL_MS = 25 * 60_000; + +type Server = import("node:http").Server; +type AuthorizationInput = { code?: string; state?: string }; +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; +} + +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); + directAccessCache.clear(); + return credentials; +} + +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; +} + +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, +}; diff --git a/packages/ai/src/utils/oauth/glm-zcode.ts b/packages/ai/src/utils/oauth/glm-zcode.ts new file mode 100644 index 000000000..7155ce9c2 --- /dev/null +++ b/packages/ai/src/utils/oauth/glm-zcode.ts @@ -0,0 +1,498 @@ +import type { OAuthAuth } from "../../auth/types.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; + +const REQUEST_TIMEOUT_MS = 30_000; +const API_KEY_TTL_MS = 10 * 365 * 24 * 60 * 60_000; +const API_KEY_NAME = "zcode-api-key"; + +export const GLM_ZCODE_OAUTH_AUTHORIZE_URL = "https://chat.z.ai/api/oauth/authorize"; +export const GLM_ZCODE_OAUTH_CLIENT_ID = "client_P8X5CMWmlaRO9gyO-KSqtg"; +export const GLM_ZCODE_OAUTH_REDIRECT_URI = "zcode://oauth/callback"; +export const GLM_ZCODE_OAUTH_BROKER_TOKEN_URL = "https://zcode.z.ai/api/v1/oauth/token"; +export const GLM_ZCODE_ZAI_LOGIN_URL = "https://api.z.ai/api/auth/z/login"; +export const GLM_ZCODE_USERINFO_URL = "https://chat.z.ai/api/oauth/userinfo"; +export const GLM_ZCODE_ZAI_API_BASE = "https://api.z.ai"; +export const GLM_ZCODE_ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic"; + +type FetchImpl = typeof globalThis.fetch; +type AuthorizationInput = { code?: string; state?: string }; +type Identity = { email?: string; accountId?: string }; + +function envOr(name: string, fallback: string): string { + if (typeof process === "undefined") return fallback; + const value = process.env[name]?.trim(); + return value || fallback; +} + +function resolveAuthorizeUrl(): string { + return envOr("ZCODE_OAUTH_AUTHORIZE_URL", GLM_ZCODE_OAUTH_AUTHORIZE_URL); +} + +function resolveClientId(): string { + return envOr("ZCODE_OAUTH_CLIENT_ID", GLM_ZCODE_OAUTH_CLIENT_ID); +} + +function resolveRedirectUri(): string { + return envOr("ZCODE_OAUTH_REDIRECT_URI", GLM_ZCODE_OAUTH_REDIRECT_URI); +} + +function resolveBrokerTokenUrl(): string { + return envOr("ZCODE_OAUTH_BROKER_TOKEN_URL", GLM_ZCODE_OAUTH_BROKER_TOKEN_URL); +} + +function resolveZaiLoginUrl(): string { + return envOr("ZCODE_OAUTH_ZAI_LOGIN_URL", GLM_ZCODE_ZAI_LOGIN_URL); +} + +function resolveUserinfoUrl(): string { + return envOr("ZCODE_OAUTH_USERINFO_URL", GLM_ZCODE_USERINFO_URL); +} + +function resolveZaiApiBase(): string { + return envOr("ZCODE_OAUTH_ZAI_API_BASE", GLM_ZCODE_ZAI_API_BASE).replace(/\/+$/, ""); +} + +export function isGlmZcodeOAuthConfigured(): boolean { + return resolveClientId().length > 0; +} + +function validateHttpsEndpoint(rawUrl: string, label: string): string { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error(`GLM ZCode ${label} endpoint is invalid`); + } + if (url.protocol !== "https:") throw new Error(`GLM ZCode ${label} endpoint must use HTTPS`); + return url.toString().replace(/\/+$/, ""); +} + +function requestSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw signal.reason ?? new DOMException("GLM ZCode OAuth login was cancelled", "AbortError"); + } +} + +async function request( + fetchImpl: FetchImpl, + url: string, + init: RequestInit, + label: string, + signal?: AbortSignal, +): Promise { + try { + const response = await fetchImpl(url, { ...init, signal: requestSignal(signal) }); + if (!response.ok) throw new Error(`GLM ZCode ${label} request failed (${response.status})`); + return response; + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + if (error instanceof Error && /^GLM ZCode .* request failed \(\d{3}\)$/.test(error.message)) throw error; + throw new Error(`GLM ZCode ${label} request failed`); + } +} + +async function postJson( + fetchImpl: FetchImpl, + url: string, + body: Record, + label: string, + signal?: AbortSignal, + bearer?: string, +): Promise { + const headers: Record = { Accept: "application/json", "Content-Type": "application/json" }; + if (bearer) headers.Authorization = `Bearer ${bearer}`; + const response = await request( + fetchImpl, + url, + { method: "POST", headers, body: JSON.stringify(body) }, + label, + signal, + ); + return response.json(); +} + +async function getJson( + fetchImpl: FetchImpl, + url: string, + bearer: string, + label: string, + signal?: AbortSignal, +): Promise { + const response = await request( + fetchImpl, + url, + { headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` } }, + label, + signal, + ); + return response.json(); +} + +export function parseGlmZcodeAuthorizationInput(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 decodeJwtIdentity(token: string): Identity { + const parts = token.split("."); + const payload = parts[1]; + if (parts.length !== 3 || !payload) return {}; + try { + const padded = payload + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(payload.length / 4) * 4, "="); + const decoded = JSON.parse(atob(padded)) as Record; + return { + accountId: typeof decoded.sub === "string" && decoded.sub ? decoded.sub : undefined, + email: typeof decoded.email === "string" && decoded.email ? decoded.email.toLowerCase() : undefined, + }; + } catch { + return {}; + } +} + +function parseBrokerResponse(payload: unknown): { upstreamAccess: string; zcodeToken: string } { + const data = isRecord(payload) && isRecord(payload.data) ? payload.data : undefined; + const zai = data && isRecord(data.zai) ? data.zai : undefined; + const zcodeToken = data && typeof data.token === "string" ? data.token : undefined; + const upstreamAccess = zai && typeof zai.access_token === "string" ? zai.access_token : undefined; + if (!zcodeToken || !upstreamAccess) { + throw new Error("GLM ZCode broker response missing required tokens"); + } + return { upstreamAccess, zcodeToken }; +} + +async function resolveBusinessToken( + fetchImpl: FetchImpl, + upstreamAccess: string, + signal?: AbortSignal, +): Promise { + const url = validateHttpsEndpoint(resolveZaiLoginUrl(), "z/login"); + const payload = await postJson(fetchImpl, url, { token: upstreamAccess }, "z/login", signal); + const data = isRecord(payload) && isRecord(payload.data) ? payload.data : undefined; + if (!data || typeof data.access_token !== "string" || !data.access_token) { + throw new Error("GLM ZCode z/login response missing access token"); + } + return data.access_token; +} + +function pickOrgProject(payload: unknown): { + organizationId: string; + projectId: string; + identity: Identity; +} { + const data = isRecord(payload) && isRecord(payload.data) ? payload.data : payload; + const root = isRecord(data) ? data : {}; + const organizations = Array.isArray(root.organizations) ? root.organizations : []; + const organization = (organizations.find((value) => isRecord(value) && value.isDefault === true) ?? + organizations[0]) as Record | undefined; + const projects = organization && Array.isArray(organization.projects) ? organization.projects : []; + const project = (projects.find((value) => isRecord(value) && value.isDefault === true) ?? projects[0]) as + | Record + | undefined; + const organizationId = + organization && typeof organization.organizationId === "string" + ? organization.organizationId + : organization && typeof organization.id === "string" + ? organization.id + : undefined; + const projectId = + project && typeof project.projectId === "string" + ? project.projectId + : project && typeof project.id === "string" + ? project.id + : undefined; + if (!organizationId || !projectId) { + throw new Error("GLM ZCode customer response missing default organization or project"); + } + return { + organizationId, + projectId, + identity: { + email: typeof root.email === "string" && root.email ? root.email.toLowerCase() : undefined, + accountId: typeof root.id === "string" ? root.id : typeof root.id === "number" ? String(root.id) : undefined, + }, + }; +} + +async function provisionApiKey( + fetchImpl: FetchImpl, + businessToken: string, + signal?: AbortSignal, +): Promise<{ apiKey: string; identity: Identity }> { + const apiBase = validateHttpsEndpoint(resolveZaiApiBase(), "business API"); + const customer = await getJson( + fetchImpl, + `${apiBase}/api/biz/customer/getCustomerInfo`, + businessToken, + "getCustomerInfo", + signal, + ); + const { organizationId, projectId, identity } = pickOrgProject(customer); + const keysUrl = `${apiBase}/api/biz/v1/organization/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/api_keys`; + const listPayload = await getJson(fetchImpl, keysUrl, businessToken, "api_keys.list", signal); + const listData = isRecord(listPayload) && Array.isArray(listPayload.data) ? listPayload.data : []; + let entry = listData.find((value) => isRecord(value) && value.name === API_KEY_NAME) as + | Record + | undefined; + if (!entry) { + const created = await postJson( + fetchImpl, + keysUrl, + { name: API_KEY_NAME }, + "api_keys.create", + signal, + businessToken, + ); + entry = isRecord(created) && isRecord(created.data) ? created.data : isRecord(created) ? created : undefined; + } + const apiKeyId = + entry && typeof entry.apiKey === "string" + ? entry.apiKey.trim() + : entry && typeof entry.id === "string" + ? entry.id.trim() + : ""; + if (!apiKeyId) throw new Error("GLM ZCode API-key response missing key ID"); + const copied = await getJson( + fetchImpl, + `${keysUrl}/copy/${encodeURIComponent(apiKeyId)}`, + businessToken, + "api_keys.copy", + signal, + ); + const copyData = isRecord(copied) && isRecord(copied.data) ? copied.data : copied; + const secretKey = isRecord(copyData) && typeof copyData.secretKey === "string" ? copyData.secretKey.trim() : ""; + if (!secretKey) throw new Error("GLM ZCode API-key copy response missing secret"); + return { apiKey: `${apiKeyId}.${secretKey}`, identity }; +} + +async function resolveIdentity( + fetchImpl: FetchImpl, + upstreamAccess: string, + fallback: Identity, + jwtCandidates: readonly string[], + signal?: AbortSignal, +): Promise { + if (fallback.email || fallback.accountId) return fallback; + try { + const userinfo = await getJson( + fetchImpl, + validateHttpsEndpoint(resolveUserinfoUrl(), "userinfo"), + upstreamAccess, + "userinfo", + signal, + ); + const data = isRecord(userinfo) && isRecord(userinfo.data) ? userinfo.data : userinfo; + if (isRecord(data)) { + const identity = { + email: typeof data.email === "string" && data.email ? data.email.toLowerCase() : undefined, + accountId: + typeof data.id === "string" && data.id + ? data.id + : typeof data.sub === "string" && data.sub + ? data.sub + : undefined, + }; + if (identity.email || identity.accountId) return identity; + } + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + // Identity is optional; continue with JWT claims. + } + for (const token of jwtCandidates) { + const identity = decodeJwtIdentity(token); + if (identity.email || identity.accountId) return identity; + } + return {}; +} + +async function provisionFromUpstream( + fetchImpl: FetchImpl, + upstreamAccess: string, + zcodeIdentityToken: string | undefined, + signal?: AbortSignal, +): Promise { + const businessToken = await resolveBusinessToken(fetchImpl, upstreamAccess, signal); + const { apiKey, identity: provisionedIdentity } = await provisionApiKey(fetchImpl, businessToken, signal); + const identity = await resolveIdentity( + fetchImpl, + upstreamAccess, + provisionedIdentity, + [zcodeIdentityToken ?? "", businessToken].filter(Boolean), + signal, + ); + return { + access: apiKey, + refresh: upstreamAccess, + expires: Date.now() + API_KEY_TTL_MS, + ...(identity.email ? { email: identity.email } : {}), + ...(identity.accountId ? { accountId: identity.accountId } : {}), + }; +} + +export interface GlmZcodeOAuthOptions { + fetch?: FetchImpl; + signal?: AbortSignal; +} + +export async function exchangeGlmZcodeAuthorizationCode( + code: string, + state: string, + redirectUri = resolveRedirectUri(), + options: GlmZcodeOAuthOptions = {}, +): Promise { + const fetchImpl = options.fetch ?? globalThis.fetch; + const brokerUrl = validateHttpsEndpoint(resolveBrokerTokenUrl(), "broker"); + const payload = await postJson( + fetchImpl, + brokerUrl, + { provider: "zai", code, redirect_uri: redirectUri, state }, + "broker", + options.signal, + ); + const { upstreamAccess, zcodeToken } = parseBrokerResponse(payload); + return provisionFromUpstream(fetchImpl, upstreamAccess, zcodeToken, options.signal); +} + +function abortPromise(signal?: AbortSignal): Promise | undefined { + if (!signal) return undefined; + return new Promise((_, reject) => { + const rejectAbort = () => + reject(signal.reason ?? new DOMException("GLM ZCode OAuth login was cancelled", "AbortError")); + if (signal.aborted) rejectAbort(); + else signal.addEventListener("abort", rejectAbort, { once: true }); + }); +} + +export async function loginGlmZcode( + callbacks: OAuthLoginCallbacks, + options: Omit = {}, +): Promise { + throwIfAborted(callbacks.signal); + const readManualCode = + callbacks.onManualCodeInput ?? + (() => + callbacks.onPrompt({ + message: "Paste the zcode:// callback URL or authorization code", + placeholder: GLM_ZCODE_OAUTH_REDIRECT_URI, + })); + const state = crypto.randomUUID(); + const redirectUri = resolveRedirectUri(); + const authorizeUrl = validateHttpsEndpoint(resolveAuthorizeUrl(), "authorize"); + const params = new URLSearchParams({ + redirect_uri: redirectUri, + response_type: "code", + client_id: resolveClientId(), + state, + }); + callbacks.onAuth({ + url: `${authorizeUrl}?${params}`, + instructions: + "Complete Z.AI login in your browser. This is an UNOFFICIAL, opt-in ZCode login. Paste the final zcode:// redirect URL or authorization code.", + }); + const manualCode = readManualCode().then((input) => { + const parsed = parseGlmZcodeAuthorizationInput(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 candidates: Promise[] = [manualCode]; + const aborted = abortPromise(callbacks.signal); + if (aborted) candidates.push(aborted); + const code = await Promise.race(candidates); + return exchangeGlmZcodeAuthorizationCode(code, state, redirectUri, { + fetch: options.fetch, + signal: callbacks.signal, + }); +} + +function safeRefreshDetail(error: unknown): string { + const status = String(error).match(/\((\d{3})\)/)?.[1]; + return status ? `request failed (${status})` : "request failed"; +} + +export async function refreshGlmZcodeToken( + credentials: OAuthCredentials, + options: GlmZcodeOAuthOptions | AbortSignal = {}, +): Promise { + if (!credentials.refresh) throw new Error("GLM ZCode credentials require re-login; no upstream token is stored"); + const resolved = options instanceof AbortSignal ? { signal: options } : options; + try { + return await provisionFromUpstream( + resolved.fetch ?? globalThis.fetch, + credentials.refresh, + undefined, + resolved.signal, + ); + } catch (error) { + if (resolved.signal?.aborted) throw resolved.signal.reason ?? error; + throw new Error(`GLM ZCode credentials require re-login; API-key provisioning ${safeRefreshDetail(error)}`); + } +} + +export const glmZcodeOAuth: OAuthAuth = { + name: "GLM ZCode OAuth (unofficial, opt-in)", + async login(callbacks) { + const manualAbort = new AbortController(); + try { + const credentials = await loginGlmZcode({ + 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 zcode:// callback URL or authorization code:", + placeholder: GLM_ZCODE_OAUTH_REDIRECT_URI, + signal: manualAbort.signal, + }), + onSelect: async () => undefined, + signal: callbacks.signal, + }); + return { ...credentials, type: "oauth" }; + } finally { + manualAbort.abort(); + } + }, + refresh: async (credential) => ({ ...(await refreshGlmZcodeToken(credential)), type: "oauth" }), + toAuth: async (credential) => ({ + apiKey: credential.access, + headers: { Authorization: `Bearer ${credential.access}` }, + }), +}; + +export const glmZcodeOAuthProvider: OAuthProviderInterface = { + id: "glm-zcode", + name: "GLM ZCode OAuth (unofficial, opt-in)", + // Senpi uses this flag to expose the manual redirect input alongside the browser URL. + usesCallbackServer: true, + login: loginGlmZcode, + refreshToken: refreshGlmZcodeToken, + getApiKey: (credentials) => credentials.access, +}; diff --git a/packages/ai/src/utils/oauth/google-antigravity.ts b/packages/ai/src/utils/oauth/google-antigravity.ts new file mode 100644 index 000000000..3d15fd903 --- /dev/null +++ b/packages/ai/src/utils/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/utils/oauth/google-gemini-cli.ts b/packages/ai/src/utils/oauth/google-gemini-cli.ts new file mode 100644 index 000000000..ffc227f72 --- /dev/null +++ b/packages/ai/src/utils/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/utils/oauth/google-oauth-node.ts b/packages/ai/src/utils/oauth/google-oauth-node.ts new file mode 100644 index 000000000..b38ca2597 --- /dev/null +++ b/packages/ai/src/utils/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/utils/oauth/google-oauth-shared.ts b/packages/ai/src/utils/oauth/google-oauth-shared.ts new file mode 100644 index 000000000..305099b4a --- /dev/null +++ b/packages/ai/src/utils/oauth/google-oauth-shared.ts @@ -0,0 +1,255 @@ +import type { OAuthAuth } from "../../auth/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/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index a57baddaf..5f3a204a8 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -9,6 +9,7 @@ // Anthropic export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.ts"; +export * from "./cursor.ts"; export * from "./device-code.ts"; // GitHub Copilot export { @@ -18,6 +19,12 @@ export { normalizeDomain, refreshGitHubCopilotToken, } from "./github-copilot.ts"; +export * from "./gitlab-duo.ts"; +export * from "./glm-zcode.ts"; +export { googleAntigravityOAuthProvider, loginAntigravity, refreshAntigravityToken } from "./google-antigravity.ts"; +export { googleGeminiCliOAuthProvider, loginGeminiCli, refreshGoogleCloudToken } from "./google-gemini-cli.ts"; +export * from "./kilo.ts"; +export * from "./kimi-code.ts"; // OpenAI Codex (ChatGPT OAuth) export { loginOpenAICodex, @@ -27,28 +34,63 @@ export { openaiCodexOAuthProvider, refreshOpenAICodexToken, } from "./openai-codex.ts"; - +export * from "./openai-codex-device.ts"; +export * from "./perplexity.ts"; export * from "./types.ts"; +export { + discoverXaiOAuthEndpoints, + exchangeXaiAuthorizationCode, + loginXai, + refreshXaiToken, + xaiOAuthProvider, +} from "./xai.ts"; // ============================================================================ // Provider Registry // ============================================================================ import { anthropicOAuthProvider } from "./anthropic.ts"; +import { cursorOAuthProvider } from "./cursor.ts"; import { githubCopilotOAuthProvider } from "./github-copilot.ts"; +import { gitlabDuoOAuthProvider } from "./gitlab-duo.ts"; +import { glmZcodeOAuthProvider } from "./glm-zcode.ts"; +import { googleAntigravityOAuthProvider } from "./google-antigravity.ts"; +import { googleGeminiCliOAuthProvider } from "./google-gemini-cli.ts"; +import { kiloOAuthProvider } from "./kilo.ts"; +import { kimiCodeOAuthProvider } from "./kimi-code.ts"; import { openaiCodexOAuthProvider } from "./openai-codex.ts"; +import { openaiCodexDeviceOAuthProvider } from "./openai-codex-device.ts"; +import { perplexityOAuthProvider } from "./perplexity.ts"; import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.ts"; +import { xaiOAuthProvider } from "./xai.ts"; const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [ anthropicOAuthProvider, githubCopilotOAuthProvider, openaiCodexOAuthProvider, + openaiCodexDeviceOAuthProvider, + kimiCodeOAuthProvider, + cursorOAuthProvider, + gitlabDuoOAuthProvider, + perplexityOAuthProvider, + kiloOAuthProvider, + glmZcodeOAuthProvider, + xaiOAuthProvider, + googleGeminiCliOAuthProvider, + googleAntigravityOAuthProvider, ]; const oauthProviderRegistry = new Map( BUILT_IN_OAUTH_PROVIDERS.map((provider) => [provider.id, provider]), ); +/** + * Resolve login-only provider aliases to the credential owner used by models. + */ +export function resolveOAuthStorageProvider(id: OAuthProviderId): OAuthProviderId { + return id === "openai-codex-device" ? "openai-codex" : id; +} + /** * Get an OAuth provider by ID */ diff --git a/packages/ai/src/utils/oauth/kilo.ts b/packages/ai/src/utils/oauth/kilo.ts new file mode 100644 index 000000000..e58fb19b5 --- /dev/null +++ b/packages/ai/src/utils/oauth/kilo.ts @@ -0,0 +1,126 @@ +import type { OAuthAuth } from "../../auth/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/utils/oauth/kimi-code.ts b/packages/ai/src/utils/oauth/kimi-code.ts new file mode 100644 index 000000000..859939ece --- /dev/null +++ b/packages/ai/src/utils/oauth/kimi-code.ts @@ -0,0 +1,305 @@ +import type { OAuthAuth } from "../../auth/types.ts"; +import { getProviderEnvValue } from "../provider-env.ts"; +import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; + +const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; +const DEFAULT_OAUTH_HOST = "https://auth.kimi.com"; +const DEVICE_ID_FILENAME = "kimi-device-id"; +const DEFAULT_POLL_INTERVAL_SECONDS = 5; +const DEFAULT_DEVICE_FLOW_TTL_SECONDS = 15 * 60; +const OAUTH_EXPIRY_SKEW_MS = 5 * 60_000; +const REQUEST_TIMEOUT_MS = 30_000; +const KIMI_CLIENT_VERSION = "1.5"; + +interface DeviceAuthorizationResponse { + user_code?: unknown; + device_code?: unknown; + verification_uri?: unknown; + verification_uri_complete?: unknown; + expires_in?: unknown; + interval?: unknown; +} + +interface TokenResponse { + access_token?: unknown; + refresh_token?: unknown; + expires_in?: unknown; + error?: unknown; + interval?: unknown; +} + +export interface KimiCommonHeaders { + "User-Agent": string; + "X-Msh-Platform": string; + "X-Msh-Version": string; + "X-Msh-Device-Name": string; + "X-Msh-Device-Model": string; + "X-Msh-Os-Version": string; + "X-Msh-Device-Id": string; +} + +let commonHeadersPromise: Promise> | undefined; + +function requestSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +function validateKimiUrl(value: unknown, label: string): URL { + if (typeof value !== "string" || !value) throw new Error(`Kimi OAuth response is missing ${label}`); + const url = new URL(value); + const host = url.hostname.toLowerCase(); + if ( + url.protocol !== "https:" || + url.username !== "" || + url.password !== "" || + (host !== "kimi.com" && !host.endsWith(".kimi.com")) + ) { + throw new Error(`Kimi OAuth returned an unexpected ${label}`); + } + return url; +} + +function resolveOAuthHost(): string { + const configured = getProviderEnvValue("KIMI_CODE_OAUTH_HOST") || getProviderEnvValue("KIMI_OAUTH_HOST"); + const url = validateKimiUrl(configured || DEFAULT_OAUTH_HOST, "OAuth host"); + if (url.pathname !== "/" || url.search || url.hash) { + throw new Error("Kimi OAuth returned an unexpected OAuth host"); + } + return url.origin; +} + +function isNodeErrorCode(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} + +async function readDeviceId(): Promise { + const [{ mkdir, readFile, writeFile, chmod }, os, path] = await Promise.all([ + import("node:fs/promises"), + import("node:os"), + import("node:path"), + ]); + const agentDir = + getProviderEnvValue("SENPI_CODING_AGENT_DIR") || + getProviderEnvValue("PI_CODING_AGENT_DIR") || + path.join(os.homedir(), ".senpi", "agent"); + const deviceIdPath = path.join(agentDir, DEVICE_ID_FILENAME); + await mkdir(agentDir, { recursive: true, mode: 0o700 }); + + try { + const existing = (await readFile(deviceIdPath, "utf8")).trim(); + if (/^[A-Za-z0-9_-]{16,128}$/.test(existing)) return existing; + } catch (error) { + if (!isNodeErrorCode(error, "ENOENT")) throw error; + } + + const deviceId = crypto.randomUUID().replace(/-/g, ""); + try { + await writeFile(deviceIdPath, `${deviceId}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); + } catch (error) { + if (!isNodeErrorCode(error, "EEXIST")) throw error; + const existing = (await readFile(deviceIdPath, "utf8")).trim(); + if (/^[A-Za-z0-9_-]{16,128}$/.test(existing)) return existing; + await writeFile(deviceIdPath, `${deviceId}\n`, { encoding: "utf8", mode: 0o600 }); + await chmod(deviceIdPath, 0o600); + } + return deviceId; +} + +export function getKimiCommonHeaders(): Promise> { + commonHeadersPromise ??= (async () => { + const os = await import("node:os"); + const deviceId = await readDeviceId(); + return Object.freeze({ + "User-Agent": `KimiCLI/${KIMI_CLIENT_VERSION}`, + "X-Msh-Platform": "kimi_cli", + "X-Msh-Version": KIMI_CLIENT_VERSION, + "X-Msh-Device-Name": os.hostname(), + "X-Msh-Device-Model": `${os.platform()} ${os.release()} ${os.arch()}`, + "X-Msh-Os-Version": os.version(), + "X-Msh-Device-Id": deviceId, + }); + })(); + return commonHeadersPromise; +} + +async function readJsonObject(response: Response): Promise> { + const value = await response.json().catch(() => undefined); + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{ + userCode: string; + deviceCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresInSeconds: number; + intervalSeconds: number; +}> { + const response = await fetch(`${resolveOAuthHost()}/api/oauth/device_authorization`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + ...(await getKimiCommonHeaders()), + }, + body: new URLSearchParams({ client_id: CLIENT_ID }).toString(), + signal: requestSignal(signal), + }); + const payload = (await readJsonObject(response)) as DeviceAuthorizationResponse; + if (!response.ok) throw new Error(`Kimi device authorization failed (${response.status})`); + if (typeof payload.user_code !== "string" || typeof payload.device_code !== "string") { + throw new Error("Kimi device authorization response missing required fields"); + } + const verificationUri = validateKimiUrl(payload.verification_uri, "verification URI").toString(); + const verificationUriComplete = + payload.verification_uri_complete === undefined + ? verificationUri + : validateKimiUrl(payload.verification_uri_complete, "complete verification URI").toString(); + const expiresInSeconds = + typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in) && payload.expires_in > 0 + ? payload.expires_in + : DEFAULT_DEVICE_FLOW_TTL_SECONDS; + const intervalSeconds = + typeof payload.interval === "number" && Number.isFinite(payload.interval) && payload.interval > 0 + ? payload.interval + : DEFAULT_POLL_INTERVAL_SECONDS; + return { + userCode: payload.user_code, + deviceCode: payload.device_code, + verificationUri, + verificationUriComplete, + expiresInSeconds, + intervalSeconds, + }; +} + +function parseTokenPayload(payload: TokenResponse, refreshTokenFallback?: string): OAuthCredentials { + if ( + typeof payload.access_token !== "string" || + !payload.access_token || + typeof payload.expires_in !== "number" || + !Number.isFinite(payload.expires_in) || + payload.expires_in <= 0 + ) { + throw new Error("Kimi token response missing required fields"); + } + const refresh = + typeof payload.refresh_token === "string" && payload.refresh_token ? payload.refresh_token : refreshTokenFallback; + if (!refresh) throw new Error("Kimi token response missing refresh token"); + return { + access: payload.access_token, + refresh, + expires: Date.now() + payload.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS, + }; +} + +async function pollForToken( + deviceCode: string, + intervalSeconds: number, + expiresInSeconds: number, + signal?: AbortSignal, +): Promise { + return pollOAuthDeviceCodeFlow({ + intervalSeconds, + expiresInSeconds, + signal, + poll: async () => { + const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + ...(await getKimiCommonHeaders()), + }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }).toString(), + signal: requestSignal(signal), + }); + const payload = (await readJsonObject(response)) as TokenResponse; + if (response.ok && typeof payload.access_token === "string") { + return { status: "complete", value: parseTokenPayload(payload) }; + } + const error = typeof payload.error === "string" ? payload.error : undefined; + if (error === "authorization_pending") return { status: "pending" }; + if (error === "slow_down") { + return { + status: "slow_down", + intervalSeconds: + typeof payload.interval === "number" && Number.isFinite(payload.interval) && payload.interval > 0 + ? payload.interval + : undefined, + }; + } + if (error === "expired_token") return { status: "failed", message: "Kimi device authorization expired" }; + if (error === "access_denied") return { status: "failed", message: "Kimi device authorization denied" }; + return { status: "failed", message: `Kimi device flow failed (${response.status})` }; + }, + }); +} + +export async function loginKimiCode(callbacks: OAuthLoginCallbacks): Promise { + const device = await requestDeviceAuthorization(callbacks.signal); + callbacks.onDeviceCode({ + userCode: device.userCode, + verificationUri: device.verificationUriComplete, + intervalSeconds: device.intervalSeconds, + expiresInSeconds: device.expiresInSeconds, + }); + return pollForToken(device.deviceCode, device.intervalSeconds, device.expiresInSeconds, callbacks.signal); +} + +export async function refreshKimiCodeToken(refreshToken: string, signal?: AbortSignal): Promise { + if (!refreshToken) throw new Error("Kimi credentials do not include a refresh token"); + const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + ...(await getKimiCommonHeaders()), + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }).toString(), + signal: requestSignal(signal), + }); + const payload = (await readJsonObject(response)) as TokenResponse; + if (!response.ok) throw new Error(`Kimi token refresh failed (${response.status})`); + return parseTokenPayload(payload, refreshToken); +} + +export const loginKimi = loginKimiCode; +export const refreshKimiToken = refreshKimiCodeToken; + +export const kimiCodeOAuth: OAuthAuth = { + name: "Kimi Code", + async login(callbacks) { + const credentials = await loginKimiCode({ + 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 }), + onSelect: async () => undefined, + signal: callbacks.signal, + }); + return { ...credentials, type: "oauth" }; + }, + refresh: async (credential) => ({ ...(await refreshKimiCodeToken(credential.refresh)), type: "oauth" }), + toAuth: async (credential) => ({ apiKey: credential.access }), +}; + +export const kimiCodeOAuthProvider: OAuthProviderInterface = { + id: "kimi-code", + name: "Kimi Code", + login: loginKimiCode, + refreshToken: (credentials) => refreshKimiCodeToken(credentials.refresh), + getApiKey: (credentials) => credentials.access, +}; diff --git a/packages/ai/src/utils/oauth/load.ts b/packages/ai/src/utils/oauth/load.ts index 111988532..bf1c472b8 100644 --- a/packages/ai/src/utils/oauth/load.ts +++ b/packages/ai/src/utils/oauth/load.ts @@ -17,5 +17,37 @@ export const loadAnthropicOAuth = async (): Promise => export const loadOpenAICodexOAuth = async (): Promise => ((await importOAuthModule("./openai-codex.ts")) as { openaiCodexOAuth: OAuthAuth }).openaiCodexOAuth; +export const loadOpenAICodexDeviceOAuth = async (): Promise => + ((await importOAuthModule("./openai-codex-device.ts")) as { openaiCodexDeviceOAuth: OAuthAuth }) + .openaiCodexDeviceOAuth; + export const loadGitHubCopilotOAuth = async (): Promise => ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth; + +export const loadCursorOAuth = async (): Promise => + ((await importOAuthModule("./cursor.ts")) as { cursorOAuth: OAuthAuth }).cursorOAuth; + +export const loadGitLabDuoOAuth = async (): Promise => + ((await importOAuthModule("./gitlab-duo.ts")) as { gitlabDuoOAuth: OAuthAuth }).gitlabDuoOAuth; + +export const loadPerplexityOAuth = async (): Promise => + ((await importOAuthModule("./perplexity.ts")) as { perplexityOAuth: OAuthAuth }).perplexityOAuth; + +export const loadKiloOAuth = async (): Promise => + ((await importOAuthModule("./kilo.ts")) as { kiloOAuth: OAuthAuth }).kiloOAuth; + +export const loadKimiCodeOAuth = async (): Promise => + ((await importOAuthModule("./kimi-code.ts")) as { kimiCodeOAuth: OAuthAuth }).kimiCodeOAuth; + +export const loadGlmZcodeOAuth = async (): Promise => + ((await importOAuthModule("./glm-zcode.ts")) as { glmZcodeOAuth: OAuthAuth }).glmZcodeOAuth; + +export const loadXaiOAuth = async (): Promise => + ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth; + +export const loadGoogleGeminiCliOAuth = async (): Promise => + ((await importOAuthModule("./google-gemini-cli.ts")) as { googleGeminiCliOAuth: OAuthAuth }).googleGeminiCliOAuth; + +export const loadGoogleAntigravityOAuth = async (): Promise => + ((await importOAuthModule("./google-antigravity.ts")) as { googleAntigravityOAuth: OAuthAuth }) + .googleAntigravityOAuth; diff --git a/packages/ai/src/utils/oauth/openai-codex-device.ts b/packages/ai/src/utils/oauth/openai-codex-device.ts new file mode 100644 index 000000000..156db9563 --- /dev/null +++ b/packages/ai/src/utils/oauth/openai-codex-device.ts @@ -0,0 +1,33 @@ +import type { OAuthAuth } from "../../auth/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/utils/oauth/perplexity.ts b/packages/ai/src/utils/oauth/perplexity.ts new file mode 100644 index 000000000..bcb88bf59 --- /dev/null +++ b/packages/ai/src/utils/oauth/perplexity.ts @@ -0,0 +1,202 @@ +import type { OAuthAuth } from "../../auth/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"); + } + + 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", ...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", ...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); +} + +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/utils/oauth/xai.ts b/packages/ai/src/utils/oauth/xai.ts new file mode 100644 index 000000000..c79aada26 --- /dev/null +++ b/packages/ai/src/utils/oauth/xai.ts @@ -0,0 +1,251 @@ +import type { OAuthAuth } from "../../auth/types.ts"; +import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; +import { generatePKCE } from "./pkce.ts"; +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; + +export const XAI_OAUTH_DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration"; +export const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"; +const SCOPE = "openid profile email offline_access grok-cli:access api:access"; +const REDIRECT_URI = "http://127.0.0.1:56121/callback"; +const SKEW_MS = 2 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 30_000; + +type Server = import("node:http").Server; +type Endpoints = { authorizationEndpoint: string; tokenEndpoint: string }; +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; +} + +function validateEndpoint(value: unknown): string { + if (typeof value !== "string") throw new Error("xAI OAuth discovery response is missing an endpoint"); + const url = new URL(value); + const host = url.hostname.toLowerCase(); + if (url.protocol !== "https:" || (host !== "x.ai" && !host.endsWith(".x.ai"))) { + throw new Error("xAI OAuth discovery returned an unexpected endpoint"); + } + return url.toString(); +} + +export function parseXaiAuthorizationInput(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 non-URL callback 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 }; +} + +export async function discoverXaiOAuthEndpoints(signal?: AbortSignal): Promise { + const response = await fetch(XAI_OAUTH_DISCOVERY_URL, { + headers: { Accept: "application/json" }, + signal: requestSignal(signal), + }); + if (!response.ok) throw new Error(`xAI OAuth discovery failed (${response.status})`); + const data = (await response.json()) as Record; + return { + authorizationEndpoint: validateEndpoint(data.authorization_endpoint), + tokenEndpoint: validateEndpoint(data.token_endpoint), + }; +} + +async function tokenRequest( + endpoint: string, + body: Record, + signal?: AbortSignal, +): Promise { + const response = await fetch(endpoint, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(body).toString(), + signal: requestSignal(signal), + }); + if (!response.ok) throw new Error(`xAI token request failed (${response.status})`); + const data = (await response.json()) as Record; + if (typeof data.access_token !== "string" || !data.access_token) + throw new Error("xAI 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("xAI token response missing refresh token"); + const expiresIn = typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600; + return { access: data.access_token, refresh, expires: Date.now() + expiresIn * 1000 - SKEW_MS }; +} + +export async function exchangeXaiAuthorizationCode( + code: string, + verifier: string, + redirectUri = REDIRECT_URI, + signal?: AbortSignal, +) { + const { tokenEndpoint } = await discoverXaiOAuthEndpoints(signal); + return tokenRequest( + tokenEndpoint, + { + grant_type: "authorization_code", + client_id: XAI_OAUTH_CLIENT_ID, + code, + redirect_uri: redirectUri, + code_verifier: verifier, + }, + signal, + ); +} + +export async function refreshXaiToken(refreshToken: string, signal?: AbortSignal): Promise { + if (!refreshToken) throw new Error("xAI credentials do not include a refresh token"); + const { tokenEndpoint } = await discoverXaiOAuthEndpoints(signal); + return tokenRequest( + tokenEndpoint, + { grant_type: "refresh_token", client_id: XAI_OAUTH_CLIENT_ID, refresh_token: refreshToken }, + signal, + ); +} + +function abortPromise(signal?: AbortSignal): Promise | undefined { + if (!signal) return undefined; + return new Promise((_, reject) => { + const rejectAbort = () => + reject(signal.reason ?? new DOMException("xAI 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 xAI OAuth callback.")); + return; + } + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(oauthSuccessHtml("xAI authentication completed.")); + settle(code); + }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(56121, "127.0.0.1", resolve); + }); + return server; + } catch (error) { + server.close(); + throw error; + } +} + +export async function loginXai(callbacks: OAuthLoginCallbacks): Promise { + const { verifier, challenge } = await generatePKCE(); + const state = crypto.randomUUID(); + const endpoints = await discoverXaiOAuthEndpoints(callbacks.signal); + 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("xAI OAuth callback port 56121 is unavailable and manual code input is not supported"); + } + const params = new URLSearchParams({ + response_type: "code", + client_id: XAI_OAUTH_CLIENT_ID, + redirect_uri: REDIRECT_URI, + scope: SCOPE, + code_challenge: challenge, + code_challenge_method: "S256", + state, + nonce: crypto.randomUUID(), + }); + callbacks.onAuth({ + url: `${endpoints.authorizationEndpoint}?${params}`, + instructions: + "Complete xAI/Grok login in your browser, then paste the redirect URL or authorization code if needed.", + }); + const candidates: Promise[] = []; + if (server) candidates.push(callbackCode); + if (callbacks.onManualCodeInput) { + candidates.push( + callbacks.onManualCodeInput().then((input) => { + const parsed = parseXaiAuthorizationInput(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 tokenRequest( + endpoints.tokenEndpoint, + { + grant_type: "authorization_code", + client_id: XAI_OAUTH_CLIENT_ID, + code, + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }, + callbacks.signal, + ); + } finally { + server?.close(); + } +} + +export const xaiOAuth: OAuthAuth = { + name: "xAI (Grok account)", + async login(callbacks) { + const manualAbort = new AbortController(); + try { + const credentials = await loginXai({ + 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 xAI 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 refreshXaiToken(credential.refresh)), type: "oauth" }), + toAuth: async (credential) => ({ apiKey: credential.access }), +}; + +export const xaiOAuthProvider: OAuthProviderInterface = { + id: "xai", + name: "xAI (Grok account)", + usesCallbackServer: true, + login: loginXai, + refreshToken: (credentials) => refreshXaiToken(credentials.refresh), + getApiKey: (credentials) => credentials.access, +}; 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..8099d2623 --- /dev/null +++ b/packages/ai/test/api-key-provider-parity.test.ts @@ -0,0 +1,106 @@ +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", + "kagi", + "litellm", + "lm-studio", + "nanogpt", + "ollama", + "ollama-cloud", + "parallel", + "qianfan", + "qwen-portal", + "synthetic", + "tavily", + "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"], + kagi: ["KAGI_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"], + parallel: ["PARALLEL_API_KEY"], + qianfan: ["QIANFAN_API_KEY"], + "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], + synthetic: ["SYNTHETIC_API_KEY"], + tavily: ["TAVILY_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; + +describe("Gajae API-key provider parity", () => { + 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({ + model, + 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({ + model, + ctx: { env: async () => undefined, fileExists: async () => false }, + }); + expect(resolved?.auth.apiKey).toBe(dummyKey); + } + }); +}); diff --git a/packages/ai/test/cursor-oauth.test.ts b/packages/ai/test/cursor-oauth.test.ts new file mode 100644 index 000000000..19369e1a6 --- /dev/null +++ b/packages/ai/test/cursor-oauth.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { builtinProviders } from "../src/providers/all.ts"; +import { getOAuthProvider } from "../src/utils/oauth/index.ts"; +import type { OAuthProviderInterface } from "../src/utils/oauth/types.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..836a749e4 --- /dev/null +++ b/packages/ai/test/gajae-provider-id-gaps.test.ts @@ -0,0 +1,48 @@ +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({ + model, + ctx: { env: async (name) => (name === envVar ? "test-key" : undefined), fileExists: async () => false }, + }); + expect(resolved).toMatchObject({ auth: { apiKey: "test-key" }, source: envVar }); + } + }); + + it("registers Kimi OAuth models and the OpenAI device-code model alias", () => { + const providers = new Map(builtinProviders().map((provider) => [provider.id, provider])); + const kimi = providers.get("kimi-code"); + expect(kimi?.auth.oauth?.name).toBe("Kimi Code"); + expect(kimi?.getModels().map((model) => model.id)).toEqual([ + "kimi-for-coding", + "kimi-k2", + "kimi-k2-turbo-preview", + "kimi-k2.5", + "kimi-k2.7-code", + ]); + + 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/gitlab-duo-oauth.test.ts b/packages/ai/test/gitlab-duo-oauth.test.ts new file mode 100644 index 000000000..9a9e99394 --- /dev/null +++ b/packages/ai/test/gitlab-duo-oauth.test.ts @@ -0,0 +1,84 @@ +import { createServer } from "node:http"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { builtinProviders } from "../src/providers/all.ts"; +import { getOAuthProvider } from "../src/utils/oauth/index.ts"; +import type { OAuthProviderInterface } from "../src/utils/oauth/types.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"); + }); +}); diff --git a/packages/ai/test/glm-zcode-oauth.test.ts b/packages/ai/test/glm-zcode-oauth.test.ts new file mode 100644 index 000000000..03f42de04 --- /dev/null +++ b/packages/ai/test/glm-zcode-oauth.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { builtinProviders } from "../src/providers/all.ts"; +import { getOAuthProvider } from "../src/utils/oauth/index.ts"; +import type { OAuthProviderInterface } from "../src/utils/oauth/types.ts"; + +const UPSTREAM = "upstream-private-value"; +const BUSINESS = "business-private-value"; +const ORG = "org-default"; +const PROJECT = "project-default"; +const KEY_ID = "key-id"; +const SECRET = "key-secret"; + +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("glm-zcode"); + expect(value).toBeDefined(); + if (!value) throw new Error("glm-zcode OAuth provider is not registered"); + return value; +} + +function provisioningFetch() { + const keysUrl = `https://api.z.ai/api/biz/v1/organization/${ORG}/projects/${PROJECT}/api_keys`; + return vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url === "https://zcode.z.ai/api/v1/oauth/token") { + return jsonResponse({ data: { token: "zcode-identity-token", zai: { access_token: UPSTREAM } } }); + } + if (url === "https://api.z.ai/api/auth/z/login") { + return jsonResponse({ data: { access_token: BUSINESS } }); + } + if (url === "https://api.z.ai/api/biz/customer/getCustomerInfo") { + return jsonResponse({ + data: { + id: "account-id", + email: "Member@Example.com", + organizations: [ + { organizationId: ORG, isDefault: true, projects: [{ projectId: PROJECT, isDefault: true }] }, + ], + }, + }); + } + if (url === keysUrl && (init?.method ?? "GET") === "GET") { + return jsonResponse({ data: [{ name: "zcode-api-key", apiKey: KEY_ID }] }); + } + if (url === `${keysUrl}/copy/${encodeURIComponent(KEY_ID)}`) { + return jsonResponse({ data: { secretKey: SECRET } }); + } + throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${url}`); + }); +} + +describe.sequential("GLM ZCode OAuth", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("is registered and advertised as an unofficial opt-in model provider", () => { + expect(getOAuthProvider("glm-zcode")?.name).toMatch(/unofficial.*opt-in/i); + expect(builtinProviders().map((entry) => entry.id)).toContain("glm-zcode"); + }); + + it("exchanges a manual ZCode callback, provisions an API key, and refreshes it", async () => { + const fetchMock = provisioningFetch(); + vi.stubGlobal("fetch", fetchMock); + let state = ""; + const credentials = await provider().login({ + onAuth: (info) => { + const url = new URL(info.url); + state = url.searchParams.get("state") ?? ""; + expect(url.searchParams.get("redirect_uri")).toBe("zcode://oauth/callback"); + expect(info.instructions).toMatch(/unofficial/i); + }, + onDeviceCode: () => {}, + onPrompt: async () => "", + onManualCodeInput: async () => `zcode://oauth/callback?code=manual-code&state=${state}`, + onSelect: async () => undefined, + }); + expect(credentials).toMatchObject({ + access: `${KEY_ID}.${SECRET}`, + refresh: UPSTREAM, + email: "member@example.com", + accountId: "account-id", + }); + const brokerBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(brokerBody).toMatchObject({ code: "manual-code", redirect_uri: "zcode://oauth/callback", state }); + + const refreshed = await provider().refreshToken({ ...credentials, access: "old-key", expires: 0 }); + expect(refreshed).toMatchObject({ access: `${KEY_ID}.${SECRET}`, refresh: UPSTREAM }); + }); + + it("redacts stored and response secrets when refresh provisioning fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ access_token: "glm-response-private-value" }, 401)), + ); + const error = await provider() + .refreshToken({ access: "old-private-key", refresh: UPSTREAM, expires: 0 }) + .catch((value: unknown) => value); + expect(String(error)).toMatch(/re-login/i); + expect(String(error)).toContain("401"); + expect(String(error)).not.toContain(UPSTREAM); + expect(String(error)).not.toContain("glm-response-private-value"); + }); +}); 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..e4ffb5a25 --- /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 { builtinModels } from "../src/providers/all.ts"; +import { + discoverAntigravityProject, + googleAntigravityOAuth, + refreshAntigravityToken, +} from "../src/utils/oauth/google-antigravity.ts"; +import { + discoverGeminiCliProject, + googleGeminiCliOAuth, + refreshGoogleCloudToken, +} from "../src/utils/oauth/google-gemini-cli.ts"; +import { googleOAuthExports } from "../src/utils/oauth/google-oauth-shared.ts"; +import { getOAuthProvider } from "../src/utils/oauth/index.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/utils/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..9bf631d17 --- /dev/null +++ b/packages/ai/test/google-cca-runtime.test.ts @@ -0,0 +1,192 @@ +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 }); + }); +}); diff --git a/packages/ai/test/kilo-oauth.test.ts b/packages/ai/test/kilo-oauth.test.ts new file mode 100644 index 000000000..195395741 --- /dev/null +++ b/packages/ai/test/kilo-oauth.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { builtinProviders } from "../src/providers/all.ts"; +import { getOAuthProvider } from "../src/utils/oauth/index.ts"; +import type { OAuthProviderInterface } from "../src/utils/oauth/types.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/kimi-code-oauth.test.ts b/packages/ai/test/kimi-code-oauth.test.ts new file mode 100644 index 000000000..3de260014 --- /dev/null +++ b/packages/ai/test/kimi-code-oauth.test.ts @@ -0,0 +1,151 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { getOAuthProvider } from "../src/utils/oauth/index.ts"; +import { loginKimi, refreshKimiToken } from "../src/utils/oauth/kimi-code.ts"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +describe.sequential("Kimi Code OAuth", () => { + let agentDir: string; + const originalAgentDir = process.env.SENPI_CODING_AGENT_DIR; + + beforeAll(async () => { + agentDir = await mkdtemp(join(tmpdir(), "senpi-kimi-oauth-")); + process.env.SENPI_CODING_AGENT_DIR = agentDir; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + afterAll(async () => { + if (originalAgentDir === undefined) delete process.env.SENPI_CODING_AGENT_DIR; + else process.env.SENPI_CODING_AGENT_DIR = originalAgentDir; + await rm(agentDir, { recursive: true, force: true }); + }); + + it("logs in with device authorization and refreshes without exposing tokens", async () => { + const issuedAt = Date.parse("2026-07-12T00:00:00Z"); + vi.spyOn(Date, "now").mockReturnValue(issuedAt); + 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.endsWith("/device_authorization")) { + return jsonResponse({ + user_code: "KIMI-1234", + device_code: "device-secret", + verification_uri: "https://auth.kimi.com/device", + verification_uri_complete: "https://auth.kimi.com/device?user_code=KIMI-1234", + expires_in: 900, + interval: 5, + }); + } + if (url.endsWith("/token")) { + const body = new URLSearchParams(String(init?.body)); + return body.get("grant_type") === "refresh_token" + ? jsonResponse({ access_token: "access-refreshed", expires_in: 3600 }) + : jsonResponse({ access_token: "access-secret", refresh_token: "refresh-secret", expires_in: 3600 }); + } + throw new Error(`Unexpected URL: ${url}`); + }), + ); + + const deviceCodes: Array<{ userCode: string; verificationUri: string }> = []; + const credentials = await loginKimi({ + onAuth: () => {}, + onDeviceCode: (info) => deviceCodes.push(info), + onPrompt: async () => "", + onSelect: async () => undefined, + }); + expect(deviceCodes).toEqual([ + { + userCode: "KIMI-1234", + verificationUri: "https://auth.kimi.com/device?user_code=KIMI-1234", + intervalSeconds: 5, + expiresInSeconds: 900, + }, + ]); + expect(credentials).toEqual({ + access: "access-secret", + refresh: "refresh-secret", + expires: issuedAt + 55 * 60_000, + }); + + const authorizationBody = new URLSearchParams(String(requests[0]?.init?.body)); + expect(authorizationBody.get("client_id")).toBe("17e5f671-d194-4dfb-9706-5516cb48c098"); + const tokenBody = new URLSearchParams(String(requests[1]?.init?.body)); + expect(tokenBody.get("device_code")).toBe("device-secret"); + expect(requests[1]?.init?.signal).toBeInstanceOf(AbortSignal); + + const refreshed = await refreshKimiToken(credentials.refresh); + expect(refreshed).toEqual({ + access: "access-refreshed", + refresh: "refresh-secret", + expires: issuedAt + 55 * 60_000, + }); + expect(getOAuthProvider("kimi-code")?.id).toBe("kimi-code"); + }); + + it("rejects an untrusted verification URI before presenting it", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + jsonResponse({ + user_code: "KIMI-1234", + device_code: "device-secret", + verification_uri: "https://phishing.example/device", + }), + ), + ); + + await expect( + loginKimi({ + onAuth: () => {}, + onDeviceCode: () => {}, + onPrompt: async () => "", + onSelect: async () => undefined, + }), + ).rejects.toThrow("unexpected verification URI"); + }); + + it("cancels before polling after the device code is presented", async () => { + const controller = new AbortController(); + const fetchMock = vi.fn(async () => + jsonResponse({ + user_code: "KIMI-1234", + device_code: "device-secret", + verification_uri: "https://auth.kimi.com/device", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + loginKimi({ + onAuth: () => {}, + onDeviceCode: () => controller.abort(), + onPrompt: async () => "", + onSelect: async () => undefined, + signal: controller.signal, + }), + ).rejects.toThrow("Login cancelled"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("keeps refresh credentials out of endpoint errors", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ error: "invalid_grant", refresh_token: "leaked-token" }, 400)), + ); + const error = await refreshKimiToken("refresh-secret").catch((value: unknown) => value); + expect(String(error)).not.toContain("refresh-secret"); + expect(String(error)).not.toContain("leaked-token"); + }); +}); 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..ec62cd1b8 --- /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/utils/oauth/index.ts"; +import { openaiCodexDeviceOAuthProvider } from "../src/utils/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/perplexity-oauth.test.ts b/packages/ai/test/perplexity-oauth.test.ts new file mode 100644 index 000000000..e2d7a2b52 --- /dev/null +++ b/packages/ai/test/perplexity-oauth.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { builtinProviders } from "../src/providers/all.ts"; +import { getOAuthProvider } from "../src/utils/oauth/index.ts"; +import type { OAuthProviderInterface } from "../src/utils/oauth/types.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)}.`; +} + +function provider(): OAuthProviderInterface { + const value = getOAuthProvider("perplexity"); + expect(value).toBeDefined(); + if (!value) throw new Error("perplexity OAuth provider is not registered"); + return value; +} + +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("is registered and advertised by a model provider", () => { + expect(getOAuthProvider("perplexity")?.id).toBe("perplexity"); + expect(builtinProviders().map((entry) => entry.id)).toContain("perplexity"); + }); + + it("logs in through mocked OTP endpoints and keeps JWTs without exp non-expiring", async () => { + process.env.PI_AUTH_NO_BORROW = "1"; + const tokenWithoutExpiry = jwt({ sub: "perplexity-account" }); + const urls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = String(input); + urls.push(url); + if (url.endsWith("/api/auth/csrf")) return jsonResponse({ csrfToken: "csrf-value" }); + 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"]; + const credentials = await provider().login({ + onAuth: () => {}, + onDeviceCode: () => {}, + onPrompt: async () => answers.shift() ?? "", + onSelect: async () => undefined, + }); + expect(urls).toHaveLength(3); + expect(credentials).toMatchObject({ access: tokenWithoutExpiry, refresh: tokenWithoutExpiry }); + expect(credentials.expires).toBe(8.64e15); + + const refreshed = await provider().refreshToken({ ...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 provider().refreshToken({ 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 jsonResponse({ csrfToken: "csrf-private" }); + 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 provider() + .login({ + 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/ai/test/xai-oauth.test.ts b/packages/ai/test/xai-oauth.test.ts new file mode 100644 index 000000000..ada94bd87 --- /dev/null +++ b/packages/ai/test/xai-oauth.test.ts @@ -0,0 +1,212 @@ +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { xaiProvider } from "../src/providers/xai.ts"; +import { + discoverXaiOAuthEndpoints, + exchangeXaiAuthorizationCode, + getOAuthProvider, + refreshXaiToken, +} from "../src/utils/oauth/index.ts"; +import { loginXai, parseXaiAuthorizationInput, xaiOAuth } from "../src/utils/oauth/xai.ts"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +describe.sequential("xAI OAuth", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("does not statically import node:http from the registry-loaded xAI module", async () => { + const source = await readFile(new URL("../src/utils/oauth/xai.ts", import.meta.url), "utf8"); + expect(source).not.toMatch(/^import .*node:http/m); + }); + + it("aborts the manual prompt after login settles", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => + String(input).includes("openid-configuration") + ? jsonResponse({ + authorization_endpoint: "https://auth.x.ai/authorize", + token_endpoint: "https://auth.x.ai/token", + }) + : jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 }), + ), + ); + let manualSignal: AbortSignal | undefined; + await xaiOAuth.login({ + notify: () => {}, + prompt: async (prompt) => { + if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`); + manualSignal = prompt.signal; + return "manual-code"; + }, + }); + expect(manualSignal).toBeDefined(); + expect(manualSignal?.aborted).toBe(true); + }); + + it("cancels callback waiting from the caller signal", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + jsonResponse({ + authorization_endpoint: "https://auth.x.ai/authorize", + token_endpoint: "https://auth.x.ai/token", + }), + ), + ); + const controller = new AbortController(); + const login = loginXai({ + onAuth: () => controller.abort(new DOMException("cancelled", "AbortError")), + onDeviceCode: () => {}, + onPrompt: async () => "", + onSelect: async () => undefined, + signal: controller.signal, + }); + await expect(login).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("falls back to manual paste when callback port is occupied", async () => { + const blocker = createServer(); + await new Promise((resolve, reject) => { + blocker.once("error", reject); + blocker.listen(56121, "127.0.0.1", resolve); + }); + try { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => + String(input).includes("openid-configuration") + ? jsonResponse({ + authorization_endpoint: "https://auth.x.ai/authorize", + token_endpoint: "https://auth.x.ai/token", + }) + : jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 }), + ), + ); + let state = ""; + const credentials = await loginXai({ + onAuth: (info) => { + state = new URL(info.url).searchParams.get("state") ?? ""; + }, + onDeviceCode: () => {}, + onPrompt: async () => "", + onSelect: async () => undefined, + onManualCodeInput: async () => `code=manual-code&state=${state}`, + }); + expect(credentials.access).toBe("access"); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); + + it("parses supported manual callback formats and validates state during login", async () => { + expect(parseXaiAuthorizationInput("https://127.0.0.1/callback?code=a&state=s")).toEqual({ + code: "a", + state: "s", + }); + expect(parseXaiAuthorizationInput("?code=b&state=s")).toEqual({ code: "b", state: "s" }); + expect(parseXaiAuthorizationInput("c#s")).toEqual({ code: "c", state: "s" }); + + vi.stubGlobal( + "fetch", + vi.fn(async () => + jsonResponse({ + authorization_endpoint: "https://auth.x.ai/authorize", + token_endpoint: "https://auth.x.ai/token", + }), + ), + ); + await expect( + loginXai({ + onAuth: () => {}, + onDeviceCode: () => {}, + onPrompt: async () => "", + onSelect: async () => undefined, + onManualCodeInput: async () => "code#wrong-state", + }), + ).rejects.toThrow("state mismatch"); + }); + + it("combines caller cancellation with a request timeout", async () => { + const controller = new AbortController(); + let requestSignal: AbortSignal | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + requestSignal = init?.signal ?? undefined; + return await new Promise((_resolve, reject) => + requestSignal?.addEventListener("abort", () => reject(requestSignal?.reason), { once: true }), + ); + }), + ); + const discovery = discoverXaiOAuthEndpoints(controller.signal); + expect(requestSignal).not.toBe(controller.signal); + controller.abort(new DOMException("cancelled", "AbortError")); + await expect(discovery).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("is registered and advertised by the xAI model provider", () => { + expect(getOAuthProvider("xai")?.id).toBe("xai"); + expect(xaiProvider().auth.oauth?.name).toBe("xAI (Grok account)"); + }); + + it("rejects discovery endpoints outside HTTPS x.ai", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + jsonResponse({ + authorization_endpoint: "https://evil.example/authorize", + token_endpoint: "https://auth.x.ai/token", + }), + ), + ); + await expect(discoverXaiOAuthEndpoints()).rejects.toThrow("unexpected endpoint"); + }); + + it("exchanges and refreshes tokens with PKCE form requests", async () => { + const bodies: URLSearchParams[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + if (url.includes("openid-configuration")) + return jsonResponse({ + authorization_endpoint: "https://auth.x.ai/authorize", + token_endpoint: "https://auth.x.ai/token", + }); + bodies.push(new URLSearchParams(String(init?.body))); + return jsonResponse( + bodies.length === 1 + ? { access_token: "access-secret", refresh_token: "refresh-secret", expires_in: 3600 } + : { access_token: "new-access", expires_in: 3600 }, + ); + }), + ); + const exchanged = await exchangeXaiAuthorizationCode("code-secret", "pkce-verifier"); + const refreshed = await refreshXaiToken(exchanged.refresh); + expect(bodies[0]?.get("code_verifier")).toBe("pkce-verifier"); + expect(bodies[0]?.get("grant_type")).toBe("authorization_code"); + expect(bodies[1]?.get("refresh_token")).toBe("refresh-secret"); + expect(refreshed).toMatchObject({ access: "new-access", refresh: "refresh-secret" }); + }); + + it("does not expose tokens from failed endpoint responses", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => + String(input).includes("openid-configuration") + ? jsonResponse({ + authorization_endpoint: "https://auth.x.ai/authorize", + token_endpoint: "https://auth.x.ai/token", + }) + : jsonResponse({ error: "invalid_grant", access_token: "leaked-token" }, 400), + ), + ); + const error = await refreshXaiToken("refresh-secret").catch((value: unknown) => value); + expect(String(error)).not.toContain("refresh-secret"); + expect(String(error)).not.toContain("leaked-token"); + }); +}); diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 57b31eacd..3d8c829ee 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -19,7 +19,7 @@ Use `/login` in interactive mode, then select a provider: - Claude Pro/Max - GitHub Copilot -Use `/logout` to clear credentials. Tokens are stored in `~/.senpi/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 diff --git a/packages/coding-agent/src/core/auth-providers.ts b/packages/coding-agent/src/core/auth-providers.ts index 139fac1cc..bbdebc6a2 100644 --- a/packages/coding-agent/src/core/auth-providers.ts +++ b/packages/coding-agent/src/core/auth-providers.ts @@ -14,6 +14,7 @@ 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()); +const OAUTH_ONLY_MODEL_PROVIDERS = new Set(["openai-codex-device"]); /** One provider entry for a login/logout selector. */ export interface AuthProviderInfo { @@ -35,6 +36,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/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 0c528ad8e..919c4f983 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -13,41 +13,72 @@ import type { ModelRegistry } from "./model-registry.ts"; /** Default model IDs for each known provider */ export const defaultModelPerProvider: Record = { + "alibaba-coding-plan": "qwen3.5-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", - 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: "deepseek-ai/DeepSeek-V3.2", deepseek: "deepseek-v4-pro", + firepass: "accounts/fireworks/routers/kimi-k2p6-turbo", + fireworks: "accounts/fireworks/models/kimi-k2p6", + fugu: "fugu", + "github-copilot": "gpt-5.4", + "gitlab-duo": "claude-sonnet-4-6", + "glm-zcode": "glm-5.2", 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.20-0309-reasoning", 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", + kagi: "search", + kilo: "anthropic/claude-sonnet-4.5", + "kimi-code": "kimi-k2.7-code", + "kimi-coding": "kimi-for-coding", + litellm: "claude-opus-4-6", + "lm-studio": "llama-3-8b", minimax: "MiniMax-M2.7", "minimax-cn": "MiniMax-M2.7", + "minimax-code": "MiniMax-M3", + "minimax-code-cn": "MiniMax-M3", + mistral: "devstral-medium-latest", + moonshot: "kimi-k2.6", 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: "openai/gpt-5.4", + nvidia: "nvidia/nemotron-3-super-120b-a12b", + ollama: "gpt-oss:20b", + "ollama-cloud": "gpt-oss:120b", + openai: "gpt-5.5", + "openai-codex": "gpt-5.5", + "openai-codex-device": "gpt-5.5", 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": "kimi-k2.6", + openrouter: "moonshotai/kimi-k2.6", + parallel: "search", + perplexity: "sonar-pro", + qianfan: "deepseek-v3.2", + "qwen-portal": "coder-model", + synthetic: "hf:moonshotai/Kimi-K2.5", + tavily: "search", + together: "moonshotai/Kimi-K2.6", + venice: "llama-3.3-70b", + "vercel-ai-gateway": "zai/glm-5.1", + vllm: "gpt-oss-20b", + xai: "grok-4.20-0309-reasoning", 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: "anthropic/claude-opus-4.6", }; 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 d33c3d7d6..6627f3031 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -1,35 +1,66 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { - anthropic: "Anthropic", + "alibaba-coding-plan": "Alibaba Coding Plan", "amazon-bedrock": "Amazon Bedrock", "ant-ling": "Ant Ling", + anthropic: "Anthropic", "azure-openai-responses": "Azure OpenAI Responses", cerebras: "Cerebras", "cloudflare-ai-gateway": "Cloudflare AI Gateway", "cloudflare-workers-ai": "Cloudflare Workers AI", + cursor: "Cursor", + deepinfra: "DeepInfra", deepseek: "DeepSeek", + firepass: "Fire Pass", fireworks: "Fireworks", + fugu: "Sakana Fugu", + "gitlab-duo": "GitLab Duo", + "glm-zcode": "GLM ZCode (Unofficial)", google: "Google Gemini", + "google-antigravity": "Google Antigravity", + "google-gemini-cli": "Google Gemini CLI (Cloud Code Assist)", "google-vertex": "Google Vertex AI", groq: "Groq", huggingface: "Hugging Face", + kagi: "Kagi", + kilo: "Kilo Gateway", + "kimi-code": "Kimi Code", "kimi-coding": "Kimi For Coding", - mistral: "Mistral", + litellm: "LiteLLM", + "lm-studio": "LM Studio", minimax: "MiniMax", "minimax-cn": "MiniMax (China)", + "minimax-code": "MiniMax Coding Plan (International)", + "minimax-code-cn": "MiniMax Coding Plan (China)", + mistral: "Mistral", + moonshot: "Moonshot (Kimi API)", moonshotai: "Moonshot AI", "moonshotai-cn": "Moonshot AI (China)", + nanogpt: "NanoGPT", nvidia: "NVIDIA NIM", + ollama: "Ollama", + "ollama-cloud": "Ollama Cloud", + openai: "OpenAI", + "openai-codex-device": "OpenAI Codex (Device Code)", opencode: "OpenCode Zen", "opencode-go": "OpenCode Go", - openai: "OpenAI", + "opencode-zen": "OpenCode Zen", openrouter: "OpenRouter", + parallel: "Parallel", + perplexity: "Perplexity", + qianfan: "Qianfan", + "qwen-portal": "Qwen Portal", + synthetic: "Synthetic", + tavily: "Tavily", together: "Together AI", + venice: "Venice", "vercel-ai-gateway": "Vercel AI Gateway", + vllm: "vLLM", xai: "xAI", - zai: "ZAI Coding Plan (Global)", - "zai-coding-cn": "ZAI Coding Plan (China)", xiaomi: "Xiaomi MiMo", - "xiaomi-token-plan-cn": "Xiaomi MiMo Token Plan (China)", "xiaomi-token-plan-ams": "Xiaomi MiMo Token Plan (Amsterdam)", + "xiaomi-token-plan-cn": "Xiaomi MiMo Token Plan (China)", "xiaomi-token-plan-sgp": "Xiaomi MiMo Token Plan (Singapore)", + zai: "ZAI Coding Plan (Global)", + "zai-coding-cn": "ZAI Coding Plan (China)", + zenmux: "ZenMux", }; From 03c07dfec74ae7f5bec111ec7961f3cabe14be33 Mon Sep 17 00:00:00 2001 From: islee Date: Sun, 12 Jul 2026 22:24:11 +0900 Subject: [PATCH 02/11] feat(devenv): wire new provider API keys into devcontainer and env setup Declare the API-key providers added for /login parity (alibaba-coding-plan, deepinfra, firepass, fugu, kagi, litellm, lm-studio, nanogpt, ollama, ollama-cloud, parallel, qianfan, qwen-portal, synthetic, tavily, venice, vllm, zenmux) across the three synchronized environment surfaces: .devcontainer/devcontainer.json secrets, scripts/devenv-setup.mjs PROVIDER_KEYS, and .agents/skills/senpi-qa/references/env-vars.md. Source of truth remains packages/ai/src/env-api-keys.ts. --- .../skills/senpi-qa/references/env-vars.md | 8 ++- .devcontainer/devcontainer.json | 57 +++++++++++++++++++ scripts/devenv-setup.mjs | 19 +++++++ 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/.agents/skills/senpi-qa/references/env-vars.md b/.agents/skills/senpi-qa/references/env-vars.md index 81aad3681..274bcd16c 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`, `KAGI_API_KEY`, `LITELLM_API_KEY`, +`LM_STUDIO_API_KEY`, `NANO_GPT_API_KEY`, `OLLAMA_API_KEY`, +`OLLAMA_CLOUD_API_KEY`, `PARALLEL_API_KEY`, `QIANFAN_API_KEY`, +`QWEN_OAUTH_TOKEN`, `QWEN_PORTAL_API_KEY`, `SYNTHETIC_API_KEY`, +`TAVILY_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..c91aedd76 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -26,6 +26,63 @@ }, "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." + }, + "KAGI_API_KEY": { + "description": "(Optional) Kagi 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." + }, + "PARALLEL_API_KEY": { + "description": "(Optional) Parallel 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." + }, + "TAVILY_API_KEY": { + "description": "(Optional) Tavily 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/scripts/devenv-setup.mjs b/scripts/devenv-setup.mjs index 2fead16ca..57c670be4 100644 --- a/scripts/devenv-setup.mjs +++ b/scripts/devenv-setup.mjs @@ -74,6 +74,25 @@ 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", + "KAGI_API_KEY", + "LITELLM_API_KEY", + "LM_STUDIO_API_KEY", + "NANO_GPT_API_KEY", + "OLLAMA_API_KEY", + "OLLAMA_CLOUD_API_KEY", + "PARALLEL_API_KEY", + "QIANFAN_API_KEY", + "QWEN_OAUTH_TOKEN", + "QWEN_PORTAL_API_KEY", + "SYNTHETIC_API_KEY", + "TAVILY_API_KEY", + "VENICE_API_KEY", + "VLLM_API_KEY", + "ZENMUX_API_KEY", ]; function hasCmd(cmd) { From b6d7614e524cba74a8b105a23971f3ebac643f6b Mon Sep 17 00:00:00 2001 From: islee Date: Tue, 14 Jul 2026 02:46:30 +0900 Subject: [PATCH 03/11] fix(ai): parse CRLF-delimited SSE and pin GLM ZCode endpoints to z.ai --- packages/ai/src/api/google-gemini-cli.ts | 10 +++---- packages/ai/src/utils/oauth/glm-zcode.ts | 3 ++ packages/ai/test/glm-zcode-oauth.test.ts | 21 ++++++++++++++ packages/ai/test/google-cca-runtime.test.ts | 31 +++++++++++++++++++++ 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/ai/src/api/google-gemini-cli.ts b/packages/ai/src/api/google-gemini-cli.ts index 2928ba43d..84356db53 100644 --- a/packages/ai/src/api/google-gemini-cli.ts +++ b/packages/ai/src/api/google-gemini-cli.ts @@ -178,17 +178,17 @@ async function* parseSse(body: ReadableStream, signal?: AbortSignal) const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); - let boundary = buffer.indexOf("\n\n"); - while (boundary >= 0) { - const event = buffer.slice(0, boundary); - buffer = buffer.slice(boundary + 2); + 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 = buffer.indexOf("\n\n"); + boundary = /\r?\n\r?\n/.exec(buffer); } } } finally { diff --git a/packages/ai/src/utils/oauth/glm-zcode.ts b/packages/ai/src/utils/oauth/glm-zcode.ts index 7155ce9c2..e7694f3fb 100644 --- a/packages/ai/src/utils/oauth/glm-zcode.ts +++ b/packages/ai/src/utils/oauth/glm-zcode.ts @@ -64,6 +64,9 @@ function validateHttpsEndpoint(rawUrl: string, label: string): string { throw new Error(`GLM ZCode ${label} endpoint is invalid`); } if (url.protocol !== "https:") throw new Error(`GLM ZCode ${label} endpoint must use HTTPS`); + const host = url.hostname.toLowerCase(); + if (host !== "z.ai" && !host.endsWith(".z.ai")) + throw new Error(`GLM ZCode ${label} endpoint must be on a z.ai host`); return url.toString().replace(/\/+$/, ""); } diff --git a/packages/ai/test/glm-zcode-oauth.test.ts b/packages/ai/test/glm-zcode-oauth.test.ts index 03f42de04..a58f4321d 100644 --- a/packages/ai/test/glm-zcode-oauth.test.ts +++ b/packages/ai/test/glm-zcode-oauth.test.ts @@ -102,4 +102,25 @@ describe.sequential("GLM ZCode OAuth", () => { expect(String(error)).not.toContain(UPSTREAM); expect(String(error)).not.toContain("glm-response-private-value"); }); + + it("rejects an OAuth broker token endpoint override outside z.ai", async () => { + process.env.ZCODE_OAUTH_BROKER_TOKEN_URL = "https://evil.example.com/api/v1/oauth/token"; + try { + vi.stubGlobal("fetch", provisioningFetch()); + let state = ""; + await expect( + provider().login({ + onAuth: (info) => { + state = new URL(info.url).searchParams.get("state") ?? ""; + }, + onDeviceCode: () => {}, + onPrompt: async () => "", + onManualCodeInput: async () => `zcode://oauth/callback?code=manual-code&state=${state}`, + onSelect: async () => undefined, + }), + ).rejects.toThrow(/z\.ai/); + } finally { + delete process.env.ZCODE_OAUTH_BROKER_TOKEN_URL; + } + }); }); diff --git a/packages/ai/test/google-cca-runtime.test.ts b/packages/ai/test/google-cca-runtime.test.ts index 9bf631d17..2ecbe2165 100644 --- a/packages/ai/test/google-cca-runtime.test.ts +++ b/packages/ai/test/google-cca-runtime.test.ts @@ -189,4 +189,35 @@ describe("Google Cloud Code Assist runtime", () => { .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"); + }); }); From 33a5a9208b8ad33ac54e322db2d56b2bb4c26cac Mon Sep 17 00:00:00 2001 From: islee Date: Tue, 14 Jul 2026 18:46:42 +0900 Subject: [PATCH 04/11] fix(ai): parse CRLF-delimited SSE and pin GLM ZCode endpoints to z.ai - Remove kagi, parallel, and tavily providers (search-only, no chat) - Fix GLM ZCode OAuth to pin endpoints to z.ai - Add CRLF-delimited SSE parsing for provider streams - Add GitLab Duo and Perplexity OAuth test coverage - Update model-registry and model-resolver for removed providers --- packages/ai/changes.md | 33 +++++++++++ packages/ai/scripts/generate-models.ts | 39 ------------- packages/ai/src/changes.md | 31 +++++++++++ packages/ai/src/env-api-keys.ts | 3 - packages/ai/src/models.generated.ts | 6 -- packages/ai/src/providers/all.ts | 6 -- packages/ai/src/providers/kagi.models.ts | 25 --------- packages/ai/src/providers/kagi.ts | 15 ----- packages/ai/src/providers/parallel.models.ts | 25 --------- packages/ai/src/providers/parallel.ts | 15 ----- packages/ai/src/providers/tavily.models.ts | 25 --------- packages/ai/src/providers/tavily.ts | 15 ----- packages/ai/src/types.ts | 3 - packages/ai/src/utils/oauth/gitlab-duo.ts | 7 +++ packages/ai/src/utils/oauth/perplexity.ts | 13 ++++- packages/ai/src/utils/oauth/types.ts | 8 +++ .../ai/test/api-key-provider-parity.test.ts | 21 +++++-- packages/ai/test/gitlab-duo-oauth.test.ts | 26 +++++++++ packages/ai/test/perplexity-oauth.test.ts | 26 ++++++++- packages/coding-agent/CHANGELOG.md | 31 +++++++++++ .../coding-agent/src/core/auth-storage.ts | 49 ++++++++++++++--- packages/coding-agent/src/core/changes.md | 17 ++++++ .../coding-agent/src/core/model-registry.ts | 7 ++- .../coding-agent/src/core/model-resolver.ts | 3 - .../src/core/provider-display-names.ts | 3 - .../coding-agent/test/model-registry.test.ts | 55 +++++++++++++++++++ 26 files changed, 303 insertions(+), 204 deletions(-) delete mode 100644 packages/ai/src/providers/kagi.models.ts delete mode 100644 packages/ai/src/providers/kagi.ts delete mode 100644 packages/ai/src/providers/parallel.models.ts delete mode 100644 packages/ai/src/providers/parallel.ts delete mode 100644 packages/ai/src/providers/tavily.models.ts delete mode 100644 packages/ai/src/providers/tavily.ts diff --git a/packages/ai/changes.md b/packages/ai/changes.md index 392389c52..812465720 100644 --- a/packages/ai/changes.md +++ b/packages/ai/changes.md @@ -1,5 +1,38 @@ # changes.md — ai +## Provider and OAuth catalog parity (2026-07-14) + +### What changed + +- Added model catalogs and API-key discovery for supported coding providers, plus OAuth implementations for providers + whose credentials can be translated into the provider wire contract. +- Added deterministic parity tests for provider registration, environment keys, OAuth exchanges, and generated catalogs. +- Kept Kagi, Parallel, and Tavily out of the chat model catalog because their APIs are search-only. + +### Why + +- /login and model resolution need one truthful provider catalog; credential placeholders that cannot serve chat + completions create selectable models that always fail at runtime. + +### Why extension system couldn't handle this + +- Provider catalogs, generated models, credential discovery, and OAuth wire exchanges live in pi-ai before coding-agent + extensions are loaded. + +### Modified upstream files + +- scripts/generate-models.ts +- src/env-api-keys.ts +- src/types.ts +- src/providers/all.ts +- src/utils/oauth/index.ts +- src/utils/oauth/load.ts + +### Expected merge conflict zones + +- HIGH: generated model catalogs and provider unions when upstream refreshes provider metadata. +- MEDIUM: OAuth registration and credential discovery when upstream changes auth APIs. + ## Upstream model generation and test sync (2026-07-02) ### What changed diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 7cd184692..95edaa643 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -2210,19 +2210,6 @@ async function generateModels() { contextWindow: 200000, maxTokens: 65536, }, - { - id: "search", - name: "Kagi Search (credential placeholder)", - api: "openai-completions", - provider: "kagi", - baseUrl: "https://kagi.com/api/v0", - compat: localOpenAICompat, - reasoning: false, - input: ["text"], - cost: zeroCost, - contextWindow: 8192, - maxTokens: 1024, - }, { id: "claude-opus-4-6", name: "Anthropic Opus 4.6", @@ -2287,19 +2274,6 @@ async function generateModels() { contextWindow: 131072, maxTokens: 16384, }, - { - id: "search", - name: "Parallel Search (credential placeholder)", - api: "openai-completions", - provider: "parallel", - baseUrl: "https://api.parallel.ai/v1beta", - compat: localOpenAICompat, - reasoning: false, - input: ["text"], - cost: zeroCost, - contextWindow: 8192, - maxTokens: 1024, - }, { id: "deepseek-v3.2", name: "DeepSeek V3.2", @@ -2344,19 +2318,6 @@ async function generateModels() { contextWindow: 262144, maxTokens: 8192, }, - { - id: "search", - name: "Tavily Search (credential placeholder)", - api: "openai-completions", - provider: "tavily", - baseUrl: "https://api.tavily.com", - compat: localOpenAICompat, - reasoning: false, - input: ["text"], - cost: zeroCost, - contextWindow: 8192, - maxTokens: 1024, - }, { id: "llama-3.3-70b", name: "Llama 3.3 70B", diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index cd0fb3137..9ea58071a 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -1,5 +1,36 @@ # AI Source Changes +## 2026-07-14 - Provider and OAuth parity + +### What changed and why + +- 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. + +### Files modified + +- api/google-gemini-cli.ts +- auth/helpers.ts +- providers/all.ts +- types.ts +- utils/oauth/index.ts +- utils/oauth/load.ts +- provider-specific files under providers/ and utils/oauth/ + +### Why the higher-level extension system couldn't handle this alone + +- 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 + +- HIGH: provider unions, OAuth registration, and generated catalogs. +- MEDIUM: Google shared transport and provider-specific OAuth refresh paths. + ## 2026-07-08 - Anthropic web search replay encrypted content ### What changed and why diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 923cf345f..0c45c9e48 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -100,7 +100,6 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { "google-vertex": "GOOGLE_CLOUD_API_KEY", groq: "GROQ_API_KEY", huggingface: "HF_TOKEN", - kagi: "KAGI_API_KEY", kilo: "KILO_API_KEY", "kimi-code": "KIMI_API_KEY", "kimi-coding": "KIMI_API_KEY", @@ -123,11 +122,9 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { "opencode-go": "OPENCODE_API_KEY", "opencode-zen": "OPENCODE_API_KEY", openrouter: "OPENROUTER_API_KEY", - parallel: "PARALLEL_API_KEY", perplexity: "PERPLEXITY_API_KEY", qianfan: "QIANFAN_API_KEY", synthetic: "SYNTHETIC_API_KEY", - tavily: "TAVILY_API_KEY", together: "TOGETHER_API_KEY", venice: "VENICE_API_KEY", "vercel-ai-gateway": "AI_GATEWAY_API_KEY", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 6539ab104..9a4713739 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -24,7 +24,6 @@ import { GOOGLE_GEMINI_CLI_MODELS } from "./providers/google-gemini-cli.models.t 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 { KAGI_MODELS } from "./providers/kagi.models.ts"; import { KILO_MODELS } from "./providers/kilo.models.ts"; import { KIMI_CODE_MODELS } from "./providers/kimi-code.models.ts"; import { KIMI_CODING_MODELS } from "./providers/kimi-coding.models.ts"; @@ -49,12 +48,10 @@ 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 { PARALLEL_MODELS } from "./providers/parallel.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 { TAVILY_MODELS } from "./providers/tavily.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"; @@ -92,7 +89,6 @@ export const MODELS = { "google-vertex": GOOGLE_VERTEX_MODELS, "groq": GROQ_MODELS, "huggingface": HUGGINGFACE_MODELS, - "kagi": KAGI_MODELS, "kilo": KILO_MODELS, "kimi-code": KIMI_CODE_MODELS, "kimi-coding": KIMI_CODING_MODELS, @@ -117,12 +113,10 @@ export const MODELS = { "opencode-go": OPENCODE_GO_MODELS, "opencode-zen": OPENCODE_ZEN_MODELS, "openrouter": OPENROUTER_MODELS, - "parallel": PARALLEL_MODELS, "perplexity": PERPLEXITY_MODELS, "qianfan": QIANFAN_MODELS, "qwen-portal": QWEN_PORTAL_MODELS, "synthetic": SYNTHETIC_MODELS, - "tavily": TAVILY_MODELS, "together": TOGETHER_MODELS, "venice": VENICE_MODELS, "vercel-ai-gateway": VERCEL_AI_GATEWAY_MODELS, diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index ec3942654..2da466cff 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -24,7 +24,6 @@ import { googleAntigravityProvider, googleGeminiCliProvider } from "./google-gem import { googleVertexProvider } from "./google-vertex.ts"; import { groqProvider } from "./groq.ts"; import { huggingfaceProvider } from "./huggingface.ts"; -import { kagiProvider } from "./kagi.ts"; import { kiloProvider } from "./kilo.ts"; import { kimiCodeProvider } from "./kimi-code.ts"; import { kimiCodingProvider } from "./kimi-coding.ts"; @@ -50,12 +49,10 @@ import { opencodeGoProvider } from "./opencode-go.ts"; import { opencodeZenProvider } from "./opencode-zen.ts"; import { openrouterProvider } from "./openrouter.ts"; import { openrouterImagesProvider } from "./openrouter-images.ts"; -import { parallelProvider } from "./parallel.ts"; import { perplexityProvider } from "./perplexity.ts"; import { qianfanProvider } from "./qianfan.ts"; import { qwenPortalProvider } from "./qwen-portal.ts"; import { syntheticProvider } from "./synthetic.ts"; -import { tavilyProvider } from "./tavily.ts"; import { togetherProvider } from "./together.ts"; import { veniceProvider } from "./venice.ts"; import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts"; @@ -161,7 +158,6 @@ export function builtinProviders(): Provider[] { googleVertexProvider(), groqProvider(), huggingfaceProvider(), - kagiProvider(), kiloProvider(), kimiCodeProvider(), kimiCodingProvider(), @@ -186,12 +182,10 @@ export function builtinProviders(): Provider[] { opencodeGoProvider(), opencodeZenProvider(), openrouterProvider(), - parallelProvider(), perplexityProvider(), qianfanProvider(), qwenPortalProvider(), syntheticProvider(), - tavilyProvider(), togetherProvider(), veniceProvider(), vercelAIGatewayProvider(), diff --git a/packages/ai/src/providers/kagi.models.ts b/packages/ai/src/providers/kagi.models.ts deleted file mode 100644 index d879bd44f..000000000 --- a/packages/ai/src/providers/kagi.models.ts +++ /dev/null @@ -1,25 +0,0 @@ -// This file is auto-generated by scripts/generate-models.ts -// Do not edit manually - run 'npm run generate-models' to update - -import type { Model } from "../types.ts"; - -export const KAGI_MODELS = { - "search": { - id: "search", - name: "Kagi Search (credential placeholder)", - api: "openai-completions", - provider: "kagi", - baseUrl: "https://kagi.com/api/v0", - 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: 1024, - } satisfies Model<"openai-completions">, -} as const; diff --git a/packages/ai/src/providers/kagi.ts b/packages/ai/src/providers/kagi.ts deleted file mode 100644 index a95670caf..000000000 --- a/packages/ai/src/providers/kagi.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; -import { envApiKeyAuth } from "../auth/helpers.ts"; -import { createProvider, type Provider } from "../models.ts"; -import { KAGI_MODELS } from "./kagi.models.ts"; - -export function kagiProvider(): Provider<"openai-completions"> { - return createProvider({ - id: "kagi", - name: "Kagi", - baseUrl: "https://kagi.com/api/v0", - auth: { apiKey: envApiKeyAuth("Kagi API key", ["KAGI_API_KEY"]) }, - models: Object.values(KAGI_MODELS), - api: openAICompletionsApi(), - }); -} diff --git a/packages/ai/src/providers/parallel.models.ts b/packages/ai/src/providers/parallel.models.ts deleted file mode 100644 index f2b0316ac..000000000 --- a/packages/ai/src/providers/parallel.models.ts +++ /dev/null @@ -1,25 +0,0 @@ -// This file is auto-generated by scripts/generate-models.ts -// Do not edit manually - run 'npm run generate-models' to update - -import type { Model } from "../types.ts"; - -export const PARALLEL_MODELS = { - "search": { - id: "search", - name: "Parallel Search (credential placeholder)", - api: "openai-completions", - provider: "parallel", - baseUrl: "https://api.parallel.ai/v1beta", - 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: 1024, - } satisfies Model<"openai-completions">, -} as const; diff --git a/packages/ai/src/providers/parallel.ts b/packages/ai/src/providers/parallel.ts deleted file mode 100644 index 00ac72d90..000000000 --- a/packages/ai/src/providers/parallel.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; -import { envApiKeyAuth } from "../auth/helpers.ts"; -import { createProvider, type Provider } from "../models.ts"; -import { PARALLEL_MODELS } from "./parallel.models.ts"; - -export function parallelProvider(): Provider<"openai-completions"> { - return createProvider({ - id: "parallel", - name: "Parallel", - baseUrl: "https://api.parallel.ai/v1beta", - auth: { apiKey: envApiKeyAuth("Parallel API key", ["PARALLEL_API_KEY"]) }, - models: Object.values(PARALLEL_MODELS), - api: openAICompletionsApi(), - }); -} diff --git a/packages/ai/src/providers/tavily.models.ts b/packages/ai/src/providers/tavily.models.ts deleted file mode 100644 index 5ce9f9ed4..000000000 --- a/packages/ai/src/providers/tavily.models.ts +++ /dev/null @@ -1,25 +0,0 @@ -// This file is auto-generated by scripts/generate-models.ts -// Do not edit manually - run 'npm run generate-models' to update - -import type { Model } from "../types.ts"; - -export const TAVILY_MODELS = { - "search": { - id: "search", - name: "Tavily Search (credential placeholder)", - api: "openai-completions", - provider: "tavily", - baseUrl: "https://api.tavily.com", - 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: 1024, - } satisfies Model<"openai-completions">, -} as const; diff --git a/packages/ai/src/providers/tavily.ts b/packages/ai/src/providers/tavily.ts deleted file mode 100644 index 24fa9f29b..000000000 --- a/packages/ai/src/providers/tavily.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; -import { envApiKeyAuth } from "../auth/helpers.ts"; -import { createProvider, type Provider } from "../models.ts"; -import { TAVILY_MODELS } from "./tavily.models.ts"; - -export function tavilyProvider(): Provider<"openai-completions"> { - return createProvider({ - id: "tavily", - name: "Tavily", - baseUrl: "https://api.tavily.com", - auth: { apiKey: envApiKeyAuth("Tavily API key", ["TAVILY_API_KEY"]) }, - models: Object.values(TAVILY_MODELS), - api: openAICompletionsApi(), - }); -} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index e0cbd1f6c..f43ca87de 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -55,7 +55,6 @@ export type KnownProvider = | "google-vertex" | "groq" | "huggingface" - | "kagi" | "kilo" | "kimi-code" | "kimi-coding" @@ -80,12 +79,10 @@ export type KnownProvider = | "opencode-go" | "opencode-zen" | "openrouter" - | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" - | "tavily" | "together" | "venice" | "vercel-ai-gateway" diff --git a/packages/ai/src/utils/oauth/gitlab-duo.ts b/packages/ai/src/utils/oauth/gitlab-duo.ts index 6629275f9..2f961b305 100644 --- a/packages/ai/src/utils/oauth/gitlab-duo.ts +++ b/packages/ai/src/utils/oauth/gitlab-duo.ts @@ -292,4 +292,11 @@ export const gitlabDuoOAuthProvider: OAuthProviderInterface = { 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}` }, + }; + }, }; diff --git a/packages/ai/src/utils/oauth/perplexity.ts b/packages/ai/src/utils/oauth/perplexity.ts index bcb88bf59..261d6e803 100644 --- a/packages/ai/src/utils/oauth/perplexity.ts +++ b/packages/ai/src/utils/oauth/perplexity.ts @@ -111,13 +111,14 @@ async function emailOtpLogin(callbacks: OAuthLoginCallbacks): Promise { throwIfAborted(callbacks.signal); callbacks.onProgress?.("Checking for a Perplexity desktop session..."); diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/utils/oauth/types.ts index 008be405e..82117638e 100644 --- a/packages/ai/src/utils/oauth/types.ts +++ b/packages/ai/src/utils/oauth/types.ts @@ -9,6 +9,11 @@ export type OAuthCredentials = { export type OAuthProviderId = string; +export type OAuthRequestAuth = { + readonly apiKey: string; + readonly headers?: Readonly>; +}; + /** @deprecated Use OAuthProviderId instead */ export type OAuthProvider = OAuthProviderId; @@ -67,6 +72,9 @@ export interface OAuthProviderInterface { /** 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[]; } diff --git a/packages/ai/test/api-key-provider-parity.test.ts b/packages/ai/test/api-key-provider-parity.test.ts index 8099d2623..147b96758 100644 --- a/packages/ai/test/api-key-provider-parity.test.ts +++ b/packages/ai/test/api-key-provider-parity.test.ts @@ -7,17 +7,14 @@ const API_KEY_PROVIDER_IDS = [ "deepinfra", "firepass", "fugu", - "kagi", "litellm", "lm-studio", "nanogpt", "ollama", "ollama-cloud", - "parallel", "qianfan", "qwen-portal", "synthetic", - "tavily", "venice", "vllm", "zenmux", @@ -28,17 +25,14 @@ const API_KEY_ENV_VARS = { deepinfra: ["DEEPINFRA_API_KEY"], firepass: ["FIREPASS_API_KEY"], fugu: ["FUGU_API_KEY"], - kagi: ["KAGI_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"], - parallel: ["PARALLEL_API_KEY"], qianfan: ["QIANFAN_API_KEY"], "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], synthetic: ["SYNTHETIC_API_KEY"], - tavily: ["TAVILY_API_KEY"], venice: ["VENICE_API_KEY"], vllm: ["VLLM_API_KEY"], zenmux: ["ZENMUX_API_KEY"], @@ -50,7 +44,22 @@ const LOCAL_DUMMY_KEYS = { 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])); diff --git a/packages/ai/test/gitlab-duo-oauth.test.ts b/packages/ai/test/gitlab-duo-oauth.test.ts index 9a9e99394..3468e8546 100644 --- a/packages/ai/test/gitlab-duo-oauth.test.ts +++ b/packages/ai/test/gitlab-duo-oauth.test.ts @@ -81,4 +81,30 @@ describe.sequential("GitLab Duo OAuth", () => { 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", + }, + }); + }); }); diff --git a/packages/ai/test/perplexity-oauth.test.ts b/packages/ai/test/perplexity-oauth.test.ts index e2d7a2b52..ce3541f83 100644 --- a/packages/ai/test/perplexity-oauth.test.ts +++ b/packages/ai/test/perplexity-oauth.test.ts @@ -34,28 +34,41 @@ describe.sequential("Perplexity OAuth", () => { }); 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) => { + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); urls.push(url); - if (url.endsWith("/api/auth/csrf")) return jsonResponse({ csrfToken: "csrf-value" }); + 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 provider().login({ 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); @@ -75,7 +88,14 @@ describe.sequential("Perplexity OAuth", () => { "fetch", vi.fn(async (input: string | URL | Request) => { const url = String(input); - if (url.endsWith("/api/auth/csrf")) return jsonResponse({ csrfToken: "csrf-private" }); + 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); }), diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index afd677b59..5ec383c77 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,37 @@ ### Removed +## [2026.7.14] - 2026-07-14 + +### Added + +### Changed + +- Improved the bundled `eval` tool instructions and examples to teach persistent-state reuse, batch file processing, and parallel session-tool fan-out within a single cell. + +### Fixed + +- Fixed built-in web search discovery so model aliases sharing a provider endpoint no longer multiply serial fallback attempts, aborting a search stops pending native authentication discovery, and dotted private host spellings are rejected before auth or fetch ([upstream #5](https://github.com/code-yeongyu/pi-websearch/pull/5), [upstream #6](https://github.com/code-yeongyu/pi-websearch/pull/6)). + +### Removed + +## [2026.7.13] - 2026-07-13 + +### Added + +- Added a bundled persistent-kernel `eval` extension for JavaScript, Python, Ruby, and Julia, with persistent state, `agent()` and `output()` bridges, structured status streaming, bounded spillable output, rich terminal rendering, completion schemas, and cancellation-safe cleanup. + +### Changed + +- Reworked the GPT-5.6 prompt preset into a Hephaestus-parity autonomous deep worker with explicit manual QA, failure recovery, shared-worktree safeguards, and outcome-based stop rules. + +### Fixed + +- Fixed post-compaction queued input and tool continuations so they no longer deadlock, lose ownership, or surface stale continuation failures while the TUI and neo resume work. +- Fixed project-trust reloads dropping builtin and bundled extensions, including the bundled `eval` extension. + +### Removed + ## [2026.7.11] - 2026-07-11 ### Added diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 725b6f3d7..abd2a3546 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -13,7 +13,12 @@ import { type OAuthLoginCallbacks, type OAuthProviderId, } from "@earendil-works/pi-ai/compat"; -import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@earendil-works/pi-ai/oauth"; +import { + getOAuthApiKey, + getOAuthProvider, + getOAuthProviders, + type OAuthProviderInterface, +} from "@earendil-works/pi-ai/oauth"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import lockfile from "proper-lockfile"; @@ -45,6 +50,19 @@ export interface GetApiKeyOptions { includeFallback?: boolean; } +export type ResolvedCredentialAuth = { + readonly apiKey: string; + readonly headers?: Readonly>; +}; + +type RequestAuthOAuthProvider = OAuthProviderInterface & { + getRequestAuth(credentials: OAuthCredentials): Promise; +}; + +function supportsRequestAuth(provider: OAuthProviderInterface): provider is RequestAuthOAuthProvider { + return "getRequestAuth" in provider && typeof provider.getRequestAuth === "function"; +} + type LockResult = { result: T; next?: string; @@ -471,16 +489,24 @@ export class AuthStorage { * 4. Environment variable */ async getApiKey(providerId: string, options: GetApiKeyOptions = {}): Promise { + return (await this.getRequestAuth(providerId, options))?.apiKey; + } + + async getRequestAuth( + providerId: string, + options: GetApiKeyOptions = {}, + ): Promise { // Runtime override takes highest priority const runtimeKey = this.runtimeOverrides.get(providerId); if (runtimeKey) { - return runtimeKey; + return { apiKey: runtimeKey }; } const cred = this.data[providerId]; if (cred?.type === "api_key") { - return resolveConfigValue(cred.key, cred.env); + const apiKey = await resolveConfigValue(cred.key, cred.env); + return apiKey === undefined ? undefined : { apiKey }; } if (cred?.type === "oauth") { @@ -498,7 +524,7 @@ export class AuthStorage { try { const result = await this.refreshOAuthTokenWithLock(providerId); if (result) { - return result.apiKey; + return this.resolveOAuthRequestAuth(provider, result.newCredentials); } } catch (error) { this.recordError(error); @@ -508,7 +534,7 @@ export class AuthStorage { if (updatedCred?.type === "oauth" && Date.now() < updatedCred.expires) { // Another instance refreshed successfully, use those credentials - return provider.getApiKey(updatedCred); + return this.resolveOAuthRequestAuth(provider, updatedCred); } // Refresh truly failed - return undefined so model discovery skips this provider @@ -517,7 +543,7 @@ export class AuthStorage { } } else { // Token not expired, use current access token - return provider.getApiKey(cred); + return this.resolveOAuthRequestAuth(provider, cred); } } @@ -525,11 +551,20 @@ export class AuthStorage { // Fall back to environment variable const envKey = getEnvApiKey(providerId); - if (envKey) return envKey; + if (envKey) return { apiKey: envKey }; return undefined; } + private async resolveOAuthRequestAuth( + provider: OAuthProviderInterface, + credentials: OAuthCredentials, + ): Promise { + return supportsRequestAuth(provider) + ? provider.getRequestAuth(credentials) + : { apiKey: provider.getApiKey(credentials) }; + } + /** * Get all registered OAuth providers */ diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index 05638a51d..7af1558f3 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,22 @@ # changes +## Provider login and model-resolution parity (2026-07-14) + +### What changed + +- 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. + +### Why extension system couldn't handle this + +- Login selection and startup model resolution run in core before user extensions can provide a replacement catalog. + +### Expected merge conflict zones + +- MEDIUM: provider display/default maps when upstream changes model resolution or login discovery. + ## Upstream model context overflow recovery (2026-07-08) ### What changed diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index fe7c99862..4c0319538 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -925,7 +925,8 @@ export class ModelRegistry { try { const providerConfig = this.providerRequestConfigs.get(model.provider); const providerEnv = this.authStorage.getProviderEnv(model.provider); - const apiKeyFromAuthStorage = await this.authStorage.getApiKey(model.provider, { includeFallback: false }); + const storedAuth = await this.authStorage.getRequestAuth(model.provider, { includeFallback: false }); + const apiKeyFromAuthStorage = storedAuth?.apiKey; const apiKey = apiKeyFromAuthStorage ?? (providerConfig?.apiKey @@ -949,8 +950,8 @@ export class ModelRegistry { ); let headers = - model.headers || providerHeaders || modelHeaders - ? { ...model.headers, ...providerHeaders, ...modelHeaders } + storedAuth?.headers || model.headers || providerHeaders || modelHeaders + ? { ...storedAuth?.headers, ...model.headers, ...providerHeaders, ...modelHeaders } : undefined; if (providerConfig?.authHeader) { diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 919c4f983..07cfe39e7 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -36,7 +36,6 @@ export const defaultModelPerProvider: Record = { "google-vertex": "gemini-3.1-pro-preview", groq: "openai/gpt-oss-120b", huggingface: "moonshotai/Kimi-K2.6", - kagi: "search", kilo: "anthropic/claude-sonnet-4.5", "kimi-code": "kimi-k2.7-code", "kimi-coding": "kimi-for-coding", @@ -61,12 +60,10 @@ export const defaultModelPerProvider: Record = { "opencode-go": "kimi-k2.6", "opencode-zen": "kimi-k2.6", openrouter: "moonshotai/kimi-k2.6", - parallel: "search", perplexity: "sonar-pro", qianfan: "deepseek-v3.2", "qwen-portal": "coder-model", synthetic: "hf:moonshotai/Kimi-K2.5", - tavily: "search", together: "moonshotai/Kimi-K2.6", venice: "llama-3.3-70b", "vercel-ai-gateway": "zai/glm-5.1", diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index 6627f3031..bbfe474c8 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -21,7 +21,6 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { "google-vertex": "Google Vertex AI", groq: "Groq", huggingface: "Hugging Face", - kagi: "Kagi", kilo: "Kilo Gateway", "kimi-code": "Kimi Code", "kimi-coding": "Kimi For Coding", @@ -45,12 +44,10 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { "opencode-go": "OpenCode Go", "opencode-zen": "OpenCode Zen", openrouter: "OpenRouter", - parallel: "Parallel", perplexity: "Perplexity", qianfan: "Qianfan", "qwen-portal": "Qwen Portal", synthetic: "Synthetic", - tavily: "Tavily", together: "Together AI", venice: "Venice", "vercel-ai-gateway": "Vercel AI Gateway", diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 71dfe3fc4..e665cb854 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -1158,6 +1158,61 @@ describe("ModelRegistry", () => { }); }); + test("applies provider-specific OAuth request auth from legacy storage", async () => { + // Given: an OAuth provider whose request credential requires an exchanged token and headers. + const providerId = `oauth-request-auth-${Date.now()}`; + const oauthProvider = { + id: providerId, + name: "OAuth request auth fixture", + async login() { + throw new Error("Not used in this test"); + }, + async refreshToken(credentials: { access: string; expires: number; refresh: string }) { + return credentials; + }, + getApiKey: (credentials: { access: string }) => credentials.access, + getRequestAuth: async () => ({ + apiKey: "direct-access-token", + headers: { "x-provider-instance": "instance-a" }, + }), + }; + authStorage.set(providerId, { + access: "oauth-access-token", + expires: Date.now() + 60_000, + refresh: "oauth-refresh-token", + type: "oauth", + }); + const registry = ModelRegistry.create(authStorage, modelsJsonPath); + registry.registerProvider(providerId, { + api: "openai-completions", + baseUrl: "https://provider.test/v1", + oauth: oauthProvider, + models: [ + { + contextWindow: 1_000, + cost: { cacheRead: 0, cacheWrite: 0, input: 0, output: 0 }, + id: "fixture-model", + input: ["text"], + maxTokens: 100, + name: "Fixture model", + reasoning: false, + }, + ], + }); + const model = registry.find(providerId, "fixture-model"); + if (model === undefined) throw new Error("Fixture model was not registered"); + + // When: request authentication is resolved for the model. + const auth = await registry.getApiKeyAndHeaders(model); + + // Then: the exchanged token and required provider headers replace the raw OAuth access token. + expect(auth).toMatchObject({ + apiKey: "direct-access-token", + headers: { "x-provider-instance": "instance-a" }, + ok: true, + }); + }); + test("registerProvider treats uppercase apiKey and headers as literals", async () => { const envKeys = ["CUSTOM_NAME", "BEARER", "MODEL_TOKEN"]; const savedEnv: Record = {}; From eda7aba9cf12acea39ebb17d44d39446859a60a8 Mon Sep 17 00:00:00 2001 From: islee Date: Sun, 19 Jul 2026 04:53:47 +0900 Subject: [PATCH 05/11] =?UTF-8?q?fix(ai):=20address=20PR=20#193=20review?= =?UTF-8?q?=20=E2=80=94=20re-port=20OAuth,=20protocol=20P1s,=20drop=20GLM?= =?UTF-8?q?=20ZCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-port OAuth flows from utils/oauth to auth/oauth with bundledLoaders, route Cursor through Connect/protobuf ChatService, fix GitLab PAT exchange and gpt-5.1 chat-completions routing, delist Perplexity session OAuth from /login, exclude Google CCA providers from API-key login, remove unofficial GLM ZCode login, keep browser smoke Node-free, and update stale kimi-coding live-test model ids. --- packages/ai/scripts/generate-models.ts | 25 +- packages/ai/src/api/cursor-connect.lazy.ts | 5 + packages/ai/src/api/cursor-connect.ts | 503 ++++++++++++++++++ .../ai/src/{utils => auth}/oauth/anthropic.ts | 4 +- .../ai/src/{utils => auth}/oauth/cursor.ts | 2 +- .../src/{utils => auth}/oauth/device-code.ts | 0 .../{utils => auth}/oauth/github-copilot.ts | 2 +- .../auth/oauth/gitlab-duo-direct-access.ts | 59 ++ .../src/{utils => auth}/oauth/gitlab-duo.ts | 50 +- .../oauth/google-antigravity.ts | 0 .../oauth/google-gemini-cli.ts | 0 .../oauth/google-oauth-node.ts | 0 .../oauth/google-oauth-shared.ts | 2 +- .../ai/src/{utils => auth}/oauth/index.ts | 8 +- packages/ai/src/{utils => auth}/oauth/kilo.ts | 2 +- .../ai/src/{utils => auth}/oauth/kimi-code.ts | 25 +- packages/ai/src/auth/oauth/load.ts | 107 ++++ .../src/{utils => auth}/oauth/oauth-page.ts | 0 .../oauth/openai-codex-device.ts | 2 +- .../src/{utils => auth}/oauth/openai-codex.ts | 4 +- .../src/{utils => auth}/oauth/perplexity.ts | 2 +- packages/ai/src/{utils => auth}/oauth/pkce.ts | 0 .../ai/src/{utils => auth}/oauth/radius.ts | 47 ++ .../ai/src/{utils => auth}/oauth/types.ts | 0 packages/ai/src/{utils => auth}/oauth/xai.ts | 2 +- packages/ai/src/auth/types.ts | 2 +- packages/ai/src/bun-oauth.ts | 33 ++ packages/ai/src/cli.ts | 4 +- packages/ai/src/env-api-keys.ts | 1 - packages/ai/src/index.ts | 20 +- packages/ai/src/models.generated.ts | 2 - packages/ai/src/oauth.ts | 2 +- packages/ai/src/providers/all.ts | 3 +- packages/ai/src/providers/anthropic.ts | 2 +- packages/ai/src/providers/cursor.models.ts | 13 +- packages/ai/src/providers/cursor.ts | 8 +- .../ai/src/providers/github-copilot.models.ts | 6 +- packages/ai/src/providers/github-copilot.ts | 2 +- .../ai/src/providers/gitlab-duo.models.ts | 23 +- packages/ai/src/providers/gitlab-duo.ts | 56 +- packages/ai/src/providers/glm-zcode.models.ts | 28 - packages/ai/src/providers/glm-zcode.ts | 19 - .../ai/src/providers/google-gemini-cli.ts | 2 +- packages/ai/src/providers/kilo.models.ts | 7 +- packages/ai/src/providers/kilo.ts | 2 +- packages/ai/src/providers/kimi-code.ts | 2 +- .../ai/src/providers/kimi-coding.models.ts | 18 +- packages/ai/src/providers/moonshot.models.ts | 18 + .../ai/src/providers/moonshotai-cn.models.ts | 18 + .../ai/src/providers/moonshotai.models.ts | 18 + .../ai/src/providers/openai-codex-device.ts | 2 +- packages/ai/src/providers/openai-codex.ts | 2 +- .../ai/src/providers/opencode-go.models.ts | 48 +- .../ai/src/providers/opencode-zen.models.ts | 19 + .../ai/src/providers/openrouter.models.ts | 386 ++++++++------ .../ai/src/providers/perplexity.models.ts | 7 +- packages/ai/src/providers/perplexity.ts | 9 +- packages/ai/src/providers/together.models.ts | 19 + .../src/providers/vercel-ai-gateway.models.ts | 106 ++-- packages/ai/src/providers/xai.ts | 2 +- packages/ai/src/types.ts | 4 +- packages/ai/src/utils/oauth/glm-zcode.ts | 501 ----------------- packages/ai/src/utils/oauth/load.ts | 53 -- packages/ai/test/abort.test.ts | 2 +- packages/ai/test/anthropic-oauth.test.ts | 2 +- packages/ai/test/context-overflow.test.ts | 4 +- .../ai/test/cross-provider-handoff.test.ts | 2 +- packages/ai/test/cursor-connect.test.ts | 69 +++ packages/ai/test/cursor-oauth.test.ts | 4 +- packages/ai/test/empty.test.ts | 2 +- packages/ai/test/github-copilot-oauth.test.ts | 4 +- packages/ai/test/gitlab-duo-models.test.ts | 14 + packages/ai/test/gitlab-duo-oauth.test.ts | 28 +- packages/ai/test/glm-zcode-oauth.test.ts | 126 ----- packages/ai/test/google-account-oauth.test.ts | 12 +- packages/ai/test/image-tool-result.test.ts | 2 +- packages/ai/test/kilo-oauth.test.ts | 4 +- packages/ai/test/kimi-code-oauth.test.ts | 4 +- packages/ai/test/oauth-auth.test.ts | 6 +- packages/ai/test/oauth-device-code.test.ts | 2 +- packages/ai/test/oauth.ts | 4 +- .../ai/test/openai-codex-device-oauth.test.ts | 4 +- packages/ai/test/openai-codex-oauth.test.ts | 2 +- packages/ai/test/perplexity-oauth.test.ts | 39 +- packages/ai/test/stream.test.ts | 39 +- packages/ai/test/tokens.test.ts | 2 +- .../ai/test/tool-call-without-result.test.ts | 2 +- packages/ai/test/total-tokens.test.ts | 4 +- packages/ai/test/unicode-surrogate.test.ts | 2 +- packages/ai/test/xai-oauth.test.ts | 8 +- .../custom-provider-anthropic/index.ts | 2 +- .../coding-agent/src/core/auth-providers.ts | 2 +- .../coding-agent/src/core/model-resolver.ts | 1 - .../src/core/provider-display-names.ts | 1 - .../coding-agent/test/oauth-selector.test.ts | 4 + 95 files changed, 1545 insertions(+), 1176 deletions(-) create mode 100644 packages/ai/src/api/cursor-connect.lazy.ts create mode 100644 packages/ai/src/api/cursor-connect.ts rename packages/ai/src/{utils => auth}/oauth/anthropic.ts (99%) rename packages/ai/src/{utils => auth}/oauth/cursor.ts (99%) rename packages/ai/src/{utils => auth}/oauth/device-code.ts (100%) rename packages/ai/src/{utils => auth}/oauth/github-copilot.ts (99%) create mode 100644 packages/ai/src/auth/oauth/gitlab-duo-direct-access.ts rename packages/ai/src/{utils => auth}/oauth/gitlab-duo.ts (82%) rename packages/ai/src/{utils => auth}/oauth/google-antigravity.ts (100%) rename packages/ai/src/{utils => auth}/oauth/google-gemini-cli.ts (100%) rename packages/ai/src/{utils => auth}/oauth/google-oauth-node.ts (100%) rename packages/ai/src/{utils => auth}/oauth/google-oauth-shared.ts (99%) rename packages/ai/src/{utils => auth}/oauth/index.ts (95%) rename packages/ai/src/{utils => auth}/oauth/kilo.ts (98%) rename packages/ai/src/{utils => auth}/oauth/kimi-code.ts (89%) create mode 100644 packages/ai/src/auth/oauth/load.ts rename packages/ai/src/{utils => auth}/oauth/oauth-page.ts (100%) rename packages/ai/src/{utils => auth}/oauth/openai-codex-device.ts (95%) rename packages/ai/src/{utils => auth}/oauth/openai-codex.ts (99%) rename packages/ai/src/{utils => auth}/oauth/perplexity.ts (99%) rename packages/ai/src/{utils => auth}/oauth/pkce.ts (100%) rename packages/ai/src/{utils => auth}/oauth/radius.ts (90%) rename packages/ai/src/{utils => auth}/oauth/types.ts (100%) rename packages/ai/src/{utils => auth}/oauth/xai.ts (99%) create mode 100644 packages/ai/src/bun-oauth.ts delete mode 100644 packages/ai/src/providers/glm-zcode.models.ts delete mode 100644 packages/ai/src/providers/glm-zcode.ts delete mode 100644 packages/ai/src/utils/oauth/glm-zcode.ts delete mode 100644 packages/ai/src/utils/oauth/load.ts create mode 100644 packages/ai/test/cursor-connect.test.ts create mode 100644 packages/ai/test/gitlab-duo-models.test.ts delete mode 100644 packages/ai/test/glm-zcode-oauth.test.ts diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 8c1f16c6b..58183fcb6 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -2637,7 +2637,7 @@ async function generateModels() { { id: "default", name: "Auto", - api: "openai-completions", + api: "cursor-connect", provider: "cursor", baseUrl: "https://api2.cursor.sh", reasoning: false, @@ -2662,7 +2662,7 @@ async function generateModels() { { id: "gpt-5.1-2025-11-13", name: "GPT-5.1", - api: "openai-responses", + api: "openai-completions", provider: "gitlab-duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", reasoning: true, @@ -2695,27 +2695,6 @@ async function generateModels() { contextWindow: 200000, maxTokens: 64000, }, - { - id: "glm-5.2", - name: "GLM-5.2 (ZCode)", - api: "anthropic-messages", - provider: "glm-zcode", - baseUrl: "https://api.z.ai/api/anthropic", - headers: { - "User-Agent": "ZCode/3.1.2", - "X-Title": "Z Code@electron", - "HTTP-Referer": "https://zcode.z.ai", - "X-ZCode-Agent": "glm", - "X-ZCode-App-Version": "3.1.2", - "X-Release-Channel": "production", - }, - reasoning: true, - thinkingLevelMap: { minimal: null, low: "high", medium: "high", high: "high", max: "max" }, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 131072, - }, ]; allModels.push(...oauthParityModels); 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/utils/oauth/anthropic.ts b/packages/ai/src/auth/oauth/anthropic.ts similarity index 99% rename from packages/ai/src/utils/oauth/anthropic.ts rename to packages/ai/src/auth/oauth/anthropic.ts index 591e9cde1..19b7c0605 100644 --- a/packages/ai/src/utils/oauth/anthropic.ts +++ b/packages/ai/src/auth/oauth/anthropic.ts @@ -6,8 +6,8 @@ */ import type { Server } from "node:http"; -import type { OAuthAuth } from "../../auth/types.ts"; -import { getProviderEnvValue } from "../provider-env.ts"; +import { getProviderEnvValue } from "../../utils/provider-env.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"; diff --git a/packages/ai/src/utils/oauth/cursor.ts b/packages/ai/src/auth/oauth/cursor.ts similarity index 99% rename from packages/ai/src/utils/oauth/cursor.ts rename to packages/ai/src/auth/oauth/cursor.ts index b4646787d..cc26a790f 100644 --- a/packages/ai/src/utils/oauth/cursor.ts +++ b/packages/ai/src/auth/oauth/cursor.ts @@ -1,4 +1,4 @@ -import type { OAuthAuth } from "../../auth/types.ts"; +import type { OAuthAuth } from "../types.ts"; import { generatePKCE } from "./pkce.ts"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; diff --git a/packages/ai/src/utils/oauth/device-code.ts b/packages/ai/src/auth/oauth/device-code.ts similarity index 100% rename from packages/ai/src/utils/oauth/device-code.ts rename to packages/ai/src/auth/oauth/device-code.ts diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/auth/oauth/github-copilot.ts similarity index 99% rename from packages/ai/src/utils/oauth/github-copilot.ts rename to packages/ai/src/auth/oauth/github-copilot.ts index 01f869578..b4ad6aa7f 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/auth/oauth/github-copilot.ts @@ -2,9 +2,9 @@ * GitHub Copilot OAuth flow */ -import type { OAuthAuth, OAuthCredential } from "../../auth/types.ts"; import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.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"; 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/utils/oauth/gitlab-duo.ts b/packages/ai/src/auth/oauth/gitlab-duo.ts similarity index 82% rename from packages/ai/src/utils/oauth/gitlab-duo.ts rename to packages/ai/src/auth/oauth/gitlab-duo.ts index 2f961b305..6b9f950ed 100644 --- a/packages/ai/src/utils/oauth/gitlab-duo.ts +++ b/packages/ai/src/auth/oauth/gitlab-duo.ts @@ -1,4 +1,5 @@ -import type { OAuthAuth } from "../../auth/types.ts"; +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"; @@ -10,14 +11,8 @@ const CALLBACK_PORT = 8080; const SCOPE = "api"; const REFRESH_SKEW_MS = 5 * 60_000; const REQUEST_TIMEOUT_MS = 30_000; -const DIRECT_ACCESS_TTL_MS = 25 * 60_000; - type Server = import("node:http").Server; type AuthorizationInput = { code?: string; state?: string }; -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; @@ -83,47 +78,10 @@ async function tokenRequest( } if (!response.ok) throw new Error(`GitLab OAuth token request failed (${response.status})`); const credentials = tokenCredentials((await response.json()) as Record, refreshFallback); - directAccessCache.clear(); + clearGitLabDuoDirectAccessCache(); return credentials; } -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; -} - export async function exchangeGitLabDuoAuthorizationCode( code: string, verifier: string, @@ -300,3 +258,5 @@ export const gitlabDuoOAuthProvider: OAuthProviderInterface = { }; }, }; + +export { getGitLabDuoDirectAccess } from "./gitlab-duo-direct-access.ts"; diff --git a/packages/ai/src/utils/oauth/google-antigravity.ts b/packages/ai/src/auth/oauth/google-antigravity.ts similarity index 100% rename from packages/ai/src/utils/oauth/google-antigravity.ts rename to packages/ai/src/auth/oauth/google-antigravity.ts diff --git a/packages/ai/src/utils/oauth/google-gemini-cli.ts b/packages/ai/src/auth/oauth/google-gemini-cli.ts similarity index 100% rename from packages/ai/src/utils/oauth/google-gemini-cli.ts rename to packages/ai/src/auth/oauth/google-gemini-cli.ts diff --git a/packages/ai/src/utils/oauth/google-oauth-node.ts b/packages/ai/src/auth/oauth/google-oauth-node.ts similarity index 100% rename from packages/ai/src/utils/oauth/google-oauth-node.ts rename to packages/ai/src/auth/oauth/google-oauth-node.ts diff --git a/packages/ai/src/utils/oauth/google-oauth-shared.ts b/packages/ai/src/auth/oauth/google-oauth-shared.ts similarity index 99% rename from packages/ai/src/utils/oauth/google-oauth-shared.ts rename to packages/ai/src/auth/oauth/google-oauth-shared.ts index 305099b4a..393153e7c 100644 --- a/packages/ai/src/utils/oauth/google-oauth-shared.ts +++ b/packages/ai/src/auth/oauth/google-oauth-shared.ts @@ -1,4 +1,4 @@ -import type { OAuthAuth } from "../../auth/types.ts"; +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"; diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/auth/oauth/index.ts similarity index 95% rename from packages/ai/src/utils/oauth/index.ts rename to packages/ai/src/auth/oauth/index.ts index 123d2ec7e..e20a14c90 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/auth/oauth/index.ts @@ -20,7 +20,6 @@ export { refreshGitHubCopilotToken, } from "./github-copilot.ts"; export * from "./gitlab-duo.ts"; -export * from "./glm-zcode.ts"; export { googleAntigravityOAuthProvider, loginAntigravity, refreshAntigravityToken } from "./google-antigravity.ts"; export { googleGeminiCliOAuthProvider, loginGeminiCli, refreshGoogleCloudToken } from "./google-gemini-cli.ts"; export * from "./kilo.ts"; @@ -35,7 +34,6 @@ export { refreshOpenAICodexToken, } from "./openai-codex.ts"; export * from "./openai-codex-device.ts"; -export * from "./perplexity.ts"; // Radius (pi-messages gateway) export { @@ -59,19 +57,17 @@ export { // Provider Registry // ============================================================================ -import { getProviderEnvValue } from "../provider-env.ts"; +import { getProviderEnvValue } from "../../utils/provider-env.ts"; import { anthropicOAuthProvider } from "./anthropic.ts"; import { cursorOAuthProvider } from "./cursor.ts"; import { githubCopilotOAuthProvider } from "./github-copilot.ts"; import { gitlabDuoOAuthProvider } from "./gitlab-duo.ts"; -import { glmZcodeOAuthProvider } from "./glm-zcode.ts"; import { googleAntigravityOAuthProvider } from "./google-antigravity.ts"; import { googleGeminiCliOAuthProvider } from "./google-gemini-cli.ts"; import { kiloOAuthProvider } from "./kilo.ts"; import { kimiCodeOAuthProvider } from "./kimi-code.ts"; import { openaiCodexOAuthProvider } from "./openai-codex.ts"; import { openaiCodexDeviceOAuthProvider } from "./openai-codex-device.ts"; -import { perplexityOAuthProvider } from "./perplexity.ts"; import { createRadiusOAuthProvider, DEFAULT_RADIUS_GATEWAY } from "./radius.ts"; import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.ts"; import { xaiOAuthProvider } from "./xai.ts"; @@ -89,9 +85,7 @@ const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [ kimiCodeOAuthProvider, cursorOAuthProvider, gitlabDuoOAuthProvider, - perplexityOAuthProvider, kiloOAuthProvider, - glmZcodeOAuthProvider, xaiOAuthProvider, googleGeminiCliOAuthProvider, googleAntigravityOAuthProvider, diff --git a/packages/ai/src/utils/oauth/kilo.ts b/packages/ai/src/auth/oauth/kilo.ts similarity index 98% rename from packages/ai/src/utils/oauth/kilo.ts rename to packages/ai/src/auth/oauth/kilo.ts index e58fb19b5..2740d2b71 100644 --- a/packages/ai/src/utils/oauth/kilo.ts +++ b/packages/ai/src/auth/oauth/kilo.ts @@ -1,4 +1,4 @@ -import type { OAuthAuth } from "../../auth/types.ts"; +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"; diff --git a/packages/ai/src/utils/oauth/kimi-code.ts b/packages/ai/src/auth/oauth/kimi-code.ts similarity index 89% rename from packages/ai/src/utils/oauth/kimi-code.ts rename to packages/ai/src/auth/oauth/kimi-code.ts index 859939ece..2e9bbd7ac 100644 --- a/packages/ai/src/utils/oauth/kimi-code.ts +++ b/packages/ai/src/auth/oauth/kimi-code.ts @@ -1,5 +1,26 @@ -import type { OAuthAuth } from "../../auth/types.ts"; -import { getProviderEnvValue } from "../provider-env.ts"; +/** + * Kimi Code OAuth flow (device-code against auth.kimi.com). + * + * Kimi reconciliation (reviewer point #3): + * `kimi-coding` (main) and `kimi-code` (this PR) are the SAME product — Kimi + * For Coding at api.kimi.com/coding, same `KIMI_API_KEY`, same `KimiCLI/1.5` + * User-Agent, OAuth host auth.kimi.com. They differ only in API flavor: + * `kimi-coding` speaks the Anthropic-messages API; `kimi-code` speaks the + * OpenAI-completions API (baseUrl .../coding/v1). Because a single provider + * entry is bound to one `api`/`baseUrl`, they cannot collapse into one + * provider without dropping a flavor, so both provider entries are kept. + * + * Login entry decision: the OAuth flow lives here on `kimi-code` (the + * OpenAI-flavor provider). The OAuth `access` token is the bearer the + * `kimi-coding` Anthropic-flavor endpoint also accepts, so one login + * credential could serve both; wiring OAuth into `kimi-coding` (currently + * api-key-only via `envApiKeyAuth`) is deferred to a follow-up that owns the + * shared-credential architecture. Until then, `kimi-coding` keeps api-key + * auth and `kimi-code` owns OAuth — two providers, one product. + */ + +import { getProviderEnvValue } from "../../utils/provider-env.ts"; +import type { OAuthAuth } from "../types.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; diff --git a/packages/ai/src/auth/oauth/load.ts b/packages/ai/src/auth/oauth/load.ts new file mode 100644 index 000000000..8e0911833 --- /dev/null +++ b/packages/ai/src/auth/oauth/load.ts @@ -0,0 +1,107 @@ +import type { OAuthAuth } from "../types.ts"; + +/** + * Loads an OAuth flow module through a variable specifier so bundlers cannot + * follow the import into Node-only flow code (`node:http` callback servers, + * `node:crypto` PKCE). The `.ts`/`.js` rewrite keeps the trick working from + * both source and built output. + */ +const importOAuthModule = (specifier: string): Promise => { + const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier; + return import(runtimeSpecifier); +}; + +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; + kimiCode: () => OAuthAuth | Promise; + xai: () => OAuthAuth | Promise; + googleGeminiCli: () => OAuthAuth | Promise; + googleAntigravity: () => OAuthAuth | Promise; + radius: (options: { id: string; name: string; gateway: string }) => OAuthAuth | Promise; +}; + +let bundledLoaders: OAuthFlowLoaders | undefined; + +/** Registers statically bundled OAuth flows for standalone Bun binaries. */ +export function registerBundledOAuthFlowLoaders(loaders: OAuthFlowLoaders): void { + bundledLoaders = loaders; +} + +export const loadAnthropicOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.anthropic(); + return ((await importOAuthModule("./anthropic.ts")) as { anthropicOAuth: OAuthAuth }).anthropicOAuth; +}; + +export const loadOpenAICodexOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.openaiCodex(); + 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 loadKimiCodeOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.kimiCode(); + return ((await importOAuthModule("./kimi-code.ts")) as { kimiCodeOAuth: OAuthAuth }).kimiCodeOAuth; +}; + +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: { id: string; name: string; gateway: string }): Promise => { + if (bundledLoaders) return bundledLoaders.radius(options); + return ( + (await importOAuthModule("./radius.ts")) as { + createRadiusOAuth: (input: { id: string; name: string; gateway: string }) => OAuthAuth; + } + ).createRadiusOAuth(options); +}; diff --git a/packages/ai/src/utils/oauth/oauth-page.ts b/packages/ai/src/auth/oauth/oauth-page.ts similarity index 100% rename from packages/ai/src/utils/oauth/oauth-page.ts rename to packages/ai/src/auth/oauth/oauth-page.ts diff --git a/packages/ai/src/utils/oauth/openai-codex-device.ts b/packages/ai/src/auth/oauth/openai-codex-device.ts similarity index 95% rename from packages/ai/src/utils/oauth/openai-codex-device.ts rename to packages/ai/src/auth/oauth/openai-codex-device.ts index 156db9563..d96a3a662 100644 --- a/packages/ai/src/utils/oauth/openai-codex-device.ts +++ b/packages/ai/src/auth/oauth/openai-codex-device.ts @@ -1,4 +1,4 @@ -import type { OAuthAuth } from "../../auth/types.ts"; +import type { OAuthAuth } from "../types.ts"; import { loginOpenAICodexDeviceCode, refreshOpenAICodexToken } from "./openai-codex.ts"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/auth/oauth/openai-codex.ts similarity index 99% rename from packages/ai/src/utils/oauth/openai-codex.ts rename to packages/ai/src/auth/oauth/openai-codex.ts index 6b25fc9f2..7bee5d18e 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/auth/oauth/openai-codex.ts @@ -17,8 +17,8 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } -import type { OAuthAuth } from "../../auth/types.ts"; -import { getProviderEnvValue } from "../provider-env.ts"; +import { getProviderEnvValue } from "../../utils/provider-env.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"; diff --git a/packages/ai/src/utils/oauth/perplexity.ts b/packages/ai/src/auth/oauth/perplexity.ts similarity index 99% rename from packages/ai/src/utils/oauth/perplexity.ts rename to packages/ai/src/auth/oauth/perplexity.ts index 261d6e803..883e8febd 100644 --- a/packages/ai/src/utils/oauth/perplexity.ts +++ b/packages/ai/src/auth/oauth/perplexity.ts @@ -1,4 +1,4 @@ -import type { OAuthAuth } from "../../auth/types.ts"; +import type { OAuthAuth } from "../types.ts"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; const API_VERSION = "2.18"; diff --git a/packages/ai/src/utils/oauth/pkce.ts b/packages/ai/src/auth/oauth/pkce.ts similarity index 100% rename from packages/ai/src/utils/oauth/pkce.ts rename to packages/ai/src/auth/oauth/pkce.ts diff --git a/packages/ai/src/utils/oauth/radius.ts b/packages/ai/src/auth/oauth/radius.ts similarity index 90% rename from packages/ai/src/utils/oauth/radius.ts rename to packages/ai/src/auth/oauth/radius.ts index 077619ec3..af0c6cb8d 100644 --- a/packages/ai/src/utils/oauth/radius.ts +++ b/packages/ai/src/auth/oauth/radius.ts @@ -19,6 +19,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version } import type { Api, Model, ThinkingLevelMap } 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"; @@ -555,3 +556,49 @@ export function createRadiusOAuthProvider(options: RadiusOAuthProviderOptions): }, }; } + +/** + * Builds an `OAuthAuth` adapter around the legacy `OAuthProviderInterface` + * flow so Radius can be registered in `bundledLoaders`/`bun-oauth.ts` the same + * way as the other OAuth flows. Mirrors the `OAuthAuth.login` adapters in + * `anthropic.ts`/`cursor.ts` that bridge `AuthLoginCallbacks` to the legacy + * `OAuthLoginCallbacks` (`onAuth`/`onPrompt`/`onSelect`/`onDeviceCode`). + */ +export function createRadiusOAuth(options: RadiusOAuthProviderOptions): OAuthAuth { + const provider = createRadiusOAuthProvider(options); + return { + name: options.name, + async login(callbacks) { + const credentials = await provider.login({ + onAuth: (info) => callbacks.notify({ type: "auth_url", url: info.url, instructions: info.instructions }), + onDeviceCode: (info) => + callbacks.notify({ + type: "device_code", + userCode: info.userCode, + verificationUri: info.verificationUri, + intervalSeconds: info.intervalSeconds, + expiresInSeconds: info.expiresInSeconds, + }), + onProgress: (message) => callbacks.notify({ type: "progress", message }), + onPrompt: (prompt) => + callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }), + onSelect: async (prompt) => { + const selected = await callbacks.prompt({ + type: "select", + message: prompt.message, + options: prompt.options, + }); + return selected || undefined; + }, + signal: callbacks.signal, + }); + return { ...credentials, type: "oauth" }; + }, + async refresh(credential) { + return { ...(await provider.refreshToken(credential)), type: "oauth" }; + }, + async toAuth(credential) { + return { apiKey: provider.getApiKey(credential) }; + }, + }; +} diff --git a/packages/ai/src/utils/oauth/types.ts b/packages/ai/src/auth/oauth/types.ts similarity index 100% rename from packages/ai/src/utils/oauth/types.ts rename to packages/ai/src/auth/oauth/types.ts diff --git a/packages/ai/src/utils/oauth/xai.ts b/packages/ai/src/auth/oauth/xai.ts similarity index 99% rename from packages/ai/src/utils/oauth/xai.ts rename to packages/ai/src/auth/oauth/xai.ts index c79aada26..b882f020c 100644 --- a/packages/ai/src/utils/oauth/xai.ts +++ b/packages/ai/src/auth/oauth/xai.ts @@ -1,4 +1,4 @@ -import type { OAuthAuth } from "../../auth/types.ts"; +import type { OAuthAuth } from "../types.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; diff --git a/packages/ai/src/auth/types.ts b/packages/ai/src/auth/types.ts index 9710dbcbc..5ac5693f9 100644 --- a/packages/ai/src/auth/types.ts +++ b/packages/ai/src/auth/types.ts @@ -1,5 +1,5 @@ import type { Api, ImagesApi, ImagesModel, Model, ProviderEnv, ProviderHeaders } from "../types.ts"; -import type { OAuthCredentials } from "../utils/oauth/types.ts"; +import type { OAuthCredentials } from "./oauth/types.ts"; /** * Request auth for a single model request. If a value cannot be expressed as diff --git a/packages/ai/src/bun-oauth.ts b/packages/ai/src/bun-oauth.ts new file mode 100644 index 000000000..f02e804c0 --- /dev/null +++ b/packages/ai/src/bun-oauth.ts @@ -0,0 +1,33 @@ +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 { kimiCodeOAuth } from "./auth/oauth/kimi-code.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"; + +/** Register OAuth flows statically embedded in the standalone Bun binary. */ +export function registerBunOAuthFlows(): void { + registerBundledOAuthFlowLoaders({ + anthropic: () => anthropicOAuth, + openaiCodex: () => openaiCodexOAuth, + openaiCodexDevice: () => openaiCodexDeviceOAuth, + githubCopilot: () => githubCopilotOAuth, + cursor: () => cursorOAuth, + gitlabDuo: () => gitlabDuoOAuth, + perplexity: () => perplexityOAuth, + kilo: () => kiloOAuth, + kimiCode: () => kimiCodeOAuth, + xai: () => xaiOAuth, + googleGeminiCli: () => googleGeminiCliOAuth, + googleAntigravity: () => googleAntigravityOAuth, + radius: createRadiusOAuth, + }); +} diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index 21699dbdb..39530d96a 100644 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -2,8 +2,8 @@ import { createInterface } from "node:readline"; import { existsSync, readFileSync, writeFileSync } from "fs"; -import { getOAuthProvider, getOAuthProviders } from "./utils/oauth/index.ts"; -import type { OAuthCredentials, OAuthProviderId } from "./utils/oauth/types.ts"; +import { getOAuthProvider, getOAuthProviders } from "./auth/oauth/index.ts"; +import type { OAuthCredentials, OAuthProviderId } from "./auth/oauth/types.ts"; const AUTH_FILE = "auth.json"; const PROVIDERS = getOAuthProviders(); diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 1233130a4..4783f0913 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -95,7 +95,6 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { fireworks: "FIREWORKS_API_KEY", fugu: "FUGU_API_KEY", "gitlab-duo": "GITLAB_TOKEN", - "glm-zcode": "GLM_ZCODE_API_KEY", google: "GEMINI_API_KEY", "google-vertex": "GOOGLE_CLOUD_API_KEY", groq: "GROQ_API_KEY", diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 3bb2f9344..16cdcce39 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -21,15 +21,6 @@ export type { PiMessagesEvent, PiMessagesOptions, PiMessagesRewriteImpact } from export * from "./auth/context.ts"; export * from "./auth/credential-store.ts"; export * from "./auth/helpers.ts"; -export * from "./auth/types.ts"; -export * from "./images-models.ts"; -export * from "./models.ts"; -export * from "./providers/faux.ts"; -export * from "./session-resources.ts"; -export * from "./types.ts"; -export * from "./utils/diagnostics.ts"; -export * from "./utils/event-stream.ts"; -export * from "./utils/json-parse.ts"; export type { OAuthAuthInfo, OAuthCredentials, @@ -42,7 +33,16 @@ export type { OAuthProviderInterface, OAuthSelectOption, OAuthSelectPrompt, -} from "./utils/oauth/types.ts"; +} from "./auth/oauth/types.ts"; +export * from "./auth/types.ts"; +export * from "./images-models.ts"; +export * from "./models.ts"; +export * from "./providers/faux.ts"; +export * from "./session-resources.ts"; +export * from "./types.ts"; +export * from "./utils/diagnostics.ts"; +export * from "./utils/event-stream.ts"; +export * from "./utils/json-parse.ts"; export * from "./utils/overflow.ts"; export * from "./utils/retry.ts"; export * from "./utils/tool-pair-repair.ts"; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 9a4713739..efce7abb6 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -17,7 +17,6 @@ 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 { GLM_ZCODE_MODELS } from "./providers/glm-zcode.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"; @@ -82,7 +81,6 @@ export const MODELS = { "fugu": FUGU_MODELS, "github-copilot": GITHUB_COPILOT_MODELS, "gitlab-duo": GITLAB_DUO_MODELS, - "glm-zcode": GLM_ZCODE_MODELS, "google": GOOGLE_MODELS, "google-antigravity": GOOGLE_ANTIGRAVITY_MODELS, "google-gemini-cli": GOOGLE_GEMINI_CLI_MODELS, diff --git a/packages/ai/src/oauth.ts b/packages/ai/src/oauth.ts index 487816d8f..9fcdf9390 100644 --- a/packages/ai/src/oauth.ts +++ b/packages/ai/src/oauth.ts @@ -1 +1 @@ -export * from "./utils/oauth/index.ts"; +export * from "./auth/oauth/index.ts"; diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index d601b6e66..a92cfd718 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -18,7 +18,6 @@ import { fireworksProvider } from "./fireworks.ts"; import { fuguProvider } from "./fugu.ts"; import { githubCopilotProvider } from "./github-copilot.ts"; import { gitlabDuoProvider } from "./gitlab-duo.ts"; -import { glmZcodeProvider } from "./glm-zcode.ts"; import { googleProvider } from "./google.ts"; import { googleAntigravityProvider, googleGeminiCliProvider } from "./google-gemini-cli.ts"; import { googleVertexProvider } from "./google-vertex.ts"; @@ -156,7 +155,7 @@ export function builtinProviders(): Provider[] { fuguProvider(), githubCopilotProvider(), gitlabDuoProvider(), - glmZcodeProvider(), + googleProvider(), googleProvider(), googleGeminiCliProvider(), googleAntigravityProvider(), diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index c308d80b9..6548f9261 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2,9 +2,9 @@ import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; import type { AnthropicOptions } from "../api/anthropic-messages.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { loadAnthropicOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; import type { SimpleStreamOptions, StreamFunction } from "../types.ts"; -import { loadAnthropicOAuth } from "../utils/oauth/load.ts"; import { ANTHROPIC_MODELS } from "./anthropic.models.ts"; const anthropicStreams = anthropicMessagesApi(); diff --git a/packages/ai/src/providers/cursor.models.ts b/packages/ai/src/providers/cursor.models.ts index 464e8bd63..06e04032c 100644 --- a/packages/ai/src/providers/cursor.models.ts +++ b/packages/ai/src/providers/cursor.models.ts @@ -4,16 +4,21 @@ import type { Model } from "../types.ts"; export const CURSOR_MODELS = { - default: { + "default": { id: "default", name: "Auto", - api: "openai-completions", + api: "cursor-connect", provider: "cursor", baseUrl: "https://api2.cursor.sh", reasoning: false, input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, contextWindow: 200000, maxTokens: 64000, - } satisfies Model<"openai-completions">, + } satisfies Model<"cursor-connect">, } as const; diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 847022caf..9373c6c02 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -1,10 +1,10 @@ -import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +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 { loadCursorOAuth } from "../utils/oauth/load.ts"; import { CURSOR_MODELS } from "./cursor.models.ts"; -export function cursorProvider(): Provider<"openai-completions"> { +export function cursorProvider(): Provider<"cursor-connect"> { return createProvider({ id: "cursor", name: "Cursor", @@ -14,6 +14,6 @@ export function cursorProvider(): Provider<"openai-completions"> { oauth: lazyOAuth({ name: "Cursor (Claude, GPT, etc.)", load: loadCursorOAuth }), }, models: Object.values(CURSOR_MODELS), - api: openAICompletionsApi(), + api: cursorConnectApi(), }); } diff --git a/packages/ai/src/providers/github-copilot.models.ts b/packages/ai/src/providers/github-copilot.models.ts index 49ba22132..0609f1e78 100644 --- a/packages/ai/src/providers/github-copilot.models.ts +++ b/packages/ai/src/providers/github-copilot.models.ts @@ -460,7 +460,7 @@ export const GITHUB_COPILOT_MODELS = { input: 1, output: 6, cacheRead: 0.1, - cacheWrite: 0, + cacheWrite: 1.25, }, contextWindow: 1050000, maxTokens: 128000, @@ -479,7 +479,7 @@ export const GITHUB_COPILOT_MODELS = { input: 5, output: 30, cacheRead: 0.5, - cacheWrite: 0, + cacheWrite: 6.25, }, contextWindow: 1050000, maxTokens: 128000, @@ -498,7 +498,7 @@ export const GITHUB_COPILOT_MODELS = { input: 2.5, output: 15, cacheRead: 0.25, - cacheWrite: 0, + cacheWrite: 3.125, }, contextWindow: 1050000, maxTokens: 128000, diff --git a/packages/ai/src/providers/github-copilot.ts b/packages/ai/src/providers/github-copilot.ts index c935ad5d1..af339b864 100644 --- a/packages/ai/src/providers/github-copilot.ts +++ b/packages/ai/src/providers/github-copilot.ts @@ -2,8 +2,8 @@ import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { loadGitHubCopilotOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; -import { loadGitHubCopilotOAuth } from "../utils/oauth/load.ts"; import { GITHUB_COPILOT_MODELS } from "./github-copilot.models.ts"; export function githubCopilotProvider(): Provider<"anthropic-messages" | "openai-completions" | "openai-responses"> { diff --git a/packages/ai/src/providers/gitlab-duo.models.ts b/packages/ai/src/providers/gitlab-duo.models.ts index a0ea9a2fe..51bd9ad11 100644 --- a/packages/ai/src/providers/gitlab-duo.models.ts +++ b/packages/ai/src/providers/gitlab-duo.models.ts @@ -10,25 +10,34 @@ export const GITLAB_DUO_MODELS = { api: "anthropic-messages", provider: "gitlab-duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/anthropic/", - compat: { forceAdaptiveThinking: true }, + compat: {"forceAdaptiveThinking":true}, reasoning: true, - thinkingLevelMap: { xhigh: "max", max: "max" }, + thinkingLevelMap: {"xhigh":"max","max":"max"}, input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, "gpt-5.1-2025-11-13": { id: "gpt-5.1-2025-11-13", name: "GPT-5.1", - api: "openai-responses", + api: "openai-completions", provider: "gitlab-duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", reasoning: true, - thinkingLevelMap: { off: null }, input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, contextWindow: 128000, maxTokens: 16384, - } satisfies Model<"openai-responses">, + } satisfies Model<"openai-completions">, } as const; diff --git a/packages/ai/src/providers/gitlab-duo.ts b/packages/ai/src/providers/gitlab-duo.ts index 948ea40a8..f8170381c 100644 --- a/packages/ai/src/providers/gitlab-duo.ts +++ b/packages/ai/src/providers/gitlab-duo.ts @@ -1,23 +1,67 @@ import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; -import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; -import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.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 { loadGitLabDuoOAuth } from "../utils/oauth/load.ts"; import { GITLAB_DUO_MODELS } from "./gitlab-duo.models.ts"; -export function gitlabDuoProvider(): Provider<"anthropic-messages" | "openai-responses"> { +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: envApiKeyAuth("GitLab token", ["GITLAB_TOKEN"]), + apiKey: gitlabDuoApiKeyAuth(), oauth: lazyOAuth({ name: "GitLab Duo", load: loadGitLabDuoOAuth }), }, models: Object.values(GITLAB_DUO_MODELS), api: { "anthropic-messages": anthropicMessagesApi(), - "openai-responses": openAIResponsesApi(), + "openai-completions": openAICompletionsApi(), }, }); } diff --git a/packages/ai/src/providers/glm-zcode.models.ts b/packages/ai/src/providers/glm-zcode.models.ts deleted file mode 100644 index e46407f68..000000000 --- a/packages/ai/src/providers/glm-zcode.models.ts +++ /dev/null @@ -1,28 +0,0 @@ -// This file is auto-generated by scripts/generate-models.ts -// Do not edit manually - run 'npm run generate-models' to update - -import type { Model } from "../types.ts"; - -export const GLM_ZCODE_MODELS = { - "glm-5.2": { - id: "glm-5.2", - name: "GLM-5.2 (ZCode)", - api: "anthropic-messages", - provider: "glm-zcode", - baseUrl: "https://api.z.ai/api/anthropic", - headers: { - "User-Agent": "ZCode/3.1.2", - "X-Title": "Z Code@electron", - "HTTP-Referer": "https://zcode.z.ai", - "X-ZCode-Agent": "glm", - "X-ZCode-App-Version": "3.1.2", - "X-Release-Channel": "production", - }, - reasoning: true, - thinkingLevelMap: { minimal: null, low: "high", medium: "high", high: "high", max: "max" }, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 131072, - } satisfies Model<"anthropic-messages">, -} as const; diff --git a/packages/ai/src/providers/glm-zcode.ts b/packages/ai/src/providers/glm-zcode.ts deleted file mode 100644 index 11535db5b..000000000 --- a/packages/ai/src/providers/glm-zcode.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; -import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; -import { createProvider, type Provider } from "../models.ts"; -import { loadGlmZcodeOAuth } from "../utils/oauth/load.ts"; -import { GLM_ZCODE_MODELS } from "./glm-zcode.models.ts"; - -export function glmZcodeProvider(): Provider<"anthropic-messages"> { - return createProvider({ - id: "glm-zcode", - name: "GLM ZCode (unofficial, opt-in)", - baseUrl: "https://api.z.ai/api/anthropic", - auth: { - apiKey: envApiKeyAuth("GLM ZCode API key", ["GLM_ZCODE_API_KEY"]), - oauth: lazyOAuth({ name: "GLM ZCode OAuth (unofficial, opt-in)", load: loadGlmZcodeOAuth }), - }, - models: Object.values(GLM_ZCODE_MODELS), - api: anthropicMessagesApi(), - }); -} diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 63403c048..b042fb7ae 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -1,7 +1,7 @@ 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, type Provider } from "../models.ts"; -import { loadGoogleAntigravityOAuth, loadGoogleGeminiCliOAuth } from "../utils/oauth/load.ts"; import { GOOGLE_ANTIGRAVITY_MODELS } from "./google-antigravity.models.ts"; import { GOOGLE_GEMINI_CLI_MODELS } from "./google-gemini-cli.models.ts"; diff --git a/packages/ai/src/providers/kilo.models.ts b/packages/ai/src/providers/kilo.models.ts index 9a60381ce..1ceccf91a 100644 --- a/packages/ai/src/providers/kilo.models.ts +++ b/packages/ai/src/providers/kilo.models.ts @@ -12,7 +12,12 @@ export const KILO_MODELS = { baseUrl: "https://api.kilo.ai/api/gateway", reasoning: true, input: ["text", "image"], - cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + }, contextWindow: 200000, maxTokens: 64000, } satisfies Model<"openai-completions">, diff --git a/packages/ai/src/providers/kilo.ts b/packages/ai/src/providers/kilo.ts index ea5d5781e..09f02865f 100644 --- a/packages/ai/src/providers/kilo.ts +++ b/packages/ai/src/providers/kilo.ts @@ -1,7 +1,7 @@ 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 { loadKiloOAuth } from "../utils/oauth/load.ts"; import { KILO_MODELS } from "./kilo.models.ts"; export function kiloProvider(): Provider<"openai-completions"> { diff --git a/packages/ai/src/providers/kimi-code.ts b/packages/ai/src/providers/kimi-code.ts index 8134e954c..02f797d3f 100644 --- a/packages/ai/src/providers/kimi-code.ts +++ b/packages/ai/src/providers/kimi-code.ts @@ -1,7 +1,7 @@ import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { loadKimiCodeOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; -import { loadKimiCodeOAuth } from "../utils/oauth/load.ts"; import { KIMI_CODE_MODELS } from "./kimi-code.models.ts"; export function kimiCodeProvider(): Provider<"openai-completions"> { diff --git a/packages/ai/src/providers/kimi-coding.models.ts b/packages/ai/src/providers/kimi-coding.models.ts index a3b1f2668..395b8faee 100644 --- a/packages/ai/src/providers/kimi-coding.models.ts +++ b/packages/ai/src/providers/kimi-coding.models.ts @@ -22,9 +22,9 @@ export const KIMI_CODING_MODELS = { contextWindow: 262144, maxTokens: 32768, } satisfies Model<"anthropic-messages">, - "kimi-for-coding": { - id: "kimi-for-coding", - name: "Kimi For Coding", + "k3": { + id: "k3", + name: "Kimi K3", api: "anthropic-messages", provider: "kimi-coding", baseUrl: "https://api.kimi.com/coding", @@ -37,18 +37,18 @@ export const KIMI_CODING_MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 32768, + contextWindow: 1048576, + maxTokens: 131072, } satisfies Model<"anthropic-messages">, - "kimi-k2-thinking": { - id: "kimi-k2-thinking", - name: "Kimi K2 Thinking", + "kimi-for-coding-highspeed": { + id: "kimi-for-coding-highspeed", + name: "Kimi For Coding HighSpeed", api: "anthropic-messages", provider: "kimi-coding", baseUrl: "https://api.kimi.com/coding", headers: {"User-Agent":"KimiCLI/1.5"}, reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { input: 0, output: 0, diff --git a/packages/ai/src/providers/moonshot.models.ts b/packages/ai/src/providers/moonshot.models.ts index e683b1cea..2bcae9ca3 100644 --- a/packages/ai/src/providers/moonshot.models.ts +++ b/packages/ai/src/providers/moonshot.models.ts @@ -168,4 +168,22 @@ export const MOONSHOT_MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "kimi-k3": { + id: "kimi-k3", + name: "Kimi K3", + api: "openai-completions", + provider: "moonshot", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, } as const; diff --git a/packages/ai/src/providers/moonshotai-cn.models.ts b/packages/ai/src/providers/moonshotai-cn.models.ts index 899f9b116..effdd2c45 100644 --- a/packages/ai/src/providers/moonshotai-cn.models.ts +++ b/packages/ai/src/providers/moonshotai-cn.models.ts @@ -168,4 +168,22 @@ export const MOONSHOTAI_CN_MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "kimi-k3": { + id: "kimi-k3", + name: "Kimi K3", + api: "openai-completions", + provider: "moonshotai-cn", + baseUrl: "https://api.moonshot.cn/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, } as const; diff --git a/packages/ai/src/providers/moonshotai.models.ts b/packages/ai/src/providers/moonshotai.models.ts index 2ec685e69..01c670479 100644 --- a/packages/ai/src/providers/moonshotai.models.ts +++ b/packages/ai/src/providers/moonshotai.models.ts @@ -168,4 +168,22 @@ export const MOONSHOTAI_MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "kimi-k3": { + id: "kimi-k3", + name: "Kimi K3", + api: "openai-completions", + provider: "moonshotai", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, } as const; diff --git a/packages/ai/src/providers/openai-codex-device.ts b/packages/ai/src/providers/openai-codex-device.ts index b4ec0e4f9..bbcd898c2 100644 --- a/packages/ai/src/providers/openai-codex-device.ts +++ b/packages/ai/src/providers/openai-codex-device.ts @@ -1,7 +1,7 @@ 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 { loadOpenAICodexDeviceOAuth } from "../utils/oauth/load.ts"; import { OPENAI_CODEX_DEVICE_MODELS } from "./openai-codex-device.models.ts"; export function openaiCodexDeviceProvider(): Provider<"openai-codex-responses"> { diff --git a/packages/ai/src/providers/openai-codex.ts b/packages/ai/src/providers/openai-codex.ts index 6ccdb6efa..cbf9d7929 100644 --- a/packages/ai/src/providers/openai-codex.ts +++ b/packages/ai/src/providers/openai-codex.ts @@ -1,7 +1,7 @@ import { openAICodexResponsesApi } from "../api/openai-codex-responses.lazy.ts"; import { lazyOAuth } from "../auth/helpers.ts"; +import { loadOpenAICodexOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; -import { loadOpenAICodexOAuth } from "../utils/oauth/load.ts"; import { OPENAI_CODEX_MODELS } from "./openai-codex.models.ts"; export function openaiCodexProvider(): Provider<"openai-codex-responses"> { diff --git a/packages/ai/src/providers/opencode-go.models.ts b/packages/ai/src/providers/opencode-go.models.ts index 3e8709c8e..2e34d0d78 100644 --- a/packages/ai/src/providers/opencode-go.models.ts +++ b/packages/ai/src/providers/opencode-go.models.ts @@ -34,9 +34,9 @@ export const OPENCODE_GO_MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","max":"max"}, input: ["text"], cost: { - input: 1.74, - output: 3.48, - cacheRead: 0.0145, + input: 0.435, + output: 0.87, + cacheRead: 0.003625, cacheWrite: 0, }, contextWindow: 1000000, @@ -79,6 +79,24 @@ export const OPENCODE_GO_MODELS = { contextWindow: 1000000, maxTokens: 131072, } satisfies Model<"openai-completions">, + "grok-4.5": { + id: "grok-4.5", + name: "Grok 4.5", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 500000, + maxTokens: 500000, + } satisfies Model<"openai-completions">, "kimi-k2.6": { id: "kimi-k2.6", name: "Kimi K2.6", @@ -116,6 +134,24 @@ export const OPENCODE_GO_MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "kimi-k3": { + id: "kimi-k3", + name: "Kimi K3 (2x usage)", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "mimo-v2.5": { id: "mimo-v2.5", name: "MiMo V2.5", @@ -144,9 +180,9 @@ export const OPENCODE_GO_MODELS = { reasoning: true, input: ["text"], cost: { - input: 1.74, - output: 3.48, - cacheRead: 0.0145, + input: 0.435, + output: 0.87, + cacheRead: 0.003625, cacheWrite: 0, }, contextWindow: 1048576, diff --git a/packages/ai/src/providers/opencode-zen.models.ts b/packages/ai/src/providers/opencode-zen.models.ts index 082cb810a..cac63985d 100644 --- a/packages/ai/src/providers/opencode-zen.models.ts +++ b/packages/ai/src/providers/opencode-zen.models.ts @@ -392,6 +392,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -410,6 +411,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -428,6 +430,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -446,6 +449,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -464,6 +468,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -482,6 +487,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -500,6 +506,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null}, input: ["text", "image"], @@ -518,6 +525,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -536,6 +544,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -554,6 +563,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -572,6 +582,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -590,6 +601,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -608,6 +620,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -626,6 +639,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -644,6 +658,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], @@ -662,6 +677,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"low":null}, input: ["text", "image"], @@ -680,6 +696,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"}, input: ["text", "image"], @@ -698,6 +715,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"}, input: ["text", "image"], @@ -716,6 +734,7 @@ export const OPENCODE_ZEN_MODELS = { api: "openai-responses", provider: "opencode-zen", baseUrl: "https://opencode.ai/zen/v1", + compat: {"sessionAffinityFormat":"openai-nosession"}, reasoning: true, thinkingLevelMap: {"off":null,"xhigh":"xhigh","max":"max"}, input: ["text", "image"], diff --git a/packages/ai/src/providers/openrouter.models.ts b/packages/ai/src/providers/openrouter.models.ts index 70cef3d72..279da2051 100644 --- a/packages/ai/src/providers/openrouter.models.ts +++ b/packages/ai/src/providers/openrouter.models.ts @@ -385,7 +385,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0.3, cacheWrite: 3.75, }, - contextWindow: 1000000, + contextWindow: 200000, maxTokens: 64000, } satisfies Model<"openai-completions">, "anthropic/claude-sonnet-4.5": { @@ -652,13 +652,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.24, - output: 0.9, + input: 0.27, + output: 1.12, cacheRead: 0.135, cacheWrite: 0, }, contextWindow: 163840, - maxTokens: 16384, + maxTokens: 65536, } satisfies Model<"openai-completions">, "deepseek/deepseek-chat-v3.1": { id: "deepseek/deepseek-chat-v3.1", @@ -725,11 +725,11 @@ export const OPENROUTER_MODELS = { input: ["text"], cost: { input: 0.27, - output: 0.95, - cacheRead: 0.13, + output: 1, + cacheRead: 0.135, cacheWrite: 0, }, - contextWindow: 163840, + contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, "deepseek/deepseek-v3.2": { @@ -742,13 +742,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.2145, - output: 0.32175, - cacheRead: 0.02145, + input: 0.269, + output: 0.4, + cacheRead: 0.1345, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 64000, + contextWindow: 163840, + maxTokens: 65536, } satisfies Model<"openai-completions">, "deepseek/deepseek-v3.2-exp": { id: "deepseek/deepseek-v3.2-exp", @@ -779,13 +779,13 @@ export const OPENROUTER_MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","max":null,"xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.09, - output: 0.18, - cacheRead: 0.018, + input: 0.098, + output: 0.196, + cacheRead: 0.0196, cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 65536, + contextWindow: 1048575, + maxTokens: 4096, } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-pro": { id: "deepseek/deepseek-v4-pro", @@ -1050,13 +1050,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.08, - output: 0.16, + input: 0.1, + output: 0.3, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 16384, + contextWindow: 110000, + maxTokens: 4096, } satisfies Model<"openai-completions">, "google/gemma-4-26b-a4b-it": { id: "google/gemma-4-26b-a4b-it", @@ -1068,13 +1068,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.06, - output: 0.33, + input: 0.1, + output: 0.3, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 4096, + contextWindow: 256000, + maxTokens: 256000, } satisfies Model<"openai-completions">, "google/gemma-4-26b-a4b-it:free": { id: "google/gemma-4-26b-a4b-it:free", @@ -1104,13 +1104,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.06, - output: 0.35, + input: 0.12, + output: 0.37, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 8192, + maxTokens: 16384, } satisfies Model<"openai-completions">, "google/gemma-4-31b-it:free": { id: "google/gemma-4-31b-it:free", @@ -1303,13 +1303,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, + input: 0.05, + output: 0.08, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 16384, + maxTokens: 131072, } satisfies Model<"openai-completions">, "meta-llama/llama-3.3-70b-instruct": { id: "meta-llama/llama-3.3-70b-instruct", @@ -1321,13 +1321,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.1, - output: 0.32, + input: 0.13, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 16384, + maxTokens: 128000, } satisfies Model<"openai-completions">, "meta-llama/llama-3.3-70b-instruct:free": { id: "meta-llama/llama-3.3-70b-instruct:free", @@ -1383,6 +1383,24 @@ export const OPENROUTER_MODELS = { contextWindow: 327680, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta/muse-spark-1.1": { + id: "meta/muse-spark-1.1", + name: "Meta: Muse Spark 1.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"openrouter"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 4.25, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "minimax/minimax-m1": { id: "minimax/minimax-m1", name: "MiniMax: MiniMax M1", @@ -1393,7 +1411,7 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.4, + input: 0.55, output: 2.2, cacheRead: 0, cacheWrite: 0, @@ -1465,13 +1483,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.24, - output: 0.96, - cacheRead: 0, + input: 0.25, + output: 1, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 196608, - maxTokens: 196608, + maxTokens: 131072, } satisfies Model<"openai-completions">, "minimax/minimax-m3": { id: "minimax/minimax-m3", @@ -1488,8 +1506,8 @@ export const OPENROUTER_MODELS = { cacheRead: 0.06, cacheWrite: 0, }, - contextWindow: 1000000, - maxTokens: 131072, + contextWindow: 524288, + maxTokens: 512000, } satisfies Model<"openai-completions">, "mistralai/codestral-2508": { id: "mistralai/codestral-2508", @@ -1699,13 +1717,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.02, + input: 0.019, output: 0.03, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 4096, + maxTokens: 16384, } satisfies Model<"openai-completions">, "mistralai/mistral-saba": { id: "mistralai/mistral-saba", @@ -1753,13 +1771,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.075, - output: 0.2, - cacheRead: 0, + input: 0.1, + output: 0.3, + cacheRead: 0.01, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 16384, + contextWindow: 131072, + maxTokens: 4096, } satisfies Model<"openai-completions">, "mistralai/mixtral-8x22b-instruct": { id: "mistralai/mixtral-8x22b-instruct", @@ -1879,13 +1897,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.66, - output: 3.41, - cacheRead: 0.15, + input: 0.95, + output: 4, + cacheRead: 0.16, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 4096, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2.7-code": { id: "moonshotai/kimi-k2.7-code", @@ -1897,14 +1915,32 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.719, - output: 3.49, - cacheRead: 0.149, + input: 1, + output: 4.4, + cacheRead: 0.2, cacheWrite: 0, }, contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "moonshotai/kimi-k3": { + id: "moonshotai/kimi-k3", + name: "MoonshotAI: Kimi K3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"openrouter"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "nex-agi/nex-n2-mini": { id: "nex-agi/nex-n2-mini", name: "Nex AGI: Nex-N2-Mini", @@ -1941,24 +1977,6 @@ export const OPENROUTER_MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, - "nvidia/llama-3.3-nemotron-super-49b-v1.5": { - id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", - name: "NVIDIA: Llama 3.3 Nemotron Super 49B V1.5", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - compat: {"supportsDeveloperRole":false,"thinkingFormat":"openrouter"}, - reasoning: true, - input: ["text"], - cost: { - input: 0.4, - output: 0.4, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "nvidia/nemotron-3-nano-30b-a3b": { id: "nvidia/nemotron-3-nano-30b-a3b", name: "NVIDIA: Nemotron 3 Nano 30B A3B", @@ -2023,12 +2041,12 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.08, - output: 0.45, - cacheRead: 0, + input: 0.21, + output: 0.455, + cacheRead: 0.06, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 1000000, maxTokens: 4096, } satisfies Model<"openai-completions">, "nvidia/nemotron-3-super-120b-a12b:free": { @@ -2059,13 +2077,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.5, - output: 2.2, - cacheRead: 0.1, + input: 0.6, + output: 3.6, + cacheRead: 0.2, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 16384, + contextWindow: 512288, + maxTokens: 4096, } satisfies Model<"openai-completions">, "nvidia/nemotron-3-ultra-550b-a55b:free": { id: "nvidia/nemotron-3-ultra-550b-a55b:free", @@ -2245,7 +2263,7 @@ export const OPENROUTER_MODELS = { cacheWrite: 0, }, contextWindow: 1047576, - maxTokens: 4096, + maxTokens: 32768, } satisfies Model<"openai-completions">, "openai/gpt-4.1-mini": { id: "openai/gpt-4.1-mini", @@ -2295,7 +2313,7 @@ export const OPENROUTER_MODELS = { cost: { input: 2.5, output: 10, - cacheRead: 0, + cacheRead: 1.25, cacheWrite: 0, }, contextWindow: 128000, @@ -2511,11 +2529,11 @@ export const OPENROUTER_MODELS = { cost: { input: 1.25, output: 10, - cacheRead: 0.13, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 32000, + maxTokens: 16384, } satisfies Model<"openai-completions">, "openai/gpt-5.1-codex": { id: "openai/gpt-5.1-codex", @@ -2529,7 +2547,7 @@ export const OPENROUTER_MODELS = { cost: { input: 1.25, output: 10, - cacheRead: 0.13, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 400000, @@ -2977,8 +2995,8 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.03, - output: 0.15, + input: 0.037, + output: 0.17, cacheRead: 0, cacheWrite: 0, }, @@ -2995,13 +3013,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.029, - output: 0.14, - cacheRead: 0, + input: 0.03, + output: 0.13, + cacheRead: 0.03, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 4096, + maxTokens: 131072, } satisfies Model<"openai-completions">, "openai/gpt-oss-20b:free": { id: "openai/gpt-oss-20b:free", @@ -3219,6 +3237,24 @@ export const OPENROUTER_MODELS = { contextWindow: 2000000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openrouter/auto-beta": { + id: "openrouter/auto-beta", + name: "Auto Router (Beta)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"openrouter"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: -1000000, + output: -1000000, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 2000000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/free": { id: "openrouter/free", name: "Free Models Router", @@ -3427,13 +3463,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.1, - output: 0.24, + input: 0.2275, + output: 0.91, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 40960, - maxTokens: 40960, + contextWindow: 131072, + maxTokens: 8192, } satisfies Model<"openai-completions">, "qwen/qwen3-235b-a22b": { id: "qwen/qwen3-235b-a22b", @@ -3499,13 +3535,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.12, - output: 0.5, + input: 0.13, + output: 0.52, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 40960, - maxTokens: 16384, + contextWindow: 131072, + maxTokens: 8192, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b-instruct-2507": { id: "qwen/qwen3-30b-a3b-instruct-2507", @@ -3517,13 +3553,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.04815, - output: 0.19305, + input: 0.1, + output: 0.3, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 32000, + contextWindow: 262144, + maxTokens: 4096, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b-thinking-2507": { id: "qwen/qwen3-30b-a3b-thinking-2507", @@ -3589,9 +3625,9 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.22, - output: 1.8, - cacheRead: 0, + input: 0.3, + output: 1, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 262144, @@ -3733,13 +3769,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09, + input: 0.1, output: 1.1, - cacheRead: 0, + cacheRead: 0.07, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 16384, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3-next-80b-a3b-instruct:free": { id: "qwen/qwen3-next-80b-a3b-instruct:free", @@ -3787,13 +3823,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.2, - output: 0.88, - cacheRead: 0.11, + input: 0.21, + output: 1.9, + cacheRead: 0.1, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 16384, + contextWindow: 131072, + maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3-vl-235b-a22b-thinking": { id: "qwen/qwen3-vl-235b-a22b-thinking", @@ -3919,7 +3955,7 @@ export const OPENROUTER_MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 65536, } satisfies Model<"openai-completions">, "qwen/qwen3.5-27b": { id: "qwen/qwen3.5-27b", @@ -3931,13 +3967,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.195, - output: 1.56, + input: 0.26, + output: 2.6, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 65536, + maxTokens: 81920, } satisfies Model<"openai-completions">, "qwen/qwen3.5-35b-a3b": { id: "qwen/qwen3.5-35b-a3b", @@ -3951,11 +3987,11 @@ export const OPENROUTER_MODELS = { cost: { input: 0.14, output: 1, - cacheRead: 0.05, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 81920, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3.5-397b-a17b": { id: "qwen/qwen3.5-397b-a17b", @@ -3967,13 +4003,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.385, - output: 2.45, - cacheRead: 0.111, + input: 0.39, + output: 2.34, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 4096, + contextWindow: 262144, + maxTokens: 65536, } satisfies Model<"openai-completions">, "qwen/qwen3.5-9b": { id: "qwen/qwen3.5-9b", @@ -4057,13 +4093,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.289, - output: 2.4, + input: 0.45, + output: 2.7, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 131072, + contextWindow: 262144, + maxTokens: 65536, } satisfies Model<"openai-completions">, "qwen/qwen3.6-35b-a3b": { id: "qwen/qwen3.6-35b-a3b", @@ -4147,10 +4183,10 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 1.25, - output: 3.75, - cacheRead: 0.25, - cacheWrite: 1.5625, + input: 1.475, + output: 4.425, + cacheRead: 0.295, + cacheWrite: 1.84375, }, contextWindow: 1000000, maxTokens: 65536, @@ -4291,13 +4327,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.14, - output: 0.58, - cacheRead: 0.035, + input: 0.2, + output: 0.8, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 4096, + maxTokens: 131072, } satisfies Model<"openai-completions">, "tencent/hy3-preview": { id: "tencent/hy3-preview", @@ -4353,6 +4389,24 @@ export const OPENROUTER_MODELS = { contextWindow: 32768, maxTokens: 32768, } satisfies Model<"openai-completions">, + "thinkingmachines/inkling": { + id: "thinkingmachines/inkling", + name: "Thinking Machines: Inkling", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"openrouter"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 4.05, + cacheRead: 0.17, + cacheWrite: 0, + }, + contextWindow: 524288, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "upstage/solar-pro-3": { id: "upstage/solar-pro-3", name: "Upstage: Solar Pro 3", @@ -4453,13 +4507,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.105, + input: 0.14, output: 0.28, - cacheRead: 0.028, + cacheRead: 0.0028, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 4096, + contextWindow: 1048576, + maxTokens: 131072, } satisfies Model<"openai-completions">, "xiaomi/mimo-v2.5-pro": { id: "xiaomi/mimo-v2.5-pro", @@ -4543,13 +4597,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.43, - output: 1.75, - cacheRead: 0.08, + input: 0.5, + output: 2, + cacheRead: 0.1, cacheWrite: 0, }, - contextWindow: 198000, - maxTokens: 16384, + contextWindow: 202752, + maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-4.6v": { id: "z-ai/glm-4.6v", @@ -4597,13 +4651,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.06, + input: 0.0605, output: 0.4, - cacheRead: 0.01, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 202752, - maxTokens: 16384, + contextWindow: 131072, + maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-5": { id: "z-ai/glm-5", @@ -4620,8 +4674,8 @@ export const OPENROUTER_MODELS = { cacheRead: 0.119, cacheWrite: 0, }, - contextWindow: 198000, - maxTokens: 128000, + contextWindow: 202752, + maxTokens: 202752, } satisfies Model<"openai-completions">, "z-ai/glm-5-turbo": { id: "z-ai/glm-5-turbo", @@ -4638,7 +4692,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0.24, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 202752, maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-5.1": { @@ -4670,13 +4724,13 @@ export const OPENROUTER_MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.91, - output: 2.86, - cacheRead: 0.169, + input: 0.2912, + output: 0.9152, + cacheRead: 0.05408, cacheWrite: 0, }, - contextWindow: 1024000, - maxTokens: 128000, + contextWindow: 1048576, + maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-5v-turbo": { id: "z-ai/glm-5v-turbo", @@ -4814,13 +4868,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.66, - output: 3.41, - cacheRead: 0.15, + input: 3, + output: 15, + cacheRead: 0.3, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 262144, + contextWindow: 1048576, + maxTokens: 4096, } satisfies Model<"openai-completions">, "~openai/gpt-latest": { id: "~openai/gpt-latest", diff --git a/packages/ai/src/providers/perplexity.models.ts b/packages/ai/src/providers/perplexity.models.ts index bc6af4a02..bed5f7591 100644 --- a/packages/ai/src/providers/perplexity.models.ts +++ b/packages/ai/src/providers/perplexity.models.ts @@ -12,7 +12,12 @@ export const PERPLEXITY_MODELS = { baseUrl: "https://api.perplexity.ai", reasoning: false, input: ["text"], - cost: { input: 3, output: 15, cacheRead: 0, cacheWrite: 0 }, + cost: { + input: 3, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, diff --git a/packages/ai/src/providers/perplexity.ts b/packages/ai/src/providers/perplexity.ts index 84bcaaacb..b78eed324 100644 --- a/packages/ai/src/providers/perplexity.ts +++ b/packages/ai/src/providers/perplexity.ts @@ -1,7 +1,6 @@ import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; -import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; import { createProvider, type Provider } from "../models.ts"; -import { loadPerplexityOAuth } from "../utils/oauth/load.ts"; import { PERPLEXITY_MODELS } from "./perplexity.models.ts"; export function perplexityProvider(): Provider<"openai-completions"> { @@ -9,9 +8,13 @@ export function perplexityProvider(): Provider<"openai-completions"> { 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"]), - oauth: lazyOAuth({ name: "Perplexity (Pro/Max)", load: loadPerplexityOAuth }), }, models: Object.values(PERPLEXITY_MODELS), api: openAICompletionsApi(), diff --git a/packages/ai/src/providers/together.models.ts b/packages/ai/src/providers/together.models.ts index 098695938..b21f39f48 100644 --- a/packages/ai/src/providers/together.models.ts +++ b/packages/ai/src/providers/together.models.ts @@ -322,6 +322,25 @@ export const TOGETHER_MODELS = { contextWindow: 131072, maxTokens: 131072, } satisfies Model<"openai-completions">, + "thinkingmachines/Inkling": { + id: "thinkingmachines/Inkling", + name: "Inkling", + api: "openai-completions", + provider: "together", + baseUrl: "https://api.together.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","thinkingFormat":"together","supportsStrictMode":false,"supportsLongCacheRetention":false}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null}, + input: ["text", "image"], + cost: { + input: 1, + output: 4.05, + cacheRead: 0.17, + cacheWrite: 0, + }, + contextWindow: 524288, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "zai-org/GLM-5": { id: "zai-org/GLM-5", name: "GLM-5", diff --git a/packages/ai/src/providers/vercel-ai-gateway.models.ts b/packages/ai/src/providers/vercel-ai-gateway.models.ts index ffa15448d..6164142c1 100644 --- a/packages/ai/src/providers/vercel-ai-gateway.models.ts +++ b/packages/ai/src/providers/vercel-ai-gateway.models.ts @@ -622,6 +622,25 @@ export const VERCEL_AI_GATEWAY_MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "anthropic/claude-opus-4.7-fast": { + id: "anthropic/claude-opus-4.7-fast", + name: "Claude Opus 4.7 (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 30, + output: 150, + cacheRead: 3, + cacheWrite: 37.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "anthropic/claude-opus-4.8": { id: "anthropic/claude-opus-4.8", name: "Claude Opus 4.8", @@ -641,6 +660,25 @@ export const VERCEL_AI_GATEWAY_MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "anthropic/claude-opus-4.8-fast": { + id: "anthropic/claude-opus-4.8-fast", + name: "Claude Opus 4.8 (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "anthropic/claude-sonnet-4": { id: "anthropic/claude-sonnet-4", name: "Claude Sonnet 4", @@ -1274,40 +1312,6 @@ export const VERCEL_AI_GATEWAY_MODELS = { contextWindow: 128000, maxTokens: 8192, } satisfies Model<"anthropic-messages">, - "meta/llama-3.2-11b": { - id: "meta/llama-3.2-11b", - name: "Llama 3.2 11B Vision Instruct", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.16, - output: 0.16, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, - "meta/llama-3.2-90b": { - id: "meta/llama-3.2-90b", - name: "Llama 3.2 90B Vision Instruct", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.72, - output: 0.72, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, "meta/llama-3.3-70b": { id: "meta/llama-3.3-70b", name: "Llama 3.3 70B Instruct", @@ -1852,6 +1856,23 @@ export const VERCEL_AI_GATEWAY_MODELS = { contextWindow: 262144, maxTokens: 32768, } satisfies Model<"anthropic-messages">, + "moonshotai/kimi-k3": { + id: "moonshotai/kimi-k3", + name: "Kimi K3", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, "nvidia/nemotron-3-nano-30b-a3b": { id: "nvidia/nemotron-3-nano-30b-a3b", name: "Nemotron 3 Nano 30B A3B", @@ -2717,6 +2738,23 @@ export const VERCEL_AI_GATEWAY_MODELS = { contextWindow: 256000, maxTokens: 256000, } satisfies Model<"anthropic-messages">, + "thinkingmachines/inkling": { + id: "thinkingmachines/inkling", + name: "Inkling", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 4.05, + cacheRead: 0.17, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, "xai/grok-4.1-fast-non-reasoning": { id: "xai/grok-4.1-fast-non-reasoning", name: "Grok 4.1 Fast Non-Reasoning", diff --git a/packages/ai/src/providers/xai.ts b/packages/ai/src/providers/xai.ts index 122334aed..4af4c818d 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -1,7 +1,7 @@ import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { loadXaiOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; -import { loadXaiOAuth } from "../utils/oauth/load.ts"; import { XAI_MODELS } from "./xai.models.ts"; export function xaiProvider(): Provider<"openai-completions"> { diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 0b6e7bef5..eb71aebfb 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -24,7 +24,8 @@ export type KnownApi = | "google-vertex" | "pi-messages" | "azure-openai-responses" - | "mistral-conversations"; + | "mistral-conversations" + | "cursor-connect"; export type Api = KnownApi | (string & {}); @@ -49,7 +50,6 @@ export type KnownProvider = | "fugu" | "github-copilot" | "gitlab-duo" - | "glm-zcode" | "google" | "google-antigravity" | "google-gemini-cli" diff --git a/packages/ai/src/utils/oauth/glm-zcode.ts b/packages/ai/src/utils/oauth/glm-zcode.ts deleted file mode 100644 index e7694f3fb..000000000 --- a/packages/ai/src/utils/oauth/glm-zcode.ts +++ /dev/null @@ -1,501 +0,0 @@ -import type { OAuthAuth } from "../../auth/types.ts"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; - -const REQUEST_TIMEOUT_MS = 30_000; -const API_KEY_TTL_MS = 10 * 365 * 24 * 60 * 60_000; -const API_KEY_NAME = "zcode-api-key"; - -export const GLM_ZCODE_OAUTH_AUTHORIZE_URL = "https://chat.z.ai/api/oauth/authorize"; -export const GLM_ZCODE_OAUTH_CLIENT_ID = "client_P8X5CMWmlaRO9gyO-KSqtg"; -export const GLM_ZCODE_OAUTH_REDIRECT_URI = "zcode://oauth/callback"; -export const GLM_ZCODE_OAUTH_BROKER_TOKEN_URL = "https://zcode.z.ai/api/v1/oauth/token"; -export const GLM_ZCODE_ZAI_LOGIN_URL = "https://api.z.ai/api/auth/z/login"; -export const GLM_ZCODE_USERINFO_URL = "https://chat.z.ai/api/oauth/userinfo"; -export const GLM_ZCODE_ZAI_API_BASE = "https://api.z.ai"; -export const GLM_ZCODE_ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic"; - -type FetchImpl = typeof globalThis.fetch; -type AuthorizationInput = { code?: string; state?: string }; -type Identity = { email?: string; accountId?: string }; - -function envOr(name: string, fallback: string): string { - if (typeof process === "undefined") return fallback; - const value = process.env[name]?.trim(); - return value || fallback; -} - -function resolveAuthorizeUrl(): string { - return envOr("ZCODE_OAUTH_AUTHORIZE_URL", GLM_ZCODE_OAUTH_AUTHORIZE_URL); -} - -function resolveClientId(): string { - return envOr("ZCODE_OAUTH_CLIENT_ID", GLM_ZCODE_OAUTH_CLIENT_ID); -} - -function resolveRedirectUri(): string { - return envOr("ZCODE_OAUTH_REDIRECT_URI", GLM_ZCODE_OAUTH_REDIRECT_URI); -} - -function resolveBrokerTokenUrl(): string { - return envOr("ZCODE_OAUTH_BROKER_TOKEN_URL", GLM_ZCODE_OAUTH_BROKER_TOKEN_URL); -} - -function resolveZaiLoginUrl(): string { - return envOr("ZCODE_OAUTH_ZAI_LOGIN_URL", GLM_ZCODE_ZAI_LOGIN_URL); -} - -function resolveUserinfoUrl(): string { - return envOr("ZCODE_OAUTH_USERINFO_URL", GLM_ZCODE_USERINFO_URL); -} - -function resolveZaiApiBase(): string { - return envOr("ZCODE_OAUTH_ZAI_API_BASE", GLM_ZCODE_ZAI_API_BASE).replace(/\/+$/, ""); -} - -export function isGlmZcodeOAuthConfigured(): boolean { - return resolveClientId().length > 0; -} - -function validateHttpsEndpoint(rawUrl: string, label: string): string { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - throw new Error(`GLM ZCode ${label} endpoint is invalid`); - } - if (url.protocol !== "https:") throw new Error(`GLM ZCode ${label} endpoint must use HTTPS`); - const host = url.hostname.toLowerCase(); - if (host !== "z.ai" && !host.endsWith(".z.ai")) - throw new Error(`GLM ZCode ${label} endpoint must be on a z.ai host`); - return url.toString().replace(/\/+$/, ""); -} - -function requestSignal(signal?: AbortSignal): AbortSignal { - const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); - return signal ? AbortSignal.any([signal, timeout]) : timeout; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function throwIfAborted(signal?: AbortSignal): void { - if (signal?.aborted) { - throw signal.reason ?? new DOMException("GLM ZCode OAuth login was cancelled", "AbortError"); - } -} - -async function request( - fetchImpl: FetchImpl, - url: string, - init: RequestInit, - label: string, - signal?: AbortSignal, -): Promise { - try { - const response = await fetchImpl(url, { ...init, signal: requestSignal(signal) }); - if (!response.ok) throw new Error(`GLM ZCode ${label} request failed (${response.status})`); - return response; - } catch (error) { - if (signal?.aborted) throw signal.reason ?? error; - if (error instanceof Error && /^GLM ZCode .* request failed \(\d{3}\)$/.test(error.message)) throw error; - throw new Error(`GLM ZCode ${label} request failed`); - } -} - -async function postJson( - fetchImpl: FetchImpl, - url: string, - body: Record, - label: string, - signal?: AbortSignal, - bearer?: string, -): Promise { - const headers: Record = { Accept: "application/json", "Content-Type": "application/json" }; - if (bearer) headers.Authorization = `Bearer ${bearer}`; - const response = await request( - fetchImpl, - url, - { method: "POST", headers, body: JSON.stringify(body) }, - label, - signal, - ); - return response.json(); -} - -async function getJson( - fetchImpl: FetchImpl, - url: string, - bearer: string, - label: string, - signal?: AbortSignal, -): Promise { - const response = await request( - fetchImpl, - url, - { headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` } }, - label, - signal, - ); - return response.json(); -} - -export function parseGlmZcodeAuthorizationInput(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 decodeJwtIdentity(token: string): Identity { - const parts = token.split("."); - const payload = parts[1]; - if (parts.length !== 3 || !payload) return {}; - try { - const padded = payload - .replace(/-/g, "+") - .replace(/_/g, "/") - .padEnd(Math.ceil(payload.length / 4) * 4, "="); - const decoded = JSON.parse(atob(padded)) as Record; - return { - accountId: typeof decoded.sub === "string" && decoded.sub ? decoded.sub : undefined, - email: typeof decoded.email === "string" && decoded.email ? decoded.email.toLowerCase() : undefined, - }; - } catch { - return {}; - } -} - -function parseBrokerResponse(payload: unknown): { upstreamAccess: string; zcodeToken: string } { - const data = isRecord(payload) && isRecord(payload.data) ? payload.data : undefined; - const zai = data && isRecord(data.zai) ? data.zai : undefined; - const zcodeToken = data && typeof data.token === "string" ? data.token : undefined; - const upstreamAccess = zai && typeof zai.access_token === "string" ? zai.access_token : undefined; - if (!zcodeToken || !upstreamAccess) { - throw new Error("GLM ZCode broker response missing required tokens"); - } - return { upstreamAccess, zcodeToken }; -} - -async function resolveBusinessToken( - fetchImpl: FetchImpl, - upstreamAccess: string, - signal?: AbortSignal, -): Promise { - const url = validateHttpsEndpoint(resolveZaiLoginUrl(), "z/login"); - const payload = await postJson(fetchImpl, url, { token: upstreamAccess }, "z/login", signal); - const data = isRecord(payload) && isRecord(payload.data) ? payload.data : undefined; - if (!data || typeof data.access_token !== "string" || !data.access_token) { - throw new Error("GLM ZCode z/login response missing access token"); - } - return data.access_token; -} - -function pickOrgProject(payload: unknown): { - organizationId: string; - projectId: string; - identity: Identity; -} { - const data = isRecord(payload) && isRecord(payload.data) ? payload.data : payload; - const root = isRecord(data) ? data : {}; - const organizations = Array.isArray(root.organizations) ? root.organizations : []; - const organization = (organizations.find((value) => isRecord(value) && value.isDefault === true) ?? - organizations[0]) as Record | undefined; - const projects = organization && Array.isArray(organization.projects) ? organization.projects : []; - const project = (projects.find((value) => isRecord(value) && value.isDefault === true) ?? projects[0]) as - | Record - | undefined; - const organizationId = - organization && typeof organization.organizationId === "string" - ? organization.organizationId - : organization && typeof organization.id === "string" - ? organization.id - : undefined; - const projectId = - project && typeof project.projectId === "string" - ? project.projectId - : project && typeof project.id === "string" - ? project.id - : undefined; - if (!organizationId || !projectId) { - throw new Error("GLM ZCode customer response missing default organization or project"); - } - return { - organizationId, - projectId, - identity: { - email: typeof root.email === "string" && root.email ? root.email.toLowerCase() : undefined, - accountId: typeof root.id === "string" ? root.id : typeof root.id === "number" ? String(root.id) : undefined, - }, - }; -} - -async function provisionApiKey( - fetchImpl: FetchImpl, - businessToken: string, - signal?: AbortSignal, -): Promise<{ apiKey: string; identity: Identity }> { - const apiBase = validateHttpsEndpoint(resolveZaiApiBase(), "business API"); - const customer = await getJson( - fetchImpl, - `${apiBase}/api/biz/customer/getCustomerInfo`, - businessToken, - "getCustomerInfo", - signal, - ); - const { organizationId, projectId, identity } = pickOrgProject(customer); - const keysUrl = `${apiBase}/api/biz/v1/organization/${encodeURIComponent(organizationId)}/projects/${encodeURIComponent(projectId)}/api_keys`; - const listPayload = await getJson(fetchImpl, keysUrl, businessToken, "api_keys.list", signal); - const listData = isRecord(listPayload) && Array.isArray(listPayload.data) ? listPayload.data : []; - let entry = listData.find((value) => isRecord(value) && value.name === API_KEY_NAME) as - | Record - | undefined; - if (!entry) { - const created = await postJson( - fetchImpl, - keysUrl, - { name: API_KEY_NAME }, - "api_keys.create", - signal, - businessToken, - ); - entry = isRecord(created) && isRecord(created.data) ? created.data : isRecord(created) ? created : undefined; - } - const apiKeyId = - entry && typeof entry.apiKey === "string" - ? entry.apiKey.trim() - : entry && typeof entry.id === "string" - ? entry.id.trim() - : ""; - if (!apiKeyId) throw new Error("GLM ZCode API-key response missing key ID"); - const copied = await getJson( - fetchImpl, - `${keysUrl}/copy/${encodeURIComponent(apiKeyId)}`, - businessToken, - "api_keys.copy", - signal, - ); - const copyData = isRecord(copied) && isRecord(copied.data) ? copied.data : copied; - const secretKey = isRecord(copyData) && typeof copyData.secretKey === "string" ? copyData.secretKey.trim() : ""; - if (!secretKey) throw new Error("GLM ZCode API-key copy response missing secret"); - return { apiKey: `${apiKeyId}.${secretKey}`, identity }; -} - -async function resolveIdentity( - fetchImpl: FetchImpl, - upstreamAccess: string, - fallback: Identity, - jwtCandidates: readonly string[], - signal?: AbortSignal, -): Promise { - if (fallback.email || fallback.accountId) return fallback; - try { - const userinfo = await getJson( - fetchImpl, - validateHttpsEndpoint(resolveUserinfoUrl(), "userinfo"), - upstreamAccess, - "userinfo", - signal, - ); - const data = isRecord(userinfo) && isRecord(userinfo.data) ? userinfo.data : userinfo; - if (isRecord(data)) { - const identity = { - email: typeof data.email === "string" && data.email ? data.email.toLowerCase() : undefined, - accountId: - typeof data.id === "string" && data.id - ? data.id - : typeof data.sub === "string" && data.sub - ? data.sub - : undefined, - }; - if (identity.email || identity.accountId) return identity; - } - } catch (error) { - if (signal?.aborted) throw signal.reason ?? error; - // Identity is optional; continue with JWT claims. - } - for (const token of jwtCandidates) { - const identity = decodeJwtIdentity(token); - if (identity.email || identity.accountId) return identity; - } - return {}; -} - -async function provisionFromUpstream( - fetchImpl: FetchImpl, - upstreamAccess: string, - zcodeIdentityToken: string | undefined, - signal?: AbortSignal, -): Promise { - const businessToken = await resolveBusinessToken(fetchImpl, upstreamAccess, signal); - const { apiKey, identity: provisionedIdentity } = await provisionApiKey(fetchImpl, businessToken, signal); - const identity = await resolveIdentity( - fetchImpl, - upstreamAccess, - provisionedIdentity, - [zcodeIdentityToken ?? "", businessToken].filter(Boolean), - signal, - ); - return { - access: apiKey, - refresh: upstreamAccess, - expires: Date.now() + API_KEY_TTL_MS, - ...(identity.email ? { email: identity.email } : {}), - ...(identity.accountId ? { accountId: identity.accountId } : {}), - }; -} - -export interface GlmZcodeOAuthOptions { - fetch?: FetchImpl; - signal?: AbortSignal; -} - -export async function exchangeGlmZcodeAuthorizationCode( - code: string, - state: string, - redirectUri = resolveRedirectUri(), - options: GlmZcodeOAuthOptions = {}, -): Promise { - const fetchImpl = options.fetch ?? globalThis.fetch; - const brokerUrl = validateHttpsEndpoint(resolveBrokerTokenUrl(), "broker"); - const payload = await postJson( - fetchImpl, - brokerUrl, - { provider: "zai", code, redirect_uri: redirectUri, state }, - "broker", - options.signal, - ); - const { upstreamAccess, zcodeToken } = parseBrokerResponse(payload); - return provisionFromUpstream(fetchImpl, upstreamAccess, zcodeToken, options.signal); -} - -function abortPromise(signal?: AbortSignal): Promise | undefined { - if (!signal) return undefined; - return new Promise((_, reject) => { - const rejectAbort = () => - reject(signal.reason ?? new DOMException("GLM ZCode OAuth login was cancelled", "AbortError")); - if (signal.aborted) rejectAbort(); - else signal.addEventListener("abort", rejectAbort, { once: true }); - }); -} - -export async function loginGlmZcode( - callbacks: OAuthLoginCallbacks, - options: Omit = {}, -): Promise { - throwIfAborted(callbacks.signal); - const readManualCode = - callbacks.onManualCodeInput ?? - (() => - callbacks.onPrompt({ - message: "Paste the zcode:// callback URL or authorization code", - placeholder: GLM_ZCODE_OAUTH_REDIRECT_URI, - })); - const state = crypto.randomUUID(); - const redirectUri = resolveRedirectUri(); - const authorizeUrl = validateHttpsEndpoint(resolveAuthorizeUrl(), "authorize"); - const params = new URLSearchParams({ - redirect_uri: redirectUri, - response_type: "code", - client_id: resolveClientId(), - state, - }); - callbacks.onAuth({ - url: `${authorizeUrl}?${params}`, - instructions: - "Complete Z.AI login in your browser. This is an UNOFFICIAL, opt-in ZCode login. Paste the final zcode:// redirect URL or authorization code.", - }); - const manualCode = readManualCode().then((input) => { - const parsed = parseGlmZcodeAuthorizationInput(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 candidates: Promise[] = [manualCode]; - const aborted = abortPromise(callbacks.signal); - if (aborted) candidates.push(aborted); - const code = await Promise.race(candidates); - return exchangeGlmZcodeAuthorizationCode(code, state, redirectUri, { - fetch: options.fetch, - signal: callbacks.signal, - }); -} - -function safeRefreshDetail(error: unknown): string { - const status = String(error).match(/\((\d{3})\)/)?.[1]; - return status ? `request failed (${status})` : "request failed"; -} - -export async function refreshGlmZcodeToken( - credentials: OAuthCredentials, - options: GlmZcodeOAuthOptions | AbortSignal = {}, -): Promise { - if (!credentials.refresh) throw new Error("GLM ZCode credentials require re-login; no upstream token is stored"); - const resolved = options instanceof AbortSignal ? { signal: options } : options; - try { - return await provisionFromUpstream( - resolved.fetch ?? globalThis.fetch, - credentials.refresh, - undefined, - resolved.signal, - ); - } catch (error) { - if (resolved.signal?.aborted) throw resolved.signal.reason ?? error; - throw new Error(`GLM ZCode credentials require re-login; API-key provisioning ${safeRefreshDetail(error)}`); - } -} - -export const glmZcodeOAuth: OAuthAuth = { - name: "GLM ZCode OAuth (unofficial, opt-in)", - async login(callbacks) { - const manualAbort = new AbortController(); - try { - const credentials = await loginGlmZcode({ - 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 zcode:// callback URL or authorization code:", - placeholder: GLM_ZCODE_OAUTH_REDIRECT_URI, - signal: manualAbort.signal, - }), - onSelect: async () => undefined, - signal: callbacks.signal, - }); - return { ...credentials, type: "oauth" }; - } finally { - manualAbort.abort(); - } - }, - refresh: async (credential) => ({ ...(await refreshGlmZcodeToken(credential)), type: "oauth" }), - toAuth: async (credential) => ({ - apiKey: credential.access, - headers: { Authorization: `Bearer ${credential.access}` }, - }), -}; - -export const glmZcodeOAuthProvider: OAuthProviderInterface = { - id: "glm-zcode", - name: "GLM ZCode OAuth (unofficial, opt-in)", - // Senpi uses this flag to expose the manual redirect input alongside the browser URL. - usesCallbackServer: true, - login: loginGlmZcode, - refreshToken: refreshGlmZcodeToken, - getApiKey: (credentials) => credentials.access, -}; diff --git a/packages/ai/src/utils/oauth/load.ts b/packages/ai/src/utils/oauth/load.ts deleted file mode 100644 index bf1c472b8..000000000 --- a/packages/ai/src/utils/oauth/load.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { OAuthAuth } from "../../auth/types.ts"; - -/** - * Loads an OAuth flow module through a variable specifier so bundlers cannot - * follow the import into Node-only flow code (`node:http` callback servers, - * `node:crypto` PKCE). The `.ts`/`.js` rewrite keeps the trick working from - * both source and built output. - */ -const importOAuthModule = (specifier: string): Promise => { - const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier; - return import(runtimeSpecifier); -}; - -export const loadAnthropicOAuth = async (): Promise => - ((await importOAuthModule("./anthropic.ts")) as { anthropicOAuth: OAuthAuth }).anthropicOAuth; - -export const loadOpenAICodexOAuth = async (): Promise => - ((await importOAuthModule("./openai-codex.ts")) as { openaiCodexOAuth: OAuthAuth }).openaiCodexOAuth; - -export const loadOpenAICodexDeviceOAuth = async (): Promise => - ((await importOAuthModule("./openai-codex-device.ts")) as { openaiCodexDeviceOAuth: OAuthAuth }) - .openaiCodexDeviceOAuth; - -export const loadGitHubCopilotOAuth = async (): Promise => - ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth; - -export const loadCursorOAuth = async (): Promise => - ((await importOAuthModule("./cursor.ts")) as { cursorOAuth: OAuthAuth }).cursorOAuth; - -export const loadGitLabDuoOAuth = async (): Promise => - ((await importOAuthModule("./gitlab-duo.ts")) as { gitlabDuoOAuth: OAuthAuth }).gitlabDuoOAuth; - -export const loadPerplexityOAuth = async (): Promise => - ((await importOAuthModule("./perplexity.ts")) as { perplexityOAuth: OAuthAuth }).perplexityOAuth; - -export const loadKiloOAuth = async (): Promise => - ((await importOAuthModule("./kilo.ts")) as { kiloOAuth: OAuthAuth }).kiloOAuth; - -export const loadKimiCodeOAuth = async (): Promise => - ((await importOAuthModule("./kimi-code.ts")) as { kimiCodeOAuth: OAuthAuth }).kimiCodeOAuth; - -export const loadGlmZcodeOAuth = async (): Promise => - ((await importOAuthModule("./glm-zcode.ts")) as { glmZcodeOAuth: OAuthAuth }).glmZcodeOAuth; - -export const loadXaiOAuth = async (): Promise => - ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth; - -export const loadGoogleGeminiCliOAuth = async (): Promise => - ((await importOAuthModule("./google-gemini-cli.ts")) as { googleGeminiCliOAuth: OAuthAuth }).googleGeminiCliOAuth; - -export const loadGoogleAntigravityOAuth = async (): Promise => - ((await importOAuthModule("./google-antigravity.ts")) as { googleAntigravityOAuth: OAuthAuth }) - .googleAntigravityOAuth; diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index e4424c5e1..4ca474061 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -250,7 +250,7 @@ describe("AI Providers Abort Tests", () => { }); describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Abort", () => { - const llm = getModel("kimi-coding", "kimi-k2-thinking"); + const llm = getModel("kimi-coding", "k3"); it("should abort mid-stream", { retry: 3 }, async () => { await testAbortSignal(llm); diff --git a/packages/ai/test/anthropic-oauth.test.ts b/packages/ai/test/anthropic-oauth.test.ts index ae3ae0938..7e35f4f62 100644 --- a/packages/ai/test/anthropic-oauth.test.ts +++ b/packages/ai/test/anthropic-oauth.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { anthropicOAuth, loginAnthropic, refreshAnthropicToken } from "../src/auth/oauth/anthropic.ts"; import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts"; -import { anthropicOAuth, loginAnthropic, refreshAnthropicToken } from "../src/utils/oauth/anthropic.ts"; function jsonResponse(body: unknown, status: number = 200): Response { return new Response(JSON.stringify(body), { diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index d16869772..d8752bceb 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -480,8 +480,8 @@ describe("Context overflow error handling", () => { // ============================================================================= describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding", () => { - it("kimi-k2-thinking - should detect overflow via isContextOverflow", async () => { - const model = getModel("kimi-coding", "kimi-k2-thinking"); + it("k3 - should detect overflow via isContextOverflow", async () => { + const model = getModel("kimi-coding", "k3"); const result = await testContextOverflow(model, process.env.KIMI_API_KEY!); logResult(result); diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index 3c966685d..9d6b2143e 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -109,7 +109,7 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ // Together AI { provider: "together", model: "moonshotai/Kimi-K2.6", label: "together-kimi-k2.6" }, // Kimi For Coding - { provider: "kimi-coding", model: "kimi-k2-thinking", label: "kimi-coding-k2-thinking" }, + { provider: "kimi-coding", model: "k3", label: "kimi-coding-k3" }, // Mistral { provider: "mistral", model: "devstral-medium-latest", label: "mistral-devstral-medium" }, // MiniMax 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 index 19369e1a6..b40390ff4 100644 --- a/packages/ai/test/cursor-oauth.test.ts +++ b/packages/ai/test/cursor-oauth.test.ts @@ -1,7 +1,7 @@ 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"; -import { getOAuthProvider } from "../src/utils/oauth/index.ts"; -import type { OAuthProviderInterface } from "../src/utils/oauth/types.ts"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index a011666bf..18d7d4380 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -536,7 +536,7 @@ describe("AI Providers Empty Message Tests", () => { ); describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Empty Messages", () => { - const llm = getModel("kimi-coding", "kimi-k2-thinking"); + const llm = getModel("kimi-coding", "k3"); it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { await testEmptyMessage(llm); diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 963eef7d5..8572434e5 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getModels } from "../src/compat.ts"; import { githubCopilotOAuthProvider, loginGitHubCopilot, refreshGitHubCopilotToken, -} from "../src/utils/oauth/github-copilot.ts"; +} 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), { 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 index 3468e8546..f1a48729a 100644 --- a/packages/ai/test/gitlab-duo-oauth.test.ts +++ b/packages/ai/test/gitlab-duo-oauth.test.ts @@ -1,8 +1,8 @@ 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"; -import { getOAuthProvider } from "../src/utils/oauth/index.ts"; -import type { OAuthProviderInterface } from "../src/utils/oauth/types.ts"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); @@ -107,4 +107,28 @@ describe.sequential("GitLab Duo OAuth", () => { }, }); }); + + 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({ + model: { provider: "gitlab-duo", baseUrl: "https://cloud.gitlab.com" } as never, + 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/glm-zcode-oauth.test.ts b/packages/ai/test/glm-zcode-oauth.test.ts deleted file mode 100644 index a58f4321d..000000000 --- a/packages/ai/test/glm-zcode-oauth.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { builtinProviders } from "../src/providers/all.ts"; -import { getOAuthProvider } from "../src/utils/oauth/index.ts"; -import type { OAuthProviderInterface } from "../src/utils/oauth/types.ts"; - -const UPSTREAM = "upstream-private-value"; -const BUSINESS = "business-private-value"; -const ORG = "org-default"; -const PROJECT = "project-default"; -const KEY_ID = "key-id"; -const SECRET = "key-secret"; - -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("glm-zcode"); - expect(value).toBeDefined(); - if (!value) throw new Error("glm-zcode OAuth provider is not registered"); - return value; -} - -function provisioningFetch() { - const keysUrl = `https://api.z.ai/api/biz/v1/organization/${ORG}/projects/${PROJECT}/api_keys`; - return vi.fn(async (input: string | URL | Request, init?: RequestInit) => { - const url = String(input); - if (url === "https://zcode.z.ai/api/v1/oauth/token") { - return jsonResponse({ data: { token: "zcode-identity-token", zai: { access_token: UPSTREAM } } }); - } - if (url === "https://api.z.ai/api/auth/z/login") { - return jsonResponse({ data: { access_token: BUSINESS } }); - } - if (url === "https://api.z.ai/api/biz/customer/getCustomerInfo") { - return jsonResponse({ - data: { - id: "account-id", - email: "Member@Example.com", - organizations: [ - { organizationId: ORG, isDefault: true, projects: [{ projectId: PROJECT, isDefault: true }] }, - ], - }, - }); - } - if (url === keysUrl && (init?.method ?? "GET") === "GET") { - return jsonResponse({ data: [{ name: "zcode-api-key", apiKey: KEY_ID }] }); - } - if (url === `${keysUrl}/copy/${encodeURIComponent(KEY_ID)}`) { - return jsonResponse({ data: { secretKey: SECRET } }); - } - throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${url}`); - }); -} - -describe.sequential("GLM ZCode OAuth", () => { - afterEach(() => vi.unstubAllGlobals()); - - it("is registered and advertised as an unofficial opt-in model provider", () => { - expect(getOAuthProvider("glm-zcode")?.name).toMatch(/unofficial.*opt-in/i); - expect(builtinProviders().map((entry) => entry.id)).toContain("glm-zcode"); - }); - - it("exchanges a manual ZCode callback, provisions an API key, and refreshes it", async () => { - const fetchMock = provisioningFetch(); - vi.stubGlobal("fetch", fetchMock); - let state = ""; - const credentials = await provider().login({ - onAuth: (info) => { - const url = new URL(info.url); - state = url.searchParams.get("state") ?? ""; - expect(url.searchParams.get("redirect_uri")).toBe("zcode://oauth/callback"); - expect(info.instructions).toMatch(/unofficial/i); - }, - onDeviceCode: () => {}, - onPrompt: async () => "", - onManualCodeInput: async () => `zcode://oauth/callback?code=manual-code&state=${state}`, - onSelect: async () => undefined, - }); - expect(credentials).toMatchObject({ - access: `${KEY_ID}.${SECRET}`, - refresh: UPSTREAM, - email: "member@example.com", - accountId: "account-id", - }); - const brokerBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); - expect(brokerBody).toMatchObject({ code: "manual-code", redirect_uri: "zcode://oauth/callback", state }); - - const refreshed = await provider().refreshToken({ ...credentials, access: "old-key", expires: 0 }); - expect(refreshed).toMatchObject({ access: `${KEY_ID}.${SECRET}`, refresh: UPSTREAM }); - }); - - it("redacts stored and response secrets when refresh provisioning fails", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => jsonResponse({ access_token: "glm-response-private-value" }, 401)), - ); - const error = await provider() - .refreshToken({ access: "old-private-key", refresh: UPSTREAM, expires: 0 }) - .catch((value: unknown) => value); - expect(String(error)).toMatch(/re-login/i); - expect(String(error)).toContain("401"); - expect(String(error)).not.toContain(UPSTREAM); - expect(String(error)).not.toContain("glm-response-private-value"); - }); - - it("rejects an OAuth broker token endpoint override outside z.ai", async () => { - process.env.ZCODE_OAUTH_BROKER_TOKEN_URL = "https://evil.example.com/api/v1/oauth/token"; - try { - vi.stubGlobal("fetch", provisioningFetch()); - let state = ""; - await expect( - provider().login({ - onAuth: (info) => { - state = new URL(info.url).searchParams.get("state") ?? ""; - }, - onDeviceCode: () => {}, - onPrompt: async () => "", - onManualCodeInput: async () => `zcode://oauth/callback?code=manual-code&state=${state}`, - onSelect: async () => undefined, - }), - ).rejects.toThrow(/z\.ai/); - } finally { - delete process.env.ZCODE_OAUTH_BROKER_TOKEN_URL; - } - }); -}); diff --git a/packages/ai/test/google-account-oauth.test.ts b/packages/ai/test/google-account-oauth.test.ts index e4ffb5a25..c388c2a36 100644 --- a/packages/ai/test/google-account-oauth.test.ts +++ b/packages/ai/test/google-account-oauth.test.ts @@ -1,19 +1,19 @@ import { readFile } from "node:fs/promises"; import { createServer } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { builtinModels } from "../src/providers/all.ts"; import { discoverAntigravityProject, googleAntigravityOAuth, refreshAntigravityToken, -} from "../src/utils/oauth/google-antigravity.ts"; +} from "../src/auth/oauth/google-antigravity.ts"; import { discoverGeminiCliProject, googleGeminiCliOAuth, refreshGoogleCloudToken, -} from "../src/utils/oauth/google-gemini-cli.ts"; -import { googleOAuthExports } from "../src/utils/oauth/google-oauth-shared.ts"; -import { getOAuthProvider } from "../src/utils/oauth/index.ts"; +} 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 }); @@ -83,7 +83,7 @@ describe.sequential("Google account OAuth providers", () => { }); it("keeps node:http behind the Google OAuth Node-only module", async () => { - const source = await readFile(new URL("../src/utils/oauth/google-oauth-shared.ts", import.meta.url), "utf8"); + 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*\)/); }); diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index e4f918d49..6b6dde93b 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -384,7 +384,7 @@ describe("Tool Results with Images", () => { ); describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider (kimi-for-coding)", () => { - const llm = getModel("kimi-coding", "kimi-for-coding"); + const llm = getModel("kimi-coding", "kimi-for-coding-highspeed"); it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { await handleToolWithImageResult(llm); diff --git a/packages/ai/test/kilo-oauth.test.ts b/packages/ai/test/kilo-oauth.test.ts index 195395741..66e79542d 100644 --- a/packages/ai/test/kilo-oauth.test.ts +++ b/packages/ai/test/kilo-oauth.test.ts @@ -1,7 +1,7 @@ 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"; -import { getOAuthProvider } from "../src/utils/oauth/index.ts"; -import type { OAuthProviderInterface } from "../src/utils/oauth/types.ts"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); diff --git a/packages/ai/test/kimi-code-oauth.test.ts b/packages/ai/test/kimi-code-oauth.test.ts index 3de260014..62f7b029d 100644 --- a/packages/ai/test/kimi-code-oauth.test.ts +++ b/packages/ai/test/kimi-code-oauth.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { getOAuthProvider } from "../src/utils/oauth/index.ts"; -import { loginKimi, refreshKimiToken } from "../src/utils/oauth/kimi-code.ts"; +import { getOAuthProvider } from "../src/auth/oauth/index.ts"; +import { loginKimi, refreshKimiToken } from "../src/auth/oauth/kimi-code.ts"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); diff --git a/packages/ai/test/oauth-auth.test.ts b/packages/ai/test/oauth-auth.test.ts index 43be6f727..84b672966 100644 --- a/packages/ai/test/oauth-auth.test.ts +++ b/packages/ai/test/oauth-auth.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; 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 { createModels } from "../src/models.ts"; import { anthropicProvider } from "../src/providers/anthropic.ts"; import { githubCopilotProvider } from "../src/providers/github-copilot.ts"; -import { anthropicOAuth } from "../src/utils/oauth/anthropic.ts"; -import { githubCopilotOAuth } from "../src/utils/oauth/github-copilot.ts"; -import { openaiCodexOAuth } from "../src/utils/oauth/openai-codex.ts"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); diff --git a/packages/ai/test/oauth-device-code.test.ts b/packages/ai/test/oauth-device-code.test.ts index 23f97042d..fe146f586 100644 --- a/packages/ai/test/oauth-device-code.test.ts +++ b/packages/ai/test/oauth-device-code.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { pollOAuthDeviceCodeFlow } from "../src/utils/oauth/device-code.ts"; +import { pollOAuthDeviceCodeFlow } from "../src/auth/oauth/device-code.ts"; describe("OAuth device-code polling", () => { afterEach(() => { diff --git a/packages/ai/test/oauth.ts b/packages/ai/test/oauth.ts index 9938c7798..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 { getOAuthApiKey } from "../src/utils/oauth/index.ts"; -import type { OAuthCredentials, OAuthProvider } from "../src/utils/oauth/types.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"); diff --git a/packages/ai/test/openai-codex-device-oauth.test.ts b/packages/ai/test/openai-codex-device-oauth.test.ts index ec62cd1b8..6d0600ea7 100644 --- a/packages/ai/test/openai-codex-device-oauth.test.ts +++ b/packages/ai/test/openai-codex-device-oauth.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getOAuthProvider, resolveOAuthStorageProvider } from "../src/utils/oauth/index.ts"; -import { openaiCodexDeviceOAuthProvider } from "../src/utils/oauth/openai-codex-device.ts"; +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" } }); diff --git a/packages/ai/test/openai-codex-oauth.test.ts b/packages/ai/test/openai-codex-oauth.test.ts index 820fbe6ba..8ee719020 100644 --- a/packages/ai/test/openai-codex-oauth.test.ts +++ b/packages/ai/test/openai-codex-oauth.test.ts @@ -3,7 +3,7 @@ import { loginOpenAICodexDeviceCode, openaiCodexOAuthProvider, refreshOpenAICodexToken, -} from "../src/utils/oauth/openai-codex.ts"; +} from "../src/auth/oauth/openai-codex.ts"; function jsonResponse(body: unknown, status: number = 200): Response { return new Response(JSON.stringify(body), { diff --git a/packages/ai/test/perplexity-oauth.test.ts b/packages/ai/test/perplexity-oauth.test.ts index ce3541f83..ff018cad2 100644 --- a/packages/ai/test/perplexity-oauth.test.ts +++ b/packages/ai/test/perplexity-oauth.test.ts @@ -1,7 +1,7 @@ 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"; -import { getOAuthProvider } from "../src/utils/oauth/index.ts"; -import type { OAuthProviderInterface } from "../src/utils/oauth/types.ts"; const originalNoBorrow = process.env.PI_AUTH_NO_BORROW; @@ -14,13 +14,6 @@ function jwt(payload: Record): string { return `${encode({ alg: "none" })}.${encode(payload)}.`; } -function provider(): OAuthProviderInterface { - const value = getOAuthProvider("perplexity"); - expect(value).toBeDefined(); - if (!value) throw new Error("perplexity OAuth provider is not registered"); - return value; -} - describe.sequential("Perplexity OAuth", () => { afterEach(() => { vi.unstubAllGlobals(); @@ -28,8 +21,12 @@ describe.sequential("Perplexity OAuth", () => { else process.env.PI_AUTH_NO_BORROW = originalNoBorrow; }); - it("is registered and advertised by a model provider", () => { - expect(getOAuthProvider("perplexity")?.id).toBe("perplexity"); + 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"); }); @@ -60,7 +57,7 @@ describe.sequential("Perplexity OAuth", () => { ); const answers = ["member@example.com", "123456"]; // When: the email OTP flow sends and verifies the code. - const credentials = await provider().login({ + const credentials = await loginPerplexity({ onAuth: () => {}, onDeviceCode: () => {}, onPrompt: async () => answers.shift() ?? "", @@ -72,7 +69,7 @@ describe.sequential("Perplexity OAuth", () => { expect(credentials).toMatchObject({ access: tokenWithoutExpiry, refresh: tokenWithoutExpiry }); expect(credentials.expires).toBe(8.64e15); - const refreshed = await provider().refreshToken({ ...credentials, expires: 1 }); + const refreshed = await refreshPerplexityToken({ ...credentials, expires: 1 }); expect(refreshed.access).toBe(tokenWithoutExpiry); expect(refreshed.expires).toBe(8.64e15); }); @@ -80,7 +77,7 @@ describe.sequential("Perplexity OAuth", () => { 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 provider().refreshToken({ access: expiring, refresh: expiring, expires: 1 }); + 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"; @@ -101,14 +98,12 @@ describe.sequential("Perplexity OAuth", () => { }), ); const answers = ["private@example.com", "otp-private-value"]; - const error = await provider() - .login({ - onAuth: () => {}, - onDeviceCode: () => {}, - onPrompt: async () => answers.shift() ?? "", - onSelect: async () => undefined, - }) - .catch((value: unknown) => 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/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 0ca94afb2..5cbcb88e0 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1019,32 +1019,29 @@ describe("Generate E2E Tests", () => { }); }); - describe.skipIf(!process.env.KIMI_API_KEY)( - "Kimi For Coding Provider (kimi-k2-thinking via Anthropic Messages)", - () => { - const llm = getModel("kimi-coding", "kimi-k2-thinking"); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider (k3 via Anthropic Messages)", () => { + const llm = getModel("kimi-coding", "k3"); - it("should complete basic text generation", { retry: 3 }, async () => { - await basicTextGeneration(llm); - }); + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); - it("should handle tool calling", { retry: 3 }, async () => { - await handleToolCall(llm); - }); + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); - it("should handle streaming", { retry: 3 }, async () => { - await handleStreaming(llm); - }); + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); - it("should handle thinking mode", { retry: 3 }, async () => { - await handleThinking(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 }); - }); + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 }); + }); - it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { - await multiTurn(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 }); - }); - }, - ); + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 }); + }); + }); describe.skipIf(!process.env.XIAOMI_API_KEY)( "Xiaomi MiMo (API billing) Provider (Xiaomi MiMo-V2.5-Pro via Anthropic Messages)", diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 4601f9ffc..e8aac1ef2 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -218,7 +218,7 @@ describe("Token Statistics on Abort", () => { }); describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider", () => { - const llm = getModel("kimi-coding", "kimi-for-coding"); + const llm = getModel("kimi-coding", "kimi-for-coding-highspeed"); it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { await testTokensOnAbort(llm); diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 62322e39a..69e02827e 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -255,7 +255,7 @@ describe("Tool Call Without Result Tests", () => { }); describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider", () => { - const model = getModel("kimi-coding", "kimi-k2-thinking"); + const model = getModel("kimi-coding", "k3"); it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { await testToolCallWithoutResult(model); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 97f4dd9d2..307fd6fb7 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -556,11 +556,11 @@ describe("totalTokens field", () => { // ========================================================================= describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding", () => { - it("kimi-k2-thinking - should return totalTokens equal to sum of components", { + it("k3 - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000, }, async () => { - const llm = getModel("kimi-coding", "kimi-k2-thinking"); + const llm = getModel("kimi-coding", "k3"); console.log(`\nKimi For Coding / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.KIMI_API_KEY }); diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index 100e7adef..bf101414e 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -693,7 +693,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { ); describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Unicode Handling", () => { - const llm = getModel("kimi-coding", "kimi-k2-thinking"); + const llm = getModel("kimi-coding", "k3"); it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { await testEmojiInToolResults(llm); diff --git a/packages/ai/test/xai-oauth.test.ts b/packages/ai/test/xai-oauth.test.ts index ada94bd87..6f44a5c75 100644 --- a/packages/ai/test/xai-oauth.test.ts +++ b/packages/ai/test/xai-oauth.test.ts @@ -1,14 +1,14 @@ import { readFile } from "node:fs/promises"; import { createServer } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { xaiProvider } from "../src/providers/xai.ts"; import { discoverXaiOAuthEndpoints, exchangeXaiAuthorizationCode, getOAuthProvider, refreshXaiToken, -} from "../src/utils/oauth/index.ts"; -import { loginXai, parseXaiAuthorizationInput, xaiOAuth } from "../src/utils/oauth/xai.ts"; +} from "../src/auth/oauth/index.ts"; +import { loginXai, parseXaiAuthorizationInput, xaiOAuth } from "../src/auth/oauth/xai.ts"; +import { xaiProvider } from "../src/providers/xai.ts"; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); @@ -18,7 +18,7 @@ describe.sequential("xAI OAuth", () => { afterEach(() => vi.unstubAllGlobals()); it("does not statically import node:http from the registry-loaded xAI module", async () => { - const source = await readFile(new URL("../src/utils/oauth/xai.ts", import.meta.url), "utf8"); + const source = await readFile(new URL("../src/auth/oauth/xai.ts", import.meta.url), "utf8"); expect(source).not.toMatch(/^import .*node:http/m); }); 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 90ce569e5..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 (copied from packages/ai/src/utils/oauth/anthropic.ts) +// 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 bbdebc6a2..11d6b9bd2 100644 --- a/packages/coding-agent/src/core/auth-providers.ts +++ b/packages/coding-agent/src/core/auth-providers.ts @@ -14,7 +14,7 @@ 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()); -const OAUTH_ONLY_MODEL_PROVIDERS = new Set(["openai-codex-device"]); +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 { diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 282c8723e..850a3f12a 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -29,7 +29,6 @@ export const defaultModelPerProvider: Record = { fugu: "fugu", "github-copilot": "gpt-5.4", "gitlab-duo": "claude-sonnet-4-6", - "glm-zcode": "glm-5.2", google: "gemini-3.1-pro-preview", "google-antigravity": "claude-sonnet-4-5", "google-gemini-cli": "gemini-2.5-pro", diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index 2178d9ef4..c86290df8 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -14,7 +14,6 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { fireworks: "Fireworks", fugu: "Sakana Fugu", "gitlab-duo": "GitLab Duo", - "glm-zcode": "GLM ZCode (Unofficial)", google: "Google Gemini", "google-antigravity": "Google Antigravity", "google-gemini-cli": "Google Gemini CLI (Cloud Code Assist)", diff --git a/packages/coding-agent/test/oauth-selector.test.ts b/packages/coding-agent/test/oauth-selector.test.ts index 7164db54b..f5bec4808 100644 --- a/packages/coding-agent/test/oauth-selector.test.ts +++ b/packages/coding-agent/test/oauth-selector.test.ts @@ -38,6 +38,10 @@ describe("OAuthSelectorComponent", () => { expect(isApiKeyLoginProvider("amazon-bedrock", oauthProviderIds, builtInProviderIds)).toBe(true); expect(isApiKeyLoginProvider("custom-oauth", oauthProviderIds, builtInProviderIds)).toBe(false); expect(isApiKeyLoginProvider("custom-api", oauthProviderIds, builtInProviderIds)).toBe(true); + // Google Cloud Code Assist providers are OAuth-only (serialized token+projectId). + expect(isApiKeyLoginProvider("google-gemini-cli", oauthProviderIds, builtInProviderIds)).toBe(false); + expect(isApiKeyLoginProvider("google-antigravity", oauthProviderIds, builtInProviderIds)).toBe(false); + expect(isApiKeyLoginProvider("openai-codex-device", oauthProviderIds, builtInProviderIds)).toBe(false); }); it("shows stored OAuth auth distinctly in the API key selector", () => { From a00dc64d341a1b3d8bc8d07d390448c982581b01 Mon Sep 17 00:00:00 2001 From: islee Date: Sun, 19 Jul 2026 20:46:32 +0900 Subject: [PATCH 06/11] fix(ai): restore main xAI device OAuth and Google OAuth-only login Keep main's device-code xAI flow (drop PR browser discovery), add a thin legacy OAuthProviderInterface adapter for the compat registry, and exclude google-gemini-cli / google-antigravity from API-key /login. --- packages/ai/src/auth/oauth/xai.ts | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) 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, +}; From 5639d89b948226d5068c361b4460fe771c731db8 Mon Sep 17 00:00:00 2001 From: islee Date: Sun, 19 Jul 2026 21:25:29 +0900 Subject: [PATCH 07/11] fix(ai): remove duplicate googleProvider registration builtinProviders listed googleProvider() twice, so Models.setProvider collapsed to 63 unique providers while the array length was 64 and providers.test failed. Matches main's single registration. --- packages/ai/src/providers/all.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index f1f035dd5..a78fbd381 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -159,7 +159,6 @@ export function builtinProviders(): Provider[] { githubCopilotProvider(), gitlabDuoProvider(), googleProvider(), - googleProvider(), googleGeminiCliProvider(), googleAntigravityProvider(), googleVertexProvider(), From 8b2559e024b12b69d8a9decec9f1b118594f6bb2 Mon Sep 17 00:00:00 2001 From: islee Date: Sun, 19 Jul 2026 21:44:41 +0900 Subject: [PATCH 08/11] fix(coding-agent): add Gajae provider display names for /login Expose minimax-code, minimax-code-cn, moonshot, and opencode-zen as API-key login targets via display-name eligibility. --- packages/coding-agent/src/core/provider-display-names.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index ebfd5dc18..28d254ec4 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", From 02f9060eb99726ae6281589767ca3cc906e721ab Mon Sep 17 00:00:00 2001 From: islee Date: Sun, 19 Jul 2026 22:08:19 +0900 Subject: [PATCH 09/11] test(coding-agent): tolerate wrapped paths in tool-execution compact render Long absolute worktree paths wrap at render width 120, so compact collapsed text is no longer a single line. Flatten whitespace before asserting the compact resource label. --- .../coding-agent/test/tool-execution-component.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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); From a48f7d5819d4366788c487afd70f31aca544255e Mon Sep 17 00:00:00 2001 From: islee Date: Sun, 19 Jul 2026 23:05:23 +0900 Subject: [PATCH 10/11] test(coding-agent): stabilize app-server idle-unload fake timers Build harness under real timers and enable fake timers only for the idle-unload countdown so Vitest no longer flakes with 30s hangs on advanceTimersByTimeAsync/runOnlyPendingTimersAsync in CI. --- .../test/suite/app-server-thread-handlers-catch.test.ts | 6 ++++-- .../test/suite/app-server-thread-handlers-cold.test.ts | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) 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. From 5a55d911c77c4ec47e688cd8e10826f3b7d7bbaf Mon Sep 17 00:00:00 2001 From: islee Date: Mon, 20 Jul 2026 01:44:29 +0900 Subject: [PATCH 11/11] fix(ai): unify Kimi login to single kimi-coding entry kimi-code and kimi-coding are the same product at api.kimi.com/coding with the same KIMI_API_KEY. Upstream kimi-coding is the maintained entry (k3 video input, adaptive thinking), so drop the PR-only kimi-code OAuth provider and its catalog so /login shows exactly one Kimi entry. Adds a regression asserting a single kimi-* provider and KIMI_API_KEY resolution. --- packages/ai/scripts/generate-models.ts | 92 ----- packages/ai/src/auth/oauth/index.ts | 4 - packages/ai/src/auth/oauth/kimi-code.ts | 326 ------------------ packages/ai/src/auth/oauth/load.ts | 6 - packages/ai/src/bun-oauth.ts | 2 - packages/ai/src/env-api-keys.ts | 1 - packages/ai/src/models.generated.ts | 2 - packages/ai/src/oauth.ts | 1 - packages/ai/src/providers/all.ts | 2 - packages/ai/src/providers/data/kimi-code.json | 1 - packages/ai/src/providers/kimi-code.models.ts | 28 -- packages/ai/src/providers/kimi-code.ts | 19 - packages/ai/src/types.ts | 1 - .../ai/test/gajae-provider-id-gaps.test.ts | 27 +- packages/ai/test/kimi-code-oauth.test.ts | 151 -------- .../coding-agent/src/core/model-resolver.ts | 1 - .../src/core/provider-display-names.ts | 1 - 17 files changed, 16 insertions(+), 649 deletions(-) delete mode 100644 packages/ai/src/auth/oauth/kimi-code.ts delete mode 100644 packages/ai/src/providers/data/kimi-code.json delete mode 100644 packages/ai/src/providers/kimi-code.models.ts delete mode 100644 packages/ai/src/providers/kimi-code.ts delete mode 100644 packages/ai/test/kimi-code-oauth.test.ts diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index ba6a6a293..3bae7ba70 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -126,20 +126,6 @@ const KIMI_STATIC_HEADERS = { "User-Agent": "KimiCLI/1.5", } as const; -const KIMI_CODE_STATIC_HEADERS = { - "User-Agent": "KimiCLI/1.5", - "X-Msh-Platform": "kimi_cli", -} as const; - -const KIMI_CODE_COMPAT: OpenAICompletionsCompat = { - supportsStore: false, - supportsDeveloperRole: false, - supportsReasoningEffort: false, - maxTokensField: "max_tokens", - supportsStrictMode: false, - thinkingFormat: "zai", -}; - const TOGETHER_BASE_URL = "https://api.together.ai/v1"; const TOGETHER_BASE_COMPAT: OpenAICompletionsCompat = { supportsStore: false, @@ -3040,84 +3026,6 @@ async function generateModels() { ]); allModels.push(...googleGeminiCliModels, ...googleAntigravityModels); - // Kimi Code OAuth uses Kimi's coding endpoint rather than the API-key-only - // kimi-coding provider. Keep the bundled set explicit because the endpoint's - // unauthenticated model catalog is not available during generation. - const kimiCodeModels: Model<"openai-completions">[] = [ - { - id: "kimi-for-coding", - name: "Kimi For Coding", - api: "openai-completions", - provider: "kimi-code", - baseUrl: "https://api.kimi.com/coding/v1", - headers: KIMI_CODE_STATIC_HEADERS, - compat: KIMI_CODE_COMPAT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 262144, - maxTokens: 32000, - }, - { - id: "kimi-k2", - name: "Kimi K2", - api: "openai-completions", - provider: "kimi-code", - baseUrl: "https://api.kimi.com/coding/v1", - headers: KIMI_CODE_STATIC_HEADERS, - compat: KIMI_CODE_COMPAT, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 262144, - maxTokens: 262144, - }, - { - id: "kimi-k2-turbo-preview", - name: "Kimi K2 Turbo Preview", - api: "openai-completions", - provider: "kimi-code", - baseUrl: "https://api.kimi.com/coding/v1", - headers: KIMI_CODE_STATIC_HEADERS, - compat: KIMI_CODE_COMPAT, - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 262144, - maxTokens: 32000, - }, - { - id: "kimi-k2.5", - name: "Kimi K2.5", - api: "openai-completions", - provider: "kimi-code", - baseUrl: "https://api.kimi.com/coding/v1", - headers: KIMI_CODE_STATIC_HEADERS, - compat: KIMI_CODE_COMPAT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 262144, - maxTokens: 65536, - }, - { - id: "kimi-k2.7-code", - name: "Kimi K2.7 Code", - api: "openai-completions", - provider: "kimi-code", - baseUrl: "https://api.kimi.com/coding/v1", - headers: KIMI_CODE_STATIC_HEADERS, - compat: KIMI_CODE_COMPAT, - reasoning: true, - thinkingLevelMap: { off: null }, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 262144, - maxTokens: 65536, - }, - ]; - allModels.push(...kimiCodeModels); - for (const model of allModels) { applyThinkingLevelMetadata(model); applyOpenAICompletionsCompatMetadata(model); diff --git a/packages/ai/src/auth/oauth/index.ts b/packages/ai/src/auth/oauth/index.ts index 15d1842de..a5fd7929e 100644 --- a/packages/ai/src/auth/oauth/index.ts +++ b/packages/ai/src/auth/oauth/index.ts @@ -16,7 +16,6 @@ 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 * from "./kimi-code.ts"; export { loadAnthropicOAuth, loadCursorOAuth, @@ -25,7 +24,6 @@ export { loadGoogleAntigravityOAuth, loadGoogleGeminiCliOAuth, loadKiloOAuth, - loadKimiCodeOAuth, loadOpenAICodexDeviceOAuth, loadOpenAICodexOAuth, loadPerplexityOAuth, @@ -53,7 +51,6 @@ 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 { kimiCodeOAuthProvider } from "./kimi-code.ts"; import { openaiCodexOAuthProvider } from "./openai-codex.ts"; import { openaiCodexDeviceOAuthProvider } from "./openai-codex-device.ts"; import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.ts"; @@ -68,7 +65,6 @@ const providers: OAuthProviderInterface[] = [ gitlabDuoOAuthProvider, // Perplexity session OAuth is not advertised for model-request login. kiloOAuthProvider, - kimiCodeOAuthProvider, xaiOAuthProvider, googleGeminiCliOAuthProvider, googleAntigravityOAuthProvider, diff --git a/packages/ai/src/auth/oauth/kimi-code.ts b/packages/ai/src/auth/oauth/kimi-code.ts deleted file mode 100644 index 2e9bbd7ac..000000000 --- a/packages/ai/src/auth/oauth/kimi-code.ts +++ /dev/null @@ -1,326 +0,0 @@ -/** - * Kimi Code OAuth flow (device-code against auth.kimi.com). - * - * Kimi reconciliation (reviewer point #3): - * `kimi-coding` (main) and `kimi-code` (this PR) are the SAME product — Kimi - * For Coding at api.kimi.com/coding, same `KIMI_API_KEY`, same `KimiCLI/1.5` - * User-Agent, OAuth host auth.kimi.com. They differ only in API flavor: - * `kimi-coding` speaks the Anthropic-messages API; `kimi-code` speaks the - * OpenAI-completions API (baseUrl .../coding/v1). Because a single provider - * entry is bound to one `api`/`baseUrl`, they cannot collapse into one - * provider without dropping a flavor, so both provider entries are kept. - * - * Login entry decision: the OAuth flow lives here on `kimi-code` (the - * OpenAI-flavor provider). The OAuth `access` token is the bearer the - * `kimi-coding` Anthropic-flavor endpoint also accepts, so one login - * credential could serve both; wiring OAuth into `kimi-coding` (currently - * api-key-only via `envApiKeyAuth`) is deferred to a follow-up that owns the - * shared-credential architecture. Until then, `kimi-coding` keeps api-key - * auth and `kimi-code` owns OAuth — two providers, one product. - */ - -import { getProviderEnvValue } from "../../utils/provider-env.ts"; -import type { OAuthAuth } from "../types.ts"; -import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.ts"; - -const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; -const DEFAULT_OAUTH_HOST = "https://auth.kimi.com"; -const DEVICE_ID_FILENAME = "kimi-device-id"; -const DEFAULT_POLL_INTERVAL_SECONDS = 5; -const DEFAULT_DEVICE_FLOW_TTL_SECONDS = 15 * 60; -const OAUTH_EXPIRY_SKEW_MS = 5 * 60_000; -const REQUEST_TIMEOUT_MS = 30_000; -const KIMI_CLIENT_VERSION = "1.5"; - -interface DeviceAuthorizationResponse { - user_code?: unknown; - device_code?: unknown; - verification_uri?: unknown; - verification_uri_complete?: unknown; - expires_in?: unknown; - interval?: unknown; -} - -interface TokenResponse { - access_token?: unknown; - refresh_token?: unknown; - expires_in?: unknown; - error?: unknown; - interval?: unknown; -} - -export interface KimiCommonHeaders { - "User-Agent": string; - "X-Msh-Platform": string; - "X-Msh-Version": string; - "X-Msh-Device-Name": string; - "X-Msh-Device-Model": string; - "X-Msh-Os-Version": string; - "X-Msh-Device-Id": string; -} - -let commonHeadersPromise: Promise> | undefined; - -function requestSignal(signal?: AbortSignal): AbortSignal { - const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); - return signal ? AbortSignal.any([signal, timeout]) : timeout; -} - -function validateKimiUrl(value: unknown, label: string): URL { - if (typeof value !== "string" || !value) throw new Error(`Kimi OAuth response is missing ${label}`); - const url = new URL(value); - const host = url.hostname.toLowerCase(); - if ( - url.protocol !== "https:" || - url.username !== "" || - url.password !== "" || - (host !== "kimi.com" && !host.endsWith(".kimi.com")) - ) { - throw new Error(`Kimi OAuth returned an unexpected ${label}`); - } - return url; -} - -function resolveOAuthHost(): string { - const configured = getProviderEnvValue("KIMI_CODE_OAUTH_HOST") || getProviderEnvValue("KIMI_OAUTH_HOST"); - const url = validateKimiUrl(configured || DEFAULT_OAUTH_HOST, "OAuth host"); - if (url.pathname !== "/" || url.search || url.hash) { - throw new Error("Kimi OAuth returned an unexpected OAuth host"); - } - return url.origin; -} - -function isNodeErrorCode(error: unknown, code: string): boolean { - return typeof error === "object" && error !== null && "code" in error && error.code === code; -} - -async function readDeviceId(): Promise { - const [{ mkdir, readFile, writeFile, chmod }, os, path] = await Promise.all([ - import("node:fs/promises"), - import("node:os"), - import("node:path"), - ]); - const agentDir = - getProviderEnvValue("SENPI_CODING_AGENT_DIR") || - getProviderEnvValue("PI_CODING_AGENT_DIR") || - path.join(os.homedir(), ".senpi", "agent"); - const deviceIdPath = path.join(agentDir, DEVICE_ID_FILENAME); - await mkdir(agentDir, { recursive: true, mode: 0o700 }); - - try { - const existing = (await readFile(deviceIdPath, "utf8")).trim(); - if (/^[A-Za-z0-9_-]{16,128}$/.test(existing)) return existing; - } catch (error) { - if (!isNodeErrorCode(error, "ENOENT")) throw error; - } - - const deviceId = crypto.randomUUID().replace(/-/g, ""); - try { - await writeFile(deviceIdPath, `${deviceId}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); - } catch (error) { - if (!isNodeErrorCode(error, "EEXIST")) throw error; - const existing = (await readFile(deviceIdPath, "utf8")).trim(); - if (/^[A-Za-z0-9_-]{16,128}$/.test(existing)) return existing; - await writeFile(deviceIdPath, `${deviceId}\n`, { encoding: "utf8", mode: 0o600 }); - await chmod(deviceIdPath, 0o600); - } - return deviceId; -} - -export function getKimiCommonHeaders(): Promise> { - commonHeadersPromise ??= (async () => { - const os = await import("node:os"); - const deviceId = await readDeviceId(); - return Object.freeze({ - "User-Agent": `KimiCLI/${KIMI_CLIENT_VERSION}`, - "X-Msh-Platform": "kimi_cli", - "X-Msh-Version": KIMI_CLIENT_VERSION, - "X-Msh-Device-Name": os.hostname(), - "X-Msh-Device-Model": `${os.platform()} ${os.release()} ${os.arch()}`, - "X-Msh-Os-Version": os.version(), - "X-Msh-Device-Id": deviceId, - }); - })(); - return commonHeadersPromise; -} - -async function readJsonObject(response: Response): Promise> { - const value = await response.json().catch(() => undefined); - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; -} - -async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{ - userCode: string; - deviceCode: string; - verificationUri: string; - verificationUriComplete: string; - expiresInSeconds: number; - intervalSeconds: number; -}> { - const response = await fetch(`${resolveOAuthHost()}/api/oauth/device_authorization`, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - ...(await getKimiCommonHeaders()), - }, - body: new URLSearchParams({ client_id: CLIENT_ID }).toString(), - signal: requestSignal(signal), - }); - const payload = (await readJsonObject(response)) as DeviceAuthorizationResponse; - if (!response.ok) throw new Error(`Kimi device authorization failed (${response.status})`); - if (typeof payload.user_code !== "string" || typeof payload.device_code !== "string") { - throw new Error("Kimi device authorization response missing required fields"); - } - const verificationUri = validateKimiUrl(payload.verification_uri, "verification URI").toString(); - const verificationUriComplete = - payload.verification_uri_complete === undefined - ? verificationUri - : validateKimiUrl(payload.verification_uri_complete, "complete verification URI").toString(); - const expiresInSeconds = - typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in) && payload.expires_in > 0 - ? payload.expires_in - : DEFAULT_DEVICE_FLOW_TTL_SECONDS; - const intervalSeconds = - typeof payload.interval === "number" && Number.isFinite(payload.interval) && payload.interval > 0 - ? payload.interval - : DEFAULT_POLL_INTERVAL_SECONDS; - return { - userCode: payload.user_code, - deviceCode: payload.device_code, - verificationUri, - verificationUriComplete, - expiresInSeconds, - intervalSeconds, - }; -} - -function parseTokenPayload(payload: TokenResponse, refreshTokenFallback?: string): OAuthCredentials { - if ( - typeof payload.access_token !== "string" || - !payload.access_token || - typeof payload.expires_in !== "number" || - !Number.isFinite(payload.expires_in) || - payload.expires_in <= 0 - ) { - throw new Error("Kimi token response missing required fields"); - } - const refresh = - typeof payload.refresh_token === "string" && payload.refresh_token ? payload.refresh_token : refreshTokenFallback; - if (!refresh) throw new Error("Kimi token response missing refresh token"); - return { - access: payload.access_token, - refresh, - expires: Date.now() + payload.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS, - }; -} - -async function pollForToken( - deviceCode: string, - intervalSeconds: number, - expiresInSeconds: number, - signal?: AbortSignal, -): Promise { - return pollOAuthDeviceCodeFlow({ - intervalSeconds, - expiresInSeconds, - signal, - poll: async () => { - const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - ...(await getKimiCommonHeaders()), - }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - device_code: deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }).toString(), - signal: requestSignal(signal), - }); - const payload = (await readJsonObject(response)) as TokenResponse; - if (response.ok && typeof payload.access_token === "string") { - return { status: "complete", value: parseTokenPayload(payload) }; - } - const error = typeof payload.error === "string" ? payload.error : undefined; - if (error === "authorization_pending") return { status: "pending" }; - if (error === "slow_down") { - return { - status: "slow_down", - intervalSeconds: - typeof payload.interval === "number" && Number.isFinite(payload.interval) && payload.interval > 0 - ? payload.interval - : undefined, - }; - } - if (error === "expired_token") return { status: "failed", message: "Kimi device authorization expired" }; - if (error === "access_denied") return { status: "failed", message: "Kimi device authorization denied" }; - return { status: "failed", message: `Kimi device flow failed (${response.status})` }; - }, - }); -} - -export async function loginKimiCode(callbacks: OAuthLoginCallbacks): Promise { - const device = await requestDeviceAuthorization(callbacks.signal); - callbacks.onDeviceCode({ - userCode: device.userCode, - verificationUri: device.verificationUriComplete, - intervalSeconds: device.intervalSeconds, - expiresInSeconds: device.expiresInSeconds, - }); - return pollForToken(device.deviceCode, device.intervalSeconds, device.expiresInSeconds, callbacks.signal); -} - -export async function refreshKimiCodeToken(refreshToken: string, signal?: AbortSignal): Promise { - if (!refreshToken) throw new Error("Kimi credentials do not include a refresh token"); - const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - ...(await getKimiCommonHeaders()), - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: CLIENT_ID, - }).toString(), - signal: requestSignal(signal), - }); - const payload = (await readJsonObject(response)) as TokenResponse; - if (!response.ok) throw new Error(`Kimi token refresh failed (${response.status})`); - return parseTokenPayload(payload, refreshToken); -} - -export const loginKimi = loginKimiCode; -export const refreshKimiToken = refreshKimiCodeToken; - -export const kimiCodeOAuth: OAuthAuth = { - name: "Kimi Code", - async login(callbacks) { - const credentials = await loginKimiCode({ - 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 }), - onSelect: async () => undefined, - signal: callbacks.signal, - }); - return { ...credentials, type: "oauth" }; - }, - refresh: async (credential) => ({ ...(await refreshKimiCodeToken(credential.refresh)), type: "oauth" }), - toAuth: async (credential) => ({ apiKey: credential.access }), -}; - -export const kimiCodeOAuthProvider: OAuthProviderInterface = { - id: "kimi-code", - name: "Kimi Code", - login: loginKimiCode, - refreshToken: (credentials) => refreshKimiCodeToken(credentials.refresh), - getApiKey: (credentials) => credentials.access, -}; diff --git a/packages/ai/src/auth/oauth/load.ts b/packages/ai/src/auth/oauth/load.ts index 80bdeb793..3f0118b29 100644 --- a/packages/ai/src/auth/oauth/load.ts +++ b/packages/ai/src/auth/oauth/load.ts @@ -20,7 +20,6 @@ type OAuthFlowLoaders = { gitlabDuo: () => OAuthAuth | Promise; perplexity: () => OAuthAuth | Promise; kilo: () => OAuthAuth | Promise; - kimiCode: () => OAuthAuth | Promise; xai: () => OAuthAuth | Promise; googleGeminiCli: () => OAuthAuth | Promise; googleAntigravity: () => OAuthAuth | Promise; @@ -75,11 +74,6 @@ export const loadKiloOAuth = async (): Promise => { return ((await importOAuthModule("./kilo.ts")) as { kiloOAuth: OAuthAuth }).kiloOAuth; }; -export const loadKimiCodeOAuth = async (): Promise => { - if (bundledLoaders) return bundledLoaders.kimiCode(); - return ((await importOAuthModule("./kimi-code.ts")) as { kimiCodeOAuth: OAuthAuth }).kimiCodeOAuth; -}; - export const loadXaiOAuth = async (): Promise => { if (bundledLoaders) return bundledLoaders.xai(); return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth; diff --git a/packages/ai/src/bun-oauth.ts b/packages/ai/src/bun-oauth.ts index f02e804c0..c49c39a9e 100644 --- a/packages/ai/src/bun-oauth.ts +++ b/packages/ai/src/bun-oauth.ts @@ -5,7 +5,6 @@ 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 { kimiCodeOAuth } from "./auth/oauth/kimi-code.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"; @@ -24,7 +23,6 @@ export function registerBunOAuthFlows(): void { gitlabDuo: () => gitlabDuoOAuth, perplexity: () => perplexityOAuth, kilo: () => kiloOAuth, - kimiCode: () => kimiCodeOAuth, xai: () => xaiOAuth, googleGeminiCli: () => googleGeminiCliOAuth, googleAntigravity: () => googleAntigravityOAuth, diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 4783f0913..046baf86b 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -100,7 +100,6 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { groq: "GROQ_API_KEY", huggingface: "HF_TOKEN", kilo: "KILO_API_KEY", - "kimi-code": "KIMI_API_KEY", "kimi-coding": "KIMI_API_KEY", litellm: "LITELLM_API_KEY", "lm-studio": "LM_STUDIO_API_KEY", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index efce7abb6..4a10c6e7b 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -24,7 +24,6 @@ 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_CODE_MODELS } from "./providers/kimi-code.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"; @@ -88,7 +87,6 @@ export const MODELS = { "groq": GROQ_MODELS, "huggingface": HUGGINGFACE_MODELS, "kilo": KILO_MODELS, - "kimi-code": KIMI_CODE_MODELS, "kimi-coding": KIMI_CODING_MODELS, "litellm": LITELLM_MODELS, "lm-studio": LM_STUDIO_MODELS, diff --git a/packages/ai/src/oauth.ts b/packages/ai/src/oauth.ts index c724b5de4..03837062c 100644 --- a/packages/ai/src/oauth.ts +++ b/packages/ai/src/oauth.ts @@ -17,7 +17,6 @@ export { loadGoogleAntigravityOAuth, loadGoogleGeminiCliOAuth, loadKiloOAuth, - loadKimiCodeOAuth, loadOpenAICodexDeviceOAuth, loadOpenAICodexOAuth, loadPerplexityOAuth, diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index a78fbd381..b84b0cc28 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -24,7 +24,6 @@ import { googleVertexProvider } from "./google-vertex.ts"; import { groqProvider } from "./groq.ts"; import { huggingfaceProvider } from "./huggingface.ts"; import { kiloProvider } from "./kilo.ts"; -import { kimiCodeProvider } from "./kimi-code.ts"; import { kimiCodingProvider } from "./kimi-coding.ts"; import { litellmProvider } from "./litellm.ts"; import { lmStudioProvider } from "./lm-studio.ts"; @@ -165,7 +164,6 @@ export function builtinProviders(): Provider[] { groqProvider(), huggingfaceProvider(), kiloProvider(), - kimiCodeProvider(), kimiCodingProvider(), litellmProvider(), lmStudioProvider(), diff --git a/packages/ai/src/providers/data/kimi-code.json b/packages/ai/src/providers/data/kimi-code.json deleted file mode 100644 index b10f05270..000000000 --- a/packages/ai/src/providers/data/kimi-code.json +++ /dev/null @@ -1 +0,0 @@ -{"kimi-for-coding":{"id":"kimi-for-coding","name":"Kimi For Coding","api":"openai-completions","provider":"kimi-code","baseUrl":"https://api.kimi.com/coding/v1","headers":{"User-Agent":"KimiCLI/1.5","X-Msh-Platform":"kimi_cli"},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"zai"},"reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":32000},"kimi-k2":{"id":"kimi-k2","name":"Kimi K2","api":"openai-completions","provider":"kimi-code","baseUrl":"https://api.kimi.com/coding/v1","headers":{"User-Agent":"KimiCLI/1.5","X-Msh-Platform":"kimi_cli"},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"zai"},"reasoning":false,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144},"kimi-k2-turbo-preview":{"id":"kimi-k2-turbo-preview","name":"Kimi K2 Turbo Preview","api":"openai-completions","provider":"kimi-code","baseUrl":"https://api.kimi.com/coding/v1","headers":{"User-Agent":"KimiCLI/1.5","X-Msh-Platform":"kimi_cli"},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"zai"},"reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":32000},"kimi-k2.5":{"id":"kimi-k2.5","name":"Kimi K2.5","api":"openai-completions","provider":"kimi-code","baseUrl":"https://api.kimi.com/coding/v1","headers":{"User-Agent":"KimiCLI/1.5","X-Msh-Platform":"kimi_cli"},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"zai"},"reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":65536},"kimi-k2.7-code":{"id":"kimi-k2.7-code","name":"Kimi K2.7 Code","api":"openai-completions","provider":"kimi-code","baseUrl":"https://api.kimi.com/coding/v1","headers":{"User-Agent":"KimiCLI/1.5","X-Msh-Platform":"kimi_cli"},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"zai"},"reasoning":true,"thinkingLevelMap":{"off":null},"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":65536}} diff --git a/packages/ai/src/providers/kimi-code.models.ts b/packages/ai/src/providers/kimi-code.models.ts deleted file mode 100644 index 24096792c..000000000 --- a/packages/ai/src/providers/kimi-code.models.ts +++ /dev/null @@ -1,28 +0,0 @@ -// 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/kimi-code.json" with { type: "json" }; -import type { Model } from "../types.ts"; - -export const KIMI_CODE_MODELS = values as { - "kimi-for-coding": Model<"openai-completions"> & { - id: "kimi-for-coding"; - provider: "kimi-code"; - }; - "kimi-k2": Model<"openai-completions"> & { - id: "kimi-k2"; - provider: "kimi-code"; - }; - "kimi-k2-turbo-preview": Model<"openai-completions"> & { - id: "kimi-k2-turbo-preview"; - provider: "kimi-code"; - }; - "kimi-k2.5": Model<"openai-completions"> & { - id: "kimi-k2.5"; - provider: "kimi-code"; - }; - "kimi-k2.7-code": Model<"openai-completions"> & { - id: "kimi-k2.7-code"; - provider: "kimi-code"; - }; -}; diff --git a/packages/ai/src/providers/kimi-code.ts b/packages/ai/src/providers/kimi-code.ts deleted file mode 100644 index 02f797d3f..000000000 --- a/packages/ai/src/providers/kimi-code.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; -import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; -import { loadKimiCodeOAuth } from "../auth/oauth/load.ts"; -import { createProvider, type Provider } from "../models.ts"; -import { KIMI_CODE_MODELS } from "./kimi-code.models.ts"; - -export function kimiCodeProvider(): Provider<"openai-completions"> { - return createProvider({ - id: "kimi-code", - name: "Kimi Code", - baseUrl: "https://api.kimi.com/coding/v1", - auth: { - apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]), - oauth: lazyOAuth({ name: "Kimi Code", load: loadKimiCodeOAuth }), - }, - models: Object.values(KIMI_CODE_MODELS), - api: openAICompletionsApi(), - }); -} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index f1f1965b9..d0a5422e2 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -57,7 +57,6 @@ export type KnownProvider = | "groq" | "huggingface" | "kilo" - | "kimi-code" | "kimi-coding" | "litellm" | "lm-studio" diff --git a/packages/ai/test/gajae-provider-id-gaps.test.ts b/packages/ai/test/gajae-provider-id-gaps.test.ts index 4d20fdeda..b6bb8d93f 100644 --- a/packages/ai/test/gajae-provider-id-gaps.test.ts +++ b/packages/ai/test/gajae-provider-id-gaps.test.ts @@ -27,17 +27,22 @@ describe("Gajae provider ID gaps", () => { } }); - it("registers Kimi OAuth models and the OpenAI device-code model alias", () => { - const providers = new Map(builtinProviders().map((provider) => [provider.id, provider])); - const kimi = providers.get("kimi-code"); - expect(kimi?.auth.oauth?.name).toBe("Kimi Code"); - expect(kimi?.getModels().map((model) => model.id)).toEqual([ - "kimi-for-coding", - "kimi-k2", - "kimi-k2-turbo-preview", - "kimi-k2.5", - "kimi-k2.7-code", - ]); + 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"); diff --git a/packages/ai/test/kimi-code-oauth.test.ts b/packages/ai/test/kimi-code-oauth.test.ts deleted file mode 100644 index 62f7b029d..000000000 --- a/packages/ai/test/kimi-code-oauth.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { getOAuthProvider } from "../src/auth/oauth/index.ts"; -import { loginKimi, refreshKimiToken } from "../src/auth/oauth/kimi-code.ts"; - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); -} - -describe.sequential("Kimi Code OAuth", () => { - let agentDir: string; - const originalAgentDir = process.env.SENPI_CODING_AGENT_DIR; - - beforeAll(async () => { - agentDir = await mkdtemp(join(tmpdir(), "senpi-kimi-oauth-")); - process.env.SENPI_CODING_AGENT_DIR = agentDir; - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - afterAll(async () => { - if (originalAgentDir === undefined) delete process.env.SENPI_CODING_AGENT_DIR; - else process.env.SENPI_CODING_AGENT_DIR = originalAgentDir; - await rm(agentDir, { recursive: true, force: true }); - }); - - it("logs in with device authorization and refreshes without exposing tokens", async () => { - const issuedAt = Date.parse("2026-07-12T00:00:00Z"); - vi.spyOn(Date, "now").mockReturnValue(issuedAt); - 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.endsWith("/device_authorization")) { - return jsonResponse({ - user_code: "KIMI-1234", - device_code: "device-secret", - verification_uri: "https://auth.kimi.com/device", - verification_uri_complete: "https://auth.kimi.com/device?user_code=KIMI-1234", - expires_in: 900, - interval: 5, - }); - } - if (url.endsWith("/token")) { - const body = new URLSearchParams(String(init?.body)); - return body.get("grant_type") === "refresh_token" - ? jsonResponse({ access_token: "access-refreshed", expires_in: 3600 }) - : jsonResponse({ access_token: "access-secret", refresh_token: "refresh-secret", expires_in: 3600 }); - } - throw new Error(`Unexpected URL: ${url}`); - }), - ); - - const deviceCodes: Array<{ userCode: string; verificationUri: string }> = []; - const credentials = await loginKimi({ - onAuth: () => {}, - onDeviceCode: (info) => deviceCodes.push(info), - onPrompt: async () => "", - onSelect: async () => undefined, - }); - expect(deviceCodes).toEqual([ - { - userCode: "KIMI-1234", - verificationUri: "https://auth.kimi.com/device?user_code=KIMI-1234", - intervalSeconds: 5, - expiresInSeconds: 900, - }, - ]); - expect(credentials).toEqual({ - access: "access-secret", - refresh: "refresh-secret", - expires: issuedAt + 55 * 60_000, - }); - - const authorizationBody = new URLSearchParams(String(requests[0]?.init?.body)); - expect(authorizationBody.get("client_id")).toBe("17e5f671-d194-4dfb-9706-5516cb48c098"); - const tokenBody = new URLSearchParams(String(requests[1]?.init?.body)); - expect(tokenBody.get("device_code")).toBe("device-secret"); - expect(requests[1]?.init?.signal).toBeInstanceOf(AbortSignal); - - const refreshed = await refreshKimiToken(credentials.refresh); - expect(refreshed).toEqual({ - access: "access-refreshed", - refresh: "refresh-secret", - expires: issuedAt + 55 * 60_000, - }); - expect(getOAuthProvider("kimi-code")?.id).toBe("kimi-code"); - }); - - it("rejects an untrusted verification URI before presenting it", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => - jsonResponse({ - user_code: "KIMI-1234", - device_code: "device-secret", - verification_uri: "https://phishing.example/device", - }), - ), - ); - - await expect( - loginKimi({ - onAuth: () => {}, - onDeviceCode: () => {}, - onPrompt: async () => "", - onSelect: async () => undefined, - }), - ).rejects.toThrow("unexpected verification URI"); - }); - - it("cancels before polling after the device code is presented", async () => { - const controller = new AbortController(); - const fetchMock = vi.fn(async () => - jsonResponse({ - user_code: "KIMI-1234", - device_code: "device-secret", - verification_uri: "https://auth.kimi.com/device", - }), - ); - vi.stubGlobal("fetch", fetchMock); - - await expect( - loginKimi({ - onAuth: () => {}, - onDeviceCode: () => controller.abort(), - onPrompt: async () => "", - onSelect: async () => undefined, - signal: controller.signal, - }), - ).rejects.toThrow("Login cancelled"); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("keeps refresh credentials out of endpoint errors", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => jsonResponse({ error: "invalid_grant", refresh_token: "leaked-token" }, 400)), - ); - const error = await refreshKimiToken("refresh-secret").catch((value: unknown) => value); - expect(String(error)).not.toContain("refresh-secret"); - expect(String(error)).not.toContain("leaked-token"); - }); -}); diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 1c5c10d51..16aeab7c8 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -39,7 +39,6 @@ export const defaultModelPerProvider: Record = { groq: "openai/gpt-oss-120b", huggingface: "moonshotai/Kimi-K2.6", kilo: "claude-sonnet-4-6", - "kimi-code": "kimi-for-coding", "kimi-coding": "kimi-for-coding", litellm: "default", "lm-studio": "default", diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index 28d254ec4..3a7cd5b16 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -41,7 +41,6 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { "gitlab-duo": "GitLab Duo", perplexity: "Perplexity", kilo: "Kilo", - "kimi-code": "Kimi Code", "google-gemini-cli": "Google Gemini CLI (Cloud Code Assist)", "google-antigravity": "Google Antigravity", "openai-codex-device": "OpenAI Codex (device)",