570 lines
18 KiB
TypeScript
570 lines
18 KiB
TypeScript
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<string, unknown>): {
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
total_tokens: number;
|
|
} {
|
|
const usage = (raw.usage ?? {}) as Record<string, unknown>;
|
|
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, unknown>): 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<string, unknown>).content;
|
|
if (!Array.isArray(content)) {
|
|
continue;
|
|
}
|
|
for (const c of content) {
|
|
if (!c || typeof c !== "object") {
|
|
continue;
|
|
}
|
|
const block = c as Record<string, unknown>;
|
|
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<string, unknown>;
|
|
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, unknown>): 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<string, unknown>).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<string, unknown>).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<string, unknown>;
|
|
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<string, unknown>;
|
|
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, unknown>): 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<string, unknown>;
|
|
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<string, unknown> {
|
|
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<string, unknown>;
|
|
}
|
|
|
|
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<OpenAIResponseEnvelope> {
|
|
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<string[]> {
|
|
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<string, unknown>).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<OpenAIResponseEnvelope> {
|
|
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<Record<string, unknown>> {
|
|
return this.requestJson(config, "/models", "GET");
|
|
}
|
|
|
|
private async postResponses(config: OpenAIRequestConfig, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
return this.requestJson(config, "/responses", "POST", payload);
|
|
}
|
|
|
|
private async postChatCompletions(
|
|
config: OpenAIRequestConfig,
|
|
payload: Record<string, unknown>
|
|
): Promise<Record<string, unknown>> {
|
|
return this.requestJson(config, "/chat/completions", "POST", payload);
|
|
}
|
|
|
|
private async requestJson(
|
|
config: OpenAIRequestConfig,
|
|
routePath: string,
|
|
method: "GET" | "POST",
|
|
payload?: Record<string, unknown>
|
|
): Promise<Record<string, unknown>> {
|
|
const apiKey = resolveApiKey(config);
|
|
const baseCandidates = buildBaseUrlCandidates(config);
|
|
const canFallbackToAlternativeBase = resolveProvider(config) === "local" && baseCandidates.length > 1;
|
|
let lastNetworkError: unknown = null;
|
|
|
|
const headers: Record<string, string> = {
|
|
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<string, unknown> = {};
|
|
if (text.trim().length > 0) {
|
|
try {
|
|
data = JSON.parse(text) as Record<string, unknown>;
|
|
} 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<string, unknown>;
|
|
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")
|
|
});
|
|
}
|
|
}
|