import type { LLMProvider, LLMRequestOptions, LLMResponse, LLMStreamEvent, LLMContentBlock, LLMMessage, } from '../core/llm/llm-provider.js'; import { MossError, ErrorCode } from '../errors.js'; import { buildApiV1Url } from './api-v1-url.js'; import { fetchWithConnectionContext } from './connection-error.js'; import { createProviderErrorResponse, throwProviderErrorResponse } from './errors.js'; export interface OpenAILLMProviderConfig { apiKey: string; baseUrl?: string; defaultModel?: string; } interface OpenAIChunk { model?: string; choices?: Array<{ delta?: { role?: string; content?: string; tool_calls?: Array<{ index: number; id?: string; type?: string; function?: { name?: string; arguments?: string }; }>; }; finish_reason?: string | null; }>; usage?: { prompt_tokens: number; completion_tokens: number }; error?: { message?: string; type?: string; code?: string }; } export class OpenAILLMProvider implements LLMProvider { readonly id = 'openai'; readonly displayName = 'OpenAI'; readonly capabilities = { streaming: true }; private readonly apiKey: string; private readonly baseUrl: string; private readonly defaultModel: string; constructor(config: OpenAILLMProviderConfig) { this.apiKey = config.apiKey; this.baseUrl = (config.baseUrl || 'https://api.openai.com').replace(/\/$/, ''); this.defaultModel = config.defaultModel ?? ''; } async complete(opts: LLMRequestOptions): Promise { return this.stream(opts, () => {}); } async stream( opts: LLMRequestOptions, onEvent: (event: LLMStreamEvent) => void ): Promise { const messages = this.convertMessages(opts); const resolvedModel = opts.model || this.defaultModel; if (!resolvedModel) { throw new MossError({ code: ErrorCode.PROVIDER_CONFIG_MISSING, message: "No model configured. Run `/model` to pick from your gateway's available models, or run `moss setup` to reconfigure.", hint: 'Open the model picker with /model, then select a model from the list.', recoverable: false, }); } const body: Record = { model: resolvedModel, max_tokens: opts.maxTokens || 4096, messages, stream: true, stream_options: { include_usage: true }, ...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}), }; if (opts.tools?.length) { body.tools = opts.tools.map((t) => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.input_schema }, })); } if (opts.extraBody) { Object.assign(body, opts.extraBody); } const res = await fetchWithConnectionContext(buildApiV1Url(this.baseUrl, 'chat/completions'), { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}`, }, body: JSON.stringify(body), signal: opts.abortSignal, }); if (!res.ok) { const text = await res.text(); const retryAfter = res.headers.get('retry-after'); const retryAfterMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : undefined; const errorResponse = createProviderErrorResponse('openai', text, { status: res.status, retryAfterMs, }); throwProviderErrorResponse(errorResponse); } if (!res.body) { throw new MossError({ code: ErrorCode.PROVIDER_UPSTREAM_ERROR, message: 'OpenAI API returned no body', }); } const content: LLMContentBlock[] = []; let textBuffer = ''; const toolCalls: Map = new Map(); let stopReason: LLMResponse['stopReason'] = 'end_turn'; let inputTokens = 0; let outputTokens = 0; let responseModel: string | undefined; let sawDone = false; let sawFinishReason = false; onEvent({ type: 'message_start' }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; const processLine = (line: string): void => { const trimmed = line.trim(); if (!trimmed.startsWith('data:')) return; const payload = trimmed.slice(5).trim(); if (!payload) return; if (payload === '[DONE]') { sawDone = true; return; } let chunk: OpenAIChunk; try { chunk = JSON.parse(payload); } catch (err) { throw new MossError({ code: ErrorCode.PROVIDER_UPSTREAM_ERROR, message: 'OpenAI provider: malformed SSE JSON frame', hint: 'The upstream API or gateway returned an invalid streaming payload.', recoverable: true, cause: err, context: { payload: payload.slice(0, 200) }, }); } if (chunk.error) { const errorType = chunk.error.type ?? 'unknown_error'; const errorCode = chunk.error.code; const errorMessage = chunk.error.message ?? 'OpenAI stream error'; const label = errorCode ? `${errorType}/${errorCode}` : errorType; throw new MossError({ code: ErrorCode.PROVIDER_UPSTREAM_ERROR, message: `OpenAI stream error ${label}: ${errorMessage}`, hint: 'The upstream OpenAI-compatible API returned an error event during streaming.', recoverable: true, context: { type: errorType, ...(errorCode ? { code: errorCode } : {}) }, }); } if (!responseModel && chunk.model) { responseModel = chunk.model; } if (chunk.usage) { inputTokens = chunk.usage.prompt_tokens ?? 0; outputTokens = chunk.usage.completion_tokens ?? 0; } const choice = chunk.choices?.[0]; if (!choice) return; const delta = choice.delta; if (delta?.content) { textBuffer += delta.content; onEvent({ type: 'content_block_delta', text: delta.content, deltaRole: 'visible' }); } if (delta?.tool_calls) { for (const tc of delta.tool_calls) { const idx = tc.index; if (!toolCalls.has(idx)) { toolCalls.set(idx, { id: tc.id || '', name: tc.function?.name || '', arguments: '', }); if (tc.id) { onEvent({ type: 'content_block_start', toolUse: { id: tc.id, name: tc.function?.name || '' }, }); } } const existing = toolCalls.get(idx)!; if (tc.id) existing.id = tc.id; if (tc.function?.name) existing.name = tc.function.name; if (tc.function?.arguments) { existing.arguments += tc.function.arguments; onEvent({ type: 'content_block_delta', partialJson: tc.function.arguments }); } } } if (choice.finish_reason) { sawFinishReason = true; if (choice.finish_reason === 'tool_calls') stopReason = 'tool_use'; else if (choice.finish_reason === 'length') stopReason = 'max_tokens'; else if (choice.finish_reason === 'stop') stopReason = 'end_turn'; onEvent({ type: 'message_delta', stopReason }); } }; try { 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) { processLine(line); } } buffer += decoder.decode(); if (buffer.trim()) { processLine(buffer); } } finally { // Release the reader to prevent unhandled stream errors from the // underlying fetch response body. In Node.js 25 (undici), an // unreleased reader can cause unhandled promise rejections (#) // when the stream errors after the reader stops reading. reader.cancel().catch(() => {}); } if (!sawDone && !sawFinishReason) { throw new MossError({ code: ErrorCode.PROVIDER_UPSTREAM_ERROR, message: 'OpenAI provider: stream terminated without [DONE] or finish_reason', hint: 'The upstream API or gateway closed the SSE stream before a terminal marker.', recoverable: true, }); } if (textBuffer) { content.push({ type: 'text', text: textBuffer }); } for (const [, tc] of toolCalls) { let input: Record; try { input = JSON.parse(tc.arguments); } catch (err) { throw new MossError({ code: ErrorCode.PROVIDER_UPSTREAM_ERROR, message: `OpenAI provider: malformed tool call arguments for ${tc.name}`, hint: 'The LLM returned invalid JSON for tool parameters. This usually indicates a model or gateway issue.', recoverable: true, cause: err, context: { toolName: tc.name, arguments: tc.arguments.slice(0, 200) }, }); } content.push({ type: 'tool_use', id: tc.id, name: tc.name, input }); } onEvent({ type: 'message_stop' }); return { content, stopReason, usage: { inputTokens, outputTokens }, ...(responseModel ? { model: responseModel } : {}), }; } private convertMessages(opts: LLMRequestOptions): Array> { const result: Array> = []; if (opts.systemPrompt) { result.push({ role: 'system', content: opts.systemPrompt }); } for (const m of opts.messages) { if (typeof m.content === 'string') { result.push({ role: m.role, content: m.content }); } else if (Array.isArray(m.content)) { this.convertContentBlocks(result, m); } } return result; } private convertContentBlocks(result: Array>, m: LLMMessage): void { const blocks = m.content as LLMContentBlock[]; const textParts: string[] = []; const contentParts: Array> = []; const toolCalls: Array<{ id: string; type: 'function'; function: { name: string; arguments: string }; }> = []; for (const block of blocks) { if (block.type === 'text') { textParts.push(block.text); contentParts.push({ type: 'text', text: block.text }); } else if (block.type === 'image') { contentParts.push({ type: 'image_url', image_url: { url: `data:${block.mimeType};base64,${block.data}` }, }); } else if (block.type === 'tool_use') { toolCalls.push({ id: block.id, type: 'function', function: { name: block.name, arguments: JSON.stringify(block.input) }, }); } else if (block.type === 'tool_result') { result.push({ role: 'tool', tool_call_id: block.tool_use_id, content: block.content, }); } } if (textParts.length > 0 || contentParts.length > 0 || toolCalls.length > 0) { const msg: Record = { role: m.role, content: contentParts.some((part) => part.type === 'image_url') ? contentParts : textParts.join('\n') || '', }; if (toolCalls.length > 0) { msg.tool_calls = toolCalls; } result.push(msg); } } }