"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.OpenAIResponsesClient = void 0; const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const config_1 = require("../config"); const http_1 = require("../utils/http"); function resolveProvider(config) { return config.llmProvider === "local" ? "local" : "openai"; } function resolveApiKey(config) { 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 http_1.ApiError("OPENAI_API_KEY_MISSING", "OpenAI API key is missing.", 400); } function extractUsage(raw) { const usage = (raw.usage ?? {}); 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) { 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.content; if (!Array.isArray(content)) { continue; } for (const c of content) { if (!c || typeof c !== "object") { continue; } const block = c; if (typeof block.text === "string" && block.text.trim()) { return block.text; } } } } const response = raw.response; if (response && typeof response === "object") { const nested = response; if (typeof nested.output_text === "string" && nested.output_text.trim().length > 0) { return nested.output_text; } } throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract output_text from /responses payload.", 502, raw); } function extractOutputTextFromChatCompletions(raw) { const choices = raw.choices; if (!Array.isArray(choices) || choices.length === 0) { throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing choices in /chat/completions payload.", 502, raw); } const first = choices[0]; if (!first || typeof first !== "object") { throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Invalid first choice in /chat/completions payload.", 502, raw); } const message = first.message; if (!message || typeof message !== "object") { throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Missing message in /chat/completions payload.", 502, raw); } const content = message.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; return typeof block.text === "string" ? block.text : ""; }) .filter((item) => item.trim().length > 0); if (textParts.length > 0) { return textParts.join("\n"); } } throw new http_1.ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Failed to extract text from /chat/completions payload.", 502, raw); } function shouldFallbackToChatCompletions(error) { if (!(error instanceof http_1.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 ?? {}); 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) { const rawError = data.error; if (typeof rawError === "string" && rawError.trim().length > 0) { return rawError.trim(); } if (rawError && typeof rawError === "object") { const errorObj = rawError; const message = errorObj.message; if (typeof message === "string" && message.trim().length > 0) { return message.trim(); } } return null; } function isRouteMismatchErrorMessage(message) { 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) { 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_1.default.resolve(config_1.SCHEMAS_DIR, schemaFile); return JSON.parse(fs_1.default.readFileSync(schemaPath, "utf-8")); } function buildBaseUrlCandidates(config) { const base = (config.baseUrl ?? config_1.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`])); } class OpenAIResponsesClient { async listModels(config) { 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.id ?? "").trim(); }) .filter((item) => item.length > 0); return Array.from(new Set(ids)); } async testConnection(config) { 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 }; } async normalize(config, prompt) { 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) }; } async getModels(config) { return this.requestJson(config, "/models", "GET"); } async postResponses(config, payload) { return this.requestJson(config, "/responses", "POST", payload); } async postChatCompletions(config, payload) { return this.requestJson(config, "/chat/completions", "POST", payload); } async requestJson(config, routePath, method, payload) { const apiKey = resolveApiKey(config); const baseCandidates = buildBaseUrlCandidates(config); const canFallbackToAlternativeBase = resolveProvider(config) === "local" && baseCandidates.length > 1; let lastNetworkError = null; const headers = { 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; try { response = await fetch(url, { method, headers, body: method === "POST" ? JSON.stringify(payload ?? {}) : undefined }); } catch (error) { lastNetworkError = error; if (!isLastCandidate) { continue; } throw new http_1.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 = {}; if (text.trim().length > 0) { try { data = JSON.parse(text); } catch { if (!response.ok && canFallbackToAlternativeBase && !isLastCandidate && [404, 405].includes(response.status)) { continue; } throw new http_1.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 ?? {}); throw new http_1.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 http_1.ApiError("OPENAI_REQUEST_FAILED", modelErrorMessage, 502, { route: routePath, url, status: response.status }); } return data; } throw new http_1.ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, { route: routePath, reason: lastNetworkError instanceof Error ? lastNetworkError.message : String(lastNetworkError ?? "unknown") }); } } exports.OpenAIResponsesClient = OpenAIResponsesClient;