Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,9 @@ SMARTCAPTCHA_MODE=disabled
# LLM_AUTH_SCHEME=Bearer
# LLM_MODEL=provider-model-name
# LLM_PROVIDER=provider-id

# Дополнительные обязательные заголовки для любого выбранного провайдера:
# Значение — JSON-объект строк. Храните секретные значения только вне Git.
# Дубликаты без учёта регистра, Authorization, Content-Type, transport/privacy
# headers, Proxy-*, Sec-*, x-folder-id и x-data-logging-enabled запрещены.
# LLM_EXTRA_HEADERS='{"X-Provider-Feature":"example-v1"}'
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ production-модель. Production использует явные параме
Произвольный OpenAI-compatible провайдер настраивается переменными
`LLM_API_URL`, `LLM_API_KEY`, `LLM_AUTH_SCHEME`, `LLM_MODEL`, `LLM_PROVIDER` и
выбранным `LLM_API_PROTOCOL`.

Дополнительные обязательные заголовки провайдера можно задать через
`LLM_EXTRA_HEADERS` — JSON-объект строк, например
`'{"X-Provider-Feature":"example-v1"}'`. Имена и значения проверяются стандартными
HTTP-правилами при запуске. Дубликаты имён без учёта регистра запрещены,
включая повторные JSON-ключи. Нельзя переопределять `Authorization`, `Content-Type`,
служебные transport headers (например, `Host`, `Content-Length`, `Connection`,
`Cookie`), заголовки с префиксами `Proxy-`/`Sec-`, `x-folder-id` и
`x-data-logging-enabled`. Для последних двух используются существующие настройки
каталога и privacy. Значения дополнительных заголовков могут быть секретными:
храните их только в закрытой конфигурации. Ошибка настройки не выводит имена
и значения заголовков, а сами заголовки передаются только настроенному endpoint.
`LLM_API_URL` должен быть окончательным URL: gateway не следует HTTP redirects
в обоих протоколах и возвращает контролируемую ошибку без повторного запроса.
Пустой объект `{}` допустим, но отдельно заданная переменная требует полной
LLM-конфигурации.

Для встроенной Yandex-конфигурации и явного `LLM_PROVIDER=yandex` приложение
передаёт запрет логирования запросов. Его область действия и rollout-условие
описаны в [production runbook](docs/PRODUCTION_RUNTIME.md#отключение-логирования-yandex-ai-studio).
Expand Down
72 changes: 72 additions & 0 deletions apps/web/src/llm-config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { validateHeaderName, validateHeaderValue } from "node:http";
import type { LlmGateway } from "@uo-request-generator/core";
import {
DisabledLlmGateway,
Expand All @@ -19,6 +20,7 @@ const LLM_CONFIGURATION_VARIABLES = [
"LLM_PROVIDER",
"LLM_AUTH_SCHEME",
"LLM_FOLDER_ID",
"LLM_EXTRA_HEADERS",
] as const;
const providerIdentifierSchema = z
.string()
Expand All @@ -35,8 +37,76 @@ const llmEnvironmentSchema = z.object({
LLM_PROVIDER: providerIdentifierSchema.optional(),
LLM_AUTH_SCHEME: z.string().trim().min(1).optional(),
LLM_FOLDER_ID: z.string().trim().min(1).optional(),
LLM_EXTRA_HEADERS: z.string().optional(),
});

const reservedExtraHeaderNames = new Set([
"authorization",
"content-type",
"host",
"content-length",
"transfer-encoding",
"connection",
"keep-alive",
"content-encoding",
"accept-encoding",
"expect",
"te",
"trailer",
"upgrade",
"cookie",
"cookie2",
"set-cookie",
"origin",
"referer",
"via",
"x-folder-id",
"x-data-logging-enabled",
]);

function readExtraHeaders(serialized: string | undefined): Record<string, string> {
if (serialized === undefined) return {};

try {
const parsed: unknown = JSON.parse(serialized);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error();
}
const entries: Array<[string, string]> = [];
for (const [name, value] of Object.entries(parsed)) {
if (typeof value !== "string") throw new Error();
validateHeaderName(name);
validateHeaderValue(name, value);
const normalizedName = name.toLowerCase();
if (
reservedExtraHeaderNames.has(normalizedName) ||
/^(?:proxy-|sec-)/u.test(normalizedName)
) {
throw new Error();
}
entries.push([name, value]);
}

// JSON.parse теряет повторные ключи, включая прежние нестроковые значения.
// Каждая итоговая пара даёт два токена, а дубликат добавляет хотя бы токен имени.
const stringTokens = serialized.match(/"(?:[^"\\]|\\.)*"/gu) ?? [];
if (stringTokens.length !== entries.length * 2) throw new Error();
const names = new Set<string>();
for (let index = 0; index < stringTokens.length; index += 2) {
const token = stringTokens[index];
if (token === undefined) throw new Error();
const name: unknown = JSON.parse(token);
if (typeof name !== "string") throw new Error();
const normalizedName = name.toLowerCase();
if (names.has(normalizedName)) throw new Error();
names.add(normalizedName);
}
return Object.fromEntries(entries);
} catch {
throw new Error("Invalid LLM configuration");
}
}

function isLlmConfigurationAbsent(environment: NodeJS.ProcessEnv): boolean {
return LLM_CONFIGURATION_VARIABLES.every(
(variableName) => environment[variableName] === undefined,
Expand All @@ -62,13 +132,15 @@ export function createLlmGateway(environment: NodeJS.ProcessEnv): LlmGateway {
LLM_FOLDER_ID,
LLM_MODEL,
LLM_PROVIDER,
LLM_EXTRA_HEADERS,
} = environmentValidation.data;

if (LLM_API_KEY === undefined) {
throw new Error("Invalid LLM configuration");
}

const extraHeaders = {
...readExtraHeaders(LLM_EXTRA_HEADERS),
...(LLM_FOLDER_ID === undefined ? {} : { "x-folder-id": LLM_FOLDER_ID }),
...(LLM_API_URL === undefined || LLM_PROVIDER === "yandex"
? { "x-data-logging-enabled": "false" }
Expand Down
143 changes: 143 additions & 0 deletions apps/web/tests/llm-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,149 @@ describe("createLlmGateway", () => {
expect(() => createLlmGateway(environment)).toThrow("Invalid LLM configuration");
});

it.each([
"chat-completions",
"responses",
] as const)("передаёт дополнительные заголовки в %s без изменения авторизации", async (apiProtocol) => {
const fetchMock =
apiProtocol === "responses" ? mockOpenAiResponsesProviderResponse() : mockProviderResponse();
const gateway = createLlmGateway({
...completeCustomConfiguration,
LLM_API_PROTOCOL: apiProtocol,
LLM_EXTRA_HEADERS: JSON.stringify({
"X-Provider-Feature": "synthetic-v1",
"X-Additional-Key": "synthetic-private-value",
}),
});
if (!(gateway instanceof OpenAiCompatibleGateway)) {
throw new Error("Ожидался OpenAiCompatibleGateway");
}
const generation = await gateway.generateRequestWithMetadata({
description: "На площадке не горит свет",
});
expect(JSON.stringify(generation)).not.toContain("X-Additional-Key");
expect(JSON.stringify(generation)).not.toContain("synthetic-private-value");
expect(fetchMock.mock.calls[0]?.[1]?.headers).toEqual({
"Content-Type": "application/json",
Authorization: "Bearer test-private-api-key",
"X-Provider-Feature": "synthetic-v1",
"X-Additional-Key": "synthetic-private-value",
});
});

it("сохраняет встроенные Yandex и privacy headers вместе с дополнительными", async () => {
const fetchMock = mockProviderResponse();
const gateway = createLlmGateway({
LLM_API_KEY: "test-api-key",
LLM_FOLDER_ID: "test-folder-id",
LLM_EXTRA_HEADERS: '{"X-Provider-Feature":"synthetic-v1"}',
});
await gateway.generateRequest({ description: "На площадке не горит свет" });
expect(fetchMock.mock.calls[0]?.[1]?.headers).toEqual({
"Content-Type": "application/json",
Authorization: "Api-Key test-api-key",
"x-folder-id": "test-folder-id",
"x-data-logging-enabled": "false",
"X-Provider-Feature": "synthetic-v1",
});
});

it("не принимает строковое содержимое значения за повторное имя заголовка", () => {
expect(
createLlmGateway({
...completeCustomConfiguration,
LLM_EXTRA_HEADERS: JSON.stringify({
"X-Key": 'value: "X-Key", synthetic',
"X-Other": "value",
}),
}),
).toBeInstanceOf(OpenAiCompatibleGateway);
expect(
createLlmGateway({ ...completeCustomConfiguration, LLM_EXTRA_HEADERS: "{}" }),
).toBeInstanceOf(OpenAiCompatibleGateway);
});

it("считает дополнительные заголовки без остальных настроек неполной конфигурацией", () => {
expect(() => createLlmGateway({ LLM_EXTRA_HEADERS: "{}" })).toThrow(
"Invalid LLM configuration",
);
});

it.each([
"",
"not-json",
"null",
"[]",
'"header"',
'{"X-Key":17}',
'{"X-Key":null}',
'{"X-Key":["value"]}',
'{"X-Key":{"nested":"value"}}',
'{"Invalid Header":"value"}',
JSON.stringify({ "X-Key": "value\r\nInjected: value" }),
'{"X-Key":"Приватный ключ"}',
'{"X-Key":"one","x-key":"two"}',
'{"X-Key":"one","X-Key":"two"}',
'{"X-Key":{},"X-Key":"value"}',
'{"X-Key":false,"X-Key":"okay"}',
String.raw`{"X-Key":"one","\u0078-key":"two"}`,
])("отклоняет недопустимые дополнительные заголовки без provider request: %s", (LLM_EXTRA_HEADERS) => {
const fetchMock = vi.spyOn(globalThis, "fetch");
expect(() => createLlmGateway({ ...completeCustomConfiguration, LLM_EXTRA_HEADERS })).toThrow(
"Invalid LLM configuration",
);
expect(fetchMock).not.toHaveBeenCalled();
});

it.each([
"Authorization",
"Content-Type",
"HOST",
"Content-Length",
"Transfer-Encoding",
"Connection",
"Keep-Alive",
"Content-Encoding",
"Accept-Encoding",
"Expect",
"TE",
"Trailer",
"Upgrade",
"Cookie",
"Cookie2",
"Set-Cookie",
"Origin",
"Referer",
"Via",
"Proxy-Authorization",
"Sec-Fetch-Site",
"X-Folder-Id",
"X-Data-Logging-Enabled",
])("запрещает переопределение служебного заголовка %s", (headerName) => {
const LLM_EXTRA_HEADERS = JSON.stringify({ [headerName]: "synthetic-private-value" });
expect(() => createLlmGateway({ ...completeCustomConfiguration, LLM_EXTRA_HEADERS })).toThrow(
"Invalid LLM configuration",
);
});

it("не раскрывает имя и значение ошибочного заголовка в ошибке", () => {
const headerName = "private-header-sentinel";
const headerValue = "private-value-sentinel\r\nInjected: value";
try {
createLlmGateway({
...completeCustomConfiguration,
LLM_EXTRA_HEADERS: JSON.stringify({ [headerName]: headerValue }),
});
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect(String(error)).toBe("Error: Invalid LLM configuration");
expect(String(error)).not.toContain(headerName);
expect(String(error)).not.toContain("private-value-sentinel");
return;
}
throw new Error("Ожидалась ошибка конфигурации LLM");
});

it("не раскрывает private configuration values в сообщении ошибки", () => {
const privateApiKey = "test-private-api-key-sentinel";
const privateUrl = "https://provider.example/private-endpoint";
Expand Down
7 changes: 6 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,12 @@ Yandex AI настраивается в этом слое как один из O
пакет `llm` не зависит от Yandex AI. Тем же `extraHeaders` конфигурационная
фабрика передаёт обязательный privacy header для Yandex, не добавляя его другим
providers. [Контракт и ограничения](PRODUCTION_RUNTIME.md#отключение-логирования-yandex-ai-studio)
описаны в runbook.
описаны в runbook. Дополнительные provider headers из `LLM_EXTRA_HEADERS`
проверяются на конфигурационной границе до создания gateway. Зарезервированные
заголовки остаются под управлением приложения. Формат и ограничения описаны в
[README](../README.md#настройка-llm-провайдера). Общий fetch запрещает HTTP redirects:
настроенный URL является конечным, а перенаправление завершается контролируемой
сетевой ошибкой без передачи заголовков другому endpoint и без retry.

Только полное отсутствие поддерживаемых LLM-переменных включает
`DisabledLlmGateway`, возвращающий контролируемую ошибку
Expand Down
1 change: 1 addition & 0 deletions packages/llm/src/openai-compatible-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,7 @@ export class OpenAiCompatibleGateway implements LlmGateway {
try {
response = await fetch(this.apiUrl, {
method: "POST",
redirect: "error",
headers: {
"Content-Type": "application/json",
Authorization: `${this.authScheme} ${this.apiKey}`,
Expand Down
63 changes: 63 additions & 0 deletions packages/llm/tests/openai-compatible-gateway.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { once } from "node:events";
import { createServer } from "node:http";
import {
COMMON_AREA_CLEANING_LEGAL_BASIS_MODULE,
COMMON_AREA_DOOR_LEGAL_BASIS_MODULE,
Expand Down Expand Up @@ -1256,6 +1258,67 @@ describe("OpenAiCompatibleGateway", () => {
expect(headers["x-folder-id"]).toBe("test-folder");
});

it("не отправляет дополнительные секретные заголовки при перенаправлении на другой origin", async () => {
let sourceRequests = 0;
let destinationRequests = 0;
let receivedAdditionalKey: string | string[] | undefined;
const destination = createServer((request, response) => {
destinationRequests++;
receivedAdditionalKey = request.headers["x-additional-key"];
request.resume();
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ choices: [{ message: { content: VALID_LLM_TEXT } }] }));
});
destination.listen(0, "127.0.0.1");
await once(destination, "listening");
const destinationAddress = destination.address();
if (destinationAddress === null || typeof destinationAddress === "string") {
throw new Error("Ожидался локальный TCP-сервер");
}
const source = createServer((request, response) => {
sourceRequests++;
request.resume();
response.writeHead(307, {
Location: `http://127.0.0.1:${destinationAddress.port}/redirected`,
});
response.end();
});
source.listen(0, "127.0.0.1");
await once(source, "listening");
try {
const sourceAddress = source.address();
if (sourceAddress === null || typeof sourceAddress === "string") {
throw new Error("Ожидался локальный TCP-сервер");
}
const gateway = new OpenAiCompatibleGateway({
...GATEWAY_CONFIG,
apiUrl: `http://127.0.0.1:${sourceAddress.port}/generate`,
extraHeaders: { "X-Additional-Key": "synthetic-private-value" },
});
let generationError: unknown;
try {
await gateway.generateRequest(VALID_INPUT);
} catch (error) {
generationError = error;
}
expect(sourceRequests).toBe(1);
expect(destinationRequests).toBe(0);
expect(receivedAdditionalKey).toBeUndefined();
expect(generationError).toBeInstanceOf(GenerationNetworkError);
expect(String(generationError)).not.toContain("synthetic-private-value");
expect(String(generationError)).not.toContain("X-Additional-Key");
} finally {
await Promise.all(
[source, destination].map(
(server) =>
new Promise<void>((resolve, reject) => {
server.close((error) => (error === undefined ? resolve() : reject(error)));
}),
),
);
}
});

it("бросает GenerationNetworkError при сетевой ошибке", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));

Expand Down
Loading