476 lines
18 KiB
JavaScript
476 lines
18 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 chat(config, prompt) {
|
|
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)
|
|
};
|
|
}
|
|
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;
|