fix(coding-agent): harden clipboard copy

closes #3639
This commit is contained in:
Mario Zechner 2026-04-24 12:52:32 +02:00
parent 9fdb12f985
commit 1e33492525
24 changed files with 561 additions and 108 deletions

View file

@ -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

88
package-lock.json generated
View file

@ -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": {

View file

@ -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

View file

@ -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` |

View file

@ -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) {

View file

@ -68,6 +68,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
const envMap: Record<string, string> = {
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",

View file

@ -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",

View file

@ -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,

View file

@ -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 <thinking> 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. */

View file

@ -31,6 +31,7 @@ const compat = {
requiresToolResultName: false,
requiresAssistantAfterToolResult: false,
requiresThinkingAsText: true,
requiresReasoningContentOnAssistantMessages: false,
thinkingFormat: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},

View file

@ -29,6 +29,7 @@ const compat: Required<OpenAICompletionsCompat> = {
requiresToolResultName: false,
requiresAssistantAfterToolResult: false,
requiresThinkingAsText: false,
requiresReasoningContentOnAssistantMessages: false,
thinkingFormat: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},

View file

@ -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");

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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`. |

View file

@ -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": "..." }

View file

@ -67,7 +67,7 @@
}
},
"optionalDependencies": {
"@mariozechner/clipboard": "^0.3.2"
"@mariozechner/clipboard": "^0.3.3"
},
"devDependencies": {
"@types/diff": "^7.0.2",

View file

@ -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

View file

@ -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"),

View file

@ -17,6 +17,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
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",

View file

@ -183,6 +183,7 @@ const API_KEY_LOGIN_PROVIDERS: Record<string, string> = {
[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",

View file

@ -17,65 +17,103 @@ function copyToX11Clipboard(options: NativeClipboardExecOptions): void {
}
}
export async function copyToClipboard(text: string): Promise<void> {
// 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<void> {
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");
}
}

View file

@ -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<void>>(),
},
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<typeof process.stdout.write>) => {
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);
});
});