/** * OpenAICompatibleAdapter - Provider adapter for OpenAI-compatible APIs * * Implements ProviderAdapter; uses AnthropicCanonicalConverter. * * @module packages/llm/src/adapters/OpenAICompatibleAdapter */ import type { CanonicalMessage } from '../canonical/AnthropicCanonical.js' import { AnthropicCanonicalConverter } from '../canonical/AnthropicCanonical.js' // Local type definitions (contract types not yet finalized) type CompleteOptions = { max_tokens?: number; temperature?: number; top_p?: number; system?: string } type StreamEvent = { type: 'text' | 'thinking' | 'done'; content?: string; reason?: string } export interface OpenAICompatibleConfig { api_key?: string base_url: string model: string max_retries?: number timeout?: number } export class OpenAICompatibleAdapter { private api_key: string private base_url: string private model: string private max_retries: number private timeout: number private converter: AnthropicCanonicalConverter constructor(config: OpenAICompatibleConfig) { this.api_key = config.api_key || process.env.OPENAI_API_KEY || 'dummy' this.base_url = config.base_url this.model = config.model this.max_retries = config.max_retries || 3 this.timeout = config.timeout || 60000 this.converter = new AnthropicCanonicalConverter() } async list_models(): Promise { // Try to fetch model list, fallback to default try { const response = await fetch(`${this.base_url}/v1/models`, { headers: { Authorization: `Bearer ${this.api_key}` } }) if (response.ok) { const data = await response.json() as { data: Array<{ id: string }> } return data.data.map(m => m.id) } } catch { // Ignore } return [this.model] } async validate_model(model: string): Promise<{ valid: boolean; error?: string }> { const known = await this.list_models() if (known.includes(model)) { return { valid: true } } // Allow unknown models - might be valid return { valid: true } } async complete( messages: CanonicalMessage[], _requirement: { model: string }, options: CompleteOptions = {} ): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number } }> { // Convert to OpenAI format const openai_messages = messages.map(m => ({ role: m.role, content: m.content.map(c => { if (c.type === 'text') return { type: 'text', text: c.text } if (c.type === 'tool_use') return { type: 'tool_use', id: c.id, name: c.name, input: c.input } return { type: 'text', text: '' } }) })) const response = await this.make_request({ model: this.model, messages: openai_messages, max_tokens: options.max_tokens || 4096, temperature: options.temperature, top_p: options.top_p, stream: false }) const content = (response.choices?.[0]?.message?.content as string) || '' const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined return { content, usage } } async *stream_complete( messages: CanonicalMessage[], _requirement: { model: string }, options: CompleteOptions = {} ): AsyncGenerator { const openai_messages = messages.map(m => ({ role: m.role, content: m.content.map(c => { if (c.type === 'text') return { type: 'text', text: c.text } return { type: 'text', text: '' } }) })) const response = await this.make_request({ model: this.model, messages: openai_messages, max_tokens: options.max_tokens || 4096, temperature: options.temperature, top_p: options.top_p, stream: true }) const reader = response.body?.getReader() if (!reader) { throw new Error('No response body') } 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.trim() || !line.startsWith('data: ')) continue const data = line.slice(6) if (data === '[DONE]') { yield { type: 'done', reason: 'stop' } return } try { const event = JSON.parse(data) const choice = event.choices?.[0] if (!choice) continue if (choice.delta?.content) { yield { type: 'text', content: choice.delta.content } } if (choice.finish_reason) { yield { type: 'done', reason: choice.finish_reason } } } catch { // Skip } } } } async count_tokens(text: string): Promise { // Simple estimation return Math.ceil(text.length / 4) } private async make_request(body: Record): Promise<{ ok: boolean; status: number; body?: { getReader(): { read(): Promise<{ done: boolean; value: Uint8Array }> }; choices?: Array<{ message?: { content: string }; delta?: { content: string }; finish_reason?: string }>; usage?: { prompt_tokens: number; completion_tokens: number } } }> { const response = await fetch(`${this.base_url}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.api_key}` }, body: JSON.stringify(body) }) if (!response.ok) { const error = await response.text() throw new Error(`OpenAI-compatible API error: ${response.status} - ${error}`) } // Handle streaming vs non-streaming const is_streaming = body.stream === true if (is_streaming) { return { ok: true, status: 200, body: response.body as any } } return { ok: true, status: 200, body: await response.json() as any } } } export function createOpenAICompatibleAdapter(config: OpenAICompatibleConfig): OpenAICompatibleAdapter { return new OpenAICompatibleAdapter(config) }