import { execSync } from 'node:child_process' import type { ProviderConfig, ModelInfo, Message, StreamChunk } from '../shared/index.ts' import type { ProviderInstance, ChatRequest } from './registry' import { fetchWithRetry, streamIdleTimeoutMs } from './fetch-utils' import { OLLAMA_PRESET_MODELS } from '../shared/constants' export class OpenAICompatProvider implements ProviderInstance { constructor(public config: ProviderConfig) {} async *chat(req: ChatRequest): AsyncGenerator { // Accept both baseUrl and baseURL (common YAML typo); resolve env templates const rawBase = (this.config as any).baseUrl || (this.config as any).baseURL const baseUrl = this.resolveEnvTemplate( rawBase?.replace(/\/+$/, '') || '', 'OpenAI-compatible provider: baseUrl', ) || 'https://api.openai.com/v1' const apiKey = this.resolveApiKey(this.config.apiKey) // Priority: explicit request override (summarizer / sub-agent call sites) > // the model's declared ceiling > 8192. The fallback stays: local `ollama` // model ids are not in `config.models`, so their real ceiling is unknown. const declaredMaxOutput = this.config.models.find((m) => m.id === req.model)?.maxOutput const body = { model: req.model, messages: this.convertMessages(req.messages, req.systemPrompt), stream: true, max_tokens: req.maxTokens || declaredMaxOutput || 8192, temperature: req.temperature, tools: req.tools?.map((t) => ({ type: 'function', function: t })), } const response = await fetchWithRetry(`${baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(body), }) if (!response.ok) { const errText = await response.text() yield { type: 'error', error: `OpenAI API error ${response.status}: ${errText}` } return } if (!response.body) { yield { type: 'error', error: 'No response body' } return } const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' // Track incremental tool calls and reasoning content across streaming deltas const pendingToolCalls = new Map() let reasoningContent = '' // Streaming idle timeout: scaled by reasoning effort so long thinking passes // (DeepSeek V4 / reasoning models) aren't mistaken for a stalled connection. const STREAM_READ_TIMEOUT_MS = streamIdleTimeoutMs(req.effort) while (true) { let readResult: Awaited> let idleTimer: ReturnType | undefined try { readResult = await Promise.race([ reader.read(), new Promise((_, reject) => { idleTimer = setTimeout( () => reject( new Error( `Stream read timeout — no data for ${Math.round(STREAM_READ_TIMEOUT_MS / 1000)}s`, ), ), STREAM_READ_TIMEOUT_MS, ) }), ]) } catch (err) { yield { type: 'error', error: `Stream stalled: ${String(err)}` } return } finally { if (idleTimer) clearTimeout(idleTimer) } const { done, value } = readResult if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() || '' for (const line of lines) { const trimmed = line.trim() if (!trimmed || !trimmed.startsWith('data: ')) continue const data = trimmed.slice(6) if (data === '[DONE]') { // Emit any pending tool calls before stopping for (const [, tc] of pendingToolCalls) { if (!tc.name) continue // drop malformed tool call (missing name) yield { type: 'tool_use', toolUse: { type: 'tool_use', id: tc.id || `call_${Date.now()}`, name: tc.name, input: this.safeParseJson(tc.arguments), }, } } yield { type: 'stop', reasoning_content: reasoningContent } return } try { const parsed = JSON.parse(data) const choice = parsed.choices?.[0] // Capture token usage when available (final chunk with stream_options.include_usage) if (parsed.usage) { yield { type: 'usage', inputTokens: parsed.usage.prompt_tokens, outputTokens: parsed.usage.completion_tokens, } } if (!choice) continue const delta = choice.delta if (delta?.tool_calls) { for (const tc of delta.tool_calls) { const idx = tc.index ?? 0 const pending = pendingToolCalls.get(idx) || { id: '', name: '', arguments: '', } if (tc.id) pending.id = tc.id if (tc.function?.name) pending.name = tc.function.name if (tc.function?.arguments) pending.arguments += tc.function.arguments pendingToolCalls.set(idx, pending) } } if (delta?.content) { yield { type: 'text', content: delta.content } } if (delta?.reasoning_content) { reasoningContent += delta.reasoning_content } if (choice.finish_reason === 'tool_calls') { // Emit fully accumulated tool calls for (const [, tc] of pendingToolCalls) { if (!tc.name) continue // drop malformed tool call (missing name) yield { type: 'tool_use', toolUse: { type: 'tool_use', id: tc.id || `call_${Date.now()}`, name: tc.name, input: this.safeParseJson(tc.arguments), }, } } pendingToolCalls.clear() } if (choice.finish_reason === 'stop') { yield { type: 'stop', reasoning_content: reasoningContent } } if (choice.finish_reason === 'length') { // Truncated: the accumulated tool calls were cut off mid-arguments, so // their JSON is incomplete. Drop them rather than dispatching a broken // call, and clear the map so the `[DONE]` handler can't emit them either. pendingToolCalls.clear() yield { type: 'stop', reasoning_content: reasoningContent, truncated: true } } } catch { // skip unparseable chunks } } } yield { type: 'stop', reasoning_content: reasoningContent } } async listModels(): Promise { if (this.config.id === 'ollama') { return this.listOllamaModels() } return this.config.models.filter((m) => m.status === 'active') } private listOllamaModels(): ModelInfo[] { const seen = new Set() const result: ModelInfo[] = [] // 1. ollama list locally downloaded models try { const out = execSync('ollama list', { timeout: 5000, encoding: 'utf-8' }) const lines = out.split('\n').slice(1).filter(Boolean) for (const line of lines) { const name = line.split(/\s+/)[0]! if (!seen.has(name)) { seen.add(name) result.push({ id: name, name, providerId: 'ollama', contextWindow: 128_000, maxOutput: 32_000, vision: false, status: 'active', }) } } } catch { // ollama list failed (not installed / not running) → continue with presets } // 2. Preset models (deduplicated) for (const preset of OLLAMA_PRESET_MODELS) { if (!seen.has(preset.id)) { seen.add(preset.id) result.push({ id: preset.id, name: `${preset.id} [${preset.source}]`, providerId: 'ollama', contextWindow: 128_000, maxOutput: 32_000, vision: false, status: 'active', }) } } return result } async healthCheck(): Promise { try { const rawBase = (this.config as any).baseUrl || (this.config as any).baseURL const baseUrl = this.resolveEnvTemplate( rawBase?.replace(/\/+$/, '') || '', 'OpenAI-compatible provider: baseUrl', ) || 'https://api.openai.com/v1' const apiKey = this.resolveApiKey(this.config.apiKey) // 5s timeout so a down endpoint doesn't hang health checks (v2.1.229 alignment) const res = await fetchWithRetry( `${baseUrl}/models`, { headers: { Authorization: `Bearer ${apiKey}` } }, { timeout: 5000, maxRetries: 0 }, ) return res.ok } catch { return false } } private convertMessages(messages: Message[], systemPrompt?: string): Record[] { const result: Record[] = [] if (systemPrompt) { result.push({ role: 'system', content: systemPrompt }) } for (let i = 0; i < messages.length; i++) { const msg = messages[i]! if (!msg) continue // A `system` entry is a header, not a turn: it belongs in the first slot, // and never alongside an already-emitted system prompt. A mid-array entry // is a client-generated UI line — the engine stores a provider failure as // `system` so a resumed session can render it as an ⚠ line — and passing it // through as system role hands text that came *from a provider* the highest // authority on the very next turn. anthropic.ts drops system entries // outright (its protocol forbids them in the array at all); keeping the // head is the part OpenAI-compatible endpoints genuinely accept. const isHeader = msg.role === 'system' && i === 0 && result.length === 0 if (msg.role === 'system' && !isHeader) continue // ── String content ── if (typeof msg.content === 'string') { // Combine: assistant text + next assistant message with tool_use blocks const next: Message | undefined = messages[i + 1] if ( msg.role === 'assistant' && (msg.content.length > 0 || msg.reasoning_content) && next && next.role === 'assistant' && typeof next.content !== 'string' && next.content.some((b) => b.type === 'tool_use') ) { const toolUses = next.content.filter((b) => b.type === 'tool_use') const combinedMsg: Record = { role: 'assistant', content: msg.content, tool_calls: toolUses.map((tu) => ({ id: tu.id, type: 'function', function: { name: tu.name, arguments: JSON.stringify(tu.input), }, })), // DeepSeek V4 thinking mode: every assistant message needs reasoning_content reasoning_content: msg.reasoning_content || '', } result.push(combinedMsg) i++ // skip the next message (consumed) continue } const standaloneMsg: Record = { role: msg.role, content: msg.content } // DeepSeek V4 thinking mode: every assistant message needs reasoning_content if (msg.role === 'assistant') { standaloneMsg.reasoning_content = msg.reasoning_content || '' } result.push(standaloneMsg) continue } // ── ContentBlock[] ── const blocks = msg.content const toolUses = blocks.filter((b) => b.type === 'tool_use') const toolResults = blocks.filter((b) => b.type === 'tool_result') // ── Assistant tool_use → OpenAI tool_calls ── if (toolUses.length > 0 && msg.role === 'assistant') { const textParts: string[] = [] for (const b of blocks) { if (b.type === 'text') textParts.push(b.text) if (b.type === 'thinking') textParts.push(`[Thinking: ${b.thinking}]`) } const textContent = textParts.join('') || null const toolUseMsg: Record = { role: 'assistant', content: textContent, tool_calls: toolUses.map((tu) => ({ id: tu.id, type: 'function', function: { name: tu.name, arguments: JSON.stringify(tu.input), }, })), // DeepSeek V4 thinking mode: every assistant message needs reasoning_content reasoning_content: msg.reasoning_content || '', } result.push(toolUseMsg) continue } // ── Tool result → OpenAI tool role ── if (toolResults.length > 0) { // Emit companion text blocks as a user message before tool results const textContent = blocks .filter((b) => b.type === 'text') .map((b) => (b.type === 'text' ? b.text : '')) .join('') || null if (textContent) { result.push({ role: 'user', content: textContent }) } for (const tr of toolResults) { result.push({ role: 'tool', tool_call_id: tr.tool_use_id, content: tr.content, }) } continue } // ── Regular content blocks (text, image) ── const parts: unknown[] = [] for (const block of blocks) { if (block.type === 'text') { parts.push({ type: 'text', text: block.text }) } else if (block.type === 'image_url') { parts.push({ type: 'image_url', image_url: block.image_url }) } } result.push({ role: msg.role, content: parts }) } return result } /** Safely parse JSON, returning an empty object on failure. */ private safeParseJson(raw: string): Record { if (!raw) return {} try { return JSON.parse(raw) } catch { return { _raw: raw } } } /** Resolve a `${VAR}` / `$VAR` template against the environment. */ private resolveEnvTemplate(value: string, warnPrefix: string): string { let match = value.match(/^\$\{(.+)\}$/) if (!match) match = value.match(/^\$([A-Z_][A-Z0-9_]*)$/) if (match?.[1]) { const varName = match[1] const envValue = process.env[varName] if (!envValue) { process.stderr.write( `⚠ ${warnPrefix} references $${varName} but that environment variable is not set\n`, ) return '' } return envValue } return value } private resolveApiKey(keyTemplate: string): string { return this.resolveEnvTemplate(keyTemplate, 'OpenAI-compatible provider: apiKey') } }