Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
249 changes: 220 additions & 29 deletions packages/core/src/core/openaiContentGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,14 @@ export class OpenAIContentGenerator implements ContentGenerator {
/**
* Reinitialize the OpenAI client with current environment variables
*/
public updateClient(): void {
updateClient(): void {
this.initializeClient();
}

/**
* Update the model being used
*/
public updateModel(model: string): void {
updateModel(model: string): void {
this.model = model;
console.log('[DEBUG] Updated model to:', this.model);
}
Comment on lines +143 to 153
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify impact of visibility change.

Removing the explicit public modifiers changes these methods to package-private visibility. If external code calls updateClient() or updateModel(), this is a breaking change.

Run the following script to check for external usage:


🏁 Script executed:

#!/bin/bash
# Search for calls to updateClient and updateModel outside this file
rg -n --type=ts -C3 '\b(updateClient|updateModel)\s*\(' \
  -g '!packages/core/src/core/openaiContentGenerator.ts'

Length of output: 1562


Revert visibility changes or document breaking change.

External code calls updateModel() at packages/cli/src/ui/hooks/slashCommandProcessor.ts:839 and updateClient() at packages/cli/src/ui/hooks/slashCommandProcessor.ts:896. Removing the public modifiers breaks these call sites. Either restore the public modifiers or update the external callers.

🤖 Prompt for AI Agents
In packages/core/src/core/openaiContentGenerator.ts around lines 143 to 153, the
methods' visibility was changed causing external callers in
packages/cli/src/ui/hooks/slashCommandProcessor.ts (lines ~839 and ~896) to
break; restore the original public visibility on updateClient() and
updateModel() (i.e., re-add the public modifiers) so existing external call
sites continue to work, or if you prefer to keep them non-public, update the two
external callers to use a supported public API instead and run the typecheck to
confirm no other call sites are affected.

Expand Down Expand Up @@ -923,6 +923,34 @@ export class OpenAIContentGenerator implements ContentGenerator {
/**
* Clean up orphaned tool calls from message history to prevent OpenAI API errors
*/
/**
* Removes orphaned tool calls and their corresponding responses from message history.
*
* This helper method prevents OpenAI API errors by ensuring that every tool call
* has a corresponding tool response, and that all tool responses correspond to
* actual tool calls. This is crucial for maintaining valid message sequences
* when dealing with streaming responses or complex tool call patterns.
*
* The method performs a two-pass cleaning process:
* 1. First pass: Collect all tool call and response IDs
* 2. Second pass: Filter messages to keep only valid tool call/response pairs
* 3. Final validation: Ensure no orphaned messages remain
*
* @param messages - Array of OpenAI message objects to clean
* @returns Cleaned array of messages with orphaned tool calls removed
*
* @example
* ```typescript
* // Before: Invalid message sequence with orphaned tool call
* const messages = [
* { role: 'assistant', tool_calls: [{ id: '1', function: { name: 'search', arguments: '{}' } }] },
* { role: 'user', content: 'some response' } // Missing tool response for call '1'
* ];
*
* const cleaned = this.cleanOrphanedToolCalls(messages);
* // Result: Only valid messages remain, orphaned tool call is removed or content is preserved
* ```
*/
private cleanOrphanedToolCalls(
messages: OpenAI.Chat.ChatCompletionMessageParam[],
): OpenAI.Chat.ChatCompletionMessageParam[] {
Expand Down Expand Up @@ -1074,6 +1102,36 @@ export class OpenAIContentGenerator implements ContentGenerator {
/**
* Merge consecutive assistant messages to combine split text and tool calls
*/
/**
* Merges consecutive assistant messages to combine split text and tool calls.
*
* During streaming or complex tool interactions, OpenAI responses may be split
* into multiple consecutive assistant messages. This method consolidates these
* into single messages by combining their content and tool calls, which is
* required for proper API compatibility and message flow.
*
* The merging process:
* 1. Combines text content from consecutive assistant messages
* 2. Merges tool calls from both messages
* 3. Preserves the chronological order of all elements
* 4. Only merges consecutive assistant messages (doesn't cross message types)
*
* @param messages - Array of OpenAI message objects to merge
* @returns Array with consecutive assistant messages consolidated
*
* @example
* ```typescript
* // Before: Split assistant messages
* const messages = [
* { role: 'assistant', content: 'Here is ' },
* { role: 'assistant', content: 'my response', tool_calls: [{ id: '1', function: { name: 'search', arguments: '{}' } }] },
* { role: 'user', content: 'Continue' }
* ];
*
* const merged = this.mergeConsecutiveAssistantMessages(messages);
* // Result: Single assistant message with combined content and tool calls
* ```
*/
private mergeConsecutiveAssistantMessages(
messages: OpenAI.Chat.ChatCompletionMessageParam[],
): OpenAI.Chat.ChatCompletionMessageParam[] {
Expand Down Expand Up @@ -1149,12 +1207,14 @@ export class OpenAIContentGenerator implements ContentGenerator {
for (const toolCall of choice.message.tool_calls) {
if (toolCall.function) {
let args: Record<string, unknown> = {};
if (toolCall.function.arguments) {
if (toolCall.function?.arguments) {
try {
args = JSON.parse(toolCall.function.arguments);
} catch (error) {
console.error('Failed to parse function arguments:', error);
args = {};
} catch (parseError) {
console.error('Failed to parse function arguments:', parseError);
console.error('Problematic arguments:', toolCall.function.arguments);
// Try to extract partial JSON or provide a fallback
args = this.extractPartialJson(toolCall.function.arguments) || {};
}
}

Expand Down Expand Up @@ -1268,11 +1328,14 @@ export class OpenAIContentGenerator implements ContentGenerator {
if (accumulatedCall.arguments) {
try {
args = JSON.parse(accumulatedCall.arguments);
} catch (error) {
} catch (parseError) {
console.error(
'Failed to parse final tool call arguments:',
error,
parseError,
);
console.error('Problematic accumulated arguments:', accumulatedCall.arguments);
// Try to extract partial JSON or provide a fallback
args = this.extractPartialJson(accumulatedCall.arguments) || {};
}
}

Expand Down Expand Up @@ -1402,6 +1465,9 @@ export class OpenAIContentGenerator implements ContentGenerator {
return params;
}

/**
* Map OpenAI finish reasons to Gemini finish reasons
*/
private mapFinishReason(openaiReason: string | null): FinishReason {
if (!openaiReason) return FinishReason.FINISH_REASON_UNSPECIFIED;
const mapping: Record<string, FinishReason> = {
Expand All @@ -1414,6 +1480,33 @@ export class OpenAIContentGenerator implements ContentGenerator {
return mapping[openaiReason] || FinishReason.FINISH_REASON_UNSPECIFIED;
}

/**
* Map Gemini finish reasons to OpenAI finish reasons
*/
private mapGeminiFinishReasonToOpenAI(geminiReason?: unknown): string {
if (!geminiReason) return 'stop';

switch (geminiReason) {
case 'STOP':
case 1: // FinishReason.STOP
return 'stop';
case 'MAX_TOKENS':
case 2: // FinishReason.MAX_TOKENS
return 'length';
case 'SAFETY':
case 3: // FinishReason.SAFETY
return 'content_filter';
case 'RECITATION':
case 4: // FinishReason.RECITATION
return 'content_filter';
case 'OTHER':
case 5: // FinishReason.OTHER
return 'stop';
default:
return 'stop';
}
}

/**
* Convert Gemini request format to OpenAI chat completion format for logging
*/
Expand Down Expand Up @@ -1790,29 +1883,127 @@ export class OpenAIContentGenerator implements ContentGenerator {
}

/**
* Map Gemini finish reasons to OpenAI finish reasons
* Extract valid JSON from potentially partial JSON string
* This handles cases where streaming chunks contain incomplete JSON
*/
private mapGeminiFinishReasonToOpenAI(geminiReason?: unknown): string {
if (!geminiReason) return 'stop';
/**
* Extracts valid JSON from potentially malformed or incomplete JSON strings.
* This method handles common issues in streaming responses where JSON chunks may be incomplete.
*
* This is particularly useful for handling OpenAI streaming responses where tool call arguments
* may be split across multiple chunks, resulting in partial JSON that needs reconstruction.
*
* The method employs multiple fallback strategies:
* 1. Direct JSON parsing (for already valid JSON)
* 2. Fixing trailing commas and missing closing braces/brackets
* 3. Manual key-value pair extraction for simple cases
* 4. Quote character normalization (single to double quotes)
*
* @param input - The potentially malformed JSON string to parse
* @returns A parsed JavaScript object, or null if parsing fails
*
* @example
* ```typescript
* // Handles incomplete JSON from streaming
* const malformed = '{"key1": "value1", "key2": ';
* const result = this.extractPartialJson(malformed);
* console.log(result); // { key1: "value1" }
* ```
*
* @example
* ```typescript
* // Fixes trailing commas
* const withComma = '{"name": "test",}';
* const result = this.extractPartialJson(withComma);
* console.log(result); // { name: "test" }
* ```
*/
private extractPartialJson(input: string): Record<string, unknown> | null {
if (!input || typeof input !== 'string') {
return null;
}

switch (geminiReason) {
case 'STOP':
case 1: // FinishReason.STOP
return 'stop';
case 'MAX_TOKENS':
case 2: // FinishReason.MAX_TOKENS
return 'length';
case 'SAFETY':
case 3: // FinishReason.SAFETY
return 'content_filter';
case 'RECITATION':
case 4: // FinishReason.RECITATION
return 'content_filter';
case 'OTHER':
case 5: // FinishReason.OTHER
return 'stop';
default:
return 'stop';
const trimmed = input.trim();

// First try to parse the entire string
try {
return JSON.parse(trimmed);
} catch {
// If that fails, try to find valid JSON patterns
}

// Handle common malformed JSON cases
let fixedInput = trimmed;

// Fix common issues:
// 1. Remove trailing commas
fixedInput = fixedInput.replace(/,\s*([}\]])/g, '$1');

// 2. Add missing closing braces/brackets if possible
const openBraces = (fixedInput.match(/\{/g) || []).length;
const closeBraces = (fixedInput.match(/\}/g) || []).length;
const openBrackets = (fixedInput.match(/\[/g) || []).length;
const closeBrackets = (fixedInput.match(/\]/g) || []).length;

for (let i = 0; i < openBraces - closeBraces; i++) {
fixedInput += '}';
}
for (let i = 0; i < openBrackets - closeBrackets; i++) {
fixedInput += ']';
}

// Try to parse the fixed input
try {
return JSON.parse(fixedInput);
} catch {
// If still fails, try to extract key-value pairs
}

// Try to extract key-value pairs manually for simple cases
// This handles: key1="value1",key2="value2"
const keyValuePattern = /"([^"]+)"\s*:\s*("([^"]*)"|([^,}\]]+))/g;
const matches = [...fixedInput.matchAll(keyValuePattern)];

if (matches.length > 0) {
const result: Record<string, unknown> = {};

for (const match of matches) {
const key = match[1];
let value: unknown;

if (match[3] !== undefined) {
// String value
value = match[3];
} else if (match[4] !== undefined) {
// Try to parse as number, boolean, or leave as string
const rawValue = match[4].trim();
if (rawValue === 'true' || rawValue === 'false') {
value = rawValue === 'true';
} else if (!isNaN(Number(rawValue)) && rawValue !== '') {
value = Number(rawValue);
} else {
value = rawValue;
}
}

if (key && value !== undefined) {
result[key] = value;
}
}

return Object.keys(result).length > 0 ? result : null;
}

// Last resort: try to fix single quotes to double quotes
const singleQuoteFixed = fixedInput.replace(/'/g, '"');
try {
return JSON.parse(singleQuoteFixed);
} catch {
// Still not valid
}

// Final fallback: return empty object rather than throwing
console.warn('Could not extract valid JSON from:', input, 'Fixed version:', fixedInput);
return null;
}
}
Loading