mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-04-28 06:19:43 +00:00
parent
9fdb12f985
commit
1e33492525
24 changed files with 561 additions and 108 deletions
13
AGENTS.md
13
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
|
||||
|
||||
|
|
|
|||
88
package-lock.json
generated
88
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const compat = {
|
|||
requiresToolResultName: false,
|
||||
requiresAssistantAfterToolResult: false,
|
||||
requiresThinkingAsText: true,
|
||||
requiresReasoningContentOnAssistantMessages: false,
|
||||
thinkingFormat: "openai",
|
||||
openRouterRouting: {},
|
||||
vercelGatewayRouting: {},
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const compat: Required<OpenAICompletionsCompat> = {
|
|||
requiresToolResultName: false,
|
||||
requiresAssistantAfterToolResult: false,
|
||||
requiresThinkingAsText: false,
|
||||
requiresReasoningContentOnAssistantMessages: false,
|
||||
thinkingFormat: "openai",
|
||||
openRouterRouting: {},
|
||||
vercelGatewayRouting: {},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`. |
|
||||
|
|
|
|||
|
|
@ -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": "..." }
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
}
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@mariozechner/clipboard": "^0.3.2"
|
||||
"@mariozechner/clipboard": "^0.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/diff": "^7.0.2",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
145
packages/coding-agent/test/clipboard.test.ts
Normal file
145
packages/coding-agent/test/clipboard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue