АДРЕСНЫЙ РЕЖИМ - локальная подель на декомпозе

This commit is contained in:
2026-04-01 17:55:02 +03:00
parent 4060a5e575
commit 4d59672576
90 changed files with 19595 additions and 785 deletions
@@ -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")
});
}
}