THOUGHT_SIGNATURES.md

  1# Thought Signatures Implementation for Gemini 3 Models
  2
  3## Problem Statement
  4
  5Gemini 3 models (like `gemini-3-pro-preview`) fail when using tool calls through OpenRouter and Copilot with the error:
  6
  7```
  8Unable to submit request because function call `default_api:list_directory` in the 2. content block is missing a `thought_signature`.
  9```
 10
 11The error occurs AFTER the first tool call is executed and we send back the tool results with conversation history.
 12
 13## Background
 14
 15### What are Thought Signatures?
 16
 17Thought signatures are a validation mechanism used by Gemini reasoning models. When the model performs "thinking" (reasoning) before making a tool call, it generates a cryptographic signature of that reasoning. This signature must be preserved and sent back in subsequent requests to maintain the integrity of the conversation flow.
 18
 19### API Formats Involved
 20
 21There are three different API formats in play:
 22
 231. **Google AI Native API** - Uses `Part` objects including `FunctionCallPart` with a `thought_signature` field
 242. **OpenRouter/Copilot Chat Completions API** - OpenAI-compatible format with `tool_calls` array
 253. **Copilot Responses API** - A separate format with streaming `reasoning_details`
 26
 27## Current Architecture
 28
 29### Data Flow
 30
 311. **Model Response** → Contains tool calls with reasoning
 322. **Zed Event Stream** → Emits `LanguageModelCompletionEvent::ToolUse` events
 333. **Agent** → Collects events and constructs `LanguageModelRequestMessage` objects
 344. **Provider** → Converts messages back to provider-specific format
 355. **API Request** → Sent back to the provider with conversation history
 36
 37### Key Data Structures
 38
 39```rust
 40// Core message structure
 41pub struct LanguageModelRequestMessage {
 42    pub role: Role,
 43    pub content: Vec<MessageContent>,
 44    pub cache: bool,
 45    pub reasoning_details: Option<serde_json::Value>, // Added for thought signatures
 46}
 47
 48// Tool use structure
 49pub struct LanguageModelToolUse {
 50    pub id: LanguageModelToolUseId,
 51    pub name: Arc<str>,
 52    pub raw_input: String,
 53    pub input: serde_json::Value,
 54    pub is_input_complete: bool,
 55    pub thought_signature: Option<String>, // NOT USED - wrong approach
 56}
 57```
 58
 59## What We Tried (That Didn't Work)
 60
 61### Attempt 1: `thought_signature` as field on ToolCall
 62We added `thought_signature` as a field on the `ToolCall` structure itself.
 63
 64**Result:** 400 Bad Request - OpenRouter/Copilot don't support this field at the ToolCall level.
 65
 66### Attempt 2: `thought_signature` inside `function` object
 67We moved `thought_signature` inside the `function` object of the tool call.
 68
 69```json
 70{
 71  "function": {
 72    "name": "...",
 73    "arguments": "...",
 74    "thought_signature": "..."
 75  }
 76}
 77```
 78
 79**Result:** 400 Bad Request - Still rejected.
 80
 81### Attempt 3: Using camelCase `thoughtSignature`
 82Tried both snake_case and camelCase variants.
 83
 84**Result:** No difference, still rejected.
 85
 86## The Correct Approach (From OpenRouter Documentation)
 87
 88According to [OpenRouter's documentation](https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks):
 89
 90### Key Insight: `reasoning_details` is a message-level array
 91
 92The thought signature is NOT a property of individual tool calls. Instead, it's part of a `reasoning_details` array that belongs to the entire assistant message:
 93
 94```json
 95{
 96  "role": "assistant",
 97  "content": null,
 98  "tool_calls": [
 99    {
100      "id": "call_123",
101      "type": "function",
102      "function": {
103        "name": "list_directory",
104        "arguments": "{...}"
105      }
106    }
107  ],
108  "reasoning_details": [
109    {
110      "type": "reasoning.text",
111      "text": "Let me think through this step by step...",
112      "signature": "sha256:abc123...",
113      "id": "reasoning-text-1",
114      "format": "anthropic-claude-v1",
115      "index": 0
116    }
117  ]
118}
119```
120
121### `reasoning_details` Structure
122
123The array can contain three types of objects:
124
1251. **reasoning.summary** - High-level summary of reasoning
1262. **reasoning.encrypted** - Encrypted/redacted reasoning data
1273. **reasoning.text** - Raw text reasoning with optional signature
128
129Each object has:
130- `type`: One of the three types above
131- `id`: Unique identifier
132- `format`: Format version (e.g., "anthropic-claude-v1", "openai-responses-v1")
133- `index`: Sequential index
134- `signature`: (for reasoning.text) The cryptographic signature we need to preserve
135
136## What We've Implemented So Far
137
138### 1. Added `reasoning_details` field to core structures
139
140`LanguageModelRequestMessage` now has `reasoning_details: Option<serde_json::Value>`
141
142### 2. Added `reasoning_details` to OpenRouter structs
143
144`RequestMessage::Assistant` has `reasoning_details` field
145`ResponseMessageDelta` has `reasoning_details` field
146
147### 3. Updated `into_open_router` to send `reasoning_details`
148
149✅ When building requests, we now attach `reasoning_details` from the message to the Assistant message
150
151### 4. Added mapper to capture `reasoning_details` from responses
152
153`OpenRouterEventMapper` now has a `reasoning_details` field
154✅ We capture it from `choice.delta.reasoning_details`
155
156### 5. Added debugging
157
158`eprintln!` statements in both OpenRouter and Copilot to log requests and responses
159
160## What's Still Missing
161
162### The Critical Gap: Event → Message Flow
163
164The problem is in how events become messages. Our current flow:
165
1661. ✅ We capture `reasoning_details` from the API response
1672. ❌ We store it in `OpenRouterEventMapper` but never emit it
1683. ❌ The agent constructs messages from events, but has no way to get the `reasoning_details`
1694. ❌ When sending the next request, `message.reasoning_details` is `None`
170
171### What We Need to Do
172
173#### Option A: Add a new event type
174
175Add a `LanguageModelCompletionEvent::ReasoningDetails(serde_json::Value)` event that gets emitted when we receive reasoning details. The agent would need to:
176
1771. Collect this event along with tool use events
1782. When constructing the assistant message, attach the reasoning_details to it
179
180#### Option B: Store reasoning_details with tool use events
181
182Modify the flow so that when we emit tool use events, we somehow associate the `reasoning_details` with them. This is tricky because:
183- `reasoning_details` is per-message, not per-tool
184- Multiple tools can be in one message
185- We emit events one at a time
186
187#### Option C: Store at a higher level
188
189Have the agent or provider layer handle this separately from the event stream. For example:
190- The provider keeps track of reasoning_details for messages it processes
191- When building the next request, it looks up the reasoning_details for assistant messages that had tool calls
192
193## Current Status
194
195### What Works
196- ✅ Code compiles
197-`reasoning_details` field exists throughout the stack
198- ✅ We capture `reasoning_details` from responses
199- ✅ We send `reasoning_details` in requests (if present)
200
201### What Doesn't Work
202-`reasoning_details` never makes it from the response to the request
203- ❌ The error still occurs because we're sending `null` for `reasoning_details`
204
205### Evidence from Error Message
206
207The error says:
208```
209function call `default_api:list_directory` in the 2. content block is missing a `thought_signature`
210```
211
212This means:
2131. We're successfully making the first request (works)
2142. The model responds with tool calls including reasoning_details (works)
2153. We execute the tools (works)
2164. We send back the conversation history (works)
2175. BUT the assistant message in that history is missing the reasoning_details (broken)
2186. Google/Vertex validates the message and rejects it (error)
219
220## Next Steps
221
2221. **Choose an approach** - Decide between Option A, B, or C above
2232. **Implement the data flow** - Ensure `reasoning_details` flows from response → events → message → request
2243. **Test with debugging** - Use the `eprintln!` statements to verify:
225   - That we receive `reasoning_details` in the response
226   - That we include it in the next request
2274. **Apply to Copilot** - Once working for OpenRouter, apply the same pattern to Copilot
2285. **Handle edge cases**:
229   - What if there are multiple tool calls in one message?
230   - What if reasoning_details is empty/null?
231   - What about other providers (Anthropic, etc.)?
232
233## Files Modified
234
235- `crates/language_model/src/request.rs` - Added `reasoning_details` to `LanguageModelRequestMessage`
236- `crates/open_router/src/open_router.rs` - Added `reasoning_details` to request/response structs
237- `crates/language_models/src/provider/open_router.rs` - Added capture and send logic
238- `crates/copilot/src/copilot_responses.rs` - Already had `thought_signature` support
239- Various test files - Added `reasoning_details: None` to fix compilation
240
241## SOLUTION: Copilot Chat Completions API Implementation
242
243### Discovery: Gemini 3 Uses Chat Completions API, Not Responses API
244
245Initial plan assumed routing Gemini 3 to Responses API would work, but testing revealed:
246- **Gemini 3 models do NOT support the Responses API** through Copilot
247- Error: `{"error":{"message":"model gemini-3-pro-preview is not supported via Responses API.","code":"unsupported_api_for_model"}}`
248- Gemini 3 ONLY supports the Chat Completions API
249
250### Key Finding: `reasoning_opaque` Location in JSON
251
252Through detailed logging and JSON inspection, discovered Copilot sends thought signatures in Chat Completions API:
253- Field name: **`reasoning_opaque`** (not `thought_signature`)
254- Location: **At the `delta` level**, NOT at the `tool_calls` level!
255
256JSON structure from Copilot response:
257```json
258{
259  "choices": [{
260    "delta": {
261      "role": "assistant",
262      "tool_calls": [{
263        "function": {"arguments": "...", "name": "list_directory"},
264        "id": "call_...",
265        "index": 0,
266        "type": "function"
267      }],
268      "reasoning_opaque": "sPsUMpfe1YZXLkbc0TNW/mJLT..."  // <-- HERE!
269    }
270  }]
271}
272```
273
274### Implementation Status
275
276#### ✅ Completed Changes
277
2781. **Added `reasoning_opaque` field to `ResponseDelta`** (`crates/copilot/src/copilot_chat.rs`)
279   ```rust
280   pub struct ResponseDelta {
281       pub content: Option<String>,
282       pub role: Option<Role>,
283       pub tool_calls: Vec<ToolCallChunk>,
284       pub reasoning_opaque: Option<String>,  // Added this
285   }
286   ```
287
2882. **Added `thought_signature` fields to Chat Completions structures** (`crates/copilot/src/copilot_chat.rs`)
289   - `FunctionContent` now has `thought_signature: Option<String>`
290   - `FunctionChunk` now has `thought_signature: Option<String>`
291
2923. **Updated mapper to capture `reasoning_opaque` from delta** (`crates/language_models/src/provider/copilot_chat.rs`)
293   - Captures `reasoning_opaque` from `delta.reasoning_opaque`
294   - Applies it to all tool calls in that delta
295   - Stores in `thought_signature` field of accumulated tool call
296
2974. **Verified thought signature is being sent back**
298   - Logs show: `📤 Chat Completions: Sending tool call list_directory with thought_signature: Some("sPsUMpfe...")`
299   - Signature is being included in subsequent requests
300
301#### ❌ Current Issue: Still Getting 400 Error
302
303Despite successfully capturing and sending back the thought signature, Copilot still returns:
304```
305400 Bad Request {"error":{"message":"invalid request body","code":"invalid_request_body"}}
306```
307
308This happens on the SECOND request (after tool execution), when sending conversation history back.
309
310### Debug Logging Added
311
312Current logging shows the full flow:
313- `📥 Chat Completions: Received reasoning_opaque (length: XXX)` - Successfully captured
314- `🔍 Tool call chunk: index=..., id=..., has_function=...` - Delta processing
315- `📤 Chat Completions: Emitting ToolUse for ... with thought_signature: Some(...)` - Event emission
316- `📤 Chat Completions: Sending tool call ... with thought_signature: Some(...)` - Sending back
317- `📤 Chat Completions Request JSON: {...}` - Full request being sent
318- `📥 Chat Completions Response Event: {...}` - Full response received
319
320### Potential Issues to Investigate
321
3221. **Field name mismatch on send**: We're sending `thought_signature` but should we send `reasoning_opaque`?
323   - We added `thought_signature` to `FunctionContent` 
324   - But Copilot might expect `reasoning_opaque` in the request just like it sends it
325
3262. **Serialization issue**: Check if serde is properly serializing the field
327   - Added `#[serde(skip_serializing_if = "Option::is_none")]` - might be skipping it?
328   - Should verify field appears in actual JSON being sent
329
3303. **Location issue**: Even when sending back, should `reasoning_opaque` be at delta level?
331   - Currently putting it in `function.thought_signature`
332   - Might need to be at a different level in the request structure
333
3344. **Format validation**: The signature is a base64-encoded string ~1464 characters
335   - Copilot might be validating the signature format/content
336   - Could be rejecting it if it's malformed or in wrong structure
337
338### Next Steps to Debug
339
3401. **Check actual JSON being sent**: Look at the `📤 Chat Completions Request JSON` logs
341   - Search for `thought_signature` in the JSON
342   - Verify it's actually in the serialized output (not skipped)
343   - Check its exact location in the JSON structure
344
3452. **Try renaming field**: Change `thought_signature` to `reasoning_opaque` in request structures
346   - In `FunctionContent` struct
347   - In `FunctionChunk` struct
348   - See if Copilot expects same field name in both directions
349
3503. **Compare request format to response format**: 
351   - Response has `reasoning_opaque` at delta level
352   - Request might need it at function level OR delta level
353   - May need to restructure where we put it
354
3554. **Test with tool choice parameter**: Some APIs are sensitive to request structure
356   - Try with/without `tool_choice` parameter
357   - Try with minimal conversation history
358
3595. **Check Copilot API documentation**: 
360   - Search for official docs on `reasoning_opaque` handling
361   - Look for examples of tool calls with reasoning/thinking in Copilot API
362
363### Files Modified
364
365- ✅ `crates/copilot/src/copilot_chat.rs` - Added `reasoning_opaque` to `ResponseDelta`, `thought_signature` to function structs
366- ✅ `crates/language_models/src/provider/copilot_chat.rs` - Capture and send logic with debug logging
367- ⏳ Still need to verify serialization and field naming
368
369### References
370
371- [OpenRouter Reasoning Tokens Documentation](https://openrouter.ai/docs/use-cases/reasoning-tokens)
372- [Google Thought Signatures Documentation](https://ai.google.dev/gemini-api/docs/thinking#signatures)
373- [Original Issue #43024](https://github.com/zed-industries/zed/issues/43024)
374## ✅ FINAL FIX (2025-01-21)
375
376### The Critical Issues Found
377
378After testing, we discovered TWO problems:
379
3801. **Wrong Location**: We were sending `thought_signature` inside the `function` object, but Copilot expects `reasoning_opaque` at the **message level**
3812. **Wrong Content Format**: We were sending `"content": []` (empty array), but Copilot expects `"content": null` when there are tool calls
382
383### The Solution
384
385#### Issue 1: Message-Level Field
386- **Added** `reasoning_opaque: Option<String>` to `ChatMessage::Assistant`
387- **Removed** `thought_signature` from `FunctionContent` (it doesn't belong there)
388- **Updated** request builder to collect signature from first tool use and pass at message level
389
390#### Issue 2: Null vs Empty Array
391- **Changed** `content` field type from `ChatMessageContent` to `Option<ChatMessageContent>`
392- **Set** `content: None` when we have tool calls and no text (serializes to `null`)
393- **Set** `content: Some(text)` when we have text content
394
395### Correct Request Format
396
397```json
398{
399  "role": "assistant",
400  "content": null,  // ✅ Explicit null, not []
401  "tool_calls": [{
402    "id": "call_...",
403    "type": "function",
404    "function": {
405      "name": "list_directory",
406      "arguments": "{\"path\":\"deleteme\"}"
407      // NO thought_signature here!
408    }
409  }],
410  "reasoning_opaque": "XLn4be0..."  // ✅ At message level!
411}
412```
413
414### Files Modified in Final Fix
415
416- `zed/crates/copilot/src/copilot_chat.rs`:
417  - Added `reasoning_opaque` to `ChatMessage::Assistant`
418  - Changed `content` to `Option<ChatMessageContent>`
419  - Fixed vision detection pattern match
420- `zed/crates/language_models/src/provider/copilot_chat.rs`:
421  - Collect `reasoning_opaque` from first tool use
422  - Pass to Assistant message, not function
423  - Set `content: None` for tool-only messages
424  - Removed function-level thought_signature handling
425
426### Compilation Status
427
428✅ All packages compile successfully
429
430Ready for testing!