-
Notifications
You must be signed in to change notification settings - Fork 29
fix: handle malformed JSON in OpenAI API responses #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
0a3787b
9b01df7
905eafa
420fd33
d8dafaf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||||||||||||
| } | ||||||||||||||||
|
|
@@ -1154,7 +1154,9 @@ export class OpenAIContentGenerator implements ContentGenerator { | |||||||||||||||
| args = JSON.parse(toolCall.function.arguments); | ||||||||||||||||
| } catch (error) { | ||||||||||||||||
| console.error('Failed to parse function arguments:', error); | ||||||||||||||||
| args = {}; | ||||||||||||||||
| console.error('Problematic arguments:', toolCall.function.arguments); | ||||||||||||||||
| // Try to extract partial JSON or provide a fallback | ||||||||||||||||
| args = this.extractPartialJson(toolCall.function.arguments) || {}; | ||||||||||||||||
| } | ||||||||||||||||
|
||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -1273,6 +1275,9 @@ export class OpenAIContentGenerator implements ContentGenerator { | |||||||||||||||
| 'Failed to parse final tool call arguments:', | ||||||||||||||||
| error, | ||||||||||||||||
| ); | ||||||||||||||||
| console.error('Problematic accumulated arguments:', accumulatedCall.arguments); | ||||||||||||||||
| // Try to extract partial JSON or provide a fallback | ||||||||||||||||
| args = this.extractPartialJson(accumulatedCall.arguments) || {}; | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -1402,6 +1407,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> = { | ||||||||||||||||
|
|
@@ -1414,6 +1422,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 | ||||||||||||||||
| */ | ||||||||||||||||
|
|
@@ -1790,29 +1825,104 @@ 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'; | ||||||||||||||||
| 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'; | ||||||||||||||||
| // First try to parse the entire string | ||||||||||||||||
| try { | ||||||||||||||||
| return JSON.parse(input); | ||||||||||||||||
| } catch { | ||||||||||||||||
| // If that fails, try to find valid JSON patterns | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // Try to find a complete JSON object in the string | ||||||||||||||||
| // This handles cases like: {"key": "value_partial | ||||||||||||||||
| // or: {"key": "value"} | ||||||||||||||||
| const trimmed = input.trim(); | ||||||||||||||||
|
|
||||||||||||||||
| // Check if it looks like a complete object | ||||||||||||||||
| if (trimmed.startsWith('{') && trimmed.endsWith('}')) { | ||||||||||||||||
| // Try to parse character by character to find where it breaks | ||||||||||||||||
| let braceCount = 0; | ||||||||||||||||
| let inString = false; | ||||||||||||||||
| let escapeNext = false; | ||||||||||||||||
|
|
||||||||||||||||
| for (let i = 0; i < trimmed.length; i++) { | ||||||||||||||||
| const char = trimmed[i]; | ||||||||||||||||
|
|
||||||||||||||||
| if (escapeNext) { | ||||||||||||||||
| escapeNext = false; | ||||||||||||||||
| continue; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if (char === '\\') { | ||||||||||||||||
| escapeNext = true; | ||||||||||||||||
| continue; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if (char === '"' && !escapeNext) { | ||||||||||||||||
| inString = !inString; | ||||||||||||||||
| continue; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if (!inString) { | ||||||||||||||||
| if (char === '{') braceCount++; | ||||||||||||||||
| else if (char === '}') braceCount--; | ||||||||||||||||
|
|
||||||||||||||||
| // If we have balanced braces and are at the end, try parsing | ||||||||||||||||
| if (braceCount === 0 && i === trimmed.length - 1) { | ||||||||||||||||
| try { | ||||||||||||||||
| return JSON.parse(trimmed); | ||||||||||||||||
| } catch { | ||||||||||||||||
| // Still not valid, continue | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // Try to extract key-value pairs manually for simple cases | ||||||||||||||||
| // This handles: key1="value1",key2="value2" | ||||||||||||||||
| const keyValuePattern = /"([^"]+)"\s*:\s*("([^"]*)"|([0-9.]+)|(true|false)|(null))/g; | ||||||||||||||||
| const matches = [...trimmed.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) { | ||||||||||||||||
| // Number value | ||||||||||||||||
| value = parseFloat(match[4]); | ||||||||||||||||
| if (Number.isNaN(value)) { | ||||||||||||||||
| value = match[4]; | ||||||||||||||||
| } | ||||||||||||||||
| } else if (match[5] !== undefined) { | ||||||||||||||||
| // Boolean value | ||||||||||||||||
| value = match[5] === 'true'; | ||||||||||||||||
| } else if (match[6] !== undefined) { | ||||||||||||||||
| // Null value | ||||||||||||||||
| value = null; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| result[key] = value; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| return result; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // Last resort: return an empty object rather than throwing | ||||||||||||||||
| console.warn('Could not extract valid JSON from:', input); | ||||||||||||||||
|
||||||||||||||||
| // Last resort: return an empty object rather than throwing | |
| console.warn('Could not extract valid JSON from:', input); | |
| // Last resort: return an empty object rather than throwing | |
| console.warn( | |
| 'Could not extract valid JSON from tool arguments (length=%d)', | |
| input.length | |
| ); |
🤖 Prompt for AI Agents
In packages/core/src/core/openaiContentGenerator.ts around lines 1924-1925, the
extractPartialJson helper currently logs the full input on failure; change that
to avoid leaking request content by replacing the console.warn call with a
metadata-only log: compute and log the input length and a short cryptographic
hash (e.g., first 8-12 chars of a SHA-256 hex) plus a clear message and any
parse error details, but do NOT include the raw input string itself; ensure
dependencies used for hashing are already imported or use Node's crypto module.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Verify impact of visibility change.
Removing the explicit
publicmodifiers changes these methods to package-private visibility. If external code callsupdateClient()orupdateModel(), this is a breaking change.Run the following script to check for external usage:
🏁 Script executed:
Length of output: 1562
Revert visibility changes or document breaking change.
External code calls
updateModel()atpackages/cli/src/ui/hooks/slashCommandProcessor.ts:839andupdateClient()atpackages/cli/src/ui/hooks/slashCommandProcessor.ts:896. Removing thepublicmodifiers breaks these call sites. Either restore thepublicmodifiers or update the external callers.🤖 Prompt for AI Agents