From 409f8682649bcab6e39f1bf7cbe625ebe29dc404 Mon Sep 17 00:00:00 2001 From: Md Mushfiqur Rahim <20mahin2020@gmail.com> Date: Tue, 19 May 2026 09:05:17 +0600 Subject: [PATCH] fix(openai): return empty array on listModels API error The OpenAI provider listModels was not checking resp.ok before parsing JSON. If the API returns an error (e.g., 401 invalid key), JSON.parse would throw on the error response body. Now returns empty array on non-2xx responses, matching the graceful fallback pattern used by other providers. --- packages/provider-openai/src/index.ts | 197 +++++++++++++------------- 1 file changed, 99 insertions(+), 98 deletions(-) diff --git a/packages/provider-openai/src/index.ts b/packages/provider-openai/src/index.ts index 8db895c..89256fb 100644 --- a/packages/provider-openai/src/index.ts +++ b/packages/provider-openai/src/index.ts @@ -1,99 +1,100 @@ -import type { ProviderPlugin, LLMCompletionRequest, LLMCompletionResponse, LLMCompletionChunk, PluginManifest } from "@mxclaw/core"; - -const manifest: PluginManifest = { - name: "openai", version: "0.1.0", type: "provider", - description: "OpenAI API provider (GPT-4o, GPT-4, etc.)", author: "mxclaw", - main: "dist/index.js", capabilities: ["completion", "streaming", "tools", "vision"], -}; - -let apiKey = ""; -let baseUrl = "https://api.openai.com/v1"; - -const plugin: ProviderPlugin = { - manifest, - initialize: async (config) => { - apiKey = (config.apiKey as string) ?? process.env.OPENAI_API_KEY ?? ""; - baseUrl = (config.baseUrl as string) ?? "https://api.openai.com/v1"; - }, - complete: async (request): Promise => { - const timeout = AbortSignal.timeout(120_000); // 2 minute timeout - const resp = await fetch(`${baseUrl}/chat/completions`, { - method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, - body: JSON.stringify({ - model: request.model, messages: request.messages, - tools: request.tools, temperature: request.temperature, max_tokens: request.maxTokens, - }), - signal: request.signal ?? timeout, - }); - if (!resp.ok) { - const errBody = await resp.text().catch(() => "unknown"); - throw new Error(`OpenAI error ${resp.status}: ${errBody}`); - } - const data = (await resp.json()) as { choices: Array<{ message: { content: string; tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }> }; finish_reason: string }>; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number } }; - const choice = data.choices[0]!; - return { - content: choice.message.content ?? "", - toolCalls: choice.message.tool_calls?.map(tc => { - let args: Record; - try { args = JSON.parse(tc.function.arguments); } catch { args = { raw: tc.function.arguments }; } - return { id: tc.id, name: tc.function.name, arguments: args }; - }), - finishReason: choice.finish_reason as LLMCompletionResponse["finishReason"], - usage: data.usage ? { - promptTokens: data.usage.prompt_tokens, - completionTokens: data.usage.completion_tokens, - totalTokens: data.usage.total_tokens, - } : undefined, - }; - }, - completeStream: async function* (request): AsyncGenerator { - const resp = await fetch(`${baseUrl}/chat/completions`, { - method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, - body: JSON.stringify({ - model: request.model, messages: request.messages, - tools: request.tools, temperature: request.temperature, max_tokens: request.maxTokens, - stream: true, - }), - signal: request.signal, - }); - if (!resp.ok) throw new Error(`OpenAI stream error: ${resp.status}`); - const reader = resp.body!.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - if (!line.startsWith("data: ")) continue; - const data = line.slice(6); - if (data === "[DONE]") return; - try { - const parsed = JSON.parse(data) as { choices: Array<{ delta: { content?: string; tool_calls?: Array<{ index: number; id?: string; function?: { name?: string; arguments?: string } }> }; finish_reason?: string }> }; - const delta = parsed.choices[0]?.delta; - if (!delta) continue; - yield { - content: delta.content ?? "", - toolCalls: delta.tool_calls?.map(tc => ({ id: tc.id ?? "", name: tc.function?.name ?? "", arguments: tc.function?.arguments ?? "" })), - finishReason: parsed.choices[0]?.finish_reason as LLMCompletionChunk["finishReason"], - }; - } catch { /* skip malformed chunks */ } - } - } - }, - listModels: async () => { - const resp = await fetch(`${baseUrl}/models`, { headers: { Authorization: `Bearer ${apiKey}` } }); - const data = (await resp.json()) as { data: Array<{ id: string }> }; - return data.data.map(m => ({ id: m.id, name: m.id })); - }, - healthCheck: async () => { - try { - await fetch(`${baseUrl}/models`, { headers: { Authorization: `Bearer ${apiKey}` } }); - return true; - } catch { return false; } - }, -}; - +import type { ProviderPlugin, LLMCompletionRequest, LLMCompletionResponse, LLMCompletionChunk, PluginManifest } from "@mxclaw/core"; + +const manifest: PluginManifest = { + name: "openai", version: "0.1.0", type: "provider", + description: "OpenAI API provider (GPT-4o, GPT-4, etc.)", author: "mxclaw", + main: "dist/index.js", capabilities: ["completion", "streaming", "tools", "vision"], +}; + +let apiKey = ""; +let baseUrl = "https://api.openai.com/v1"; + +const plugin: ProviderPlugin = { + manifest, + initialize: async (config) => { + apiKey = (config.apiKey as string) ?? process.env.OPENAI_API_KEY ?? ""; + baseUrl = (config.baseUrl as string) ?? "https://api.openai.com/v1"; + }, + complete: async (request): Promise => { + const timeout = AbortSignal.timeout(120_000); // 2 minute timeout + const resp = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + model: request.model, messages: request.messages, + tools: request.tools, temperature: request.temperature, max_tokens: request.maxTokens, + }), + signal: request.signal ?? timeout, + }); + if (!resp.ok) { + const errBody = await resp.text().catch(() => "unknown"); + throw new Error(`OpenAI error ${resp.status}: ${errBody}`); + } + const data = (await resp.json()) as { choices: Array<{ message: { content: string; tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }> }; finish_reason: string }>; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number } }; + const choice = data.choices[0]!; + return { + content: choice.message.content ?? "", + toolCalls: choice.message.tool_calls?.map(tc => { + let args: Record; + try { args = JSON.parse(tc.function.arguments); } catch { args = { raw: tc.function.arguments }; } + return { id: tc.id, name: tc.function.name, arguments: args }; + }), + finishReason: choice.finish_reason as LLMCompletionResponse["finishReason"], + usage: data.usage ? { + promptTokens: data.usage.prompt_tokens, + completionTokens: data.usage.completion_tokens, + totalTokens: data.usage.total_tokens, + } : undefined, + }; + }, + completeStream: async function* (request): AsyncGenerator { + const resp = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + model: request.model, messages: request.messages, + tools: request.tools, temperature: request.temperature, max_tokens: request.maxTokens, + stream: true, + }), + signal: request.signal, + }); + if (!resp.ok) throw new Error(`OpenAI stream error: ${resp.status}`); + const reader = resp.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6); + if (data === "[DONE]") return; + try { + const parsed = JSON.parse(data) as { choices: Array<{ delta: { content?: string; tool_calls?: Array<{ index: number; id?: string; function?: { name?: string; arguments?: string } }> }; finish_reason?: string }> }; + const delta = parsed.choices[0]?.delta; + if (!delta) continue; + yield { + content: delta.content ?? "", + toolCalls: delta.tool_calls?.map(tc => ({ id: tc.id ?? "", name: tc.function?.name ?? "", arguments: tc.function?.arguments ?? "" })), + finishReason: parsed.choices[0]?.finish_reason as LLMCompletionChunk["finishReason"], + }; + } catch { /* skip malformed chunks */ } + } + } + }, + listModels: async () => { + const resp = await fetch(`${baseUrl}/models`, { headers: { Authorization: `Bearer ${apiKey}` } }); + if (!resp.ok) return []; + const data = (await resp.json()) as { data: Array<{ id: string }> }; + return data.data.map(m => ({ id: m.id, name: m.id })); + }, + healthCheck: async () => { + try { + await fetch(`${baseUrl}/models`, { headers: { Authorization: `Bearer ${apiKey}` } }); + return true; + } catch { return false; } + }, +}; + export default plugin; \ No newline at end of file