Merge branch 'main' into fix/vercel-gateway-v1-models-endpoint
This commit is contained in:
@@ -4,7 +4,10 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Vercel AI Gateway dynamic model discovery to use the versioned `/v1/models` catalog endpoint, restoring newly published models.
|
||||
- Fixed DeepSeek V4 tool-call follow-up 400 errors from three root causes:
|
||||
- Mapped `reasoning_effort` "xhigh" to "max" for DeepSeek-family models on any provider (NVIDIA, OpenCode-Go, etc.), not just `deepseek`
|
||||
- Recovered `reasoning_content` from thinking blocks with valid signatures that were filtered by the non-empty-text check
|
||||
- Added empty-string fallback when `reasoning_content` is genuinely absent (e.g. proxy-stripped) but the provider requires the field
|
||||
|
||||
## [14.5.13] - 2026-05-01
|
||||
|
||||
|
||||
@@ -108,7 +108,9 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
|
||||
high: "default",
|
||||
xhigh: "default",
|
||||
} satisfies Partial<Record<OpenAIReasoningEffort, string>>)
|
||||
: {};
|
||||
: isDeepseekFamily && Boolean(model.reasoning)
|
||||
? { xhigh: "max" }
|
||||
: {};
|
||||
|
||||
return {
|
||||
supportsStore: !isNonStandard,
|
||||
@@ -141,6 +143,9 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
|
||||
isKimiModel ||
|
||||
(isDeepseekFamily && Boolean(model.reasoning)) ||
|
||||
((provider === "openrouter" || baseUrl.includes("openrouter.ai")) && Boolean(model.reasoning)),
|
||||
// DeepSeek V4 rejects synthetic reasoning_content placeholders (".") on tool-call turns.
|
||||
// Kimi and OpenRouter accept them when actual reasoning is unavailable.
|
||||
allowsSyntheticReasoningContentForToolCalls: !isDeepseekFamily || !Boolean(model.reasoning),
|
||||
requiresAssistantContentForToolCalls: isKimiModel,
|
||||
openRouterRouting: undefined,
|
||||
vercelGatewayRouting: undefined,
|
||||
@@ -183,6 +188,9 @@ export function resolveOpenAICompat(
|
||||
reasoningContentField: model.compat.reasoningContentField ?? detected.reasoningContentField,
|
||||
requiresReasoningContentForToolCalls:
|
||||
model.compat.requiresReasoningContentForToolCalls ?? detected.requiresReasoningContentForToolCalls,
|
||||
allowsSyntheticReasoningContentForToolCalls:
|
||||
model.compat.allowsSyntheticReasoningContentForToolCalls ??
|
||||
detected.allowsSyntheticReasoningContentForToolCalls,
|
||||
requiresAssistantContentForToolCalls:
|
||||
model.compat.requiresAssistantContentForToolCalls ?? detected.requiresAssistantContentForToolCalls,
|
||||
disableReasoningOnForcedToolChoice:
|
||||
|
||||
@@ -1203,9 +1203,12 @@ export function convertMessages(
|
||||
assistantMsg.content = [{ type: "text", text: thinkingText }];
|
||||
}
|
||||
} else {
|
||||
// Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss)
|
||||
// Use the signature from the first thinking block if available, but only for
|
||||
// recognized OpenAI-compat reasoning field names. Opaque signatures from other
|
||||
// providers (Anthropic encrypted, OpenAI Responses JSON) are not valid property names.
|
||||
const signature = nonEmptyThinkingBlocks[0].thinkingSignature;
|
||||
if (signature && signature.length > 0) {
|
||||
const recognizedFields = ["reasoning_content", "reasoning", "reasoning_text"];
|
||||
if (signature && recognizedFields.includes(signature)) {
|
||||
(assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map(b => b.thinking).join("\n");
|
||||
}
|
||||
}
|
||||
@@ -1234,25 +1237,71 @@ export function convertMessages(
|
||||
}
|
||||
|
||||
const toolCalls = msg.content.filter(b => b.type === "toolCall") as ToolCall[];
|
||||
// Inject a `reasoning_content` placeholder on assistant tool-call turns when the backend
|
||||
// rejects history without it. The compat flag captures the rule:
|
||||
// - Kimi (native or via OpenCode-Go): chat completion endpoint demands the field.
|
||||
// - Reasoning models reached through OpenRouter (e.g. DeepSeek V4 Pro): the underlying
|
||||
// provider's thinking-mode validator demands it on every prior assistant turn. omp
|
||||
// cannot synthesize real reasoning when the conversation was warmed up by another
|
||||
// provider whose reasoning is redacted/encrypted (Anthropic) or simply absent, so we
|
||||
// emit a placeholder. Real captured reasoning, when present, is preserved earlier via
|
||||
// the `thinkingSignature` echo path and short-circuits via `hasReasoningField`.
|
||||
// `thinkingFormat` is gated to formats that consume the field (openai/openrouter chat
|
||||
// completions); formats with their own conventions (zai, qwen) are excluded.
|
||||
const stubsReasoningContent =
|
||||
// Replay reasoning_content on assistant turns for backends that validate
|
||||
// thinking-mode history. DeepSeek V4 requires reasoning_content on EVERY
|
||||
// assistant turn once any prior turn included it — not just tool-call turns.
|
||||
// The replay logic has three tiers:
|
||||
// 1. Recover from thinking blocks with valid signatures (covers same-model replay
|
||||
// where nonEmptyThinkingBlocks may have filtered out empty-text blocks)
|
||||
// 2. For providers that require the field but returned no reasoning at all
|
||||
// (e.g. proxy-stripped reasoning_content), emit an empty string
|
||||
// 3. For providers that accept synthetic placeholders (Kimi, OpenRouter), emit "."
|
||||
// DeepSeek V4 rejects synthetic "." placeholders — it validates the exact value —
|
||||
// so the allowsSyntheticReasoningContentForToolCalls flag controls tier 3.
|
||||
const canUseSyntheticReasoningContent =
|
||||
compat.requiresReasoningContentForToolCalls &&
|
||||
compat.allowsSyntheticReasoningContentForToolCalls &&
|
||||
(compat.thinkingFormat === "openai" || compat.thinkingFormat === "openrouter");
|
||||
// DeepSeek reasoning models require reasoning_content on ALL assistant turns,
|
||||
// not just tool-call turns. Other providers (Kimi, OpenRouter) only require it
|
||||
// on tool-call turns.
|
||||
const needsReasoningOnAllTurns =
|
||||
compat.requiresReasoningContentForToolCalls && !compat.allowsSyntheticReasoningContentForToolCalls;
|
||||
const needsReasoningField = needsReasoningOnAllTurns || toolCalls.length > 0;
|
||||
let hasReasoningField =
|
||||
(assistantMsg as any).reasoning_content !== undefined ||
|
||||
(assistantMsg as any).reasoning !== undefined ||
|
||||
(assistantMsg as any).reasoning_text !== undefined;
|
||||
if (toolCalls.length > 0 && stubsReasoningContent && !hasReasoningField) {
|
||||
// Tier 1: Recover reasoning_content from ALL thinking blocks (including empty-text
|
||||
// ones) when the provider requires exact replay and rejects synthetic placeholders.
|
||||
// This covers the case where thinking blocks have valid signatures but were excluded
|
||||
// by the nonEmptyThinkingBlocks filter above, or where thinking text is empty but
|
||||
// the signature identifies the correct field name for replay.
|
||||
// Only recognized OpenAI-compat reasoning field names qualify — opaque signatures
|
||||
// from other providers (Anthropic encrypted, OpenAI Responses JSON, etc.) are not
|
||||
// valid property names for the wire message.
|
||||
if (
|
||||
needsReasoningField &&
|
||||
!hasReasoningField &&
|
||||
compat.requiresReasoningContentForToolCalls &&
|
||||
!compat.allowsSyntheticReasoningContentForToolCalls
|
||||
) {
|
||||
const allThinkingBlocks = msg.content.filter(b => b.type === "thinking") as ThinkingContent[];
|
||||
if (allThinkingBlocks.length > 0) {
|
||||
const signature = allThinkingBlocks[0].thinkingSignature;
|
||||
const recognizedFields = ["reasoning_content", "reasoning", "reasoning_text"];
|
||||
if (signature && recognizedFields.includes(signature)) {
|
||||
(assistantMsg as any)[signature] = allThinkingBlocks.map(b => b.thinking).join("\n");
|
||||
hasReasoningField = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tier 2: When the provider requires reasoning_content but there are genuinely no
|
||||
// thinking blocks at all (e.g. proxy stripped reasoning_content from the response),
|
||||
// emit an empty string. The field must be present; an empty string is the most honest
|
||||
// representation of "no reasoning was captured."
|
||||
if (
|
||||
needsReasoningField &&
|
||||
!hasReasoningField &&
|
||||
compat.requiresReasoningContentForToolCalls &&
|
||||
!compat.allowsSyntheticReasoningContentForToolCalls
|
||||
) {
|
||||
const reasoningField = compat.reasoningContentField ?? "reasoning_content";
|
||||
(assistantMsg as any)[reasoningField] = "";
|
||||
hasReasoningField = true;
|
||||
}
|
||||
// Tier 3: For providers that accept synthetic placeholders (Kimi, OpenRouter).
|
||||
if (toolCalls.length > 0 && canUseSyntheticReasoningContent && !hasReasoningField) {
|
||||
const reasoningField = compat.reasoningContentField ?? "reasoning_content";
|
||||
(assistantMsg as any)[reasoningField] = ".";
|
||||
hasReasoningField = true;
|
||||
|
||||
@@ -553,6 +553,8 @@ export interface OpenAICompat {
|
||||
reasoningContentField?: "reasoning_content" | "reasoning" | "reasoning_text";
|
||||
/** Whether assistant tool-call messages must include reasoning content. Default: false. */
|
||||
requiresReasoningContentForToolCalls?: boolean;
|
||||
/** Whether the provider accepts a synthetic placeholder (e.g. ".") for missing reasoning_content on tool-call turns. Default: true. Set to false for providers like DeepSeek that validate the exact reasoning_content value. */
|
||||
allowsSyntheticReasoningContentForToolCalls?: boolean;
|
||||
/** Whether assistant tool-call messages must include non-empty content. Default: false. */
|
||||
requiresAssistantContentForToolCalls?: boolean;
|
||||
/** Whether the provider supports the `tool_choice` parameter. Default: true. */
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { getBundledModel } from "../src/models";
|
||||
import { convertMessages, detectCompat } from "../src/providers/openai-completions";
|
||||
import type { AssistantMessage, Model, ThinkingContent, ToolCall } from "../src/types";
|
||||
|
||||
function deepseekModel(overrides: Partial<Model<"openai-completions">>): Model<"openai-completions"> {
|
||||
return {
|
||||
...getBundledModel("openai", "gpt-4o-mini"),
|
||||
api: "openai-completions",
|
||||
reasoning: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function assistantToolCall(
|
||||
model: Model<"openai-completions">,
|
||||
content?: Array<{ type: string; [key: string]: unknown }>,
|
||||
): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: content ?? [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_test_1",
|
||||
name: "read",
|
||||
arguments: { path: "/tmp/test" },
|
||||
},
|
||||
],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("DeepSeek reasoning_content tool-call replay", () => {
|
||||
// ----------------------------------------------------------------
|
||||
// Fix 1: reasoningEffortMap for DeepSeek-family on any provider
|
||||
// ----------------------------------------------------------------
|
||||
describe("reasoningEffortMap (Fix 1)", () => {
|
||||
it("maps xhigh → max for DeepSeek-family on opencode-go", () => {
|
||||
const compat = detectCompat(
|
||||
deepseekModel({
|
||||
provider: "opencode-go",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
id: "deepseek-v4-flash",
|
||||
}),
|
||||
);
|
||||
expect(compat.reasoningEffortMap.xhigh).toBe("max");
|
||||
});
|
||||
|
||||
it("maps xhigh → max for DeepSeek-family on NVIDIA", () => {
|
||||
const compat = detectCompat(
|
||||
deepseekModel({
|
||||
provider: "nvidia",
|
||||
baseUrl: "https://integrate.api.nvidia.com/v1",
|
||||
id: "deepseek-ai/deepseek-v4-flash",
|
||||
}),
|
||||
);
|
||||
expect(compat.reasoningEffortMap.xhigh).toBe("max");
|
||||
});
|
||||
|
||||
it("maps xhigh → max for DeepSeek on the official endpoint", () => {
|
||||
const compat = detectCompat(
|
||||
deepseekModel({
|
||||
provider: "deepseek",
|
||||
baseUrl: "https://api.deepseek.com/v1",
|
||||
id: "deepseek-v4-pro",
|
||||
}),
|
||||
);
|
||||
expect(compat.reasoningEffortMap.xhigh).toBe("max");
|
||||
});
|
||||
|
||||
it("does NOT map xhigh for non-DeepSeek models", () => {
|
||||
const compat = detectCompat(
|
||||
deepseekModel({
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
id: "gpt-4o-mini",
|
||||
reasoning: false,
|
||||
}),
|
||||
);
|
||||
expect(compat.reasoningEffortMap.xhigh).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// allowsSyntheticReasoningContentForToolCalls flag
|
||||
// ----------------------------------------------------------------
|
||||
describe("allowsSyntheticReasoningContentForToolCalls flag", () => {
|
||||
it("is false for DeepSeek-family reasoning models", () => {
|
||||
const compat = detectCompat(
|
||||
deepseekModel({
|
||||
provider: "deepseek",
|
||||
baseUrl: "https://api.deepseek.com/v1",
|
||||
id: "deepseek-v4-pro",
|
||||
}),
|
||||
);
|
||||
expect(compat.allowsSyntheticReasoningContentForToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for DeepSeek-family on NVIDIA", () => {
|
||||
const compat = detectCompat(
|
||||
deepseekModel({
|
||||
provider: "nvidia",
|
||||
baseUrl: "https://integrate.api.nvidia.com/v1",
|
||||
id: "deepseek-ai/deepseek-v4-flash",
|
||||
}),
|
||||
);
|
||||
expect(compat.allowsSyntheticReasoningContentForToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
it("is true for non-DeepSeek reasoning models on OpenRouter", () => {
|
||||
const compat = detectCompat({
|
||||
...getBundledModel("openai", "gpt-4o-mini"),
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
id: "qwen/qwq-32b",
|
||||
reasoning: true,
|
||||
});
|
||||
// Qwen is not isDeepseekFamily, so synthetic is allowed
|
||||
expect(compat.allowsSyntheticReasoningContentForToolCalls).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Fix 2: reasoning_content from empty thinking blocks with signature
|
||||
// ----------------------------------------------------------------
|
||||
describe("thinking-block signature recovery (Fix 2)", () => {
|
||||
it("recovers reasoning_content from empty thinking block with valid signature", () => {
|
||||
const model = deepseekModel({
|
||||
provider: "opencode-go",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
id: "deepseek-v4-flash",
|
||||
});
|
||||
const compat = detectCompat(model);
|
||||
// Simulate a tool-call turn with an empty thinking block that has a valid
|
||||
// signature — this happens when reasoning text was lost but the signature
|
||||
// (field name) is preserved.
|
||||
const msg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
thinkingSignature: "reasoning_content",
|
||||
} as ThinkingContent,
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_empty_thinking",
|
||||
name: "read",
|
||||
arguments: { path: "/tmp/test" },
|
||||
} as ToolCall,
|
||||
],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const messages = convertMessages(model, { messages: [msg] }, compat);
|
||||
const assistant = messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
// The reasoning_content field should be set from the signature, even if empty.
|
||||
expect(Reflect.get(assistant as object, "reasoning_content")).toBe("");
|
||||
});
|
||||
|
||||
it("recovers reasoning_content from non-empty thinking block with signature", () => {
|
||||
const model = deepseekModel({
|
||||
provider: "opencode-go",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
id: "deepseek-v4-flash",
|
||||
});
|
||||
const compat = detectCompat(model);
|
||||
const msg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "I need to read the file first.",
|
||||
thinkingSignature: "reasoning_content",
|
||||
} as ThinkingContent,
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_with_thinking",
|
||||
name: "read",
|
||||
arguments: { path: "/tmp/test" },
|
||||
} as ToolCall,
|
||||
],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const messages = convertMessages(model, { messages: [msg] }, compat);
|
||||
const assistant = messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(Reflect.get(assistant as object, "reasoning_content")).toBe("I need to read the file first.");
|
||||
});
|
||||
it("does not use opaque signature as property name but still sets reasoning_content from thinking text", () => {
|
||||
const model = deepseekModel({
|
||||
provider: "opencode-go",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
id: "deepseek-v4-flash",
|
||||
});
|
||||
const compat = detectCompat(model);
|
||||
// Simulate a thinking block with an opaque signature from another provider
|
||||
// (e.g. Anthropic encrypted signature, OpenAI Responses JSON item).
|
||||
// The code should NOT write to a property named after the opaque signature.
|
||||
// It should still set reasoning_content from the thinking text via the
|
||||
// existing thinkingFormat="openai" path.
|
||||
const msg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "some reasoning",
|
||||
thinkingSignature: "rs_6f3a1b2c4d5e6f7a8b9c0d1e2f3a4b5c",
|
||||
} as ThinkingContent,
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_opaque_sig",
|
||||
name: "read",
|
||||
arguments: { path: "/tmp/test" },
|
||||
} as ToolCall,
|
||||
],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const messages = convertMessages(model, { messages: [msg] }, compat);
|
||||
const assistant = messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
// Should NOT have used the opaque signature as a property name.
|
||||
expect(Reflect.get(assistant as object, "rs_6f3a1b2c4d5e6f7a8b9c0d1e2f3a4b5c")).toBeUndefined();
|
||||
// Should have set reasoning_content from the thinking text via the openai path.
|
||||
expect(Reflect.get(assistant as object, "reasoning_content")).toBe("some reasoning");
|
||||
});
|
||||
it("falls through to empty-string when thinking block has opaque signature and empty text", () => {
|
||||
const model = deepseekModel({
|
||||
provider: "opencode-go",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
id: "deepseek-v4-flash",
|
||||
});
|
||||
const compat = detectCompat(model);
|
||||
// Empty-text thinking block with opaque signature — Tier 1 should reject the
|
||||
// opaque signature, nonEmptyThinkingBlocks won't include it, and the openai path
|
||||
// won't set anything. Tier 2 should then emit empty reasoning_content.
|
||||
const msg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
thinkingSignature: "rs_6f3a1b2c4d5e6f7a8b9c0d1e2f3a4b5c",
|
||||
} as ThinkingContent,
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_empty_opaque",
|
||||
name: "read",
|
||||
arguments: { path: "/tmp/test" },
|
||||
} as ToolCall,
|
||||
],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const messages = convertMessages(model, { messages: [msg] }, compat);
|
||||
const assistant = messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(Reflect.get(assistant as object, "rs_6f3a1b2c4d5e6f7a8b9c0d1e2f3a4b5c")).toBeUndefined();
|
||||
expect(Reflect.get(assistant as object, "reasoning_content")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Fix 3: Empty-string fallback when NO thinking blocks exist
|
||||
// (matches the actual observed 400 failure: proxy-stripped reasoning)
|
||||
// ----------------------------------------------------------------
|
||||
describe("empty-string fallback for missing reasoning_content (Fix 3)", () => {
|
||||
it("sets reasoning_content to empty string when no thinking blocks exist for DeepSeek", () => {
|
||||
const model = deepseekModel({
|
||||
provider: "opencode-go",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
id: "deepseek-v4-flash",
|
||||
});
|
||||
const compat = detectCompat(model);
|
||||
// Tool-call turn with NO thinking blocks at all — matches the actual
|
||||
// observed 400 error pattern where proxy stripped reasoning_content.
|
||||
const msg = assistantToolCall(model, [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_no_thinking",
|
||||
name: "read",
|
||||
arguments: { path: "/tmp/test" },
|
||||
} as ToolCall,
|
||||
]);
|
||||
const messages = convertMessages(model, { messages: [msg] }, compat);
|
||||
const assistant = messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
// reasoning_content must be present (empty string) — not absent and not "."
|
||||
const rc = Reflect.get(assistant as object, "reasoning_content");
|
||||
expect(rc).toBeDefined();
|
||||
expect(rc).toBe("");
|
||||
});
|
||||
|
||||
it("sets content to empty string (not null) when reasoning_content is present", () => {
|
||||
const model = deepseekModel({
|
||||
provider: "nvidia",
|
||||
baseUrl: "https://integrate.api.nvidia.com/v1",
|
||||
id: "deepseek-ai/deepseek-v4-flash",
|
||||
});
|
||||
const compat = detectCompat(model);
|
||||
const msg = assistantToolCall(model, [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_no_content",
|
||||
name: "list_files",
|
||||
arguments: { path: "." },
|
||||
} as ToolCall,
|
||||
]);
|
||||
const messages = convertMessages(model, { messages: [msg] }, compat);
|
||||
const assistant = messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect((assistant as { content: unknown }).content).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Fix 4: reasoning_content on ALL assistant turns, not just tool-call turns
|
||||
// DeepSeek V4 requires reasoning_content on every assistant message once any
|
||||
// prior turn included it — including plain text responses with no tool calls.
|
||||
// ----------------------------------------------------------------
|
||||
describe("reasoning_content on non-tool-call assistant turns (Fix 4)", () => {
|
||||
it("injects empty reasoning_content on plain text assistant turn for DeepSeek", () => {
|
||||
const model = deepseekModel({
|
||||
provider: "deepseek",
|
||||
baseUrl: "https://api.deepseek.com/v1",
|
||||
id: "deepseek-v4-pro",
|
||||
});
|
||||
const compat = detectCompat(model);
|
||||
// Plain text assistant response — no tool calls, no thinking blocks.
|
||||
// This is the exact pattern from the observed 400 error.
|
||||
const msg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Here is the answer to your question." }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const messages = convertMessages(model, { messages: [msg] }, compat);
|
||||
const assistant = messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
// reasoning_content must be present — even on non-tool-call turns
|
||||
const rc = Reflect.get(assistant as object, "reasoning_content");
|
||||
expect(rc).toBeDefined();
|
||||
expect(rc).toBe("");
|
||||
});
|
||||
|
||||
it("injects reasoning_content from thinking blocks on plain text assistant turn", () => {
|
||||
const model = deepseekModel({
|
||||
provider: "opencode-go",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
id: "deepseek-v4-flash",
|
||||
});
|
||||
const compat = detectCompat(model);
|
||||
const msg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "Let me think about this.",
|
||||
thinkingSignature: "reasoning_content",
|
||||
} as ThinkingContent,
|
||||
{ type: "text", text: "The answer is 42." },
|
||||
],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const messages = convertMessages(model, { messages: [msg] }, compat);
|
||||
const assistant = messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(Reflect.get(assistant as object, "reasoning_content")).toBe("Let me think about this.");
|
||||
expect((assistant as { content: unknown }).content).toBe("The answer is 42.");
|
||||
});
|
||||
|
||||
it("does NOT inject reasoning_content on non-tool-call turn for non-DeepSeek providers", () => {
|
||||
const model: Model<"openai-completions"> = {
|
||||
...getBundledModel("openai", "gpt-4o-mini"),
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
id: "qwen/qwq-32b",
|
||||
reasoning: true,
|
||||
};
|
||||
const compat = detectCompat(model);
|
||||
const msg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Plain answer." }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const messages = convertMessages(model, { messages: [msg] }, compat);
|
||||
const assistant = messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
// OpenRouter reasoning models only need reasoning_content on tool-call turns
|
||||
expect(Reflect.get(assistant as object, "reasoning_content")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Tier 3: Synthetic placeholder for non-DeepSeek providers
|
||||
// ----------------------------------------------------------------
|
||||
describe("synthetic placeholder for non-DeepSeek providers (Tier 3)", () => {
|
||||
it('still uses "." placeholder for Kimi models that accept it', () => {
|
||||
const model: Model<"openai-completions"> = {
|
||||
...getBundledModel("openai", "gpt-4o-mini"),
|
||||
api: "openai-completions",
|
||||
provider: "opencode-go",
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
id: "moonshotai/kimi-k2.5",
|
||||
reasoning: true,
|
||||
};
|
||||
const compat = detectCompat(model);
|
||||
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
expect(compat.allowsSyntheticReasoningContentForToolCalls).toBe(true);
|
||||
const msg = assistantToolCall(model, [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_kimi",
|
||||
name: "read",
|
||||
arguments: { path: "/tmp" },
|
||||
} as ToolCall,
|
||||
]);
|
||||
const messages = convertMessages(model, { messages: [msg] }, compat);
|
||||
const assistant = messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(Reflect.get(assistant as object, "reasoning_content")).toBe(".");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,7 @@ describe("issue #883 / #810 — DeepSeek V4 reasoning_content tool-call replay",
|
||||
expect(compat.requiresReasoningContentForToolCalls).toBe(true);
|
||||
});
|
||||
|
||||
it("injects reasoning_content placeholder on assistant tool-call turn for deepseek-v4-pro", () => {
|
||||
it("sets reasoning_content to empty string for deepseek-v4-pro tool-call turn with no thinking blocks", () => {
|
||||
const model = deepseekModel({
|
||||
provider: "deepseek",
|
||||
baseUrl: "https://api.deepseek.com/v1",
|
||||
@@ -74,8 +74,9 @@ describe("issue #883 / #810 — DeepSeek V4 reasoning_content tool-call replay",
|
||||
const assistant = messages.find(m => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
const reasoningContent = Reflect.get(assistant as object, "reasoning_content");
|
||||
expect(typeof reasoningContent).toBe("string");
|
||||
expect((reasoningContent as string).length).toBeGreaterThan(0);
|
||||
expect(reasoningContent).toBeDefined();
|
||||
// DeepSeek rejects synthetic "." — when no thinking blocks exist, we emit empty string
|
||||
expect(reasoningContent).toBe("");
|
||||
});
|
||||
|
||||
it("normalizes assistant content to '' when reasoning_content placeholder is injected (DeepSeek invariant)", () => {
|
||||
|
||||
Reference in New Issue
Block a user