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

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