From 73db5417bd794b5eaa9f947d56cb4849579b496c Mon Sep 17 00:00:00 2001 From: expiren Date: Mon, 13 Jul 2026 21:17:25 +0100 Subject: [PATCH 1/5] fix: model-aware sentinels and wrapped path sanitization --- .../src/plugin/request-helpers.test.ts | 20 +- .../opencode/src/plugin/request-helpers.ts | 16 +- packages/opencode/src/plugin/request.test.ts | 15 +- packages/opencode/src/plugin/request.ts | 219 +++++++++++++----- .../opencode/src/plugin/thinking-recovery.ts | 4 +- 5 files changed, 194 insertions(+), 80 deletions(-) diff --git a/packages/opencode/src/plugin/request-helpers.test.ts b/packages/opencode/src/plugin/request-helpers.test.ts index 4020e9b..7f8a434 100644 --- a/packages/opencode/src/plugin/request-helpers.test.ts +++ b/packages/opencode/src/plugin/request-helpers.test.ts @@ -327,7 +327,7 @@ describe("filterUnsignedThinkingBlocks", () => { const result = filterUnsignedThinkingBlocks(contents); // Sentinel replacement preserves array length expect(result[0].parts).toHaveLength(2); - expect(result[0].parts[0]).toMatchObject({ text: "." }); + expect(result[0].parts[0]).toMatchObject({ text: "" }); expect(result[0].parts[1].type).toBe("text"); }); it("keeps signed thinking parts with valid signatures from our cache", () => { @@ -365,7 +365,7 @@ describe("filterUnsignedThinkingBlocks", () => { ]; const result = filterUnsignedThinkingBlocks(contents); expect(result[0].parts).toHaveLength(2); - expect(result[0].parts[0]).toMatchObject({ text: "." }); + expect(result[0].parts[0]).toMatchObject({ text: "" }); expect(result[0].parts[1].type).toBe("text"); }); @@ -383,7 +383,7 @@ describe("filterUnsignedThinkingBlocks", () => { ]; const result = filterUnsignedThinkingBlocks(contents); expect(result[0].parts).toHaveLength(2); - expect(result[0].parts[0]).toMatchObject({ text: "." }); + expect(result[0].parts[0]).toMatchObject({ text: "" }); expect(result[0].parts[1].type).toBe("text"); }); @@ -406,7 +406,7 @@ describe("filterUnsignedThinkingBlocks", () => { ]; const result = filterUnsignedThinkingBlocks(contents, "session-1", getCachedSignatureFn); expect(result[0].parts).toHaveLength(2); - expect(result[0].parts[0]).toMatchObject({ text: "." }); + expect(result[0].parts[0]).toMatchObject({ text: "" }); expect(result[0].parts[1].thoughtSignature).toBe(validSignature); }); @@ -422,7 +422,7 @@ describe("filterUnsignedThinkingBlocks", () => { const result = filterUnsignedThinkingBlocks(contents); // Sentinel replacement: thinking parts become plain empty text parts to preserve array indices expect(result[0].parts).toHaveLength(1); - expect(result[0].parts[0]).toMatchObject({ text: "." }); + expect(result[0].parts[0]).toMatchObject({ text: "" }); expect(result[0].parts[0]).not.toHaveProperty("thought"); expect(result[0].parts[0]).not.toHaveProperty("thoughtSignature"); }); @@ -452,7 +452,7 @@ describe("filterUnsignedThinkingBlocks", () => { ]; const result = filterUnsignedThinkingBlocks(contents); expect(result[0].parts).toHaveLength(2); - expect(result[0].parts[0]).toMatchObject({ text: "." }); + expect(result[0].parts[0]).toMatchObject({ text: "" }); expect(result[0].parts[1].type).toBe("text"); }); @@ -557,7 +557,7 @@ describe("deepFilterThinkingBlocks", () => { deepFilterThinkingBlocks(payload); const filtered = (payload as any).extra_body.messages[0].content; expect(filtered).toHaveLength(2); - expect(filtered[0]).toMatchObject({ text: "." }); + expect(filtered[0]).toMatchObject({ text: "" }); expect(filtered[1].type).toBe("text"); }); @@ -578,7 +578,7 @@ describe("filterMessagesThinkingBlocks", () => { const result = filterMessagesThinkingBlocks(messages) as any; expect(result[0].content).toHaveLength(2); - expect(result[0].content[0]).toMatchObject({ text: "." }); + expect(result[0].content[0]).toMatchObject({ text: "" }); expect(result[0].content[1].type).toBe("text"); }); @@ -632,7 +632,7 @@ describe("filterMessagesThinkingBlocks", () => { const result = filterMessagesThinkingBlocks(messages) as any; expect(result[0].content).toHaveLength(2); - expect(result[0].content[0]).toMatchObject({ text: "." }); + expect(result[0].content[0]).toMatchObject({ text: "" }); expect(result[0].content[1].type).toBe("text"); }); @@ -650,7 +650,7 @@ describe("filterMessagesThinkingBlocks", () => { const result = filterMessagesThinkingBlocks(messages) as any; expect(result[0].content).toHaveLength(2); - expect(result[0].content[0]).toMatchObject({ text: "." }); + expect(result[0].content[0]).toMatchObject({ text: "" }); expect(result[0].content).toContainEqual({ type: "text", text: "visible" }); }); diff --git a/packages/opencode/src/plugin/request-helpers.ts b/packages/opencode/src/plugin/request-helpers.ts index 30fa04f..69b1baa 100644 --- a/packages/opencode/src/plugin/request-helpers.ts +++ b/packages/opencode/src/plugin/request-helpers.ts @@ -928,7 +928,7 @@ function stripAllThinkingBlocks(contentArray: any[]): any[] { const cc = (item as Record).cache_control; // Use plain empty text part — thinking-format sentinels get converted by the proxy // into Claude thinking blocks missing the required `thinking` field. - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: "" }; if (cc) sentinel.cache_control = cc; return sentinel; } @@ -956,7 +956,7 @@ function removeTrailingThinkingBlocks( // Replace with sentinel instead of popping — preserves array length const cc = part?.cache_control; - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: "" }; if (cc) sentinel.cache_control = cc; result[i] = sentinel; } @@ -1173,7 +1173,7 @@ function filterContentArray( if (isClaudeModel && (isThinking || hasSignature)) { // Use plain empty text part — thinking-format sentinels get converted by the proxy // into Claude thinking blocks with missing required fields - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: "" }; if (item.cache_control) sentinel.cache_control = item.cache_control; filtered.push(sentinel); continue; @@ -1191,7 +1191,7 @@ function filterContentArray( filtered.push(sanitized); } else { // sanitizeThinkingPart returned null — use sentinel to preserve array length - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: "" }; if (item.cache_control) sentinel.cache_control = item.cache_control; filtered.push(sentinel); } @@ -1202,7 +1202,7 @@ function filterContentArray( const existingSignature = item.signature || item.thoughtSignature; const signatureInfo = existingSignature ? `foreign signature (${String(existingSignature).length} chars)` : "no signature"; log.debug(`Injecting plain text sentinel for last-message thinking block with ${signatureInfo}`); - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: "" }; if (item.cache_control) sentinel.cache_control = item.cache_control; filtered.push(sentinel); continue; } @@ -1213,7 +1213,7 @@ function filterContentArray( filtered.push(sanitized); } else { // sanitizeThinkingPart returned null — use sentinel to preserve array length - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: "" }; if (item.cache_control) sentinel.cache_control = item.cache_control; filtered.push(sentinel); } @@ -1236,7 +1236,7 @@ function filterContentArray( filtered.push(sanitized); } else { // sanitizeThinkingPart returned null — use sentinel to preserve array length - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: "" }; if (item.cache_control) sentinel.cache_control = item.cache_control; filtered.push(sentinel); } @@ -1247,7 +1247,7 @@ function filterContentArray( // Catch-all: thinking/signature part that didn't match any branch above // Use sentinel instead of silently dropping to preserve array length - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: "" }; if (item.cache_control) sentinel.cache_control = item.cache_control; filtered.push(sentinel); } diff --git a/packages/opencode/src/plugin/request.test.ts b/packages/opencode/src/plugin/request.test.ts index e6fbe67..fd99981 100644 --- a/packages/opencode/src/plugin/request.test.ts +++ b/packages/opencode/src/plugin/request.test.ts @@ -1165,7 +1165,7 @@ it("removes x-api-key header", () => { // Sentinel replacement: thinking parts are replaced with plain empty text parts (not deleted) to preserve array indices for cache // Plain text sentinels avoid the proxy converting them to Claude thinking blocks with missing fields expect(parts).toHaveLength(2); // Array length preserved (1 sentinel + 1 functionCall) - expect(parts[0]).toMatchObject({ text: "." }); // Thinking replaced with plain space text + expect(parts[0]).toMatchObject({ text: "." }); // Thinking replaced with sentinel (Claude uses "." — proxy absorbs it) expect(parts[0]).not.toHaveProperty("thought"); expect(parts[0]).not.toHaveProperty("thoughtSignature"); expect(result.needsSignedThinkingWarmup).toBe(false); @@ -1203,7 +1203,7 @@ it("removes x-api-key header", () => { // Sentinel replacement: thinking parts are replaced with plain empty text parts (not deleted) to preserve array indices for cache expect(parts).toHaveLength(2); // Array length preserved (1 sentinel + 1 functionCall) - expect(parts[0]).toMatchObject({ text: "." }); // Thinking replaced with plain space text + expect(parts[0]).toMatchObject({ text: "." }); // Thinking replaced with sentinel (Claude uses "." — proxy absorbs it) expect(parts[0]).not.toHaveProperty("thought"); expect(parts[0]).not.toHaveProperty("thoughtSignature"); expect(result.needsSignedThinkingWarmup).toBe(false); }); @@ -1284,11 +1284,12 @@ it("removes x-api-key header", () => { // Sentinel replacement: thinking blocks become plain empty text parts // This avoids the proxy converting them to Claude thinking blocks with missing required fields - const textSentinel = content.find((block) => block.text === "." && !block.type); + const textSentinel = content.find((block) => block.text === "" && !block.type); expect(textSentinel).toBeTruthy(); expect(JSON.stringify(content)).not.toContain(foreignSignature); - // With plain text sentinels, there's no signed thinking block → warmup is needed - expect(result.needsSignedThinkingWarmup).toBe(true); }); + // In antigravity mode, the proxy handles signed thinking warmup — client skips it + expect(result.needsSignedThinkingWarmup).toBe(false); + }); it("returns requestedModel matching URL model", () => { const result = prepareAntigravityRequest( @@ -1351,10 +1352,10 @@ it("removes x-api-key header", () => { // Entry with valid parts keeps them (invalid parts replaced with sentinel to preserve indices) expect(wrapped.request.contents[1]).toEqual({ role: "model", - parts: [{ text: "." }, { text: "kept" }], + parts: [{ text: "" }, { text: "kept" }], }); // systemInstruction parts: null replaced with sentinel, valid parts kept - expect(wrapped.request.systemInstruction.parts).toEqual([{ text: "." }, { text: "system kept" }]); + expect(wrapped.request.systemInstruction.parts).toEqual([{ text: "" }, { text: "system kept" }]); }); it("drops systemInstruction when all parts are invalid", () => { diff --git a/packages/opencode/src/plugin/request.ts b/packages/opencode/src/plugin/request.ts index 23ae7d6..95f03d9 100644 --- a/packages/opencode/src/plugin/request.ts +++ b/packages/opencode/src/plugin/request.ts @@ -82,6 +82,42 @@ import type { GoogleSearchConfig } from "./transform/types"; const log = createLogger("request"); const PLUGIN_SESSION_ID = `-${crypto.randomUUID()}`; +const CONVERSATION_ID = crypto.randomUUID(); +const TRAJECTORY_ID = crypto.randomUUID(); +let requestStepIndex = 0; + +// FNV-1a 64-bit hash — deterministic sessionId matching real Antigravity IDE +// Real IDE computes FNV-1a(workspaceUri) — stable across accounts, restarts, conversations +const FNV1A_64_OFFSET_BASIS = 0xCBF29CE484222325n +const FNV1A_64_PRIME = 0x00000100000001B3n + +function fnv1a64(input: string): string { + let hash = FNV1A_64_OFFSET_BASIS + const bytes = Buffer.from(input, "utf-8") + for (const byte of bytes) { + hash ^= BigInt(byte) + hash = BigInt.asUintN(64, hash * FNV1A_64_PRIME) + } + // Convert to signed 64-bit integer string (matching real IDE format) + const signed = hash > 0x7FFFFFFFFFFFFFFFn + ? hash - 0x10000000000000000n + : hash + return signed.toString() +} + +// Deterministic session ID from workspace directory (FNV-1a 64-bit hash) +// Default: empty string input = FNV-1a offset basis = "-3750763034362895579" +let NUMERIC_SESSION_ID = fnv1a64("") + +export function initSessionId(directory: string): void { + NUMERIC_SESSION_ID = fnv1a64(directory) +} + +export function getNumericSessionId(): string { + return NUMERIC_SESSION_ID +} + +let lastExecutionId = crypto.randomUUID() const sessionDisplayedThinkingHashes = new Set(); @@ -414,7 +450,7 @@ function stripInjectedDebugFromParts(parts: unknown): unknown { // Replace debug blocks and synthetic thinking placeholders with empty text sentinel if (text && (text.startsWith(DEBUG_MESSAGE_PREFIX) || text.startsWith(SYNTHETIC_THINKING_PLACEHOLDER.trim()))) { - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: "" }; if (record.cache_control !== undefined) sentinel.cache_control = record.cache_control; return sentinel; } @@ -477,16 +513,18 @@ function isValidRequestPart(part: unknown): boolean { ); } -function sanitizeRequestPayloadForAntigravity(payload: Record): void { +function sanitizeRequestPayloadForAntigravity(payload: Record, isClaudeModel?: boolean): void { const anyPayload = payload as any; - + // Claude: normalize empty text to "." (proxy absorbs it during format conversion). + // Gemini: keep empty text as "" (Gemini echoes "." as literal dots in output). + const emptySentinelText = isClaudeModel === false ? "" : "."; if (Array.isArray(anyPayload.contents)) { // Use .map() instead of .map().filter() to preserve array indices for prompt cache stability anyPayload.contents = anyPayload.contents .map((content: unknown) => { if (!content || typeof content !== "object") { // Preserve non-object entries as empty sentinel instead of filtering - return { role: "user", parts: [{ text: "." }] }; + return { role: "user", parts: [{ text: emptySentinelText }] }; } const contentRecord = content as Record; @@ -496,7 +534,17 @@ function sanitizeRequestPayloadForAntigravity(payload: Record): const sanitizedParts = rawParts.map((part: any) => { // Replace invalid parts with sentinel to preserve array indices for cache stability if (!isValidRequestPart(part)) { - return { text: "." }; + return { text: emptySentinelText }; + } + // Normalize empty/whitespace-only text parts to the model-appropriate sentinel. + // MC strips thinking to { text: "" }; Claude plugin strips to { text: "." }. + // For Claude: normalize MC's "" → "." so both match and don't bust prompt cache. + // For Gemini: both MC and plugin use "" — skip normalization to avoid dot echo. + if (part.text !== undefined && (typeof part.text !== "string" || part.text.trim().length === 0)) { + const sentinel: Record = { text: emptySentinelText }; + if (part.cache_control) sentinel.cache_control = part.cache_control; + if (part.cacheControl) sentinel.cacheControl = part.cacheControl; + return sentinel; } return part; }).map((part: any) => { @@ -531,7 +579,7 @@ function sanitizeRequestPayloadForAntigravity(payload: Record): if (sanitizedParts.length === 0) { // Preserve as empty text sentinel instead of filtering out - return { ...contentRecord, parts: [{ text: "." }] }; + return { ...contentRecord, parts: [{ text: emptySentinelText }] }; } return { @@ -544,7 +592,7 @@ function sanitizeRequestPayloadForAntigravity(payload: Record): if (Array.isArray(anyPayload.messages)) { anyPayload.messages = anyPayload.messages.map((message: unknown) => { if (!message || typeof message !== "object") { - return { role: "user", content: [{ type: "text", text: "." }] } + return { role: "user", content: [{ type: "text", text: emptySentinelText }] } } const messageRecord = message as Record @@ -556,14 +604,14 @@ function sanitizeRequestPayloadForAntigravity(payload: Record): const sanitizedContent = rawContent.map((block: unknown) => { if (!block || typeof block !== "object") { - return { type: "text", text: "." } + return { type: "text", text: emptySentinelText } } const blockRecord = block as Record if (blockRecord.type === "text") { const text = blockRecord.text if (typeof text !== "string" || text.trim().length === 0) { - const sentinel: Record = { type: "text", text: "." } + const sentinel: Record = { type: "text", text: emptySentinelText } if (blockRecord.cache_control !== undefined) sentinel.cache_control = blockRecord.cache_control return sentinel } @@ -573,7 +621,7 @@ function sanitizeRequestPayloadForAntigravity(payload: Record): }) if (sanitizedContent.length === 0) { - return { ...messageRecord, content: [{ type: "text", text: "." }] } + return { ...messageRecord, content: [{ type: "text", text: emptySentinelText }] } } return { @@ -592,13 +640,13 @@ function sanitizeRequestPayloadForAntigravity(payload: Record): const sanitizedSystemParts = sys.parts.map((part: unknown) => { if (isValidRequestPart(part)) return part; const record = part as Record; - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: emptySentinelText }; if (record?.cache_control !== undefined) sentinel.cache_control = record.cache_control; return sentinel; }); // Only delete systemInstruction if ALL parts were invalid (all sentinels, no real content) const hasRealContent = sanitizedSystemParts.some((p: any) => - p && typeof p === "object" && typeof p.text === "string" && p.text !== "." + p && typeof p === "object" && typeof p.text === "string" && p.text !== emptySentinelText && p.text.trim().length > 0 ); if (hasRealContent) { sys.parts = sanitizedSystemParts; @@ -761,7 +809,7 @@ function ensureThinkingBeforeToolUseInContents(contents: any[], signatureSession const cc = (p as Record).cache_control; // Use plain empty text part — thinking-format sentinels get converted by the proxy // into Claude thinking blocks missing the required `thinking` field. - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: "" }; if (cc) sentinel.cache_control = cc; return sentinel; }); @@ -863,7 +911,7 @@ function ensureThinkingBeforeToolUseInMessages(messages: any[], signatureSession const cc = (b as Record).cache_control; // Use plain empty text part — thinking-format sentinels get converted by the proxy // into Claude thinking blocks missing the required `thinking` field. - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: "" }; if (cc) sentinel.cache_control = cc; return sentinel; }) }; @@ -994,7 +1042,8 @@ export function prepareAntigravityRequest( const transformedUrl = `${baseEndpoint}/v1internal:${rawAction}${streaming ? "?alt=sse" : ""}`; const isClaude = isClaudeModel(resolved.actualModel); - const isClaudeThinking = isClaudeThinkingModel(resolved.actualModel); const keepThinkingEnabled = getKeepThinking(); + const isClaudeThinking = headerStyle !== "antigravity" && isClaudeThinkingModel(resolved.actualModel); + const keepThinkingEnabled = getKeepThinking(); // Tier-based thinking configuration from model resolver (can be overridden by variant config) let tierThinkingBudget = resolved.thinkingBudget; @@ -1054,8 +1103,8 @@ export function prepareAntigravityRequest( } for (const req of requestObjects) { - // Use stable session ID for signature caching across multi-turn conversations - (req as any).sessionId = signatureSessionKey; + // Set FNV-1a numeric session ID matching real IDE format + (req as any).sessionId = NUMERIC_SESSION_ID; stripInjectedDebugFromRequestPayload(req as Record); if (isClaude) { @@ -1075,9 +1124,33 @@ export function prepareAntigravityRequest( // Step 3: Apply tool pairing fixes (ID assignment, response matching, orphan recovery) applyToolPairingFixes(req as Record, true); + // Step 4: Sanitize empty/invalid parts with model-appropriate sentinel + sanitizeRequestPayloadForAntigravity(req as Record, isClaude); } } + // Inject labels/toolConfig into wrapped requests (same as unwrapped path) + if (headerStyle === "antigravity") { + const claudeUsed = isClaude ? "true" : "false" + for (const req of requestObjects) { + (req as any).labels = { + last_execution_id: lastExecutionId, + last_step_index: String(requestStepIndex), + model_enum: "MODEL_PLACEHOLDER_M132", + trajectory_id: TRAJECTORY_ID, + used_claude: claudeUsed, + used_claude_conservative: claudeUsed, + } + if (Array.isArray((req as any).tools) && (req as any).tools.length > 0) { + (req as any).toolConfig = { + functionCallingConfig: { mode: "VALIDATED" }, + } + } + } + lastExecutionId = crypto.randomUUID() + requestStepIndex++ + } + // Guard against assistant prefill: Claude rejects conversations ending // with model/assistant messages. After context compaction, the conversation // can end with a model message — append synthetic user message to fix. @@ -1125,6 +1198,8 @@ export function prepareAntigravityRequest( const isGemini3 = effectiveModel.toLowerCase().includes("gemini-3"); log.debug(`[ThinkingResolution] rawModel=${rawModel} resolvedModel=${effectiveModel} resolvedTier=${tierThinkingLevel ?? "none"} variantLevel=${variantConfig?.thinkingLevel ?? "none"} variantBudget=${variantConfig?.thinkingBudget ?? "none"} providerOptions.google=${JSON.stringify((requestPayload.providerOptions as any)?.google ?? null)} generationConfig.thinkingConfig=${JSON.stringify((rawGenerationConfig as any)?.thinkingConfig ?? null)}`); + // Strip providerOptions — AI SDK construct, never sent by real Antigravity IDE + delete requestPayload.providerOptions; if (variantConfig?.thinkingLevel && isGemini3) { // Gemini 3 native format - use thinkingLevel directly @@ -1227,7 +1302,7 @@ export function prepareAntigravityRequest( // Build thinking config based on model type let thinkingConfig: Record; - if (isClaudeThinking && headerStyle !== "antigravity") { + if (isClaudeThinking) { // Claude-on-Gemini fallback uses snake_case keys. thinkingConfig = { include_thoughts: normalizedThinking.includeThoughts ?? true, @@ -1255,9 +1330,7 @@ export function prepareAntigravityRequest( if (isClaudeThinking && typeof thinkingBudget === "number" && thinkingBudget > 0) { const currentMax = (rawGenerationConfig.maxOutputTokens ?? rawGenerationConfig.max_output_tokens) as number | undefined; - if (headerStyle === "antigravity" && !currentMax) { - rawGenerationConfig.maxOutputTokens = 64000; - } else if (!currentMax || currentMax <= thinkingBudget) { + if (!currentMax || currentMax <= thinkingBudget) { rawGenerationConfig.maxOutputTokens = computeClaudeMaxOutputTokens(thinkingBudget); } if (rawGenerationConfig.max_output_tokens !== undefined) { @@ -1269,9 +1342,7 @@ export function prepareAntigravityRequest( const generationConfig: Record = { thinkingConfig }; if (isClaudeThinking && typeof thinkingBudget === "number" && thinkingBudget > 0) { - generationConfig.maxOutputTokens = headerStyle === "antigravity" - ? 64000 - : computeClaudeMaxOutputTokens(thinkingBudget); + generationConfig.maxOutputTokens = computeClaudeMaxOutputTokens(thinkingBudget); } applyAgyGenerationDefaults(effectiveModel, generationConfig, headerStyle); requestPayload.generationConfig = generationConfig; @@ -1657,7 +1728,33 @@ export function prepareAntigravityRequest( } stripInjectedDebugFromRequestPayload(requestPayload); - sanitizeRequestPayloadForAntigravity(requestPayload); + sanitizeRequestPayloadForAntigravity(requestPayload, isClaude); + + // Inject fields inside request payload matching real Antigravity IDE format + // sessionId, labels, toolConfig go INSIDE request (not envelope top level) + if (headerStyle === "antigravity") { + // Session ID — stable signed integer string per workspace directory + requestPayload.sessionId = NUMERIC_SESSION_ID + + // Labels — tracking metadata for agent requests + const claudeUsed = isClaude ? "true" : "false" + requestPayload.labels = { + last_execution_id: lastExecutionId, + last_step_index: String(requestStepIndex), + model_enum: "MODEL_PLACEHOLDER_M132", + trajectory_id: TRAJECTORY_ID, + used_claude: claudeUsed, + used_claude_conservative: claudeUsed, + } + lastExecutionId = crypto.randomUUID() + + // Tool config — only when function declarations are present + if (Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0) { + requestPayload.toolConfig = { + functionCallingConfig: { mode: "VALIDATED" }, + } + } + } // Use the stable default project ID (never a per-request random one): // a fresh random project each request busts the prompt cache and // fragments server-side quota/session state. ensureProjectContext @@ -1683,52 +1780,68 @@ export function prepareAntigravityRequest( if (wrappedBody.request && typeof wrappedBody.request === 'object') { // Use stable session ID for signature caching across multi-turn conversations sessionId = signatureSessionKey; - (wrappedBody.request as any).sessionId = signatureSessionKey; + // Note: requestPayload.sessionId is already set to NUMERIC_SESSION_ID above + // (FNV-1a hash matching real IDE) — do NOT overwrite it here } + requestStepIndex++; body = safeStringify(headerStyle === "antigravity" ? orderAntigravityEnvelope(wrappedBody) : wrappedBody); } } catch { throw new Error("Failed to build Antigravity request body"); } } - // agy CLI does not send an Accept header on streamGenerateContent requests. - // Avoid adding one here; the response is selected by ?alt=sse. - - // Add interleaved thinking header for Claude thinking models - // This enables real-time streaming of thinking tokens - if (isClaudeThinking) { - const existing = headers.get("anthropic-beta"); - const interleavedHeader = "interleaved-thinking-2025-05-14"; - - if (existing) { - if (!existing.includes(interleavedHeader)) { - headers.set("anthropic-beta", `${existing},${interleavedHeader}`); - } - } else { - headers.set("anthropic-beta", interleavedHeader); - } - } - if (headerStyle === "antigravity") { - // Use randomized headers as the fallback pool for Antigravity mode - const selectedHeaders = getRandomizedHeaders("antigravity", requestedModel); - - // Antigravity mode: Match Antigravity Manager behavior - // AM only sends User-Agent on content requests — no X-Goog-Api-Client, no Client-Metadata header - // (ideType=ANTIGRAVITY goes in request body metadata via project.ts, not as a header) + // Real Antigravity IDE content requests send ONLY these headers: + // Host, User-Agent, Authorization, Content-Type, Transfer-Encoding, Accept-Encoding + // Host, Transfer-Encoding, and Accept-Encoding are auto-set by the fetch runtime. + // Strip ALL inherited OpenCode/AI SDK headers (x-session-affinity, x-parent-session-id, + // x-stainless-*, anthropic-version, anthropic-beta, Accept, etc.) + // to match real IDE signature exactly. + // The streaming format is signaled by ?alt=sse in the URL, not by Accept header. const fingerprint = options?.fingerprint ?? getSessionFingerprint(); const fingerprintHeaders = buildFingerprintHeaders(fingerprint); + const selectedHeaders = getRandomizedHeaders("antigravity", requestedModel); + const cleanUA = fingerprintHeaders["User-Agent"] || selectedHeaders["User-Agent"]; + const authValue = headers.get("Authorization"); - headers.set("User-Agent", fingerprintHeaders["User-Agent"] || selectedHeaders["User-Agent"]); - headers.set("Accept-Encoding", "gzip"); + // Clear all inherited headers and rebuild with only real-IDE-matching set + const keysToDelete: string[] = []; + headers.forEach((_value, key) => { keysToDelete.push(key); }); + for (const key of keysToDelete) { headers.delete(key); } + + if (authValue) headers.set("Authorization", authValue); + headers.set("Content-Type", "application/json"); + headers.set("User-Agent", cleanUA); } else { // Gemini CLI mode: match official google-gemini/gemini-cli User-Agent format + // Accept: text/event-stream only for gemini-cli mode (antigravity uses ?alt=sse URL param) + if (streaming) { + headers.set("Accept", "text/event-stream"); + } + + // Add interleaved thinking header for Claude thinking models (gemini-cli only) + // Real Antigravity IDE does NOT send anthropic-beta — proxy handles it server-side + if (isClaudeThinking) { + const existing = headers.get("anthropic-beta"); + const interleavedHeader = "interleaved-thinking-2025-05-14"; + + if (existing) { + if (!existing.includes(interleavedHeader)) { + headers.set("anthropic-beta", `${existing},${interleavedHeader}`); + } + } else { + headers.set("anthropic-beta", interleavedHeader); + } + } + const geminiCliHeaders = getRandomizedHeaders("gemini-cli", requestedModel); headers.set("User-Agent", geminiCliHeaders["User-Agent"]); if (geminiCliHeaders["X-Goog-Api-Client"]) headers.set("X-Goog-Api-Client", geminiCliHeaders["X-Goog-Api-Client"]); if (geminiCliHeaders["Client-Metadata"]) headers.set("Client-Metadata", geminiCliHeaders["Client-Metadata"]); - } return { + } + + return { request: transformedUrl, init: { ...baseInit, diff --git a/packages/opencode/src/plugin/thinking-recovery.ts b/packages/opencode/src/plugin/thinking-recovery.ts index abf733e..1be015d 100644 --- a/packages/opencode/src/plugin/thinking-recovery.ts +++ b/packages/opencode/src/plugin/thinking-recovery.ts @@ -198,7 +198,7 @@ function stripAllThinkingBlocks(contents: any[]): any[] { // Replace with Gemini-format sentinel preserving cache_control // Use plain empty text part — thinking-format sentinels get converted by the proxy // into Claude thinking blocks missing the required `thinking` field. - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: "" }; if (part.cache_control !== undefined) sentinel.cache_control = part.cache_control; return sentinel; }); @@ -212,7 +212,7 @@ function stripAllThinkingBlocks(contents: any[]): any[] { // Replace with Anthropic-format sentinel preserving cache_control // Use plain empty text part — thinking-format sentinels get converted by the proxy // into Claude thinking blocks missing the required `thinking` field. - const sentinel: Record = { text: "." }; + const sentinel: Record = { text: "" }; if (block?.cache_control !== undefined) sentinel.cache_control = block.cache_control; return sentinel; }); From aca6181eab843d1e5f1025f7b3ce9ebc59cc6dbc Mon Sep 17 00:00:00 2001 From: expiren Date: Mon, 13 Jul 2026 21:17:25 +0100 Subject: [PATCH 2/5] fix: use shared FNV-1a session ID in search requests --- packages/opencode/src/plugin/search.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/plugin/search.ts b/packages/opencode/src/plugin/search.ts index 4348c33..e99d0dc 100644 --- a/packages/opencode/src/plugin/search.ts +++ b/packages/opencode/src/plugin/search.ts @@ -17,6 +17,7 @@ import { import { fetchWithAgyCliTransport } from "./agy-transport"; import { buildFingerprintHeaders, getSessionFingerprint } from "./fingerprint"; import { createLogger } from "./logger"; +import { getNumericSessionId } from "./request"; const log = createLogger("search"); @@ -101,17 +102,10 @@ export interface SearchResult { // Helper Functions // ============================================================================ -let sessionCounter = 0; -const sessionPrefix = `search-${Date.now().toString(36)}`; - function generateRequestId(): string { return `agent/${crypto.randomUUID()}/${Date.now()}/${crypto.randomUUID()}/2`; } -function getSessionId(): string { - sessionCounter++; - return `${sessionPrefix}-${sessionCounter}`; -} function formatSearchResult(result: SearchResult): string { const lines: string[] = []; @@ -268,7 +262,7 @@ export async function executeSearch( requestId: generateRequestId(), request: { ...requestPayload, - sessionId: getSessionId(), + sessionId: getNumericSessionId(), }, model: SEARCH_MODEL, userAgent: "antigravity", From de62914a458c52f5fabe06e5f973b3bad981cfd4 Mon Sep 17 00:00:00 2001 From: expiren Date: Mon, 13 Jul 2026 21:17:26 +0100 Subject: [PATCH 3/5] feat: ineligible account auto-disable and dead code cleanup --- packages/opencode/src/plugin.ts | 92 +++++++++++++++--------- packages/opencode/src/plugin/accounts.ts | 28 ++++++++ 2 files changed, 88 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/plugin.ts b/packages/opencode/src/plugin.ts index 79c7ccc..d1bda63 100644 --- a/packages/opencode/src/plugin.ts +++ b/packages/opencode/src/plugin.ts @@ -33,6 +33,7 @@ import { isGenerativeLanguageRequest, prepareAntigravityRequest, transformAntigravityResponse, + initSessionId, } from "./plugin/request"; import { resolveModelWithTier } from "./plugin/transform/model-resolver"; import { @@ -65,7 +66,7 @@ import { parseGeminiDumpCommandAction, setGeminiDumpEnabled, } from "./plugin/gemini-dump"; -import { getAntigravityOpencodeModelIds, OPENCODE_MODEL_DEFINITIONS } from "./plugin/model-registry"; +import { OPENCODE_MODEL_DEFINITIONS } from "./plugin/model-registry"; import type { GetAuth, LoaderResult, @@ -80,16 +81,11 @@ const MAX_OAUTH_ACCOUNTS = 10; const MAX_WARMUP_SESSIONS = 1000; const MAX_WARMUP_RETRIES = 2; const MAX_TOTAL_CAPACITY_RETRIES = 4; -const CAPACITY_BACKOFF_TIERS_MS = [5000, 10000, 20000, 30000, 60000]; function isCapacityRetryBudgetExhausted(totalCapacityRetries: number): boolean { return totalCapacityRetries >= MAX_TOTAL_CAPACITY_RETRIES; } -function getCapacityBackoffDelay(consecutiveFailures: number): number { - const index = Math.min(consecutiveFailures, CAPACITY_BACKOFF_TIERS_MS.length - 1); - return CAPACITY_BACKOFF_TIERS_MS[Math.max(0, index)] ?? 5000; -} const warmupAttemptedSessionIds = new Set(); const warmupSucceededSessionIds = new Set(); @@ -1165,17 +1161,6 @@ function resetRateLimitState(accountIndex: number, quotaKey: string): void { rateLimitStateByAccountQuota.delete(stateKey); } -/** - * Reset all rate limit state for an account (all quotas). - * Used when account is completely healthy. - */ -function resetAllRateLimitStateForAccount(accountIndex: number): void { - for (const key of rateLimitStateByAccountQuota.keys()) { - if (key.startsWith(`${accountIndex}:`)) { - rateLimitStateByAccountQuota.delete(key); - } - } -} function headerStyleToQuotaKey(headerStyle: HeaderStyle, family: ModelFamily): string { if (family === "claude") return "claude"; @@ -1287,6 +1272,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( // Load configuration from files and environment variables const config = loadConfig(directory); initRuntimeConfig(config); + initSessionId(directory); // Cached getAuth function for tool access let cachedGetAuth: GetAuth | null = null; @@ -1464,7 +1450,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( return { config: async (opencodeConfig: Record) => { - applyAntigravityProviderCatalog(opencodeConfig, providerId); + applyAntigravityProviderCatalog(opencodeConfig, providerId, config); const mutableConfig = opencodeConfig as Record & { command?: Record; }; @@ -1627,7 +1613,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( if (accountManager.getAccountCount() === 0) { return createSyntheticErrorResponse( - "No Antigravity accounts configured. Run `opencode auth login`.", + "[Antigravity Error] No Antigravity accounts configured. Run `opencode auth login`.", "unknown", ); } @@ -1731,7 +1717,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( if (accountCount === 0) { return createSyntheticErrorResponse( - "No Antigravity accounts available. Run `opencode auth login`.", + "[Antigravity Error] No Antigravity accounts available. Run `opencode auth login`.", model ?? "unknown", ); } @@ -1783,7 +1769,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( "error" ); return createSyntheticErrorResponse( - `Quota protection: All ${accountCount} account(s) are over ${threshold}% usage for ${family}. ` + + `[Antigravity Error] Quota protection: All ${accountCount} account(s) are over ${threshold}% usage for ${family}. ` + `Quota resets in ${waitTimeFormatted}. ` + `Add more accounts, wait for quota reset, or set soft_quota_threshold_percent: 100 to disable.`, model ?? "unknown", @@ -1834,7 +1820,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( // Return a proper rate limit error response return createSyntheticErrorResponse( - `All ${accountCount} account(s) rate-limited for ${family}. ` + + `[Antigravity Error] All ${accountCount} account(s) rate-limited for ${family}. ` + `Quota resets in ${waitTimeFormatted}. ` + `Add more accounts with \`opencode auth login\` or wait and retry.`, model ?? "unknown", @@ -1938,7 +1924,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( } return createSyntheticErrorResponse( - "All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.", + "[Antigravity Error] All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.", model ?? "unknown", ); } @@ -1964,7 +1950,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( lastError = new Error("Missing access token"); if (accountCount <= 1) { return createSyntheticErrorResponse( - "Missing access token. Run `opencode auth login` to reauthenticate.", + "[Antigravity Error] Missing access token. Run `opencode auth login` to reauthenticate.", model ?? "unknown", ); } @@ -2558,6 +2544,35 @@ export const createAntigravityPlugin = (providerId: string) => async ( shouldSwitchAccount = true; break; } + + // Check for "not eligible" 403 — permanently disable account + const lowerBody = errorBodyText.toLowerCase(); + const isIneligible = ( + lowerBody.includes("not eligible") || + lowerBody.includes("ineligible") || + lowerBody.includes("not available for your account") || + lowerBody.includes("access denied") + ); + + if (isIneligible) { + const ineligibleLabel = account.email || `Account ${account.index + 1}`; + accountManager.markAccountIneligible(account.index, errorBodyText.slice(0, 200)); + + if (accountManager.shouldShowAccountToast(account.index, 60000)) { + await showToast( + `🚫 ${ineligibleLabel} is not eligible for Antigravity. Permanently disabled.`, + "warning", + ); + accountManager.markToastShown(account.index); + } + + pushDebug(`ineligible: permanently disabled account ${account.index}`); + getHealthTracker().recordFailure(account.index); + + lastFailure = createFailureContext(response); + shouldSwitchAccount = true; + break; + } } const shouldRetryEndpoint = ( @@ -2664,7 +2679,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( // Clean up and return a synthetic response after max attempts emptyResponseAttempts.delete(emptyAttemptKey); return createSyntheticErrorResponse( - `Empty response after ${currentAttempts} attempts for model ${prepared.effectiveModel ?? "unknown"}.`, + `[Antigravity Error] Empty response after ${currentAttempts} attempts for model ${prepared.effectiveModel ?? "unknown"}.`, prepared.effectiveModel ?? "unknown", ); } @@ -2816,7 +2831,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( ); } return createSyntheticErrorResponse( - lastError?.message || `Exceeded max account switches (${maxAccountSwitches}). All accounts rate-limited.`, + `[Antigravity Error] ${lastError?.message || `Exceeded max account switches (${maxAccountSwitches}). All accounts rate-limited.`}`, model ?? "unknown", ); } @@ -2841,7 +2856,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( } return createSyntheticErrorResponse( - lastError?.message || "All Antigravity endpoints failed", + `[Antigravity Error] ${lastError?.message || "All Antigravity endpoints failed"}`, model ?? "unknown", ); } @@ -2869,7 +2884,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( } return createSyntheticErrorResponse( - lastError?.message || "All Antigravity accounts failed", + `[Antigravity Error] ${lastError?.message || "All Antigravity accounts failed"}`, model ?? "unknown", ); } @@ -3851,16 +3866,29 @@ type OpencodeMutableConfig = Record & { }>; }; -function applyAntigravityProviderCatalog(config: Record, providerId: string): void { - const mutableConfig = config as OpencodeMutableConfig; +function applyAntigravityProviderCatalog( + opencodeConfig: Record, + providerId: string, + pluginConfig: AntigravityConfig +): void { + const mutableConfig = opencodeConfig as OpencodeMutableConfig; mutableConfig.provider ??= {}; const providerConfig = mutableConfig.provider[providerId] ?? {}; + + // Merge order (lowest to highest priority): + // 1. Built-in defaults: OPENCODE_MODEL_DEFINITIONS + // 2. Decoupled models (from antigravity.json / antigravity-models.json): pluginConfig.models + // 3. User's main opencode.json models (preserves backwards compatibility / custom overrides) providerConfig.models = { - ...(providerConfig.models ?? {}), ...OPENCODE_MODEL_DEFINITIONS, + ...(pluginConfig.models ?? {}), + ...(providerConfig.models ?? {}), }; - providerConfig.whitelist = getAntigravityOpencodeModelIds(); + + // Whitelist should be the union of all registered models so they aren't pruned by OpenCode + providerConfig.whitelist = Object.keys(providerConfig.models); + mutableConfig.provider[providerId] = providerConfig; } diff --git a/packages/opencode/src/plugin/accounts.ts b/packages/opencode/src/plugin/accounts.ts index e6efdcc..a422cdc 100644 --- a/packages/opencode/src/plugin/accounts.ts +++ b/packages/opencode/src/plugin/accounts.ts @@ -158,6 +158,10 @@ export interface ManagedAccount { verificationRequiredAt?: number; verificationRequiredReason?: string; verificationUrl?: string; + /** Account permanently ineligible for Antigravity */ + ineligible?: boolean; + ineligibleAt?: number; + ineligibleReason?: string; /** Daily request counts per model family */ dailyRequestCounts?: { date: string @@ -956,6 +960,30 @@ export class AccountManager { return true; } + markAccountIneligible(accountIndex: number, reason?: string): boolean { + const account = this.accounts[accountIndex]; + if (!account) { + return false; + } + + account.ineligible = true; + account.ineligibleAt = nowMs(); + account.ineligibleReason = reason?.trim() || undefined; + + if (account.enabled !== false) { + this.setAccountEnabled(accountIndex, false); + } else { + this.requestSaveToDisk(); + } + + return true; + } + + isAccountIneligible(accountIndex: number): boolean { + const account = this.accounts[accountIndex]; + return account?.ineligible === true; + } + removeAccountByIndex(accountIndex: number): boolean { if (accountIndex < 0 || accountIndex >= this.accounts.length) { return false; From c798189bb455744fd046252fd4f4eac8854e6d18 Mon Sep 17 00:00:00 2001 From: expiren Date: Mon, 13 Jul 2026 21:17:26 +0100 Subject: [PATCH 4/5] feat: decouple model definitions with multi-source loading --- packages/opencode/src/plugin/config/loader.ts | 59 +++++++++++++++++++ packages/opencode/src/plugin/config/schema.ts | 6 ++ 2 files changed, 65 insertions(+) diff --git a/packages/opencode/src/plugin/config/loader.ts b/packages/opencode/src/plugin/config/loader.ts index 514fb0f..a552ef3 100644 --- a/packages/opencode/src/plugin/config/loader.ts +++ b/packages/opencode/src/plugin/config/loader.ts @@ -118,6 +118,56 @@ function mergeConfigs( * @param directory - The project directory (for project-level config) * @returns Fully resolved configuration */ +function stripJsonCommentsAndTrailingCommas(json: string): string { + return json + .replace( + /\\"|"(?:\\"|[^"])*"|(\/{2}.*|\/\*[\s\S]*?\*\/)/g, + (match: string, group: string | undefined) => (group ? "" : match) + ) + .replace(/,(\s*[}\]])/g, "$1"); +} + +function loadModelsFile(path: string): Record | null { + try { + if (!existsSync(path)) { + return null; + } + const content = readFileSync(path, "utf-8"); + const parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(content)); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + return null; + } catch (error) { + log.warn("Failed to load decoupled models file", { path, error: String(error) }); + return null; + } +} + +export function loadDecoupledModels(directory: string): Record { + let mergedModels: Record = {}; + + const configDir = getConfigDir(); + const userJsonPath = join(configDir, "antigravity-models.json"); + const userJsoncPath = join(configDir, "antigravity-models.jsonc"); + const projectJsonPath = join(directory, ".opencode", "antigravity-models.json"); + const projectJsoncPath = join(directory, ".opencode", "antigravity-models.jsonc"); + + // User level (prefer jsonc if both exist) + const userModels = loadModelsFile(existsSync(userJsoncPath) ? userJsoncPath : userJsonPath); + if (userModels) { + mergedModels = { ...mergedModels, ...userModels }; + } + + // Project level (prefer jsonc if both exist) - overrides user level + const projectModels = loadModelsFile(existsSync(projectJsoncPath) ? projectJsoncPath : projectJsonPath); + if (projectModels) { + mergedModels = { ...mergedModels, ...projectModels }; + } + + return mergedModels; +} + export function loadConfig(directory: string): AntigravityConfig { // Start with defaults let config: AntigravityConfig = { ...DEFAULT_CONFIG }; @@ -136,6 +186,15 @@ export function loadConfig(directory: string): AntigravityConfig { config = mergeConfigs(config, projectConfig); } + // Load decoupled models from antigravity-models.json(c) and merge into config.models + const decoupledModels = loadDecoupledModels(directory); + if (Object.keys(decoupledModels).length > 0) { + config.models = { + ...(config.models ?? {}), + ...decoupledModels, + }; + } + return config; } diff --git a/packages/opencode/src/plugin/config/schema.ts b/packages/opencode/src/plugin/config/schema.ts index 1ebef0a..6b5da3b 100644 --- a/packages/opencode/src/plugin/config/schema.ts +++ b/packages/opencode/src/plugin/config/schema.ts @@ -503,6 +503,12 @@ export const AntigravityConfigSchema = z.object({ */ auto_update: z.boolean().default(true), + /** + * Decoupled model definitions to inject. + */ + models: z.record(z.string(), z.any()).optional(), + + }); export type AntigravityConfig = z.infer; From 1d6156215dedd0006f670fffb340deb86dc6b700 Mon Sep 17 00:00:00 2001 From: expiren Date: Mon, 13 Jul 2026 21:17:26 +0100 Subject: [PATCH 5/5] feat: add gemini-3.1-flash-image to OpenCode model whitelist --- packages/core/src/model-registry.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/model-registry.ts b/packages/core/src/model-registry.ts index 5856911..a988db8 100644 --- a/packages/core/src/model-registry.ts +++ b/packages/core/src/model-registry.ts @@ -259,6 +259,7 @@ const ANTIGRAVITY_OPENCODE_MODEL_IDS = [ "antigravity-gemini-3.1-pro", "antigravity-claude-sonnet-4-6-thinking", "antigravity-claude-opus-4-6-thinking", + "antigravity-gemini-3.1-flash-image", ] as const function pickModelDefinitions(ids: readonly string[]): OpencodeModelDefinitions {