diff --git a/AGENTS.md b/AGENTS.md index aa201ff4e..1bd09a6d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,17 +153,18 @@ Create provider file exporting: ### 5. Tests (`packages/ai/test/`) -Add provider to: `stream.test.ts`, `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `image-limits.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`. - -For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (for example GPT and Claude), add at least one pair per family. - -For non-standard auth, create utility (e.g., `bedrock-utils.ts`) with credential detection. +- Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API implementation such as `openai-completions`. +- Add the provider to the broader provider matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `image-limits.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`. +- For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (for example GPT and Claude), add at least one pair per family. +- For non-standard auth, create utility (e.g., `bedrock-utils.ts`) with credential detection. ### 6. Coding Agent (`packages/coding-agent/`) -- `src/core/model-resolver.ts`: Add default model ID to `DEFAULT_MODELS` +- `src/core/model-resolver.ts`: Add default model ID to `defaultModelPerProvider` +- `src/modes/interactive/interactive-mode.ts`: Add API-key login display name to `API_KEY_LOGIN_PROVIDERS` so `/login` shows the provider for built-in API-key auth. - `src/cli/args.ts`: Add env var documentation - `README.md`: Add provider setup instructions +- `docs/providers.md`: Add setup instructions, env var, and `auth.json` key ### 7. Documentation diff --git a/package-lock.json b/package-lock.json index ca942936e..7e3071c5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1575,31 +1575,31 @@ } }, "node_modules/@mariozechner/clipboard": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.2.tgz", - "integrity": "sha512-IHQpksNjo7EAtGuHFU+tbWDp5LarH3HU/8WiB9O70ZEoBPHOg0/6afwSLK0QyNMMmx4Bpi/zl6+DcBXe95nWYA==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.3.tgz", + "integrity": "sha512-e7jASirzfm+ROiOGFh843+cFZTy3DfzP+jldCvh8RnEk0C3QihDTn7dd7Yh7KAJydwIJ18FJSZ2swHvCJhk18g==", "license": "MIT", "optional": true, "engines": { "node": ">= 10" }, "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.2", - "@mariozechner/clipboard-darwin-universal": "0.3.2", - "@mariozechner/clipboard-darwin-x64": "0.3.2", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.2", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.2", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.2", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.2", - "@mariozechner/clipboard-linux-x64-musl": "0.3.2", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.2", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.2" + "@mariozechner/clipboard-darwin-arm64": "0.3.3", + "@mariozechner/clipboard-darwin-universal": "0.3.3", + "@mariozechner/clipboard-darwin-x64": "0.3.3", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.3", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.3", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.3", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.3", + "@mariozechner/clipboard-linux-x64-musl": "0.3.3", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.3", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.3" } }, "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.2.tgz", - "integrity": "sha512-uBf6K7Je1ihsgvmWxA8UCGCeI+nbRVRXoarZdLjl6slz94Zs1tNKFZqx7aCI5O1i3e0B6ja82zZ06BWrl0MCVw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.3.tgz", + "integrity": "sha512-+zhuZGXqVrdkbIRdnwiZNbTJ7V3elq/A+C5d5laJoyhJgWs41eO5NUMkBkj6f23F2L4PRXEhdn5/ktlPx+bG3Q==", "cpu": [ "arm64" ], @@ -1613,9 +1613,9 @@ } }, "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.2.tgz", - "integrity": "sha512-mxSheKTW2U9LsBdXy0SdmdCAE5HqNS9QUmpNHLnfJ+SsbFKALjEZc5oRrVMXxGQSirDvYf5bjmRyT0QYYonnlg==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.3.tgz", + "integrity": "sha512-x9aRfTyndVqpEQ44LNNCK/EXZd9y8rWkLQgNhmWpby9PXrjPhNxfjUc2Db4mt4nJjU/4zzO8F5v/XyzlUGSdhQ==", "license": "MIT", "optional": true, "os": [ @@ -1626,9 +1626,9 @@ } }, "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.2.tgz", - "integrity": "sha512-U1BcVEoidvwIp95+HJswSW+xr28EQiHR7rZjH6pn8Sja5yO4Yoe3yCN0Zm8Lo72BbSOK/fTSq0je7CJpaPCspg==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.3.tgz", + "integrity": "sha512-6ut/NawB0KiYPCwrirgNp6Br62LntL978q7G6d/Rs2pmPvQb53bP96eUMYl+Y3a7Qk13bGZ4w9rVPFxRE9m9ag==", "cpu": [ "x64" ], @@ -1642,9 +1642,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.2.tgz", - "integrity": "sha512-BsinwG3yWTIjdgNCxsFlip7LkfwPk+ruw/aFCXHUg/fb5XC/Ksp+YMQ7u0LUtiKzIv/7LMXgZInJQH6gxbAaqQ==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.3.tgz", + "integrity": "sha512-gf3dH4kBddU1AOyHVB53mjLUFfJAKlTmxTMw51jdeg7eE7IjfEBXVvM4bifMtBxbWkT0eA0FUZ1C0KQ6Z5l6pw==", "cpu": [ "arm64" ], @@ -1658,9 +1658,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.2.tgz", - "integrity": "sha512-0/Gi5Xq2V6goXBop19ePoHvXsmJD9SzFlO3S+d6+T2b+BlPcpOu3Oa0wTjl+cZrLAAEzA86aPNBI+VVAFDFPKw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.3.tgz", + "integrity": "sha512-o1paj2+zmAQ/LaPS85XJCxhNowNQpxYM2cGY6pWvB5Kqmz6hZjl6CzDg5tbf1hZkn/Em6jpOaE2UtMxKdELBDA==", "cpu": [ "arm64" ], @@ -1674,9 +1674,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.2.tgz", - "integrity": "sha512-2AFFiXB24qf0zOZsxI1GJGb9wQGlOJyN6UwoXqmKS3dpQi/l6ix30IzDDA4c4ZcCcx4D+9HLYXhC1w7Sov8pXA==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.3.tgz", + "integrity": "sha512-dkEhE4ekePJwMbBq9HP1//CFMNmDzA/iV9AXqBfvL5CWmmDIRXqh4A3YZt3tWO/HdMerX+xNCEiR7WiOsIG+UA==", "cpu": [ "riscv64" ], @@ -1690,9 +1690,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.2.tgz", - "integrity": "sha512-v6fVnsn7WMGg73Dab8QMwyFce7tzGfgEixKgzLP8f1GJqkJZi5zO4k4FOHzSgUufgLil63gnxvMpjWkgfeQN7A==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.3.tgz", + "integrity": "sha512-lT2yANtTLlEtFBIH3uGoRa/CQas/eBoLNi3qr9axQFoRgF4RGPSJ66yHOSnMECBneTIb1Iqv3UxokTfX27CdoQ==", "cpu": [ "x64" ], @@ -1706,9 +1706,9 @@ } }, "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.2.tgz", - "integrity": "sha512-xVUtnoMQ8v2JVyfJLKKXACA6avdnchdbBkTsZs8BgJQo29qwCp5NIHAUO8gbJ40iaEGToW5RlmVk2M9V0HsHEw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.3.tgz", + "integrity": "sha512-saq/MCB0QHK/7ZZLjAZ0QkbY944dyjOsur8gneGCfMitt+GOiE1CU4OUipHC4b6x8UDY9bRLsR4aBaxu22OFPA==", "cpu": [ "x64" ], @@ -1722,9 +1722,9 @@ } }, "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.2.tgz", - "integrity": "sha512-AEgg95TNi8TGgak2wSXZkXKCvAUTjWoU1Pqb0ON7JHrX78p616XUFNTJohtIon3e0w6k0pYPZeCuqRCza/Tqeg==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.3.tgz", + "integrity": "sha512-cGuvSj0/2X2w983yEcKw+i+r1EBej6ZZIN+fXG3eY2G/HaIQpbXpLvMxKyZ9LKtbZx+Z6q/gELEoSBMLML6BaQ==", "cpu": [ "arm64" ], @@ -1738,9 +1738,9 @@ } }, "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.2.tgz", - "integrity": "sha512-tGRuYpZwDOD7HBrCpyRuhGnHHSCknELvqwKKUG4JSfSB7JIU7LKRh6zx6fMUOQd8uISK35TjFg5UcNih+vJhFA==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.3.tgz", + "integrity": "sha512-5hvaEq/bgYovTIGx43O/S7loIHYV3ue90WcV1dz0wdMXroVKZKeU/yfwM0PALQA1OcrEHiGXGySFReXr72lGtA==", "cpu": [ "x64" ], @@ -8659,7 +8659,7 @@ "node": ">=20.6.0" }, "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.2" + "@mariozechner/clipboard": "^0.3.3" } }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 56219235c..d5a5bb8bd 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,8 +2,13 @@ ## [Unreleased] +### Added + +- Added DeepSeek as a built-in OpenAI-compatible provider with V4 Flash and V4 Pro models and `DEEPSEEK_API_KEY` authentication. + ### Fixed +- Fixed DeepSeek V4 session replay 400 errors by adding `thinkingFormat: "deepseek"` (sends `thinking: { type }` + `reasoning_effort`), a `reasoningEffortMap`, and `requiresReasoningContentOnAssistantMessages` compat that injects empty `reasoning_content` on all replayed assistant messages when reasoning is enabled ([#3636](https://github.com/badlogic/pi-mono/issues/3636)) - Fixed GPT-5.5 generated context window metadata to use the observed 272k limit. ## [0.70.0] - 2026-04-23 diff --git a/packages/ai/README.md b/packages/ai/README.md index 8654ad987..7e2b40d3a 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -50,6 +50,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - **OpenAI** - **Azure OpenAI (Responses)** - **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below) +- **DeepSeek** - **Anthropic** - **Google** - **Vertex AI** (Gemini via Vertex AI) @@ -857,7 +858,8 @@ interface OpenAICompletionsCompat { requiresToolResultName?: boolean; // Whether tool results require the `name` field (default: false) requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false) requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false) - thinkingFormat?: 'openai' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'zai' uses thinking: { type: "enabled" }, 'qwen' uses enable_thinking: boolean, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai) + requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek) + thinkingFormat?: 'openai' | 'deepseek' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'deepseek' uses thinking: { type } plus reasoning_effort, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai) cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {}) vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {}) @@ -1020,6 +1022,7 @@ In Node.js environments, you can set environment variables to avoid passing API | OpenAI | `OPENAI_API_KEY` | | Azure OpenAI | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` or `AZURE_OPENAI_RESOURCE_NAME` (optional `AZURE_OPENAI_API_VERSION`, `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` like `model=deployment,model2=deployment2`) | | Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` | +| DeepSeek | `DEEPSEEK_API_KEY` | | Google | `GEMINI_API_KEY` | | Vertex AI | `GOOGLE_CLOUD_API_KEY` or `GOOGLE_CLOUD_PROJECT` (or `GCLOUD_PROJECT`) + `GOOGLE_CLOUD_LOCATION` + ADC | | Mistral | `MISTRAL_API_KEY` | diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 35afa4fc0..3c0f7e6dd 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1012,6 +1012,57 @@ async function generateModels() { }); } + const deepseekCompat: OpenAICompletionsCompat = { + requiresReasoningContentOnAssistantMessages: true, + thinkingFormat: "deepseek", + reasoningEffortMap: { + minimal: "high", + low: "high", + medium: "high", + high: "high", + xhigh: "max", + }, + }; + const deepseekV4Models: Model<"openai-completions">[] = [ + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + api: "openai-completions", + baseUrl: "https://api.deepseek.com", + provider: "deepseek", + reasoning: true, + input: ["text"], + cost: { + input: 0.14, + output: 0.28, + cacheRead: 0.028, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + compat: deepseekCompat, + }, + { + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", + api: "openai-completions", + baseUrl: "https://api.deepseek.com", + provider: "deepseek", + reasoning: true, + input: ["text"], + cost: { + input: 1.74, + output: 3.48, + cacheRead: 0.145, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + compat: deepseekCompat, + }, + ]; + allModels.push(...deepseekV4Models); + const minimaxDirectSupportedIds = new Set(["MiniMax-M2.7", "MiniMax-M2.7-highspeed"]); for (const candidate of allModels) { diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 2302b0766..f2d358d71 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -68,6 +68,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { const envMap: Record = { openai: "OPENAI_API_KEY", "azure-openai-responses": "AZURE_OPENAI_API_KEY", + deepseek: "DEEPSEEK_API_KEY", google: "GEMINI_API_KEY", "google-vertex": "GOOGLE_CLOUD_API_KEY", groq: "GROQ_API_KEY", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index bd4f782b5..cd8b0a71d 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -328,6 +328,40 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"bedrock-converse-stream">, + "au.anthropic.claude-opus-4-6-v1": { + id: "au.anthropic.claude-opus-4-6-v1", + name: "AU Anthropic Claude Opus 4.6", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 16.5, + output: 82.5, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "au.anthropic.claude-sonnet-4-6": { + id: "au.anthropic.claude-sonnet-4-6", + name: "AU Anthropic Claude Sonnet 4.6", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 3.3, + output: 16.5, + cacheRead: 0.33, + cacheWrite: 4.125, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "deepseek.r1-v1:0": { id: "deepseek.r1-v1:0", name: "DeepSeek-R1", @@ -2715,6 +2749,44 @@ export const MODELS = { maxTokens: 40000, } satisfies Model<"openai-completions">, }, + "deepseek": { + "deepseek-v4-flash": { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + api: "openai-completions", + provider: "deepseek", + baseUrl: "https://api.deepseek.com", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek","reasoningEffortMap":{"minimal":"high","low":"high","medium":"high","high":"high","xhigh":"max"}}, + reasoning: true, + input: ["text"], + cost: { + input: 0.14, + output: 0.28, + cacheRead: 0.028, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "deepseek-v4-pro": { + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", + api: "openai-completions", + provider: "deepseek", + baseUrl: "https://api.deepseek.com", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek","reasoningEffortMap":{"minimal":"high","low":"high","medium":"high","high":"high","xhigh":"max"}}, + reasoning: true, + input: ["text"], + cost: { + input: 1.74, + output: 3.48, + cacheRead: 0.145, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + }, "fireworks": { "accounts/fireworks/models/deepseek-v3p1": { id: "accounts/fireworks/models/deepseek-v3p1", @@ -8162,6 +8234,40 @@ export const MODELS = { contextWindow: 163840, maxTokens: 65536, } satisfies Model<"openai-completions">, + "deepseek/deepseek-v4-flash": { + id: "deepseek/deepseek-v4-flash", + name: "DeepSeek: DeepSeek V4 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.14, + output: 0.28, + cacheRead: 0.028, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v4-pro": { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek: DeepSeek V4 Pro", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1.74, + output: 3.48, + cacheRead: 0.145, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 384000, + } satisfies Model<"openai-completions">, "essentialai/rnj-1-instruct": { id: "essentialai/rnj-1-instruct", name: "EssentialAI: Rnj 1 Instruct", @@ -12380,6 +12486,40 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "deepseek/deepseek-v4-flash": { + id: "deepseek/deepseek-v4-flash", + name: "DeepSeek V4 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.14, + output: 0.28, + cacheRead: 0.014, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"anthropic-messages">, + "deepseek/deepseek-v4-pro": { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek V4 Pro", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1.74, + output: 3.48, + cacheRead: 0.145, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"anthropic-messages">, "google/gemini-2.0-flash": { id: "google/gemini-2.0-flash", name: "Gemini 2.0 Flash", diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index e9bc65148..e2e479e2d 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -526,6 +526,11 @@ function buildParams( enable_thinking: !!options?.reasoningEffort, preserve_thinking: true, }; + } else if (compat.thinkingFormat === "deepseek" && model.reasoning) { + (params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; + if (options?.reasoningEffort) { + (params as any).reasoning_effort = mapReasoningEffort(options.reasoningEffort, compat.reasoningEffortMap); + } } else if (compat.thinkingFormat === "openrouter" && model.reasoning) { // OpenRouter normalizes reasoning across providers via a nested reasoning object. const openRouterParams = params as typeof params & { reasoning?: { effort?: string } }; @@ -831,6 +836,13 @@ export function convertMessages( (assistantMsg as any).reasoning_details = reasoningDetails; } } + if ( + compat.requiresReasoningContentOnAssistantMessages && + model.reasoning && + (assistantMsg as { reasoning_content?: string }).reasoning_content === undefined + ) { + (assistantMsg as { reasoning_content?: string }).reasoning_content = ""; + } // Skip assistant messages that have no content and no tool calls. // Some providers require "either content or tool_calls, but not none". // Other providers also don't accept empty assistant messages. @@ -1021,10 +1033,18 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); const isGroq = provider === "groq" || baseUrl.includes("groq.com"); + const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined; - const reasoningEffortMap = - isGroq && model.id === "qwen/qwen3-32b" + const reasoningEffortMap = isDeepSeek + ? { + minimal: "high", + low: "high", + medium: "high", + high: "high", + xhigh: "max", + } + : isGroq && model.id === "qwen/qwen3-32b" ? { minimal: "default", low: "default", @@ -1043,11 +1063,14 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet requiresToolResultName: false, requiresAssistantAfterToolResult: false, requiresThinkingAsText: false, - thinkingFormat: isZai - ? "zai" - : provider === "openrouter" || baseUrl.includes("openrouter.ai") - ? "openrouter" - : "openai", + requiresReasoningContentOnAssistantMessages: isDeepSeek, + thinkingFormat: isDeepSeek + ? "deepseek" + : isZai + ? "zai" + : provider === "openrouter" || baseUrl.includes("openrouter.ai") + ? "openrouter" + : "openai", openRouterRouting: {}, vercelGatewayRouting: {}, zaiToolStream: false, @@ -1077,6 +1100,9 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletion requiresAssistantAfterToolResult: model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult, requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText, + requiresReasoningContentOnAssistantMessages: + model.compat.requiresReasoningContentOnAssistantMessages ?? + detected.requiresReasoningContentOnAssistantMessages, thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat, openRouterRouting: model.compat.openRouterRouting ?? {}, vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index e69e87eda..ce131f753 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -26,6 +26,7 @@ export type KnownProvider = | "openai" | "azure-openai-responses" | "openai-codex" + | "deepseek" | "github-copilot" | "xai" | "groq" @@ -282,8 +283,10 @@ export interface OpenAICompletionsCompat { requiresAssistantAfterToolResult?: boolean; /** Whether thinking blocks must be converted to text blocks with delimiters. Default: auto-detected from URL. */ requiresThinkingAsText?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ - thinkingFormat?: "openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template"; + /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ + requiresReasoningContentOnAssistantMessages?: boolean; + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ + thinkingFormat?: "openai" | "openrouter" | "deepseek" | "zai" | "qwen" | "qwen-chat-template"; /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */ openRouterRouting?: OpenRouterRouting; /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ diff --git a/packages/ai/test/openai-completions-thinking-as-text.test.ts b/packages/ai/test/openai-completions-thinking-as-text.test.ts index f8d87b3f6..24425f3a7 100644 --- a/packages/ai/test/openai-completions-thinking-as-text.test.ts +++ b/packages/ai/test/openai-completions-thinking-as-text.test.ts @@ -31,6 +31,7 @@ const compat = { requiresToolResultName: false, requiresAssistantAfterToolResult: false, requiresThinkingAsText: true, + requiresReasoningContentOnAssistantMessages: false, thinkingFormat: "openai", openRouterRouting: {}, vercelGatewayRouting: {}, diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index ac83b6525..ac1eb28a6 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -29,6 +29,7 @@ const compat: Required = { requiresToolResultName: false, requiresAssistantAfterToolResult: false, requiresThinkingAsText: false, + requiresReasoningContentOnAssistantMessages: false, thinkingFormat: "openai", openRouterRouting: {}, vercelGatewayRouting: {}, diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 7b6020dc3..f23a05fd7 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -447,6 +447,33 @@ describe("Generate E2E Tests", () => { }); }); + describe.skipIf(!process.env.DEEPSEEK_API_KEY)( + "DeepSeek Provider (deepseek-v4-flash via OpenAI Completions)", + () => { + const llm = getModel("deepseek", "deepseek-v4-flash"); + + 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 streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, { reasoningEffort: "high" }); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, { reasoningEffort: "high" }); + }); + }, + ); + describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses Provider (gpt-5.4)", () => { const llm = getModel("openai", "gpt-5.4"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bdd5f517f..c07f120a3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed `/copy` to avoid unbounded OSC 52 writes and clipboard races that could break terminal rendering or panic the native clipboard addon ([#3639](https://github.com/badlogic/pi-mono/issues/3639)) - Fixed extension flag docs to show `pi.getFlag()` using registered flag names without the CLI `--` prefix ([#3614](https://github.com/badlogic/pi-mono/issues/3614)) ## [0.70.0] - 2026-04-23 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 97a4334b6..2c3495bae 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -107,6 +107,7 @@ For each built-in provider, pi maintains a list of tool-capable models, updated - Anthropic - OpenAI - Azure OpenAI +- DeepSeek - Google Gemini - Google Vertex - Amazon Bedrock diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 31c1ef75a..e228fa611 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -627,11 +627,12 @@ interface ProviderModelConfig { requiresToolResultName?: boolean; requiresAssistantAfterToolResult?: boolean; requiresThinkingAsText?: boolean; - thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template"; + requiresReasoningContentOnAssistantMessages?: boolean; + thinkingFormat?: "openai" | "deepseek" | "zai" | "qwen" | "qwen-chat-template"; cacheControlFormat?: "anthropic"; }; } ``` -`qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`. +`deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`. `cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 84d6695f1..e2c4748d7 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -338,7 +338,8 @@ For providers with partial OpenAI compatibility, use the `compat` field. | `requiresToolResultName` | Include `name` on tool result messages | | `requiresAssistantAfterToolResult` | Insert an assistant message before a user message after tool results | | `requiresThinkingAsText` | Convert thinking blocks to plain text | -| `thinkingFormat` | Use `reasoning_effort`, `zai`, `qwen`, or `qwen-chat-template` thinking parameters | +| `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled | +| `thinkingFormat` | Use `reasoning_effort`, `deepseek`, `zai`, `qwen`, or `qwen-chat-template` thinking parameters | | `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. | | `supportsStrictMode` | Include the `strict` field in tool definitions | | `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_retention: "24h"` for OpenAI prompt caching, or `cache_control.ttl: "1h"` when `cacheControlFormat` is `anthropic`. Default: `true`. | diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 16cf705e4..3ec446e35 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -56,6 +56,7 @@ pi | Anthropic | `ANTHROPIC_API_KEY` | `anthropic` | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | OpenAI | `OPENAI_API_KEY` | `openai` | +| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` | | Google Gemini | `GEMINI_API_KEY` | `google` | | Mistral | `MISTRAL_API_KEY` | `mistral` | | Groq | `GROQ_API_KEY` | `groq` | @@ -82,6 +83,7 @@ Store credentials in `~/.pi/agent/auth.json`: { "anthropic": { "type": "api_key", "key": "sk-ant-..." }, "openai": { "type": "api_key", "key": "sk-..." }, + "deepseek": { "type": "api_key", "key": "sk-..." }, "google": { "type": "api_key", "key": "..." }, "opencode": { "type": "api_key", "key": "..." }, "opencode-go": { "type": "api_key", "key": "..." } diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index c612784e3..45906b443 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -67,7 +67,7 @@ } }, "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.2" + "@mariozechner/clipboard": "^0.3.3" }, "devDependencies": { "@types/diff": "^7.0.2", diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 08827c1a3..a4233343b 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -303,6 +303,7 @@ ${chalk.bold("Environment Variables:")} AZURE_OPENAI_RESOURCE_NAME - Azure OpenAI resource name (alternative to base URL) AZURE_OPENAI_API_VERSION - Azure OpenAI API version (default: v1) AZURE_OPENAI_DEPLOYMENT_NAME_MAP - Azure OpenAI model=deployment map (comma-separated) + DEEPSEEK_API_KEY - DeepSeek API key GEMINI_API_KEY - Google Gemini API key GROQ_API_KEY - Groq API key CEREBRAS_API_KEY - Cerebras API key diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 0b5848fc2..74f1efbb7 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -98,10 +98,12 @@ const OpenAICompletionsCompatSchema = Type.Object({ requiresToolResultName: Type.Optional(Type.Boolean()), requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()), requiresThinkingAsText: Type.Optional(Type.Boolean()), + requiresReasoningContentOnAssistantMessages: Type.Optional(Type.Boolean()), thinkingFormat: Type.Optional( Type.Union([ Type.Literal("openai"), Type.Literal("openrouter"), + Type.Literal("deepseek"), Type.Literal("zai"), Type.Literal("qwen"), Type.Literal("qwen-chat-template"), diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index e702c355d..ee5dd4c7b 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -17,6 +17,7 @@ export const defaultModelPerProvider: Record = { openai: "gpt-5.4", "azure-openai-responses": "gpt-5.4", "openai-codex": "gpt-5.5", + deepseek: "deepseek-v4-pro", google: "gemini-3.1-pro-preview", "google-gemini-cli": "gemini-3.1-pro-preview", "google-antigravity": "gemini-3.1-pro-high", diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b29fd90c1..fff361a12 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -183,6 +183,7 @@ const API_KEY_LOGIN_PROVIDERS: Record = { [BEDROCK_PROVIDER_ID]: "Amazon Bedrock", "azure-openai-responses": "Azure OpenAI Responses", cerebras: "Cerebras", + deepseek: "DeepSeek", fireworks: "Fireworks", google: "Google Gemini", "google-vertex": "Google Vertex AI", diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index eedcb0d53..4393679f4 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -17,65 +17,103 @@ function copyToX11Clipboard(options: NativeClipboardExecOptions): void { } } -export async function copyToClipboard(text: string): Promise { - // Always emit OSC 52 - works over SSH/mosh, harmless locally - const encoded = Buffer.from(text).toString("base64"); - process.stdout.write(`\x1b]52;c;${encoded}\x07`); +const MAX_OSC52_ENCODED_LENGTH = 100_000; +function isRemoteSession(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.MOSH_CONNECTION); +} + +function emitOsc52(text: string): boolean { + const encoded = Buffer.from(text).toString("base64"); + if (encoded.length > MAX_OSC52_ENCODED_LENGTH) { + return false; + } + process.stdout.write(`\x1b]52;c;${encoded}\x07`); + return true; +} + +export async function copyToClipboard(text: string): Promise { + let copied = false; + + // Prefer direct clipboard writes. Emitting OSC 52 first can make terminals + // write the same native clipboard concurrently with the addon, and very large + // OSC 52 payloads can desynchronize terminal rendering. try { if (clipboard) { await clipboard.setText(text); - return; + copied = true; } } catch { // Fall through to platform-specific clipboard tools. } - // Also try native tools (best effort for local sessions) + const remote = isRemoteSession(); + if (copied && !remote) { + return; + } + const p = platform(); const options: NativeClipboardExecOptions = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] }; - try { - if (p === "darwin") { - execSync("pbcopy", options); - } else if (p === "win32") { - execSync("clip", options); - } else { - // Linux. Try Termux, Wayland, or X11 clipboard tools. - if (process.env.TERMUX_VERSION) { - try { - execSync("termux-clipboard-set", options); - return; - } catch { - // Fall back to Wayland or X11 tools. - } - } - - const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY); - const hasX11Display = Boolean(process.env.DISPLAY); - const isWayland = isWaylandSession(); - if (isWayland && hasWaylandDisplay) { - try { - // Verify wl-copy exists (spawn errors are async and won't be caught) - execSync("which wl-copy", { stdio: "ignore" }); - // wl-copy with execSync hangs due to fork behavior; use spawn instead - const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] }); - proc.stdin.on("error", () => { - // Ignore EPIPE errors if wl-copy exits early - }); - proc.stdin.write(text); - proc.stdin.end(); - proc.unref(); - } catch { - if (hasX11Display) { - copyToX11Clipboard(options); + if (!copied) { + try { + if (p === "darwin") { + execSync("pbcopy", options); + copied = true; + } else if (p === "win32") { + execSync("clip", options); + copied = true; + } else { + // Linux. Try Termux, Wayland, or X11 clipboard tools. + if (process.env.TERMUX_VERSION) { + try { + execSync("termux-clipboard-set", options); + copied = true; + } catch { + // Fall back to Wayland or X11 tools. + } + } + + if (!copied) { + const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY); + const hasX11Display = Boolean(process.env.DISPLAY); + const isWayland = isWaylandSession(); + if (isWayland && hasWaylandDisplay) { + try { + // Verify wl-copy exists (spawn errors are async and won't be caught) + execSync("which wl-copy", { stdio: "ignore" }); + // wl-copy with execSync hangs due to fork behavior; use spawn instead + const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] }); + proc.stdin.on("error", () => { + // Ignore EPIPE errors if wl-copy exits early + }); + proc.stdin.write(text); + proc.stdin.end(); + proc.unref(); + copied = true; + } catch { + if (hasX11Display) { + copyToX11Clipboard(options); + copied = true; + } + } + } else if (hasX11Display) { + copyToX11Clipboard(options); + copied = true; } } - } else if (hasX11Display) { - copyToX11Clipboard(options); } + } catch { + // Fall through to OSC 52 fallback. } - } catch { - // Ignore - OSC 52 already emitted as fallback + } + + if (remote || !copied) { + const osc52Copied = emitOsc52(text); + copied = copied || osc52Copied; + } + + if (!copied) { + throw new Error("Failed to copy to clipboard"); } } diff --git a/packages/coding-agent/test/clipboard.test.ts b/packages/coding-agent/test/clipboard.test.ts new file mode 100644 index 000000000..380d6a70c --- /dev/null +++ b/packages/coding-agent/test/clipboard.test.ts @@ -0,0 +1,145 @@ +import { execSync, spawn } from "child_process"; +import { platform } from "os"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { copyToClipboard } from "../src/utils/clipboard.js"; + +const mocks = vi.hoisted(() => { + return { + clipboard: { + setText: vi.fn<(text: string) => Promise>(), + }, + execSync: vi.fn(), + spawn: vi.fn(), + platform: vi.fn<() => NodeJS.Platform>(), + isWaylandSession: vi.fn<() => boolean>(), + }; +}); + +vi.mock("../src/utils/clipboard-native.js", () => { + return { + clipboard: mocks.clipboard, + }; +}); + +vi.mock("child_process", () => { + return { + execSync: mocks.execSync, + spawn: mocks.spawn, + }; +}); + +vi.mock("os", () => { + return { + platform: mocks.platform, + }; +}); + +vi.mock("../src/utils/clipboard-image.js", () => { + return { + isWaylandSession: mocks.isWaylandSession, + }; +}); + +const mockedExecSync = vi.mocked(execSync); +const mockedSpawn = vi.mocked(spawn); +const mockedPlatform = vi.mocked(platform); + +let originalWrite: typeof process.stdout.write; +let stdoutWrites: string[]; +let nativeResolved = false; + +function osc52Writes(): string[] { + return stdoutWrites.filter((write) => write.startsWith("\x1b]52;c;")); +} + +beforeEach(() => { + vi.unstubAllEnvs(); + stdoutWrites = []; + nativeResolved = false; + mocks.clipboard.setText.mockReset(); + mocks.execSync.mockReset(); + mocks.spawn.mockReset(); + mocks.platform.mockReset(); + mocks.isWaylandSession.mockReset(); + mockedPlatform.mockReturnValue("darwin"); + mocks.isWaylandSession.mockReturnValue(false); + mocks.clipboard.setText.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + nativeResolved = true; + }); + originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((...args: Parameters) => { + const [chunk] = args; + if (typeof chunk === "string" && chunk.startsWith("\x1b]52;c;")) { + stdoutWrites.push(chunk); + return true; + } + return originalWrite(...args); + }) as typeof process.stdout.write; +}); + +afterEach(() => { + process.stdout.write = originalWrite; + vi.unstubAllEnvs(); +}); + +describe("copyToClipboard", () => { + test("local native success skips OSC 52 and shell fallbacks", async () => { + await copyToClipboard("hello"); + + expect(mocks.clipboard.setText).toHaveBeenCalledWith("hello"); + expect(osc52Writes()).toHaveLength(0); + expect(mockedExecSync).not.toHaveBeenCalled(); + expect(mockedSpawn).not.toHaveBeenCalled(); + }); + + test("remote native success emits OSC 52 after native write", async () => { + vi.stubEnv("SSH_CONNECTION", "client server"); + mocks.clipboard.setText.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(osc52Writes()).toHaveLength(0); + nativeResolved = true; + }); + + await copyToClipboard("hello"); + + expect(nativeResolved).toBe(true); + expect(osc52Writes()).toHaveLength(1); + expect(mockedExecSync).not.toHaveBeenCalled(); + }); + + test("local shell fallback success skips OSC 52", async () => { + mocks.clipboard.setText.mockRejectedValue(new Error("native failed")); + mockedExecSync.mockReturnValue(Buffer.alloc(0)); + + await copyToClipboard("hello"); + + expect(mockedExecSync).toHaveBeenCalledWith("pbcopy", { + input: "hello", + stdio: ["pipe", "ignore", "ignore"], + timeout: 5000, + }); + expect(osc52Writes()).toHaveLength(0); + }); + + test("uses OSC 52 fallback when native and shell tools fail", async () => { + mocks.clipboard.setText.mockRejectedValue(new Error("native failed")); + mockedExecSync.mockImplementation(() => { + throw new Error("pbcopy failed"); + }); + + await copyToClipboard("hello"); + + expect(osc52Writes()).toHaveLength(1); + }); + + test("does not emit oversized OSC 52 payloads", async () => { + mocks.clipboard.setText.mockRejectedValue(new Error("native failed")); + mockedExecSync.mockImplementation(() => { + throw new Error("pbcopy failed"); + }); + + await expect(copyToClipboard("x".repeat(80_000))).rejects.toThrow("Failed to copy to clipboard"); + expect(osc52Writes()).toHaveLength(0); + }); +});