Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
303 changes: 303 additions & 0 deletions src/api/providers/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
})
}),
}))
Expand Down Expand Up @@ -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()
})
})
})
36 changes: 19 additions & 17 deletions src/api/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

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,
}
// Filter out any existing reasoning.encrypted blocks - they have invalid signatures
const filteredDetails = existingDetails?.filter((d) => d.type !== "reasoning.encrypted") ?? []

// 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],
}
}
}
Expand Down
Loading