NODEDC_1C/llm_normalizer/backend/dist/services/openaiResponsesClient.js

392 lines
15 KiB
JavaScript

"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;