fix: always replace reasoning.encrypted with skip_thought_signature_validator for Gemini

- Fix 'Thought signature is not valid' error for Gemini 3 models via OpenRouter
- Old signatures from previous API sessions are always invalid
- Always replace existing reasoning.encrypted blocks instead of skipping injection
- Preserve non-encrypted reasoning_details (like reasoning.text)
- Add 4 new tests for thought signature injection behavior
This commit is contained in:
daniel-lxs 2026-01-15 14:38:43 -05:00
parent f2b16d400d
commit 7059bdb388
No known key found for this signature in database
GPG key ID: 21C74479048B3AA6
2 changed files with 321 additions and 16 deletions

View file

@ -83,6 +83,16 @@ vitest.mock("../fetchers/modelCache", () => ({
excludedTools: ["existing_excluded"],
includedTools: ["existing_included"],
},
"google/gemini-3-flash-preview": {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: true,
supportsNativeTools: true,
inputPrice: 0.5,
outputPrice: 1.5,
description: "Gemini 3 Flash Preview",
},
})
}),
}))
@ -702,4 +712,297 @@ describe("OpenRouterHandler", () => {
)
})
})
describe("Gemini thought signature injection", () => {
const geminiOptions: ApiHandlerOptions = {
openRouterApiKey: "test-key",
openRouterModelId: "google/gemini-3-flash-preview",
}
it("injects skip_thought_signature_validator for Gemini with native tools and tool_calls history", async () => {
const handler = new OpenRouterHandler(geminiOptions)
const mockStream = {
async *[Symbol.asyncIterator]() {
yield {
id: "test-id",
choices: [{ delta: { content: "response" } }],
}
yield {
id: "test-id",
choices: [{ delta: {} }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
}
},
}
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
;(OpenAI as any).prototype.chat = {
completions: { create: mockCreate },
} as any
// Simulate history with a previous tool call from another model
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [
{ type: "text", text: "I'll read that file." },
{ type: "tool_use", id: "toolu_123", name: "read_file", input: { path: "test.txt" } },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_123", content: "file contents" }],
},
]
const metadata = {
tools: [
{ type: "function", function: { name: "read_file", description: "Read a file", parameters: {} } },
],
toolProtocol: "native",
}
const generator = handler.createMessage("test system", messages, metadata as any)
for await (const _chunk of generator) {
// consume
}
// Verify that reasoning_details with skip_thought_signature_validator was injected
const createCall = mockCreate.mock.calls[0][0]
const assistantMessage = createCall.messages.find(
(msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0,
)
expect(assistantMessage).toBeDefined()
expect(assistantMessage.reasoning_details).toBeDefined()
expect(assistantMessage.reasoning_details).toHaveLength(1)
expect(assistantMessage.reasoning_details[0]).toMatchObject({
type: "reasoning.encrypted",
data: "skip_thought_signature_validator",
id: assistantMessage.tool_calls[0].id,
format: "google-gemini-v1",
index: 0,
})
})
it("replaces existing reasoning.encrypted with skip_thought_signature_validator for Gemini", async () => {
const handler = new OpenRouterHandler(geminiOptions)
const mockStream = {
async *[Symbol.asyncIterator]() {
yield {
id: "test-id",
choices: [{ delta: { content: "response" } }],
}
yield {
id: "test-id",
choices: [{ delta: {} }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
}
},
}
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
;(OpenAI as any).prototype.chat = {
completions: { create: mockCreate },
} as any
// Simulate history with an existing (invalid) reasoning.encrypted signature
const messagesWithExistingSignature: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [
{ type: "text", text: "I'll read that file." },
{ type: "tool_use", id: "toolu_456", name: "read_file", input: { path: "test.txt" } },
],
// Simulating existing reasoning_details from a previous Gemini session
reasoning_details: [
{
type: "reasoning.encrypted",
data: "old_invalid_signature_from_previous_session",
id: "toolu_456",
format: "google-gemini-v1",
index: 0,
},
],
} as any,
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_456", content: "file contents" }],
},
]
const metadata = {
tools: [
{ type: "function", function: { name: "read_file", description: "Read a file", parameters: {} } },
],
toolProtocol: "native",
}
const generator = handler.createMessage("test system", messagesWithExistingSignature, metadata as any)
for await (const _chunk of generator) {
// consume
}
// Verify that the old signature was replaced with skip_thought_signature_validator
const createCall = mockCreate.mock.calls[0][0]
const assistantMessage = createCall.messages.find(
(msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0,
)
expect(assistantMessage).toBeDefined()
expect(assistantMessage.reasoning_details).toBeDefined()
expect(assistantMessage.reasoning_details).toHaveLength(1)
// The old signature should be REPLACED, not kept
expect(assistantMessage.reasoning_details[0].data).toBe("skip_thought_signature_validator")
expect(assistantMessage.reasoning_details[0].data).not.toBe("old_invalid_signature_from_previous_session")
})
it("preserves non-encrypted reasoning_details while replacing reasoning.encrypted", async () => {
const handler = new OpenRouterHandler(geminiOptions)
const mockStream = {
async *[Symbol.asyncIterator]() {
yield {
id: "test-id",
choices: [{ delta: { content: "response" } }],
}
yield {
id: "test-id",
choices: [{ delta: {} }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
}
},
}
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
;(OpenAI as any).prototype.chat = {
completions: { create: mockCreate },
} as any
// Simulate history with both reasoning.text and reasoning.encrypted
const messagesWithMixedReasoning: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [
{ type: "text", text: "I'll read that file." },
{ type: "tool_use", id: "toolu_789", name: "read_file", input: { path: "test.txt" } },
],
reasoning_details: [
{
type: "reasoning.text",
text: "This is visible reasoning that should be preserved",
index: 0,
},
{
type: "reasoning.encrypted",
data: "old_invalid_signature",
id: "toolu_789",
format: "google-gemini-v1",
index: 1,
},
],
} as any,
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_789", content: "file contents" }],
},
]
const metadata = {
tools: [
{ type: "function", function: { name: "read_file", description: "Read a file", parameters: {} } },
],
toolProtocol: "native",
}
const generator = handler.createMessage("test system", messagesWithMixedReasoning, metadata as any)
for await (const _chunk of generator) {
// consume
}
// Verify that reasoning.text is preserved and reasoning.encrypted is replaced
const createCall = mockCreate.mock.calls[0][0]
const assistantMessage = createCall.messages.find(
(msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0,
)
expect(assistantMessage).toBeDefined()
expect(assistantMessage.reasoning_details).toBeDefined()
expect(assistantMessage.reasoning_details).toHaveLength(2)
// Find the reasoning.text entry - should be preserved
const textEntry = assistantMessage.reasoning_details.find((d: any) => d.type === "reasoning.text")
expect(textEntry).toBeDefined()
expect(textEntry.text).toBe("This is visible reasoning that should be preserved")
// Find the reasoning.encrypted entry - should be replaced with skip validator
const encryptedEntry = assistantMessage.reasoning_details.find((d: any) => d.type === "reasoning.encrypted")
expect(encryptedEntry).toBeDefined()
expect(encryptedEntry.data).toBe("skip_thought_signature_validator")
})
it("does not inject reasoning_details for non-Gemini models", async () => {
const handler = new OpenRouterHandler(mockOptions) // anthropic/claude-sonnet-4
const mockStream = {
async *[Symbol.asyncIterator]() {
yield {
id: "test-id",
choices: [{ delta: { content: "response" } }],
}
yield {
id: "test-id",
choices: [{ delta: {} }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
}
},
}
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
;(OpenAI as any).prototype.chat = {
completions: { create: mockCreate },
} as any
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [
{ type: "text", text: "I'll read that file." },
{ type: "tool_use", id: "toolu_abc", name: "read_file", input: { path: "test.txt" } },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_abc", content: "file contents" }],
},
]
const metadata = {
tools: [
{ type: "function", function: { name: "read_file", description: "Read a file", parameters: {} } },
],
toolProtocol: "native",
}
const generator = handler.createMessage("test system", messages, metadata as any)
for await (const _chunk of generator) {
// consume
}
// Verify that reasoning_details was NOT added for non-Gemini model
const createCall = mockCreate.mock.calls[0][0]
const assistantMessage = createCall.messages.find(
(msg: any) => msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0,
)
expect(assistantMessage).toBeDefined()
expect(assistantMessage.reasoning_details).toBeUndefined()
})
})
})

View file

@ -264,25 +264,27 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
const toolCalls = (msg as any).tool_calls as any[] | undefined
const existingDetails = (msg as any).reasoning_details as any[] | undefined
// Only inject if there are tool calls and no existing encrypted reasoning
// Inject skip_thought_signature_validator for all assistant messages with tool calls.
// IMPORTANT: We must ALWAYS inject (and replace any existing reasoning.encrypted),
// because signatures from previous API sessions are invalid. Gemini rejects them with
// "Thought signature is not valid" if we try to reuse old signatures.
if (toolCalls && toolCalls.length > 0) {
const hasEncrypted = existingDetails?.some((d) => d.type === "reasoning.encrypted") ?? false
// Filter out any existing reasoning.encrypted blocks - they have invalid signatures
const filteredDetails = existingDetails?.filter((d) => d.type !== "reasoning.encrypted") ?? []
if (!hasEncrypted) {
// Create ONE fake encrypted block with the FIRST tool call's ID
// This is the documented format from OpenRouter for skipping thought signature validation
const fakeEncrypted = {
type: "reasoning.encrypted",
data: "skip_thought_signature_validator",
id: toolCalls[0].id,
format: "google-gemini-v1",
index: 0,
}
// Create ONE fake encrypted block with the FIRST tool call's ID
// This is the documented format from OpenRouter for skipping thought signature validation
const fakeEncrypted = {
type: "reasoning.encrypted",
data: "skip_thought_signature_validator",
id: toolCalls[0].id,
format: "google-gemini-v1",
index: 0,
}
return {
...msg,
reasoning_details: [...(existingDetails ?? []), fakeEncrypted],
}
return {
...msg,
reasoning_details: [...filteredDetails, fakeEncrypted],
}
}
}