-
Notifications
You must be signed in to change notification settings - Fork 3.6k
fix(openai): use standard chat completions API for OpenRouter compatibility #1046
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: master
Are you sure you want to change the base?
Changes from all commits
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 | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,13 +1,11 @@ | ||||||||||||
| import OpenAI from 'openai'; | ||||||||||||
| import BaseLLM from '../../base/llm'; | ||||||||||||
| import { zodTextFormat, zodResponseFormat } from 'openai/helpers/zod'; | ||||||||||||
| import { | ||||||||||||
| GenerateObjectInput, | ||||||||||||
| GenerateOptions, | ||||||||||||
| GenerateTextInput, | ||||||||||||
| GenerateTextOutput, | ||||||||||||
| StreamTextOutput, | ||||||||||||
| ToolCall, | ||||||||||||
| } from '../../types'; | ||||||||||||
| import { parse } from 'partial-json'; | ||||||||||||
| import z from 'zod'; | ||||||||||||
|
|
@@ -19,6 +17,7 @@ import { | |||||||||||
| } from 'openai/resources/index.mjs'; | ||||||||||||
| import { Message } from '@/lib/types'; | ||||||||||||
| import { repairJson } from '@toolsycc/json-repair'; | ||||||||||||
| import { stripMarkdownFences } from '@/lib/utils/parseJson'; | ||||||||||||
|
|
||||||||||||
| type OpenAIConfig = { | ||||||||||||
| apiKey: string; | ||||||||||||
|
|
@@ -44,21 +43,24 @@ class OpenAILLM extends BaseLLM<OpenAIConfig> { | |||||||||||
| if (msg.role === 'tool') { | ||||||||||||
| return { | ||||||||||||
| role: 'tool', | ||||||||||||
| tool_call_id: msg.id, | ||||||||||||
| content: msg.content, | ||||||||||||
| tool_call_id: msg.id || '', | ||||||||||||
| content: msg.content || '', | ||||||||||||
| } as ChatCompletionToolMessageParam; | ||||||||||||
| } else if (msg.role === 'assistant') { | ||||||||||||
| return { | ||||||||||||
| role: 'assistant', | ||||||||||||
| content: msg.content, | ||||||||||||
| content: msg.content || '', | ||||||||||||
| ...(msg.tool_calls && | ||||||||||||
| msg.tool_calls.length > 0 && { | ||||||||||||
| tool_calls: msg.tool_calls?.map((tc) => ({ | ||||||||||||
| id: tc.id, | ||||||||||||
| id: tc.id || '', | ||||||||||||
| type: 'function', | ||||||||||||
| function: { | ||||||||||||
| name: tc.name, | ||||||||||||
| arguments: JSON.stringify(tc.arguments), | ||||||||||||
| name: tc.name || '', | ||||||||||||
| arguments: | ||||||||||||
| typeof tc.arguments === 'string' | ||||||||||||
| ? tc.arguments | ||||||||||||
| : JSON.stringify(tc.arguments || {}), | ||||||||||||
| }, | ||||||||||||
| })), | ||||||||||||
| }), | ||||||||||||
|
|
@@ -164,27 +166,43 @@ class OpenAILLM extends BaseLLM<OpenAIConfig> { | |||||||||||
| for await (const chunk of stream) { | ||||||||||||
| if (chunk.choices && chunk.choices.length > 0) { | ||||||||||||
| const toolCalls = chunk.choices[0].delta.tool_calls; | ||||||||||||
| yield { | ||||||||||||
| contentChunk: chunk.choices[0].delta.content || '', | ||||||||||||
| toolCallChunk: | ||||||||||||
| toolCalls?.map((tc) => { | ||||||||||||
| let parsedToolCalls: any[] = []; | ||||||||||||
|
|
||||||||||||
| if (toolCalls) { | ||||||||||||
| for (const tc of toolCalls) { | ||||||||||||
| try { | ||||||||||||
| if (!recievedToolCalls[tc.index]) { | ||||||||||||
| const call = { | ||||||||||||
| name: tc.function?.name!, | ||||||||||||
| id: tc.id!, | ||||||||||||
| name: tc.function?.name || '', | ||||||||||||
| id: tc.id || '', | ||||||||||||
| arguments: tc.function?.arguments || '', | ||||||||||||
| }; | ||||||||||||
| recievedToolCalls.push(call); | ||||||||||||
| return { ...call, arguments: parse(call.arguments || '{}') }; | ||||||||||||
| const argsToParse = call.arguments || '{}'; | ||||||||||||
| parsedToolCalls.push({ ...call, arguments: parse(argsToParse) }); | ||||||||||||
| } else { | ||||||||||||
| const existingCall = recievedToolCalls[tc.index]; | ||||||||||||
| existingCall.arguments += tc.function?.arguments || ''; | ||||||||||||
| return { | ||||||||||||
| const argsToParse = existingCall.arguments || '{}'; | ||||||||||||
| parsedToolCalls.push({ | ||||||||||||
| ...existingCall, | ||||||||||||
| arguments: parse(existingCall.arguments), | ||||||||||||
| }; | ||||||||||||
| arguments: parse(argsToParse), | ||||||||||||
| }); | ||||||||||||
| } | ||||||||||||
| }) || [], | ||||||||||||
| } catch (parseErr) { | ||||||||||||
| console.error('Error parsing tool call arguments:', parseErr, 'tc:', JSON.stringify(tc)); | ||||||||||||
| parsedToolCalls.push({ | ||||||||||||
| name: tc.function?.name || '', | ||||||||||||
| id: tc.id || recievedToolCalls[tc.index]?.id || '', | ||||||||||||
| arguments: {}, | ||||||||||||
| }); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| yield { | ||||||||||||
| contentChunk: chunk.choices[0].delta.content || '', | ||||||||||||
| toolCallChunk: parsedToolCalls, | ||||||||||||
| done: chunk.choices[0].finish_reason !== null, | ||||||||||||
| additionalInfo: { | ||||||||||||
| finishReason: chunk.choices[0].finish_reason, | ||||||||||||
|
|
@@ -195,8 +213,18 @@ class OpenAILLM extends BaseLLM<OpenAIConfig> { | |||||||||||
| } | ||||||||||||
|
|
||||||||||||
| async generateObject<T>(input: GenerateObjectInput): Promise<T> { | ||||||||||||
| const response = await this.openAIClient.chat.completions.parse({ | ||||||||||||
| messages: this.convertToOpenAIMessages(input.messages), | ||||||||||||
| // Use chat.completions.create instead of chat.completions.parse | ||||||||||||
| // for compatibility with OpenAI-compatible providers (OpenRouter, etc.) | ||||||||||||
| // that don't support the /chat/completions/parse endpoint. | ||||||||||||
| const response = await this.openAIClient.chat.completions.create({ | ||||||||||||
| messages: [ | ||||||||||||
| { | ||||||||||||
| role: 'system', | ||||||||||||
| content: | ||||||||||||
| 'You must respond with valid JSON only. No markdown code blocks, no explanatory text.', | ||||||||||||
| }, | ||||||||||||
| ...this.convertToOpenAIMessages(input.messages), | ||||||||||||
| ], | ||||||||||||
| model: this.config.model, | ||||||||||||
| temperature: | ||||||||||||
| input.options?.temperature ?? this.config.options?.temperature ?? 1.0, | ||||||||||||
|
|
@@ -209,18 +237,27 @@ class OpenAILLM extends BaseLLM<OpenAIConfig> { | |||||||||||
| this.config.options?.frequencyPenalty, | ||||||||||||
| presence_penalty: | ||||||||||||
| input.options?.presencePenalty ?? this.config.options?.presencePenalty, | ||||||||||||
| response_format: zodResponseFormat(input.schema, 'object'), | ||||||||||||
| response_format: { type: 'json_object' }, | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| if (response.choices && response.choices.length > 0) { | ||||||||||||
| try { | ||||||||||||
| return input.schema.parse( | ||||||||||||
| JSON.parse( | ||||||||||||
| repairJson(response.choices[0].message.content!, { | ||||||||||||
| extractJson: true, | ||||||||||||
| }) as string, | ||||||||||||
| ), | ||||||||||||
| ) as T; | ||||||||||||
| const content = stripMarkdownFences( | ||||||||||||
| response.choices[0].message.content || '', | ||||||||||||
| ); | ||||||||||||
| if (!content.trim()) { | ||||||||||||
| throw new Error('Empty response from model'); | ||||||||||||
| } | ||||||||||||
| let repairedJson: string; | ||||||||||||
| try { | ||||||||||||
| repairedJson = repairJson(content, { | ||||||||||||
| extractJson: true, | ||||||||||||
| }) as string; | ||||||||||||
| } catch (repairErr) { | ||||||||||||
| console.error('repairJson failed on content:', content); | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Raw model output is logged on JSON-repair failure, which can leak sensitive content into server logs. Prompt for AI agents
Suggested change
|
||||||||||||
| throw new Error(`Failed to repair JSON: ${repairErr}`); | ||||||||||||
| } | ||||||||||||
| return input.schema.parse(JSON.parse(repairedJson)) as T; | ||||||||||||
| } catch (err) { | ||||||||||||
| throw new Error(`Error parsing response from OpenAI: ${err}`); | ||||||||||||
| } | ||||||||||||
|
|
@@ -230,11 +267,21 @@ class OpenAILLM extends BaseLLM<OpenAIConfig> { | |||||||||||
| } | ||||||||||||
|
|
||||||||||||
| async *streamObject<T>(input: GenerateObjectInput): AsyncGenerator<T> { | ||||||||||||
| let recievedObj: string = ''; | ||||||||||||
| let receivedObj: string = ''; | ||||||||||||
|
|
||||||||||||
| const stream = this.openAIClient.responses.stream({ | ||||||||||||
| // Use chat.completions.create with streaming instead of responses.stream | ||||||||||||
| // for compatibility with OpenAI-compatible providers (OpenRouter, etc.) | ||||||||||||
| // that don't support the OpenAI Responses API. | ||||||||||||
| const stream = await this.openAIClient.chat.completions.create({ | ||||||||||||
| model: this.config.model, | ||||||||||||
| input: input.messages, | ||||||||||||
| messages: [ | ||||||||||||
| { | ||||||||||||
| role: 'system', | ||||||||||||
| content: | ||||||||||||
| 'You must respond with valid JSON only. No markdown code blocks, no explanatory text.', | ||||||||||||
| }, | ||||||||||||
| ...this.convertToOpenAIMessages(input.messages), | ||||||||||||
| ], | ||||||||||||
| temperature: | ||||||||||||
| input.options?.temperature ?? this.config.options?.temperature ?? 1.0, | ||||||||||||
| top_p: input.options?.topP ?? this.config.options?.topP, | ||||||||||||
|
|
@@ -246,27 +293,23 @@ class OpenAILLM extends BaseLLM<OpenAIConfig> { | |||||||||||
| this.config.options?.frequencyPenalty, | ||||||||||||
| presence_penalty: | ||||||||||||
| input.options?.presencePenalty ?? this.config.options?.presencePenalty, | ||||||||||||
| text: { | ||||||||||||
| format: zodTextFormat(input.schema, 'object'), | ||||||||||||
| }, | ||||||||||||
| stream: true, | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| for await (const chunk of stream) { | ||||||||||||
| if (chunk.type === 'response.output_text.delta' && chunk.delta) { | ||||||||||||
| recievedObj += chunk.delta; | ||||||||||||
| if (chunk.choices && chunk.choices.length > 0) { | ||||||||||||
| const delta = chunk.choices[0].delta.content || ''; | ||||||||||||
| receivedObj += delta; | ||||||||||||
|
|
||||||||||||
| // Strip markdown fences if present | ||||||||||||
| const cleanedObj = stripMarkdownFences(receivedObj); | ||||||||||||
|
|
||||||||||||
| try { | ||||||||||||
| yield parse(recievedObj) as T; | ||||||||||||
| yield parse(cleanedObj) as T; | ||||||||||||
| } catch (err) { | ||||||||||||
| console.log('Error parsing partial object from OpenAI:', err); | ||||||||||||
| // Partial JSON may not be parseable yet, yield empty object | ||||||||||||
| yield {} as T; | ||||||||||||
| } | ||||||||||||
| } else if (chunk.type === 'response.output_text.done' && chunk.text) { | ||||||||||||
| try { | ||||||||||||
| yield parse(chunk.text) as T; | ||||||||||||
| } catch (err) { | ||||||||||||
| throw new Error(`Error parsing response from OpenAI: ${err}`); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| /** | ||
| * Utilities for parsing JSON from LLM responses. | ||
| * | ||
| * Many LLMs (especially when accessed via OpenAI-compatible APIs like OpenRouter, | ||
| * LiteLLM, etc.) wrap JSON responses in markdown code fences even when | ||
| * response_format is set to json_object. These utilities help handle such cases. | ||
| */ | ||
|
|
||
| /** | ||
| * Strip markdown code fences from a string. | ||
| * Handles both ```json and plain ``` fences. | ||
| * | ||
| * @example | ||
| * stripMarkdownFences('```json\n{"foo": "bar"}\n```') // '{"foo": "bar"}' | ||
| * stripMarkdownFences('{"foo": "bar"}') // '{"foo": "bar"}' | ||
| */ | ||
| export function stripMarkdownFences(text: string): string { | ||
| const trimmed = text.trim(); | ||
| if (trimmed.startsWith('```')) { | ||
| return trimmed | ||
| .replace(/^```(?:json)?\s*/i, '') | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Opening fence stripping only handles plain or Prompt for AI agents |
||
| .replace(/```\s*$/, '') | ||
| .trim(); | ||
| } | ||
| return trimmed; | ||
| } | ||
|
|
||
| /** | ||
| * Safely parse JSON, stripping markdown fences first. | ||
| * Returns undefined if parsing fails instead of throwing. | ||
| * | ||
| * @example | ||
| * safeParseJson('```json\n{"foo": "bar"}\n```') // { foo: 'bar' } | ||
| * safeParseJson('invalid') // undefined | ||
| */ | ||
| export function safeParseJson<T = unknown>(text: string): T | undefined { | ||
| try { | ||
| const cleaned = stripMarkdownFences(text); | ||
| return JSON.parse(cleaned) as T; | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
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.
P2: Parse failures in streaming tool calls are now swallowed and emitted as a synthetic
{}arguments tool call, which downstream consumers execute without validation. This can trigger incorrect tool execution instead of surfacing the parse error.Prompt for AI agents