АДРЕСНЫЙ РЕЖИМ - локальная подель на декомпозе
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
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;
|
||||
@@ -21,6 +23,22 @@ export interface OpenAIResponseEnvelope {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -37,7 +55,7 @@ function extractUsage(raw: Record<string, unknown>): {
|
||||
};
|
||||
}
|
||||
|
||||
function extractOutputText(raw: Record<string, unknown>): string {
|
||||
function extractOutputTextFromResponses(raw: Record<string, unknown>): string {
|
||||
if (typeof raw.output_text === "string" && raw.output_text.trim().length > 0) {
|
||||
return raw.output_text;
|
||||
}
|
||||
@@ -72,7 +90,58 @@ function extractOutputText(raw: Record<string, unknown>): string {
|
||||
}
|
||||
}
|
||||
|
||||
throw new ApiError("OPENAI_OUTPUT_PARSE_FAILED", "Не удалось извлечь output_text из Responses API ответа.", 502, raw);
|
||||
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_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 loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"): Record<string, unknown> {
|
||||
@@ -83,24 +152,62 @@ function loadSchemaForTransport(schemaVersion: "v1" | "v2" | "v2_0_1" | "v2_0_2"
|
||||
? "normalized_query_v2_0_1.json"
|
||||
: schemaVersion === "v2_0_2"
|
||||
? "normalized_query_v2_0_2.json"
|
||||
: "normalized_query_v2.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 testConnection(config: OpenAIRequestConfig): Promise<{ ok: boolean; model: string }> {
|
||||
const payload = {
|
||||
model: config.model,
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "ping" }]
|
||||
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
|
||||
};
|
||||
await this.post(config, payload);
|
||||
});
|
||||
return { ok: true, model: config.model };
|
||||
}
|
||||
|
||||
@@ -123,13 +230,13 @@ export class OpenAIResponsesClient {
|
||||
? "normalized_query_v2_0_1"
|
||||
: prompt.schemaVersion === "v2_0_2"
|
||||
? "normalized_query_v2_0_2"
|
||||
: "normalized_query_v2";
|
||||
: "normalized_query_v2";
|
||||
|
||||
const developerPrompt = prompt.controlledRetryInstruction
|
||||
? `${prompt.developerPrompt}\n\n${prompt.controlledRetryInstruction}`
|
||||
: prompt.developerPrompt;
|
||||
|
||||
const payload = {
|
||||
const responsesPayload = {
|
||||
model: config.model,
|
||||
temperature: config.temperature ?? 0,
|
||||
max_output_tokens: config.maxOutputTokens ?? 700,
|
||||
@@ -147,7 +254,7 @@ export class OpenAIResponsesClient {
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `${prompt.domainPrompt}\n\nПользовательский вопрос:\n${prompt.userQuestion}`
|
||||
text: `${prompt.domainPrompt}\n\nUser question:\n${prompt.userQuestion}`
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -162,52 +269,157 @@ export class OpenAIResponsesClient {
|
||||
}
|
||||
};
|
||||
|
||||
const raw = await this.post(config, payload);
|
||||
const outputText = extractOutputText(raw);
|
||||
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,
|
||||
outputText: extractOutputTextFromChatCompletions(raw),
|
||||
usage: extractUsage(raw)
|
||||
};
|
||||
}
|
||||
|
||||
private async post(config: OpenAIRequestConfig, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
if (!config.apiKey || config.apiKey.trim().length < 10) {
|
||||
throw new ApiError("OPENAI_API_KEY_MISSING", "API ключ OpenAI не задан или слишком короткий.", 400);
|
||||
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";
|
||||
}
|
||||
|
||||
const url = `${(config.baseUrl ?? DEFAULT_OPENAI_BASE_URL).replace(/\/$/, "")}/responses`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(text) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new ApiError("OPENAI_NON_JSON_RESPONSE", "OpenAI вернул не-JSON ответ.", 502, { status: response.status, body: text.slice(0, 500) });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorObj = (data.error ?? {}) as Record<string, unknown>;
|
||||
throw new ApiError(
|
||||
"OPENAI_REQUEST_FAILED",
|
||||
String(errorObj.message ?? `OpenAI request failed with status ${response.status}`),
|
||||
response.status,
|
||||
{
|
||||
status: response.status,
|
||||
type: errorObj.type ?? null,
|
||||
code: errorObj.code ?? null
|
||||
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
|
||||
});
|
||||
} 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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorObj = (data.error ?? {}) as Record<string, unknown>;
|
||||
throw new ApiError(
|
||||
"OPENAI_REQUEST_FAILED",
|
||||
String(errorObj.message ?? `Model endpoint failed: ${response.status}`),
|
||||
response.status,
|
||||
{
|
||||
route: routePath,
|
||||
url,
|
||||
status: response.status,
|
||||
type: errorObj.type ?? null,
|
||||
code: errorObj.code ?? null
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
return data;
|
||||
throw new ApiError("OPENAI_REQUEST_FAILED", "Model endpoint is unreachable.", 502, {
|
||||
route: routePath,
|
||||
reason: lastNetworkError instanceof Error ? lastNetworkError.message : String(lastNetworkError ?? "unknown")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user