import { Logger } from '@nestjs/common'; import OpenAI from 'openai'; import type { ModelRegistry } from '@mariozechner/pi-coding-agent'; import type { CompletionEvent, CompletionParams, IProviderAdapter, ModelInfo, ProviderHealth, } from '@mosaicstack/types'; /** * OpenAI provider adapter. * * Registers OpenAI models (including Codex gpt-5.4) with the Pi ModelRegistry. * Configuration is driven by environment variables: * OPENAI_API_KEY — OpenAI API key (required; adapter skips registration when absent) */ export class OpenAIAdapter implements IProviderAdapter { readonly name = 'openai'; private readonly logger = new Logger(OpenAIAdapter.name); private registeredModels: ModelInfo[] = []; private client: OpenAI | null = null; /** Model ID used for Codex gpt-5.4 in the Pi registry. */ static readonly CODEX_MODEL_ID = 'codex-gpt-5-4'; constructor(private readonly registry: ModelRegistry) {} async register(): Promise { const apiKey = process.env['OPENAI_API_KEY']; if (!apiKey) { this.logger.debug('Skipping OpenAI provider registration: OPENAI_API_KEY not set'); return; } this.client = new OpenAI({ apiKey }); const codexModel = { id: OpenAIAdapter.CODEX_MODEL_ID, name: 'Codex gpt-5.4', /** OpenAI-compatible completions API */ api: 'openai-completions' as never, reasoning: false, input: ['text', 'image'] as ('text' | 'image')[], cost: { input: 0.003, output: 0.012, cacheRead: 0.0015, cacheWrite: 0 }, contextWindow: 128_000, maxTokens: 16_384, }; this.registry.registerProvider('openai', { apiKey, baseUrl: 'https://api.openai.com/v1', models: [codexModel], }); this.registeredModels = [ { id: OpenAIAdapter.CODEX_MODEL_ID, provider: 'openai', name: 'Codex gpt-5.4', reasoning: false, contextWindow: 128_000, maxTokens: 16_384, inputTypes: ['text', 'image'] as ('text' | 'image')[], cost: { input: 0.003, output: 0.012, cacheRead: 0.0015, cacheWrite: 0 }, }, ]; this.logger.log(`OpenAI provider registered with model: ${OpenAIAdapter.CODEX_MODEL_ID}`); } listModels(): ModelInfo[] { return this.registeredModels; } async healthCheck(): Promise { const apiKey = process.env['OPENAI_API_KEY']; if (!apiKey) { return { status: 'down', lastChecked: new Date().toISOString(), error: 'OPENAI_API_KEY not configured', }; } const start = Date.now(); try { // Lightweight call — list models to verify key validity const res = await fetch('https://api.openai.com/v1/models', { method: 'GET', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, signal: AbortSignal.timeout(5000), }); const latencyMs = Date.now() - start; if (!res.ok) { return { status: 'degraded', latencyMs, lastChecked: new Date().toISOString(), error: `HTTP ${res.status}`, }; } return { status: 'healthy', latencyMs, lastChecked: new Date().toISOString() }; } catch (err) { const latencyMs = Date.now() - start; const error = err instanceof Error ? err.message : String(err); return { status: 'down', latencyMs, lastChecked: new Date().toISOString(), error }; } } /** * Stream a completion from OpenAI using the chat completions API. * * Maps OpenAI streaming chunks to the Mosaic CompletionEvent format. */ async *createCompletion(params: CompletionParams): AsyncIterable { if (!this.client) { throw new Error( 'OpenAIAdapter: client not initialized. ' + 'Ensure OPENAI_API_KEY is set and register() was called.', ); } const stream = await this.client.chat.completions.create({ model: params.model, messages: params.messages.map((m) => ({ role: m.role, content: m.content, })), ...(params.temperature !== undefined && { temperature: params.temperature }), ...(params.maxTokens !== undefined && { max_tokens: params.maxTokens }), ...(params.tools && params.tools.length > 0 && { tools: params.tools.map((t) => ({ type: 'function' as const, function: { name: t.name, description: t.description, parameters: t.parameters, }, })), }), stream: true, stream_options: { include_usage: true }, }); let inputTokens = 0; let outputTokens = 0; for await (const chunk of stream) { const choice = chunk.choices[0]; // Accumulate usage when present (final chunk with stream_options.include_usage) if (chunk.usage) { inputTokens = chunk.usage.prompt_tokens; outputTokens = chunk.usage.completion_tokens; } if (!choice) continue; const delta = choice.delta; // Text content delta if (delta.content) { yield { type: 'text_delta', content: delta.content }; } // Tool call delta — emit when arguments are complete if (delta.tool_calls) { for (const toolCallDelta of delta.tool_calls) { if (toolCallDelta.function?.name && toolCallDelta.function.arguments !== undefined) { yield { type: 'tool_call', name: toolCallDelta.function.name, arguments: toolCallDelta.function.arguments, }; } } } // Stream finished if (choice.finish_reason === 'stop' || choice.finish_reason === 'tool_calls') { yield { type: 'done', usage: { inputTokens, outputTokens }, }; return; } } // Fallback done event when stream ends without explicit finish_reason yield { type: 'done', usage: { inputTokens, outputTokens } }; } }