mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
🐛 fix: support thoughtSignature for openrouter (#11117)
feat: support thoughtSignature for openrouter
This commit is contained in:
parent
8e0e5020db
commit
bf5d41e1a7
2 changed files with 181 additions and 1 deletions
|
|
@ -1048,6 +1048,161 @@ describe('OpenAIStream', () => {
|
|||
].map((i) => `${i}\n`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle OpenRouter tool calls with thoughtSignature (for Gemini models)', async () => {
|
||||
// OpenRouter returns thoughtSignature in tool_calls for Gemini models
|
||||
// This is required for preserving reasoning blocks across turns
|
||||
// Ref: https://openrouter.ai/docs/guides/best-practices/reasoning-tokens
|
||||
const mockOpenAIStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
function: { name: 'github__get_me', arguments: '{}' },
|
||||
id: 'call_123',
|
||||
index: 0,
|
||||
type: 'function',
|
||||
// OpenRouter adds thoughtSignature for Gemini 3 models
|
||||
thoughtSignature: 'ErEDCq4DAdHtim...',
|
||||
},
|
||||
],
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
id: 'or-123',
|
||||
});
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const onToolCallMock = vi.fn();
|
||||
|
||||
const protocolStream = OpenAIStream(mockOpenAIStream, {
|
||||
callbacks: {
|
||||
onToolsCalling: onToolCallMock,
|
||||
},
|
||||
});
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const chunks = [];
|
||||
|
||||
// @ts-ignore
|
||||
for await (const chunk of protocolStream) {
|
||||
chunks.push(decoder.decode(chunk, { stream: true }));
|
||||
}
|
||||
|
||||
expect(chunks).toEqual([
|
||||
'id: or-123\n',
|
||||
'event: tool_calls\n',
|
||||
// thoughtSignature should be preserved in the output
|
||||
`data: [{"function":{"arguments":"{}","name":"github__get_me"},"id":"call_123","index":0,"type":"function","thoughtSignature":"ErEDCq4DAdHtim..."}]\n\n`,
|
||||
]);
|
||||
|
||||
// Verify the callback receives thoughtSignature
|
||||
expect(onToolCallMock).toHaveBeenCalledWith({
|
||||
chunk: [
|
||||
{
|
||||
function: { arguments: '{}', name: 'github__get_me' },
|
||||
id: 'call_123',
|
||||
index: 0,
|
||||
thoughtSignature: 'ErEDCq4DAdHtim...',
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
toolsCalling: [
|
||||
{
|
||||
function: { arguments: '{}', name: 'github__get_me' },
|
||||
id: 'call_123',
|
||||
thoughtSignature: 'ErEDCq4DAdHtim...',
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT include thoughtSignature in output when not present in tool call', async () => {
|
||||
// Standard tool calls without thoughtSignature should not include the field
|
||||
const mockOpenAIStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
function: { name: 'search', arguments: '{"query":"test"}' },
|
||||
id: 'call_456',
|
||||
index: 0,
|
||||
type: 'function',
|
||||
// No thoughtSignature field
|
||||
},
|
||||
],
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
id: 'standard-123',
|
||||
});
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const onToolCallMock = vi.fn();
|
||||
|
||||
const protocolStream = OpenAIStream(mockOpenAIStream, {
|
||||
callbacks: {
|
||||
onToolsCalling: onToolCallMock,
|
||||
},
|
||||
});
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const chunks = [];
|
||||
|
||||
// @ts-ignore
|
||||
for await (const chunk of protocolStream) {
|
||||
chunks.push(decoder.decode(chunk, { stream: true }));
|
||||
}
|
||||
|
||||
expect(chunks).toEqual([
|
||||
'id: standard-123\n',
|
||||
'event: tool_calls\n',
|
||||
// thoughtSignature should NOT be in the output
|
||||
`data: [{"function":{"arguments":"{\\"query\\":\\"test\\"}","name":"search"},"id":"call_456","index":0,"type":"function"}]\n\n`,
|
||||
]);
|
||||
|
||||
// Verify the callback does NOT receive thoughtSignature
|
||||
expect(onToolCallMock).toHaveBeenCalledWith({
|
||||
chunk: [
|
||||
{
|
||||
function: { arguments: '{"query":"test"}', name: 'search' },
|
||||
id: 'call_456',
|
||||
index: 0,
|
||||
// thoughtSignature should not be present
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
toolsCalling: [
|
||||
{
|
||||
function: { arguments: '{"query":"test"}', name: 'search' },
|
||||
id: 'call_456',
|
||||
// thoughtSignature should not be present
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify thoughtSignature is not in the chunk
|
||||
expect(onToolCallMock.mock.calls[0][0].chunk[0]).not.toHaveProperty('thoughtSignature');
|
||||
expect(onToolCallMock.mock.calls[0][0].toolsCalling[0]).not.toHaveProperty(
|
||||
'thoughtSignature',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reasoning', () => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,23 @@ import {
|
|||
generateToolCallId,
|
||||
} from '../protocol';
|
||||
|
||||
/**
|
||||
* Extended type for OpenAI tool calls that includes provider-specific extensions
|
||||
* like OpenRouter's thoughtSignature for Gemini models
|
||||
*/
|
||||
type OpenAIExtendedToolCall = OpenAI.ChatCompletionChunk.Choice.Delta.ToolCall & {
|
||||
thoughtSignature?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type guard to check if a tool call has thoughtSignature
|
||||
*/
|
||||
const hasThoughtSignature = (
|
||||
toolCall: OpenAI.ChatCompletionChunk.Choice.Delta.ToolCall,
|
||||
): toolCall is OpenAIExtendedToolCall => {
|
||||
return 'thoughtSignature' in toolCall && typeof toolCall.thoughtSignature === 'string';
|
||||
};
|
||||
|
||||
// Process markdown base64 images: extract URLs and clean text in one pass
|
||||
const processMarkdownBase64Images = (text: string): { cleanedText: string; urls: string[] } => {
|
||||
if (!text) return { cleanedText: text, urls: [] };
|
||||
|
|
@ -150,7 +167,7 @@ const transformOpenAIStream = (
|
|||
};
|
||||
}
|
||||
|
||||
return {
|
||||
const baseData: StreamToolCallChunkData = {
|
||||
function: {
|
||||
arguments: value.function?.arguments ?? '',
|
||||
name: value.function?.name ?? null,
|
||||
|
|
@ -170,6 +187,14 @@ const transformOpenAIStream = (
|
|||
index: typeof value.index !== 'undefined' ? value.index : index,
|
||||
type: value.type || 'function',
|
||||
};
|
||||
|
||||
// OpenRouter returns thoughtSignature in tool_calls for Gemini models (e.g. gemini-3-flash-preview)
|
||||
// [{"id":"call_123","type":"function","function":{"name":"get_weather","arguments":"{}"},"thoughtSignature":"abc123"}]
|
||||
if (hasThoughtSignature(value)) {
|
||||
baseData.thoughtSignature = value.thoughtSignature;
|
||||
}
|
||||
|
||||
return baseData;
|
||||
}),
|
||||
id: chunk.id,
|
||||
type: 'tool_calls',
|
||||
|
|
|
|||
Loading…
Reference in a new issue