Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions open-sse/translator/request/openai-to-gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,15 +282,23 @@ function openaiToGeminiBase(
}

// Thinking / Reasoning support (Google Gemini 2.0+ Thinking models)
// 1. OpenAI format: reasoning_effort (low/medium/high)
// 1. OpenAI format: reasoning_effort (low/medium/high/auto/max/xhigh)
// "auto", "max", and "xhigh" are clamped to the high-tier budget because Gemini
// does not accept these strings directly. "auto" signals "use max reasonable effort"
// which maps to high. "max"/"xhigh" exceed Gemini's accepted range and are clamped.
// Port of decolua/9router#2043 by @nguyenxvotanminh3.
if (body.reasoning_effort) {
const highBudget = capThinkingBudget(model, 32768);
const budgetMap: Record<string, number> = {
low: 1024,
medium: getDefaultThinkingBudget(model) || 8192,
high: capThinkingBudget(model, 32768),
high: highBudget,
auto: highBudget,
max: highBudget,
xhigh: highBudget,
};
const budget =
budgetMap[body.reasoning_effort as string] || getDefaultThinkingBudget(model) || 8192;
budgetMap[body.reasoning_effort as string] ?? getDefaultThinkingBudget(model) ?? 8192;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using the nullish coalescing operator (??) for getDefaultThinkingBudget(model) can result in a budget of 0 if the model's default thinking budget is not configured (which defaults to 0). Since a thinking budget of 0 is invalid for Gemini when thinking is enabled, this will cause API errors. We should use the logical OR operator (||) instead, ensuring that a falsy 0 correctly falls back to 8192.

Suggested change
budgetMap[body.reasoning_effort as string] ?? getDefaultThinkingBudget(model) ?? 8192;
budgetMap[body.reasoning_effort as string] || getDefaultThinkingBudget(model) || 8192;

result.generationConfig.thinkingConfig = {
thinkingBudget: budget,
includeThoughts: true,
Expand Down
187 changes: 187 additions & 0 deletions tests/unit/translator-thinking-provider-compat-2043.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* Tests for provider thinking compatibility fixes ported from decolua/9router#2043.
*
* Covers:
* (a) DeepSeek unsigned thinking placeholders — no `signature` field in injected blocks.
* (b) DeepSeek keeps existing thinking blocks as-is (not overwritten by cache/placeholder).
* (c) Gemini `reasoning_effort: "auto"` maps to high budget (not silent fallback to default).
* (d) Gemini `reasoning_effort: "max"` / `"xhigh"` clamp to high budget.
*/
import test from "node:test";
import assert from "node:assert/strict";

const { prepareClaudeRequest } = await import("../../open-sse/translator/helpers/claudeHelper.ts");
const reasoningCache = await import("../../open-sse/services/reasoningCache.ts");
const { openaiToGeminiRequest } =
await import("../../open-sse/translator/request/openai-to-gemini.ts");
const { capThinkingBudget } = await import("../../src/lib/modelCapabilities.ts");

// ──────────────── Fix (a): DeepSeek placeholder has NO signature field ────────────────

test("deepseek: injected thinking placeholder has no signature field", () => {
reasoningCache.clearReasoningCacheAll();
const body = {
model: "deepseek-v4-pro",
thinking: { type: "enabled", budget_tokens: 4096 },
messages: [
{ role: "user", content: [{ type: "text", text: "q" }] },
{
role: "assistant",
content: [{ type: "tool_use", id: "toolu_1", name: "Read", input: { file_path: "x" } }],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }],
},
],
};
const out = prepareClaudeRequest(body as any, "deepseek");
const assistant = (out as any).messages.find((m: any) => m.role === "assistant");
assert.ok(assistant, "assistant message should exist");
assert.equal(assistant.content[0].type, "thinking", "injected block should be type=thinking");
assert.equal(
assistant.content[0].signature,
undefined,
"DeepSeek placeholder must NOT have a signature field"
);
assert.equal(assistant.content[1].type, "tool_use", "tool_use should follow the thinking block");
});

// ──────────────── Fix (b): DeepSeek keeps existing thinking blocks as-is ────────────────

test("deepseek: existing thinking blocks are preserved as-is (text and type unchanged)", () => {
reasoningCache.clearReasoningCacheAll();
const body = {
model: "deepseek-v4-pro",
thinking: { type: "enabled", budget_tokens: 4096 },
messages: [
{ role: "user", content: [{ type: "text", text: "q" }] },
{
role: "assistant",
content: [
{ type: "thinking", thinking: "my actual reasoning", signature: "ds-sig" },
{ type: "tool_use", id: "toolu_2", name: "Read", input: {} },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_2", content: "ok" }],
},
],
};
const out = prepareClaudeRequest(body as any, "deepseek");
const assistant = (out as any).messages.find((m: any) => m.role === "assistant");
assert.equal(assistant.content[0].type, "thinking");
assert.equal(
assistant.content[0].thinking,
"my actual reasoning",
"DeepSeek must keep existing thinking text verbatim"
);
// Signature stripping is acceptable (DeepSeek doesn't need it), but text must not
// be overwritten with placeholder or cache content.
assert.ok(
assistant.content.length >= 2,
"should not drop blocks: thinking + tool_use both present"
);
assert.equal(assistant.content[1].type, "tool_use");
});

test("deepseek: injected placeholder thinking text is non-empty dot sentinel", () => {
// The upstream uses "." as placeholder text for DeepSeek (not the long NON_ANTHROPIC placeholder)
reasoningCache.clearReasoningCacheAll();
const body = {
model: "deepseek-v4-pro",
thinking: { type: "enabled", budget_tokens: 4096 },
messages: [
{ role: "user", content: [{ type: "text", text: "q" }] },
{
role: "assistant",
content: [{ type: "tool_use", id: "toolu_3", name: "Write", input: {} }],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_3", content: "done" }],
},
],
};
const out = prepareClaudeRequest(body as any, "deepseek");
const assistant = (out as any).messages.find((m: any) => m.role === "assistant");
assert.equal(assistant.content[0].type, "thinking");
// Placeholder must be non-empty (DeepSeek rejects empty thinking text).
// The value is either "." (upstream canonical) or another non-empty fallback.
assert.ok(
typeof assistant.content[0].thinking === "string" && assistant.content[0].thinking.length > 0,
`thinking placeholder must be non-empty, got: ${JSON.stringify(assistant.content[0].thinking)}`
);
assert.equal(
assistant.content[0].signature,
undefined,
"no signature on injected DeepSeek block"
);
});

// ──────────────── Fix (c): Gemini reasoning_effort "auto" → high budget ────────────────

test("Gemini: reasoning_effort 'auto' maps to a defined (non-zero) thinking budget", () => {
const out = openaiToGeminiRequest(
"gemini-3-pro",
{
messages: [{ role: "user", content: "hello" }],
reasoning_effort: "auto",
},
false
) as any;

const thinkingBudget = out.generationConfig?.thinkingConfig?.thinkingBudget;
assert.ok(
typeof thinkingBudget === "number" && thinkingBudget > 0,
`reasoning_effort 'auto' should produce a positive thinkingBudget, got: ${thinkingBudget}`
);
// Specifically should be at least the high-tier budget level (clamped to model max)
const highBudget = capThinkingBudget("gemini-3-pro", 32768);
assert.equal(
thinkingBudget,
highBudget,
`reasoning_effort 'auto' should map to high budget (${highBudget})`
);
});

// ──────────────── Fix (d): Gemini reasoning_effort "max"/"xhigh" → high budget ────────────────

test("Gemini: reasoning_effort 'max' clamps to high budget (not default fallback)", () => {
const out = openaiToGeminiRequest(
"gemini-3-pro",
{
messages: [{ role: "user", content: "hello" }],
reasoning_effort: "max",
},
false
) as any;

const thinkingBudget = out.generationConfig?.thinkingConfig?.thinkingBudget;
const highBudget = capThinkingBudget("gemini-3-pro", 32768);
assert.equal(
thinkingBudget,
highBudget,
`reasoning_effort 'max' should clamp to high budget (${highBudget})`
);
});

test("Gemini: reasoning_effort 'xhigh' clamps to high budget (not default fallback)", () => {
const out = openaiToGeminiRequest(
"gemini-3-pro",
{
messages: [{ role: "user", content: "hello" }],
reasoning_effort: "xhigh",
},
false
) as any;

const thinkingBudget = out.generationConfig?.thinkingConfig?.thinkingBudget;
const highBudget = capThinkingBudget("gemini-3-pro", 32768);
assert.equal(
thinkingBudget,
highBudget,
`reasoning_effort 'xhigh' should clamp to high budget (${highBudget})`
);
});
Loading