- Add ToolRound struct to pair function calls with results per-round
- Replace single-match history rewrite (broke after first round) with
multi-round loop that rewrites ALL placeholder model turns
- Fix tool result name fallback: use positional index instead of always
picking the first call
- Set is_complete for any finishReason (FUNCTION_CALL, MAX_TOKENS, etc.)
not just STOP — prevents response_complete flag from never being set
- Legacy fallback: responses.rs path (single-round via last_calls +
pending_results) still works when tool_rounds is empty
- Add tests: multi-round rewrite, single-round legacy, no-op, and
FUNCTION_CALL/MAX_TOKENS finishReason handling
Move the in-flight blocking check to the top of the LLM request flow,
BEFORE request modification. This catches follow-ups on ALL connections
(the LS opens multiple parallel TLS connections). Only the very first
modified request reaches Google — all others get fake STOP responses.
Previously, each new connection independently allowed one request
through before blocking, letting 4-5 requests leak per turn.
- Add request_in_flight flag to MitmStore, set immediately when first
LLM request is forwarded with custom tools active
- Block ALL subsequent LS requests (agentic loop + internal flash-lite)
with fake SSE responses instead of waiting for response_complete
- Fix function call deduplication: drain() accumulator after storing
to prevent 3x duplicate tool calls across SSE chunks
- Clear all stale state (response, thinking, function calls, errors)
at the start of each streaming request
- Handle response_complete with no content (thoughtSignature-only)
gracefully with timeout instead of infinite hang
The LS silently ignores the 'images' field from our
SendUserCascadeMessageRequest proto — it never forwards image data
to Google's API.
New approach: store the image in MitmStore, then the MITM request
modifier injects it as 'inlineData' directly into the last user
message's parts array in the Google API JSON request.
Flow:
Client → Proxy (decode base64) → MitmStore.set_pending_image()
LS → Google API → MITM intercepts → inject inlineData part
→ Google receives image + text together
This works for all three API endpoints (responses, completions,
gemini).
SendUserCascadeMessageRequest proto field layout (from JS bundle analysis):
- Field 6 is 'images' (repeated ImageData) at the REQUEST level
- NOT a Blob sub-message inside ChatMessage (field 2)
ImageData proto uses base64_data (field 1) + mime_type (field 2),
not raw bytes. The LS was silently ignoring our ChatMessage blob
because the field structure didn't match.
Also protect MITM modifier from stripping messages containing
inlineData (image parts in Google API JSON).
Google's Gemini API rejects $schema, additionalProperties, $ref,
$defs, default, examples, and title in tool parameter schemas.
OpenCode/MCP tools include these standard JSON Schema fields.
Now recursively stripped during OpenAI→Gemini tool conversion.
When the MITM detects a functionCall in Google's response AND custom
tools are active, send a forged clean text response to the LS instead
of the real one. This prevents the LS from seeing function calls for
tools it doesn't manage, eliminating the retry loop entirely.
The real function call data is captured in MitmStore and returned to
the client (OpenCode) through the completions handler.
Also removes the complex chunked-encoding response rewriting approach
in favor of this simpler forge-and-break strategy.
The function call stripping was only happening when no custom tools
were present. But even with custom tools injected, the LS history
contains functionCall/functionResponse parts for LS-internal tools
that we stripped, causing MALFORMED_FUNCTION_CALL. Now always strip
regardless of custom tools presence.
Root cause: after stripping LS tool definitions, two things remained:
1. toolConfig with mode=VALIDATED (forces function calling even with
empty tools array)
2. Model's training/identity context causing it to attempt function
calls in text
Fix:
- Remove empty tools array and toolConfig when no custom tools injected
- Strip functionCall/functionResponse parts from conversation history
- Append explicit 'no tools available' instruction to system prompt
- Remove debug dump code
When LS tools are stripped from the request but the conversation history
still contains functionCall/functionResponse parts referencing those
tools, Google returns MALFORMED_FUNCTION_CALL and the LS retries in an
infinite loop, causing the request to hang forever.
Now after stripping LS tools and confirming no custom tools are injected,
we also strip all functionCall/functionResponse parts from the history
and remove any messages that become empty as a result.
- store.rs: Add tool context storage (active tools, tool config, pending
tool results, call_id mapping, last function calls for history rewrite)
- types.rs: Add tools/tool_choice fields to ResponsesRequest, add
build_function_call_output helper for OpenAI function_call output items
- modify.rs: Replace hardcoded get_weather with dynamic ToolContext
injection. Add openai_tools_to_gemini and openai_tool_choice_to_gemini
converters. Add conversation history rewriting for tool result turns
(replaces fake 'Tool call completed' model turn with real functionCall,
injects functionResponse before last user turn)
- proxy.rs: Build ToolContext from MitmStore before calling modify_request.
Save last_function_calls for history rewriting on subsequent turns
- responses.rs: Store client tools in MitmStore before LS call. Detect
function_call_output in input array for tool result submission. Return
captured functionCalls as OpenAI function_call output items with
generated call_ids and stringified arguments
- gemini.rs: New Gemini-native endpoint (POST /v1/gemini) with zero
format translation. Accepts functionDeclarations directly, returns
functionCall in Gemini format directly
- mod.rs: Wire /v1/gemini route, bump version to 3.3.0
When MITM strips LS tools and injects custom tools:
- Google returns functionCall → captured in MitmStore
- Follow-up LS requests are blocked with fake SSE response
- Proxy consumes captured calls and clears the flag
- Result: 1 real Google API call instead of 5+ per tool call
Flow: Client → Proxy → LS → MITM(inject tool) → Google
Google returns functionCall → MITM captures it
LS tries follow-up → MITM blocks (fake response)
Proxy reads captured functionCall → returns to client
With tools present, LS enters full agentic mode doing multi-turn
tool calls (file searches, terminal commands, etc.). A simple
weather question caused 40+ Google API calls in 120s before timeout.
Tool stripping is required to maintain single-turn behavior.
- Add proxyctl CLI script for systemd service management
- Add systemd user service file for background operation
- Fix standalone LS kill: properly track real LS PID via pgrep
and use sudo kill for cross-user cleanup on shutdown
- Remove deprecated scripts (dns-redirect, iptables-redirect,
mitm-wrapper, standalone-ls, parse-snapshot)
- Disable tool stripping in MITM for tool call investigation
- Update GEMINI.md with CLI tools documentation
The LS makes TWO separate Google API calls for thinking models:
Call 1: response + thinking token count (no thinking text)
Call 2: thinking summary text (no thinking tokens)
Each hits a different StreamingAccumulator, so we:
1. Capture response_text in StreamingAccumulator (non-thinking parts)
2. In MitmStore::record_usage, detect when Call 2 arrives for a
cascade that already has thinking tokens from Call 1
3. Merge Call 2's response_text as thinking_text on Call 1's usage
Also injects includeThoughts into Google API requests via MITM
modify to ensure thinking text is available in SSE responses.
Tools are only needed by the Antigravity webview for tool-call UI.
Our proxy doesn't need them — the model generates text responses fine
without tool definitions. Stripping all 20 tools saves ~15KB per request.