import fs from "fs"; import path from "path"; import { DEFAULT_OPENAI_BASE_URL, SCHEMAS_DIR } from "../config"; import type { LlmProvider } from "../types/normalizer"; import { ApiError } from "../utils/http"; export interface OpenAIRequestConfig { llmProvider?: LlmProvider; apiKey: string; model: string; baseUrl?: string; abortSignal?: AbortSignal; temperature?: number; maxOutputTokens?: number; } export interface OpenAIResponseEnvelope { raw: unknown; outputText: string; usage: { input_tokens: number; output_tokens: number; total_tokens: number; }; } function resolveProvider(config: OpenAIRequestConfig): LlmProvider { return config.llmProvider === "local" ? "local" : "openai"; } function resolveApiKey(config: OpenAIRequestConfig): string { const candidate = String(config.apiKey ?? "").trim(); if (candidate.length > 0) { return candidate; } if (resolveProvider(config) === "local") { // Local OpenAI-compatible servers often accept any token. return "local-dev-token"; } throw new ApiError("OPENAI_API_KEY_MISSING", "OpenAI API key is missing.", 400); } function extractUsage(raw: Record): { input_tokens: number; output_tokens: number; total_tokens: number; } { const usage = (raw.usage ?? {}) as Record; const input = Number(usage.input_tokens ?? usage.prompt_tokens ?? 0); const output = Number(usage.output_tokens ?? usage.completion_tokens ?? 0); const total = Number(usage.total_tokens ?? input + output); return { input_tokens: Number.isFinite(input) ? input : 0, output_tokens: Number.isFinite(output) ? output : 0, total_tokens: Number.isFinite(total) ? total : 0 }; } function extractOutputTextFromResponses(raw: Record): string { if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) { return raw.output_text; } const output = raw.output; if (Array.isArray(output)) { for (const item of output) { if (!item || typeof item !== "object") { continue; } const content = (item as Record).content; if (!Array.isArray(content)) { continue; } for (const c of content) { if (!c || typeof c !== "object") { continue; } const block = c as Record; if (typeof block.text === "string" && block.text.trim()) { return block.text; } } } } const response = raw.response; if (response && typeof response === "object") { const nested = response as Record; if (typeof nested.output_text === "string" && nested.output_text.trim().length > 0) { return nested.output_text; } } throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract output_text from /responses payload.", 502, raw); } function extractOutputTextFromChatCompletions(raw: Record): string { const choices = raw.choices; if (!Array.isArray(choices) || choices.length === 0) { throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing choices in /chat/completions payload.", 502, raw); } const first = choices[0]; if (!first || typeof first !== "object") { throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Invalid first choice in /chat/completions payload.", 502, raw); } const message = (first as Record).message; if (!message || typeof message !== "object") { throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing message in /chat/completions payload.", 502, raw); } const content = (message as Record).content; if (typeof content === "string" && content.trim().length > 0) { return content; } if (Array.isArray(content)) { const textParts = content .map((item) => { if (!item || typeof item !== "object") { return ""; } const block = item as Record; return typeof block.text === "string" ? block.text : ""; }) .filter((item) => item.trim().length > 0); if (textParts.length > 0) { return textParts.join("\n"); } } throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract text from /chat/completions payload.", 502, raw); } function shouldFallbackToChatCompletions(error: unknown): boolean { if (!(error instanceof ApiError)) { return false; } if (error.code === "OPENAI_OUTPUT_PARSE_FAILED" || error.code === "OPENAI_NON_JSON_RESPONSE") { return true; } if (error.code !== "OPENAI_REQUEST_FAILED") { return false; } const details = (error.details ?? {}) as Record; const status = Number(details.status ?? 0); if ([404, 405, 501].includes(status)) { return true; } const message = String(error.message ?? "").toLowerCase(); return message.includes("/responses") || message.includes("responses"); } function extractModelErrorMessage(data: Record): string | null { const rawError = data.error; if (typeof rawError === "string" && rawError.trim().length > 0) { return rawError.trim(); } if (rawError && typeof rawError === "object") { const errorObj = rawError as Record; const message = errorObj.message; if (typeof message === "string" && message.trim().length > 0) { return message.trim(); } } return null; } function isRouteMismatchErrorMessage(message: string): boolean { const source = String(message ?? "").toLowerCase(); if (!source) { return false; } return ( /unexpected endpoint|unexpected route|unknown endpoint|unknown route|unsupported endpoint|unsupported route/.test(source) || (/endpoint/.test(source) && /method/.test(source)) || (/endpoint/.test(source) && /not found/.test(source)) || (/route/.test(source) && /not found/.test(source)) || (/path/.test(source) && /not found/.test(source)) ); } function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"): Record { const schemaFile = schemaVersion === "v1" ? "normalized_query_v1.json" : schemaVersion === "v2_0_1" ? "normalized_query_v2_0_1.json" : schemaVersion === "v2_0_2" ? "normalized_query_v2_0_2.json" : "normalized_query_v2.json"; const schemaPath = path.resolve(SCHEMAS_DIR, schemaFile); return JSON.parse(fs.readFileSync(schemaPath, "utf-8")) as Record; } function buildBaseUrlCandidates(config: OpenAIRequestConfig): string[] { const base = (config.baseUrl ?? DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, ""); const provider = resolveProvider(config); if (provider !== "local") { return [base]; } const hasVersionSuffix = /\/v\d+$/i.test(base); if (hasVersionSuffix) { return [base]; } return Array.from(new Set([base, `${base}/v1`])); } export class OpenAIResponsesClient { public async chat( config: OpenAIRequestConfig, prompt: { systemPrompt?: string; developerPrompt?: string; userMessage: string; maxOutputTokens?: number; temperature?: number; } ): Promise { const responsesPayload = { model: config.model, temperature: prompt.temperature ?? config.temperature ?? 0.2, max_output_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400, input: [ ...(String(prompt.systemPrompt ?? "").trim().length > 0 ? [ { role: "system", content: [{ type: "input_text", text: String(prompt.systemPrompt ?? "").trim() }] } ] : []), ...(String(prompt.developerPrompt ?? "").trim().length > 0 ? [ { role: "developer", content: [{ type: "input_text", text: String(prompt.developerPrompt ?? "").trim() }] } ] : []), { role: "user", content: [{ type: "input_text", text: String(prompt.userMessage ?? "") }] } ] }; const provider = resolveProvider(config); if (provider === "openai") { const raw = await this.postResponses(config, responsesPayload); return { raw, outputText: extractOutputTextFromResponses(raw), usage: extractUsage(raw) }; } try { const raw = await this.postResponses(config, responsesPayload); return { raw, outputText: extractOutputTextFromResponses(raw), usage: extractUsage(raw) }; } catch (error) { if (!shouldFallbackToChatCompletions(error)) { throw error; } } const chatPayload = { model: config.model, temperature: prompt.temperature ?? config.temperature ?? 0.2, max_tokens: prompt.maxOutputTokens ?? config.maxOutputTokens ?? 400, messages: [ ...(String(prompt.systemPrompt ?? "").trim().length > 0 ? [ { role: "system", content: String(prompt.systemPrompt ?? "").trim() } ] : []), ...(String(prompt.developerPrompt ?? "").trim().length > 0 ? [ { role: "developer", content: String(prompt.developerPrompt ?? "").trim() } ] : []), { role: "user", content: String(prompt.userMessage ?? "") } ] }; const raw = await this.postChatCompletions(config, chatPayload); return { raw, outputText: extractOutputTextFromChatCompletions(raw), usage: extractUsage(raw) }; } public async listModels(config: OpenAIRequestConfig): Promise { const payload = await this.getModels(config); const data = Array.isArray(payload.data) ? payload.data : []; const ids = data .map((item) => { if (!item || typeof item !== "object") { return ""; } return String((item as Record).id ?? "").trim(); }) .filter((item) => item.length > 0); return Array.from(new Set(ids)); } public async testConnection(config: OpenAIRequestConfig): Promise<{ ok: boolean; model: string }> { const provider = resolveProvider(config); if (provider === "local") { try { await this.getModels(config); } catch { // Some local providers do not expose /models consistently; fallback to a tiny chat call. await this.postChatCompletions(config, { model: config.model, messages: [{ role: "user", content: "ping" }], max_tokens: 4, temperature: 0 }); } return { ok: true, model: config.model }; } await this.postResponses(config, { model: config.model, input: [{ role: "user", content: [{ type: "input_text", text: "ping" }] }], max_output_tokens: 16 }); return { ok: true, model: config.model }; } public async normalize( config: OpenAIRequestConfig, prompt: { systemPrompt: string; developerPrompt: string; domainPrompt: string; userQuestion: string; schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"; controlledRetryInstruction?: string; } ): Promise { const schema = loadSchemaForTransport(prompt.schemaVersion); const schemaName = prompt.schemaVersion === "v1" ? "normalized_query_v1" : prompt.schemaVersion === "v2_0_1" ? "normalized_query_v2_0_1" : prompt.schemaVersion === "v2_0_2" ? "normalized_query_v2_0_2" : "normalized_query_v2"; const developerPrompt = prompt.controlledRetryInstruction ? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}` : prompt.developerPrompt; const responsesPayload = { model: config.model, temperature: config.temperature ?? 0, max_output_tokens: config.maxOutputTokens ?? 700, input: [ { role: "system", content: [{ type: "input_text", text: prompt.systemPrompt }] }, { role: "developer", content: [{ type: "input_text", text: developerPrompt }] }, { role: "user", content: [ { type: "input_text", text: `${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}` } ] } ], text: { format: { type: "json_schema", name: schemaName, strict: true, schema } } }; const provider = resolveProvider(config); if (provider === "openai") { const raw = await this.postResponses(config, responsesPayload); return { raw, outputText: extractOutputTextFromResponses(raw), usage: extractUsage(raw) }; } // local provider: prefer /responses if available, fallback to /chat/completions try { const raw = await this.postResponses(config, responsesPayload); return { raw, outputText: extractOutputTextFromResponses(raw), usage: extractUsage(raw) }; } catch (error) { if (!shouldFallbackToChatCompletions(error)) { throw error; } } const chatPayload = { model: config.model, temperature: config.temperature ?? 0, max_tokens: config.maxOutputTokens ?? 700, response_format: { type: "json_object" }, messages: [ { role: "system", content: `${prompt.systemPrompt}\n\n${developerPrompt}` }, { role: "user", content: `${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}\n\n` + `Return only JSON that matches schema: ${schemaName}.` } ] }; const raw = await this.postChatCompletions(config, chatPayload); return { raw, outputText: extractOutputTextFromChatCompletions(raw), usage: extractUsage(raw) }; } private async getModels(config: OpenAIRequestConfig): Promise> { return this.requestJson(config, "/models", "GET"); } private async postResponses(config: OpenAIRequestConfig, payload: Record): Promise> { return this.requestJson(config, "/responses", "POST", payload); } private async postChatCompletions( config: OpenAIRequestConfig, payload: Record ): Promise> { return this.requestJson(config, "/chat/completions", "POST", payload); } private async requestJson( config: OpenAIRequestConfig, routePath: string, method: "GET" | "POST", payload?: Record ): Promise> { const apiKey = resolveApiKey(config); const baseCandidates = buildBaseUrlCandidates(config); const canFallbackToAlternativeBase = resolveProvider(config) === "local" && baseCandidates.length > 1; let lastNetworkError: unknown = null; const headers: Record = { Authorization: `Bearer ${apiKey}` }; if (method === "POST") { headers["Content-Type"] = "application/json"; } for (let index = 0; index < baseCandidates.length; index += 1) { const base = baseCandidates[index]; const isLastCandidate = index === baseCandidates.length - 1; const url = `${base}${routePath}`; let response: Response; try { response = await fetch(url, { method, headers, body: method === "POST" ? JSON.stringify(payload ?? {}) : undefined, signal: config.abortSignal }); } catch (error) { lastNetworkError = error; if (!isLastCandidate) { continue; } throw new ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, { route: routePath, url, reason: error instanceof Error ? error.message : String(error) }); } if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) { continue; } const text = await response.text(); let data: Record = {}; if (text.trim().length > 0) { try { data = JSON.parse(text) as Record; } catch { if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) { continue; } throw new ApiError("OPENAI_NON_JSON_RESPONSE", "Model endpoint returned non-JSON response.", 502, { route: routePath, url, status: response.status, body: text.slice(0, 500) }); } } const modelErrorMessage = extractModelErrorMessage(data); if (modelErrorMessage && canFallbackToAlternativeBase && !isLastCandidate && isRouteMismatchErrorMessage(modelErrorMessage)) { continue; } if (!response.ok) { const errorObj = (data.error ?? {}) as Record; throw new ApiError( "OPENAI_REQUEST_FAILED", modelErrorMessage ?? String(errorObj.message ?? `Model endpoint failed: ${response.status}`), response.status, { route: routePath, url, status: response.status, type: errorObj.type ?? null, code: errorObj.code ?? null } ); } if (modelErrorMessage) { throw new ApiError("OPENAI_REQUEST_FAILED", modelErrorMessage, 502, { route: routePath, url, status: response.status }); } return data; } throw new ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, { route: routePath, reason: lastNetworkError instanceof Error ? lastNetworkError.message : String(lastNetworkError ?? "unknown") }); } }