mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix provider thinking compatibility
- claude: handle DeepSeek thinking blocks defensively, unsigned placeholder; fix kept-vs-seen thinking detection - gemini: clamp unsupported max/xhigh thinking levels to high - testUtils: probe Cloud Code Assist for gemini-cli/antigravity with 401 refresh retry - tests: add translator regression coverage Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
decolua
co-authored by
Cursor
parent
cb65a45e1f
commit
c4f80d30d8
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
|
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
|
||||||
import { PROVIDERS } from "../../providers/index.js";
|
import { PROVIDERS } from "../../providers/index.js";
|
||||||
import { LEVEL_TO_BUDGET, budgetToLevel, effortToBudget } from "./thinking.js";
|
import { LEVEL_TO_BUDGET, budgetToLevel, effortToBudget, effortToThinkingLevel } from "./thinking.js";
|
||||||
|
|
||||||
// Map a target wire-format to its native thinking format (when capability has none).
|
// Map a target wire-format to its native thinking format (when capability has none).
|
||||||
const FORMAT_TO_NATIVE = {
|
const FORMAT_TO_NATIVE = {
|
||||||
@@ -127,6 +127,11 @@ function toLevel(cfg) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toGeminiThinkingLevel(cfg) {
|
||||||
|
const raw = cfg.mode === "auto" ? "high" : (toLevel(cfg) || "high");
|
||||||
|
return effortToThinkingLevel(raw);
|
||||||
|
}
|
||||||
|
|
||||||
// Gemini nests thinkingConfig under generationConfig. gemini-cli / antigravity wrap
|
// Gemini nests thinkingConfig under generationConfig. gemini-cli / antigravity wrap
|
||||||
// the whole request in a { request: { generationConfig } } envelope — target the
|
// the whole request in a { request: { generationConfig } } envelope — target the
|
||||||
// envelope's generationConfig when present, else the top-level one.
|
// envelope's generationConfig when present, else the top-level one.
|
||||||
@@ -179,7 +184,7 @@ function applyFormat(fmt, body, cfg, caps) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "gemini-level": {
|
case "gemini-level": {
|
||||||
const level = none ? "minimal" : (toLevel(eff) || "high");
|
const level = none ? "minimal" : toGeminiThinkingLevel(eff);
|
||||||
setGeminiThinking(body, { thinkingLevel: level, includeThoughts: level !== "minimal" });
|
setGeminiThinking(body, { thinkingLevel: level, includeThoughts: level !== "minimal" });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,25 @@ export function fixToolUseOrdering(messages) {
|
|||||||
// Models that reject thinking.type "adaptive" + output_config.effort (Opus 4.5+/Sonnet 4.6+ only)
|
// Models that reject thinking.type "adaptive" + output_config.effort (Opus 4.5+/Sonnet 4.6+ only)
|
||||||
const ADAPTIVE_THINKING_UNSUPPORTED = /haiku/i;
|
const ADAPTIVE_THINKING_UNSUPPORTED = /haiku/i;
|
||||||
|
|
||||||
|
function handlesThinkingBlocks(provider) {
|
||||||
|
return provider === "claude" || provider?.startsWith("anthropic-compatible") || provider === "deepseek";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildThinkingPlaceholder(provider) {
|
||||||
|
const block = {
|
||||||
|
type: CLAUDE_BLOCK.THINKING,
|
||||||
|
thinking: ".",
|
||||||
|
};
|
||||||
|
|
||||||
|
// DeepSeek's Anthropic-compatible endpoint requires a thinking block in
|
||||||
|
// thinking mode, but it does not need Anthropic's signed-thinking fallback.
|
||||||
|
if (provider !== "deepseek") {
|
||||||
|
block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize a native Claude passthrough body to match Anthropic Messages API spec.
|
// Normalize a native Claude passthrough body to match Anthropic Messages API spec.
|
||||||
// Newer Cowork/Claude Code clients emit beta-only shapes that OAuth endpoints reject:
|
// Newer Cowork/Claude Code clients emit beta-only shapes that OAuth endpoints reject:
|
||||||
// 1. thinking.type "adaptive" → unsupported on Haiku
|
// 1. thinking.type "adaptive" → unsupported on Haiku
|
||||||
@@ -216,23 +235,31 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
|
|||||||
lastAssistantProcessed = true;
|
lastAssistantProcessed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle thinking blocks for Anthropic endpoint only
|
// Handle thinking blocks for Anthropic-compatible endpoints.
|
||||||
if (provider === "claude" || provider?.startsWith("anthropic-compatible")) {
|
if (handlesThinkingBlocks(provider)) {
|
||||||
let hasToolUse = false;
|
let hasToolUse = false;
|
||||||
let hasThinking = false;
|
let hasKeptThinking = false;
|
||||||
|
|
||||||
// Claude native: preserve valid signatures, drop invalid blocks.
|
// Claude native: preserve valid signatures, drop invalid blocks.
|
||||||
// anthropic-compatible: replace with default (safe fallback for lenient upstreams).
|
// anthropic-compatible: replace with default (safe fallback for lenient upstreams).
|
||||||
|
// DeepSeek: keep existing thinking as-is; add an unsigned placeholder only if missing.
|
||||||
const isClaudeNative = provider === "claude";
|
const isClaudeNative = provider === "claude";
|
||||||
|
const isDeepSeek = provider === "deepseek";
|
||||||
const kept = [];
|
const kept = [];
|
||||||
for (const block of msg.content) {
|
for (const block of msg.content) {
|
||||||
const isThinking = block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING;
|
const isThinking = block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING;
|
||||||
if (isThinking) {
|
if (isThinking) {
|
||||||
hasThinking = true;
|
|
||||||
if (isClaudeNative) {
|
if (isClaudeNative) {
|
||||||
if (isValidClaudeSignature(block.signature)) kept.push(block);
|
if (isValidClaudeSignature(block.signature)) {
|
||||||
|
hasKeptThinking = true;
|
||||||
|
kept.push(block);
|
||||||
|
}
|
||||||
|
} else if (isDeepSeek) {
|
||||||
|
hasKeptThinking = true;
|
||||||
|
kept.push(block);
|
||||||
} else {
|
} else {
|
||||||
block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE;
|
block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE;
|
||||||
|
hasKeptThinking = true;
|
||||||
kept.push(block);
|
kept.push(block);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -243,12 +270,8 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
|
|||||||
msg.content = kept;
|
msg.content = kept;
|
||||||
|
|
||||||
// Add thinking block if thinking enabled + has tool_use but no thinking
|
// Add thinking block if thinking enabled + has tool_use but no thinking
|
||||||
if (thinkingEnabled && !hasThinking && hasToolUse) {
|
if (thinkingEnabled && !hasKeptThinking && hasToolUse) {
|
||||||
msg.content.unshift({
|
msg.content.unshift(buildThinkingPlaceholder(provider));
|
||||||
type: CLAUDE_BLOCK.THINKING,
|
|
||||||
thinking: ".",
|
|
||||||
signature: DEFAULT_THINKING_CLAUDE_SIGNATURE
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,4 +322,3 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
|
|||||||
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,53 @@ async function probeClineAccessToken(accessToken) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CLOUD_CODE_ASSIST_TEST_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist";
|
||||||
|
const CLOUD_CODE_ASSIST_TEST_BODY = JSON.stringify({
|
||||||
|
metadata: {
|
||||||
|
ideType: "IDE_UNSPECIFIED",
|
||||||
|
platform: "PLATFORM_UNSPECIFIED",
|
||||||
|
pluginType: "GEMINI",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseProviderErrorMessage(bodyText, fallback) {
|
||||||
|
if (!bodyText) return fallback;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(bodyText);
|
||||||
|
const message = parsed?.error?.message || parsed?.message || parsed?.error;
|
||||||
|
if (typeof message === "string" && message.trim()) return message.trim();
|
||||||
|
if (message) return JSON.stringify(message);
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return bodyText.trim() || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeCloudCodeAssistAccess(connection, accessToken, effectiveProxy = null) {
|
||||||
|
const userAgent = connection.provider === "antigravity"
|
||||||
|
? "google-api-nodejs-client/9.15.1 vscode-antigravity/1.107.0"
|
||||||
|
: "google-api-nodejs-client/9.15.1 gemini-cli/0.34.0";
|
||||||
|
|
||||||
|
const res = await fetchWithConnectionProxy(CLOUD_CODE_ASSIST_TEST_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": userAgent,
|
||||||
|
},
|
||||||
|
body: CLOUD_CODE_ASSIST_TEST_BODY,
|
||||||
|
}, effectiveProxy);
|
||||||
|
|
||||||
|
if (res.ok) return { valid: true, error: null };
|
||||||
|
|
||||||
|
const bodyText = await res.text().catch(() => "");
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: parseProviderErrorMessage(bodyText, `API returned ${res.status}`),
|
||||||
|
status: res.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshOAuthToken(connection) {
|
async function refreshOAuthToken(connection) {
|
||||||
const provider = connection.provider;
|
const provider = connection.provider;
|
||||||
const refreshToken = connection.refreshToken;
|
const refreshToken = connection.refreshToken;
|
||||||
@@ -253,6 +300,23 @@ async function testOAuthConnection(connection, effectiveProxy = null) {
|
|||||||
return { valid: true, error: null, refreshed: false, newTokens: null };
|
return { valid: true, error: null, refreshed: false, newTokens: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (connection.provider === "gemini-cli" || connection.provider === "antigravity") {
|
||||||
|
const initial = await probeCloudCodeAssistAccess(connection, accessToken, effectiveProxy);
|
||||||
|
if (initial.valid) return { valid: true, error: null, refreshed, newTokens };
|
||||||
|
|
||||||
|
if (initial.status === 401 && config.refreshable && !refreshed && connection.refreshToken) {
|
||||||
|
const tokens = await refreshOAuthToken(connection);
|
||||||
|
if (tokens?.accessToken) {
|
||||||
|
const retry = await probeCloudCodeAssistAccess(connection, tokens.accessToken, effectiveProxy);
|
||||||
|
if (retry.valid) return { valid: true, error: null, refreshed: true, newTokens: tokens };
|
||||||
|
return { valid: false, error: retry.error, refreshed: true, newTokens: tokens };
|
||||||
|
}
|
||||||
|
return { valid: false, error: "Token invalid or revoked", refreshed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, error: initial.error, refreshed };
|
||||||
|
}
|
||||||
|
|
||||||
if (connection.provider === "cline") {
|
if (connection.provider === "cline") {
|
||||||
const tryProbe = async (token) => {
|
const tryProbe = async (token) => {
|
||||||
const res = await probeClineAccessToken(token);
|
const res = await probeClineAccessToken(token);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import "./registerAll.js";
|
import "./registerAll.js";
|
||||||
import { translateRequest } from "../../open-sse/translator/index.js";
|
import { translateRequest } from "../../open-sse/translator/index.js";
|
||||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||||
|
import { prepareClaudeRequest } from "../../open-sse/translator/formats/claude.js";
|
||||||
|
|
||||||
// anthropic-compatible provider so prepareClaudeRequest runs the openai→claude path
|
// anthropic-compatible provider so prepareClaudeRequest runs the openai→claude path
|
||||||
const T = (body) =>
|
const T = (body) =>
|
||||||
@@ -16,9 +17,7 @@ describe("OpenAI → Claude context mapping", () => {
|
|||||||
expect(JSON.stringify(out.system), "Claude Code prompt injected").not.toContain("Claude Code");
|
expect(JSON.stringify(out.system), "Claude Code prompt injected").not.toContain("Claude Code");
|
||||||
});
|
});
|
||||||
|
|
||||||
// openai-to-claude.js:268-273 — assistant.reasoning_content not mapped to a thinking block
|
it("assistant reasoning_content becomes a thinking block", () => {
|
||||||
// KNOWN BUG
|
|
||||||
it.fails("assistant reasoning_content becomes a thinking block", () => {
|
|
||||||
const out = T({
|
const out = T({
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "user", content: "q" },
|
{ role: "user", content: "q" },
|
||||||
@@ -27,6 +26,11 @@ describe("OpenAI → Claude context mapping", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(JSON.stringify(out), "reasoning_content lost").toContain("my hidden reasoning");
|
expect(JSON.stringify(out), "reasoning_content lost").toContain("my hidden reasoning");
|
||||||
|
const assistant = out.messages.find((m) => m.role === "assistant");
|
||||||
|
expect(assistant.content[0]).toEqual(expect.objectContaining({
|
||||||
|
type: "thinking",
|
||||||
|
thinking: "my hidden reasoning",
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
// openai-to-claude.js:298 — tool_choice "none" mapped to {type:"auto"} (loses "do not call" intent)
|
// openai-to-claude.js:298 — tool_choice "none" mapped to {type:"auto"} (loses "do not call" intent)
|
||||||
@@ -62,4 +66,22 @@ describe("OpenAI → Claude context mapping", () => {
|
|||||||
});
|
});
|
||||||
expect(JSON.stringify(out), "remote image dropped").toContain("pic.png");
|
expect(JSON.stringify(out), "remote image dropped").toContain("pic.png");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("DeepSeek Claude transport adds a thinking placeholder before tool_use in thinking mode", () => {
|
||||||
|
const out = prepareClaudeRequest({
|
||||||
|
model: "deepseek-v4-pro",
|
||||||
|
thinking: { type: "enabled" },
|
||||||
|
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" }] },
|
||||||
|
{ role: "user", content: [{ type: "text", text: "continue" }] },
|
||||||
|
],
|
||||||
|
}, "deepseek");
|
||||||
|
|
||||||
|
const assistant = out.messages.find((m) => m.role === "assistant");
|
||||||
|
expect(assistant.content[0]).toEqual({ type: "thinking", thinking: "." });
|
||||||
|
expect(assistant.content[1]).toEqual(expect.objectContaining({ type: "tool_use", id: "toolu_1" }));
|
||||||
|
expect(assistant.content[0].signature).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,6 +68,16 @@ describe("applyThinking per provider format", () => {
|
|||||||
const out = apply("gemini", "gemini-3-pro", { reasoning_effort: "medium" }, "gemini");
|
const out = apply("gemini", "gemini-3-pro", { reasoning_effort: "medium" }, "gemini");
|
||||||
expect(out.generationConfig.thinkingConfig.thinkingLevel).toBe("medium");
|
expect(out.generationConfig.thinkingConfig.thinkingLevel).toBe("medium");
|
||||||
});
|
});
|
||||||
|
it("gemini-3 clamps unsupported max/xhigh thinking levels to high", () => {
|
||||||
|
const outMax = apply("gemini", "gemini-3-pro", { reasoning_effort: "max" }, "gemini");
|
||||||
|
const outXhigh = apply("gemini", "gemini-3-pro", { reasoning_effort: "xhigh" }, "gemini");
|
||||||
|
expect(outMax.generationConfig.thinkingConfig.thinkingLevel).toBe("high");
|
||||||
|
expect(outXhigh.generationConfig.thinkingConfig.thinkingLevel).toBe("high");
|
||||||
|
});
|
||||||
|
it("gemini-3 maps auto thinking level to high instead of sending unsupported auto", () => {
|
||||||
|
const out = apply("gemini", "gemini-3-pro", { reasoning_effort: "auto" }, "gemini");
|
||||||
|
expect(out.generationConfig.thinkingConfig.thinkingLevel).toBe("high");
|
||||||
|
});
|
||||||
it("gemini-2.5 → thinkingBudget", () => {
|
it("gemini-2.5 → thinkingBudget", () => {
|
||||||
const out = apply("gemini", "gemini-2.5-flash", { reasoning_effort: "high" }, "gemini");
|
const out = apply("gemini", "gemini-2.5-flash", { reasoning_effort: "high" }, "gemini");
|
||||||
expect(out.generationConfig.thinkingConfig.thinkingBudget).toBe(24576);
|
expect(out.generationConfig.thinkingConfig.thinkingBudget).toBe(24576);
|
||||||
|
|||||||
Reference in New Issue
Block a user