diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 0fdb0f9a8e..b8dc191b7b 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -15,6 +15,10 @@ import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/cr import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; import { resolveAntigravityVersion } from "../services/antigravityVersion.ts"; import { resolveAntigravityModelId } from "../config/antigravityModelAliases.ts"; +import { + shouldStripCloudCodeThinking, + stripCloudCodeThinkingConfig, +} from "../services/cloudCodeThinking.ts"; const MAX_RETRY_AFTER_MS = 60_000; const LONG_RETRY_THRESHOLD_MS = 60_000; @@ -166,9 +170,15 @@ export class AntigravityExecutor extends BaseExecutor { return resp as unknown as never; } + const upstreamModel = cleanModelName(model); + const baseBody = body && typeof body === "object" ? body : {}; + const normalizedBody = shouldStripCloudCodeThinking(this.provider, upstreamModel) + ? stripCloudCodeThinkingConfig(baseBody) + : baseBody; + // Fix contents for Claude models via Antigravity const normalizedContents = - body.request?.contents?.map((c) => { + normalizedBody.request?.contents?.map((c) => { let role = c.role; // functionResponse must be role "user" for Claude models if (c.parts?.some((p) => p.functionResponse)) { @@ -203,18 +213,16 @@ export class AntigravityExecutor extends BaseExecutor { } const transformedRequest = { - ...body.request, + ...normalizedBody.request, ...(contents.length > 0 && { contents }), - sessionId: body.request?.sessionId || this.generateSessionId(), + sessionId: normalizedBody.request?.sessionId || this.generateSessionId(), safetySettings: undefined, toolConfig: - body.request?.tools?.length > 0 + normalizedBody.request?.tools?.length > 0 ? { functionCallingConfig: { mode: "VALIDATED" } } - : body.request?.toolConfig, + : normalizedBody.request?.toolConfig, }; - const upstreamModel = cleanModelName(model); - // Obfuscate sensitive client names in user content (e.g. "OpenCode", "Cursor") const requestContents = transformedRequest.contents; if (Array.isArray(requestContents)) { @@ -230,7 +238,7 @@ export class AntigravityExecutor extends BaseExecutor { } return { - ...body, + ...normalizedBody, project: projectId, model: upstreamModel, userAgent: "antigravity", diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts index 7031dc9d12..e2560a1e07 100644 --- a/open-sse/executors/gemini-cli.ts +++ b/open-sse/executors/gemini-cli.ts @@ -3,6 +3,10 @@ import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts"; import { geminiCLIUserAgent, googApiClientHeader } from "../services/antigravityHeaders.ts"; import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts"; import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; +import { + shouldStripCloudCodeThinking, + stripCloudCodeThinkingConfig, +} from "../services/cloudCodeThinking.ts"; const LOAD_CODE_ASSIST_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; const ONBOARD_USER_URL = "https://cloudcode-pa.googleapis.com/v1internal:onboardUser"; @@ -276,16 +280,24 @@ export class GeminiCLIExecutor extends BaseExecutor { async transformRequest(model, body, stream, credentials) { this._currentModel = normalizeGeminiModel(model); + const normalizedBody = + shouldStripCloudCodeThinking(this.provider, this._currentModel) && + body && + typeof body === "object" + ? stripCloudCodeThinkingConfig(body) + : body; // Refresh the project ID via loadCodeAssist (cached for 30s). - if (body && typeof body === "object" && body.request && credentials.accessToken) { - const freshProject = await this.refreshProject(credentials.accessToken); - if (freshProject) { - body.project = freshProject; + if (normalizedBody && typeof normalizedBody === "object" && normalizedBody.request) { + if (credentials.accessToken) { + const freshProject = await this.refreshProject(credentials.accessToken); + if (freshProject) { + normalizedBody.project = freshProject; + } } // Obfuscate sensitive client names in user content - const contents = body.request?.contents; + const contents = normalizedBody.request?.contents; if (Array.isArray(contents)) { for (const msg of contents) { if (Array.isArray(msg.parts)) { @@ -298,7 +310,7 @@ export class GeminiCLIExecutor extends BaseExecutor { } } } - return body; + return normalizedBody; } async refreshCredentials(credentials, log) { diff --git a/open-sse/services/AGENTS.md b/open-sse/services/AGENTS.md index 9cfe947ac6..b5af3cbdd1 100644 --- a/open-sse/services/AGENTS.md +++ b/open-sse/services/AGENTS.md @@ -30,6 +30,7 @@ - **`intentClassifier.ts`** — Classifies request intent (chat, embedding, image, video, etc.) for intelligent routing. - **`taskAwareRouter.ts`** — Routes based on task characteristics (reasoning-heavy → o1, code-gen → Cursor, long-context → Claude). - **`thinkingBudget.ts`** — Allocates thinking tokens for o1/o3 models; enforces per-request budget. + Provider-specific Cloud Code compatibility stripping belongs in executors, not in this service. - **`contextManager.ts`** — Injects routing context (system prompts, memory) into requests. ### Model Lifecycle & Fallback diff --git a/open-sse/services/cloudCodeThinking.ts b/open-sse/services/cloudCodeThinking.ts new file mode 100644 index 0000000000..8a44a8973e --- /dev/null +++ b/open-sse/services/cloudCodeThinking.ts @@ -0,0 +1,44 @@ +import { supportsReasoning } from "./modelCapabilities.ts"; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function stripGeminiThinkingConfig(value: unknown): unknown { + if (!isRecord(value)) return value; + if (!("thinkingConfig" in value) && !("thinking_config" in value)) return value; + + const next = { ...value }; + delete next.thinkingConfig; + delete next.thinking_config; + return next; +} + +export function shouldStripCloudCodeThinking(provider: string, model: string): boolean { + if (!provider || !model) return false; + return !supportsReasoning(`${provider}/${model}`); +} + +export function stripCloudCodeThinkingConfig( + body: Record +): Record { + const next = { ...body }; + + delete next.reasoning_effort; + delete next.reasoning; + delete next.thinking; + + if ("generationConfig" in next) { + next.generationConfig = stripGeminiThinkingConfig(next.generationConfig); + } + + if (isRecord(next.request)) { + const request = { ...next.request }; + if ("generationConfig" in request) { + request.generationConfig = stripGeminiThinkingConfig(request.generationConfig); + } + next.request = request; + } + + return next; +} diff --git a/open-sse/services/thinkingBudget.ts b/open-sse/services/thinkingBudget.ts index ae03ae76db..d1004052de 100644 --- a/open-sse/services/thinkingBudget.ts +++ b/open-sse/services/thinkingBudget.ts @@ -157,10 +157,9 @@ export function applyThinkingBudget(body, config = null) { if (!body || typeof body !== "object") return body; // Early exit: strip ALL reasoning/thinking params for models that don't support them. - // Sending thinking params to unsupported models (e.g. AG claude-sonnet-4-6) causes 400 errors. + // Provider-specific Cloud Code restrictions should be handled at the executor boundary. const modelStr = typeof body.model === "string" ? body.model : ""; - const isClaude = modelStr.toLowerCase().includes("claude"); - if (modelStr && (!supportsReasoning(modelStr) || (!isClaude && modelStr.includes("gemini")))) { + if (modelStr && !supportsReasoning(modelStr)) { return stripThinkingConfig(body); } diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts index 3fa9eec55c..eeeffcbedd 100644 --- a/tests/unit/executor-antigravity.test.ts +++ b/tests/unit/executor-antigravity.test.ts @@ -88,6 +88,63 @@ test("AntigravityExecutor.transformRequest normalizes model, project and content assert.equal(result.request.contents[1].role, "user"); }); +test("AntigravityExecutor.transformRequest strips thinking config for Cloud Code models that do not support reasoning", async () => { + const executor = new AntigravityExecutor(); + const body = { + reasoning_effort: "high", + request: { + generationConfig: { + thinkingConfig: { + thinkingBudget: 8192, + includeThoughts: true, + }, + }, + contents: [{ role: "user", parts: [{ text: "Hello" }] }], + }, + }; + + const result = await executor.transformRequest("antigravity/claude-sonnet-4-6", body, true, { + projectId: "project-1", + }); + + assert.equal(result.reasoning_effort, undefined); + assert.equal(result.request.generationConfig.thinkingConfig, undefined); +}); + +test("AntigravityExecutor.transformRequest preserves thinking config for supported Gemini models", async () => { + const executor = new AntigravityExecutor(); + const body = { + request: { + generationConfig: { + thinkingConfig: { + thinkingBudget: 8192, + includeThoughts: true, + }, + }, + contents: [{ role: "user", parts: [{ text: "Hello" }] }], + }, + }; + + const result = await executor.transformRequest("antigravity/gemini-3.1-pro-high", body, true, { + projectId: "project-1", + }); + + assert.equal(result.request.generationConfig.thinkingConfig.thinkingBudget, 8192); + assert.equal(result.request.generationConfig.thinkingConfig.includeThoughts, true); +}); + +test("AntigravityExecutor.transformRequest tolerates a missing body when projectId is present", async () => { + const executor = new AntigravityExecutor(); + + const result = await executor.transformRequest("antigravity/gemini-3.1-pro", null, true, { + projectId: "project-1", + }); + + assert.equal(result.project, "project-1"); + assert.equal(result.model, "gemini-3.1-pro-low"); + assert.ok(result.request.sessionId); +}); + test("AntigravityExecutor.transformRequest returns a structured error response when projectId is missing", async () => { const executor = new AntigravityExecutor(); const result = await executor.transformRequest( diff --git a/tests/unit/executor-gemini-cli.test.ts b/tests/unit/executor-gemini-cli.test.ts index ba0d7253a3..fb28a10ac7 100644 --- a/tests/unit/executor-gemini-cli.test.ts +++ b/tests/unit/executor-gemini-cli.test.ts @@ -56,6 +56,42 @@ test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transfo } }); +test("GeminiCLIExecutor.transformRequest preserves thinking config for supported Gemini models", async () => { + const executor = new GeminiCLIExecutor(); + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => + new Response(JSON.stringify({ cloudaicompanionProject: "fresh-project-id" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + try { + const transformed = await executor.transformRequest( + "models/gemini-3.1-pro-preview", + { + request: { + contents: [{ role: "user", parts: [{ text: "Hello" }] }], + generationConfig: { + thinkingConfig: { + thinkingBudget: 8192, + includeThoughts: true, + }, + }, + }, + }, + true, + { accessToken: "access-token-1" } + ); + + assert.equal(transformed.project, "fresh-project-id"); + assert.equal(transformed.request.generationConfig.thinkingConfig.thinkingBudget, 8192); + assert.equal(transformed.request.generationConfig.thinkingConfig.includeThoughts, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("GeminiCLIExecutor.refreshProject returns null on failed loadCodeAssist responses", async () => { const executor = new GeminiCLIExecutor(); const originalFetch = globalThis.fetch; diff --git a/tests/unit/thinking-budget.test.ts b/tests/unit/thinking-budget.test.ts index ee7cdcfa3d..5242f42f8d 100644 --- a/tests/unit/thinking-budget.test.ts +++ b/tests/unit/thinking-budget.test.ts @@ -42,6 +42,18 @@ test("PASSTHROUGH: body unchanged", () => { setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG); }); +test("PASSTHROUGH: keeps reasoning_effort for OpenAI-compatible Gemini routes", () => { + setThinkingBudgetConfig({ mode: ThinkingMode.PASSTHROUGH }); + const body = { + model: "openai-compatible-sp-google/gemini-3.1-pro-preview", + messages: [{ role: "user", content: "hello" }], + reasoning_effort: "high", + }; + const result = applyThinkingBudget(body); + assert.equal(result.reasoning_effort, "high"); + setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG); +}); + // ─── AUTO Mode ────────────────────────────────────────────────────────────── test("AUTO: strips Claude thinking config", () => {