diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js index 0ed27608..4603c99b 100644 --- a/open-sse/translator/concerns/thinkingUnified.js +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -4,7 +4,7 @@ import { getCapabilitiesForModel } from "../../providers/capabilities.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). const FORMAT_TO_NATIVE = { @@ -127,6 +127,11 @@ function toLevel(cfg) { 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 // the whole request in a { request: { generationConfig } } envelope — target the // envelope's generationConfig when present, else the top-level one. @@ -179,7 +184,7 @@ function applyFormat(fmt, body, cfg, caps) { break; } case "gemini-level": { - const level = none ? "minimal" : (toLevel(eff) || "high"); + const level = none ? "minimal" : toGeminiThinkingLevel(eff); setGeminiThinking(body, { thinkingLevel: level, includeThoughts: level !== "minimal" }); break; } diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index 4c8454b8..62ce7b13 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -84,6 +84,25 @@ export function fixToolUseOrdering(messages) { // Models that reject thinking.type "adaptive" + output_config.effort (Opus 4.5+/Sonnet 4.6+ only) 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. // Newer Cowork/Claude Code clients emit beta-only shapes that OAuth endpoints reject: // 1. thinking.type "adaptive" → unsupported on Haiku @@ -216,23 +235,31 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne lastAssistantProcessed = true; } - // Handle thinking blocks for Anthropic endpoint only - if (provider === "claude" || provider?.startsWith("anthropic-compatible")) { + // Handle thinking blocks for Anthropic-compatible endpoints. + if (handlesThinkingBlocks(provider)) { let hasToolUse = false; - let hasThinking = false; + let hasKeptThinking = false; // Claude native: preserve valid signatures, drop invalid blocks. // 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 isDeepSeek = provider === "deepseek"; const kept = []; for (const block of msg.content) { const isThinking = block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING; if (isThinking) { - hasThinking = true; 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 { block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE; + hasKeptThinking = true; kept.push(block); } continue; @@ -243,12 +270,8 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne msg.content = kept; // Add thinking block if thinking enabled + has tool_use but no thinking - if (thinkingEnabled && !hasThinking && hasToolUse) { - msg.content.unshift({ - type: CLAUDE_BLOCK.THINKING, - thinking: ".", - signature: DEFAULT_THINKING_CLAUDE_SIGNATURE - }); + if (thinkingEnabled && !hasKeptThinking && hasToolUse) { + msg.content.unshift(buildThinkingPlaceholder(provider)); } } } @@ -299,4 +322,3 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne return body; } - diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index 26cb5de1..4b1797cb 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -104,6 +104,53 @@ async function probeClineAccessToken(accessToken) { 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) { const provider = connection.provider; const refreshToken = connection.refreshToken; @@ -253,6 +300,23 @@ async function testOAuthConnection(connection, effectiveProxy = 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") { const tryProbe = async (token) => { const res = await probeClineAccessToken(token); diff --git a/tests/translator/bugs-toClaude-context.test.js b/tests/translator/bugs-toClaude-context.test.js index 535d4c4f..60981e8e 100644 --- a/tests/translator/bugs-toClaude-context.test.js +++ b/tests/translator/bugs-toClaude-context.test.js @@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest"; import "./registerAll.js"; import { translateRequest } from "../../open-sse/translator/index.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 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"); }); - // openai-to-claude.js:268-273 — assistant.reasoning_content not mapped to a thinking block - // KNOWN BUG - it.fails("assistant reasoning_content becomes a thinking block", () => { + it("assistant reasoning_content becomes a thinking block", () => { const out = T({ messages: [ { role: "user", content: "q" }, @@ -27,6 +26,11 @@ describe("OpenAI → Claude context mapping", () => { ], }); 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) @@ -62,4 +66,22 @@ describe("OpenAI → Claude context mapping", () => { }); 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(); + }); }); diff --git a/tests/translator/thinking-unified.test.js b/tests/translator/thinking-unified.test.js index 45453f82..37c70511 100644 --- a/tests/translator/thinking-unified.test.js +++ b/tests/translator/thinking-unified.test.js @@ -68,6 +68,16 @@ describe("applyThinking per provider format", () => { const out = apply("gemini", "gemini-3-pro", { reasoning_effort: "medium" }, "gemini"); 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", () => { const out = apply("gemini", "gemini-2.5-flash", { reasoning_effort: "high" }, "gemini"); expect(out.generationConfig.thinkingConfig.thinkingBudget).toBe(24576);