Skip to content
Closed
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
90 changes: 71 additions & 19 deletions packages/core/src/core/openaiContentGenerator/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ interface ExtendedCompletionUsage extends OpenAI.CompletionUsage {
interface ExtendedChatCompletionAssistantMessageParam
extends OpenAI.Chat.ChatCompletionAssistantMessageParam {
reasoning_content?: string | null;
/**
* Extended tool calls to support 'signature' field for Gemini 3 Pro
*/
tool_calls?: ExtendedChatCompletionMessageToolCall[];
}

type ExtendedChatCompletionMessageParam =
Expand All @@ -54,6 +58,15 @@ export interface ExtendedCompletionChunkDelta
reasoning_content?: string | null;
}

export interface ExtendedChatCompletionMessageToolCall
extends OpenAI.Chat.ChatCompletionMessageToolCall {
/**
* Gemini 3 Pro thought signature required for verification.
* Vertex AI OpenAI-compatible endpoint maps this to/from 'thoughtSignature' in Gemini API.
*/
signature?: string;
}

/**
* Tool call accumulator for streaming responses
*/
Expand All @@ -69,7 +82,7 @@ export interface ToolCallAccumulator {
interface ParsedParts {
thoughtParts: string[];
contentParts: string[];
functionCalls: FunctionCall[];
functionCalls: Array<{ call: FunctionCall; signature?: string }>;
functionResponses: FunctionResponse[];
mediaParts: Array<{
type: 'image' | 'audio' | 'file';
Expand All @@ -87,6 +100,12 @@ export class OpenAIContentConverter {
private schemaCompliance: SchemaComplianceMode;
private streamingToolCallParser: StreamingToolCallParser =
new StreamingToolCallParser();
/**
* Cache for thought signatures during streaming.
* Maps tool call index to its signature string.
* Needed because signatures might arrive split from other data in chunks.
*/
private thoughtSignatureCache = new Map<number, string>();

constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') {
this.model = model;
Expand All @@ -100,6 +119,7 @@ export class OpenAIContentConverter {
*/
resetStreamingToolCalls(): void {
this.streamingToolCallParser.reset();
this.thoughtSignatureCache.clear();
}

/**
Expand Down Expand Up @@ -320,14 +340,18 @@ export class OpenAIContentConverter {

// Handle model messages with function calls
if (content.role === 'model' && parsedParts.functionCalls.length > 0) {
const toolCalls = parsedParts.functionCalls.map((fc, index) => ({
id: fc.id || `call_${index}`,
type: 'function' as const,
function: {
name: fc.name || '',
arguments: JSON.stringify(fc.args || {}),
},
}));
const toolCalls: ExtendedChatCompletionMessageToolCall[] =
parsedParts.functionCalls.map(({ call: fc, signature }, index) => ({
id: fc.id || `call_${index}`,
type: 'function' as const,
function: {
name: fc.name || '',
arguments: JSON.stringify(fc.args || {}),
},
// Pass the signature back to OpenAI/Vertex AI
// This allows Vertex AI to verify the thought process for this tool call
signature,
}));

const assistantMessage: ExtendedChatCompletionAssistantMessageParam = {
role: 'assistant' as const,
Expand Down Expand Up @@ -360,7 +384,7 @@ export class OpenAIContentConverter {
private parseParts(parts: Part[]): ParsedParts {
const thoughtParts: string[] = [];
const contentParts: string[] = [];
const functionCalls: FunctionCall[] = [];
const functionCalls: Array<{ call: FunctionCall; signature?: string }> = [];
const functionResponses: FunctionResponse[] = [];
const mediaParts: Array<{
type: 'image' | 'audio' | 'file';
Expand All @@ -386,7 +410,10 @@ export class OpenAIContentConverter {
) {
thoughtParts.push(part.text);
} else if ('functionCall' in part && part.functionCall) {
functionCalls.push(part.functionCall);
functionCalls.push({
call: part.functionCall,
signature: part.thoughtSignature,
});
} else if ('functionResponse' in part && part.functionResponse) {
functionResponses.push(part.functionResponse);
} else if ('inlineData' in part && part.inlineData) {
Expand Down Expand Up @@ -726,6 +753,14 @@ export class OpenAIContentConverter {
for (const toolCall of choice.delta.tool_calls) {
const index = toolCall.index ?? 0;

// Extract and cache thought signature if present in the chunk
// Vertex AI sends this in the 'signature' field of the tool call
const signature = (toolCall as ExtendedChatCompletionMessageToolCall)
.signature;
if (signature) {
this.thoughtSignatureCache.set(index, signature);
}

// Process the tool call chunk through the streaming parser
if (toolCall.function?.arguments) {
this.streamingToolCallParser.addChunk(
Expand All @@ -751,22 +786,31 @@ export class OpenAIContentConverter {
const completedToolCalls =
this.streamingToolCallParser.getCompletedToolCalls();

for (const toolCall of completedToolCalls) {
completedToolCalls.forEach((toolCall, index) => {
if (toolCall.name) {
parts.push({
const part: Part = {
functionCall: {
id:
toolCall.id ||
`call_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
name: toolCall.name,
args: toolCall.args,
},
});
};

const signature = this.thoughtSignatureCache.get(index);
// Attach the cached signature to the final part
// This ensures the Gemini 'Part' object contains the verification token
if (signature) {
part.thoughtSignature = signature;
}

parts.push(part);
}
}
});

// Clear the parser for the next stream
this.streamingToolCallParser.reset();
this.resetStreamingToolCalls();
}

// Only include finishReason key if finish_reason is present
Expand Down Expand Up @@ -846,7 +890,7 @@ export class OpenAIContentConverter {
const content = candidate?.content;

let messageContent: string | null = null;
const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = [];
const toolCalls: ExtendedChatCompletionMessageToolCall[] = [];

if (content?.parts) {
const textParts: string[] = [];
Expand All @@ -855,14 +899,22 @@ export class OpenAIContentConverter {
if ('text' in part && part.text) {
textParts.push(part.text);
} else if ('functionCall' in part && part.functionCall) {
toolCalls.push({
const toolCall: ExtendedChatCompletionMessageToolCall = {
id: part.functionCall.id || `call_${toolCalls.length}`,
type: 'function' as const,
function: {
name: part.functionCall.name || '',
arguments: JSON.stringify(part.functionCall.args || {}),
},
});
};

// Capture thoughtSignature from Gemini response for future requests
// This is critical for Gemini 3 Pro verification
if (part.thoughtSignature) {
toolCall.signature = part.thoughtSignature;
}

toolCalls.push(toolCall);
}
}

Expand Down