gigachat-openai-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # gigachat-openai-client
2
+
3
+ **gigachat-openai-client** — npm-библиотека для Node.js: доступ к API GigaChat в стиле OpenAI Chat Completions (`models`, `chat/completions` относительно `baseUrl`). Реализованы OAuth (официальный облачный эндпоинт), прямой Basic Auth на произвольный URL, заготовка под mTLS, общая обработка TLS (корпоративные CA, отладка), трассировка и отмена запросов.
4
+
5
+ Исходный код клиента — [`llm-client.ts`](./llm-client.ts); после **`npm run build`** публикуется как ESM-модуль из каталога **`dist/`**.
6
+
7
+ ### Установка
8
+
9
+ ```bash
10
+ npm install gigachat-openai-client
11
+ ```
12
+
13
+ ### Пример
14
+
15
+ ```typescript
16
+ import { ChatClientFactory } from "gigachat-openai-client";
17
+
18
+ const client = ChatClientFactory.create({
19
+ baseUrl: "https://gigachat.devices.sberbank.ru/api/v1/",
20
+ apiToken: process.env.GIGACHAT_AI_TOKEN!,
21
+ });
22
+ ```
23
+
24
+ ### Сборка и публикация (для разработчиков пакета)
25
+
26
+ **`npm run build`** — компиляция в **`dist/`**. Перед **`npm publish`** выполняется **`prepublishOnly`**. Проверить содержимое tarball без выгрузки: **`npm run pack:dry`**.
27
+
28
+ ---
29
+
30
+ ## Назначение `llm-client.ts`
31
+
32
+ Файл объединяет:
33
+
34
+ 1. **Типы запросов/ответов** — описание тел JSON в формате, близком к OpenAI Chat Completions (`ChatRequest`, `ChatResponse`, сообщения, function calling).
35
+ 2. **Три реализации клиента** — разные схемы авторизации к одному и тому же стилю URL.
36
+ 3. **Фабрику** — автоматический выбор реализации по базовому URL и наличию токена.
37
+ 4. **Общую инфраструктуру** — трассировка вызовов, отмена долгих запросов, TLS для корпоративных сетей, сериализация тела запроса под особенности GigaChat.
38
+
39
+ Ниже — как это устроено по слоям.
40
+
41
+ ---
42
+
43
+ ## Архитектура (обзор)
44
+
45
+ ```mermaid
46
+ flowchart TB
47
+ subgraph factory [ChatClientFactory.create]
48
+ A{Есть apiToken?}
49
+ B[OAuthGigaChatClient]
50
+ C[BasicAuthChatClient]
51
+ D[MtlsChatClient]
52
+ end
53
+ A -->|да и URL = официальный api/v1/| B
54
+ A -->|да и другой URL| C
55
+ A -->|нет| D
56
+ B --> L[ChatApiClient.fetchJson + OAuth]
57
+ C --> L
58
+ D --> L
59
+ ```
60
+
61
+ - Все конкретные клиенты наследуют **`ChatApiClient`** и реализуют `getModels()` и `postChatCompletions()`.
62
+ - HTTP к API идёт через **`fetchJson`**: единая точка для `fetch`, TLS, трассировки и ошибок.
63
+
64
+ ---
65
+
66
+ ## Фабрика: `ChatClientFactory`
67
+
68
+ Опции — **`ChatClientFactoryOptions`**:
69
+
70
+ | Поле | Роль |
71
+ |------|------|
72
+ | `baseUrl` | Базовый URL API (со слэшем на конце или без — внутри нормализуется к виду `…/`) |
73
+ | `apiToken` | Строка токена или `null`/`undefined` |
74
+ | `clientCertificatePath`, `clientPrivateKeyPath` | Пути к клиентскому сертификату и ключу (только при ветке без токена) |
75
+
76
+ **Правила выбора класса:**
77
+
78
+ 1. **`apiToken` задан** и после нормализации URL **строго равен**
79
+ `https://gigachat.devices.sberbank.ru/api/v1/`
80
+ → **`OAuthGigaChatClient`**.
81
+ Значение — **Basic credentials для OAuth** (не готовый Bearer): см. ниже.
82
+
83
+ 2. **`apiToken` задан**, но URL **другой** (прокси, другой хост, другой путь)
84
+ → **`BasicAuthChatClient`**: тот же токен в заголовке `Authorization: Basic …` на ваш `baseUrl`.
85
+
86
+ 3. **`apiToken` не задан**
87
+ → **`MtlsChatClient`** (клиентский сертификат). Сейчас **`buildMtlsFetchExtras()`** возвращает `{}` — для Node нужно подставить `dispatcher`/`https.Agent` с `cert`/`key` из путей в конфиге.
88
+
89
+ ---
90
+
91
+ ## Базовый класс: `ChatApiClient`
92
+
93
+ - **Нормализация URL**: в конструкторе к `baseUrl` добавляется завершающий `/`, если его не было.
94
+ - **`fetchJson(url, init, signal?)`**:
95
+ - Проверяет **`Cancelable`** (если передан): перед запросом и после — `checkCanceled()`.
96
+ - Пишет **трассировку** в **`HttpCallTraceStore`**: URL, тело запроса (форматирование JSON, если возможно), ответ, статус, длительность; при ошибке сети — запись с `error`.
97
+ - Вызывает **`fetch`** с объединением опций: сначала **`tlsFetchInit()`** (кастомный TLS через `undici.Agent`), затем ваши `init`, затем `signal`.
98
+ - Раз в **500 ms** проверяет отмену: если `cancelable` перевёлся в «отменено», **абортирует** `AbortController`, чтобы прервать `fetch`.
99
+ - Успех: `response.ok` → парсит JSON в тип `T`. Иначе бросает **`HttpError`** с телом ответа, кодом статуса и URL.
100
+
101
+ Ошибки сети оборачиваются в `Error("HTTP request failed: …")` с **`cause`**, после записи в трассировку.
102
+
103
+ ---
104
+
105
+ ## TLS: `tlsFetchInit` и переменные окружения
106
+
107
+ Встроенный **`fetch`** в Node использует **undici**. Для корпоративных CA или отладки TLS добавлен общий **`Agent`**:
108
+
109
+ | Переменная | Поведение |
110
+ |------------|-----------|
111
+ | **`GIGACHAT_TLS_CA_FILE`** | Путь к PEM-файлу (абсолютный или относительно `process.cwd()`). Содержимое **добавляется** к **`tls.rootCertificates`**; проверка сертификата **включена**. Имеет приоритет над «небезопасным» режимом. |
112
+ | **`GIGACHAT_TLS_INSECURE`** | Значения `1` или `true`: **`rejectUnauthorized: false`** — цепочка не проверяется. **Только если CA-файл не задан.** Небезопасно, только для отладки/изолированной сети. |
113
+
114
+ Агенты кэшируются (отдельно для CA-пути и для insecure-режима).
115
+
116
+ Дополнительно в среде можно использовать стандарт Node **`NODE_EXTRA_CA_CERTS`** (файл с доп. CA), если загрузка окружения происходит до первых TLS-запросов.
117
+
118
+ **Важно:** запрос OAuth в **`OAuthGigaChatClient.ensureAccessToken`** тоже использует **`...tlsFetchInit()`** — те же правила TLS действуют и на `https://ngw.devices.sberbank.ru:...`, и на запросы к `baseUrl`.
119
+
120
+ ---
121
+
122
+ ## `OAuthGigaChatClient` (официальный облачный GigaChat)
123
+
124
+ - **OAuth**: `POST` на `https://ngw.devices.sberbank.ru:9443/api/v2/oauth` (константа `SBER_OAUTH_TOKEN_URL` в коде).
125
+ Тело: `application/x-www-form-urlencoded`, поле `scope=GIGACHAT_API_PERS`.
126
+ Заголовки: `Authorization: Basic` + секрет, `RqUID` — случайный UUID (`randomUUID()` из `node:crypto`).
127
+ - Ответ JSON: `access_token`, `expires_at` (мс). Пока текущее время меньше `expires_at`, повторный OAuth не делается.
128
+ - Запросы к **`baseUrl`** (`…/models`, `…/chat/completions`): **`Authorization: Bearer`** + полученный `access_token`.
129
+
130
+ В `GIGACHAT_AI_TOKEN` / **`ChatClientFactoryOptions.apiToken`** для этой ветки нужен **ключ в формате Basic** (как выдаёт Сбер для API), а не уже готовый Bearer.
131
+
132
+ ---
133
+
134
+ ## `BasicAuthChatClient`
135
+
136
+ - На **любой** `baseUrl` отправляет **`Authorization: Basic`** с вашим токеном для обоих методов.
137
+ - Подходит для совместимых шлюзов, где не нужен отдельный OAuth к `ngw.devices.sberbank.ru`.
138
+
139
+ ---
140
+
141
+ ## `MtlsChatClient`
142
+
143
+ - Сценарий **без** `apiToken`, с **`clientCertificatePath` / `clientPrivateKeyPath`** в опциях фабрики.
144
+ - **`buildMtlsFetchExtras()`** сейчас возвращает `{}` — заготовка под **`dispatcher`** с клиентским сертификатом в Node.
145
+
146
+ ---
147
+
148
+ ## Сериализация тела запроса
149
+
150
+ Внутренние функции **`serializeChatRequestBody`** / **`serializeChatMessageForApi`**:
151
+
152
+ - В JSON всегда попадает поле **`content`**, в т.ч. **`null`** — так ожидает GigaChat для части сценариев.
153
+ - Для **function call** в TypeScript **`arguments`** хранится **строкой** (как JSON), а в тело запроса уходит **распарсенный объект** (`parseFunctionArgumentsJson`), потому что GigaChat ожидает **объект**, а не строку.
154
+
155
+ Поля `functions` и `function_call` в корне запроса добавляются только если они заданы (не `null`/`undefined`).
156
+
157
+ ---
158
+
159
+ ## Трассировка: `HttpCallTraceStore`
160
+
161
+ Глобальный буфер (до **500** последних записей типа **`HttpCallTrace`**): идентификатор, время, URL, JSON запроса/ответа, HTTP-статус, длительность, при сбое — текст ошибки. Методы: **`nextTraceId`**, **`record`**, **`getAll`**, **`clear`**.
162
+
163
+ ---
164
+
165
+ ## Отмена: `Cancelable` / `CancelFlag`
166
+
167
+ Опционально передаётся в конструкторы клиентов. При вызове **`cancel()`** последующие **`checkCanceled()`** бросают ошибку, а активный **`fetch`** стараются прервать через **AbortSignal**.
168
+
169
+ ---
170
+
171
+ ## Ошибки: `HttpError`
172
+
173
+ Наследник `Error`: помимо сообщения (часто тело ответа сервера) доступны **`statusCode`** и **`url`**.
174
+
175
+ ---
176
+
177
+ ## Связь с `llm-test.ts`
178
+
179
+ Скрипт загружает **`.env`** (`dotenv/config`), читает переменные и вызывает **`ChatClientFactory.create({ baseUrl: …, apiToken: … })`**, затем **`postChatCompletions`**. Переменные окружения для теста:
180
+
181
+ | Переменная | Назначение |
182
+ |------------|------------|
183
+ | `GIGACHAT_AI_TOKEN` | Обязательна для текущего сценария |
184
+ | `GIGACHAT_AI_URL` | По умолчанию официальный `…/api/v1/` |
185
+ | `GIGACHAT_MODEL`, `GIGACHAT_PROMPT` | Модель и текст запроса |
186
+ | `GIGACHAT_TLS_CA_FILE`, `GIGACHAT_TLS_INSECURE` | См. раздел TLS |
187
+
188
+ Запуск (нужен Node с поддержкой загрузки `.ts`, например флаг типов):
189
+
190
+ ```bash
191
+ npm test
192
+ # или
193
+ node --experimental-transform-types llm-test.ts
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Зависимости, влияющие на `llm-client.ts`
199
+
200
+ - **`undici`** — `Agent` для кастомного TLS в **`fetch`**.
201
+ - Остальное — модули Node: `fs`, `path`, `tls`, `crypto` (`randomUUID`).
202
+
203
+ ---
204
+
205
+ ## Краткая шпаргалка по выбору режима
206
+
207
+ | Условие | Клиент |
208
+ |---------|--------|
209
+ | URL = `https://gigachat.devices.sberbank.ru/api/v1/` и есть токен | `OAuthGigaChatClient` (OAuth → Bearer) |
210
+ | Другой URL и есть токен | `BasicAuthChatClient` (Basic) |
211
+ | Нет токена | `MtlsChatClient` (mTLS — доработать `buildMtlsFetchExtras`) |
212
+
213
+ Если документация расходится с кодом, ориентируйтесь на актуальную реализацию в **`llm-client.ts`** (в опубликованном пакете — на собранные файлы в **`dist/`**).
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Клиенты для GigaChat-совместимого API (`/models`, `/chat/completions`).
3
+ *
4
+ * **Реализации:** {@link ChatApiClient} — база; {@link OAuthGigaChatClient} — OAuth (официальный URL + Basic-ключ);
5
+ * {@link BasicAuthChatClient} — `Authorization: Basic` на произвольный URL; {@link MtlsChatClient} — mTLS (см. `buildMtlsFetchExtras`).
6
+ *
7
+ * **TLS:** `GIGACHAT_TLS_CA_FILE`, `GIGACHAT_TLS_INSECURE` — см. {@link tlsFetchInit}.
8
+ */
9
+ export interface PropertySchema {
10
+ type: string;
11
+ description: string;
12
+ }
13
+ export interface FunctionParameters {
14
+ type: "object";
15
+ properties: Record<string, PropertySchema>;
16
+ required?: string[];
17
+ }
18
+ export interface FunctionDefinition {
19
+ name: string;
20
+ description: string;
21
+ parameters: FunctionParameters;
22
+ }
23
+ /**
24
+ * GigaChat отправляет `arguments` как JSON-объект, OpenAI — как JSON-строку.
25
+ * В TypeScript храним всегда как строку, сериализуем в объект при отправке.
26
+ */
27
+ export interface FunctionCall {
28
+ name: string;
29
+ /** JSON-строка аргументов, например '{"path":"/foo"}' */
30
+ arguments: string;
31
+ }
32
+ export interface ChatMessage {
33
+ role: "system" | "user" | "assistant" | "function";
34
+ /** null разрешён — GigaChat требует явное поле в assistant+FC сообщении */
35
+ content: string | null;
36
+ function_call?: FunctionCall;
37
+ /** Имя функции (для role="function") */
38
+ name?: string;
39
+ }
40
+ export interface ChatRequest {
41
+ model: string;
42
+ messages: ChatMessage[];
43
+ temperature: number;
44
+ max_tokens: number;
45
+ /** null → поле не отправляется */
46
+ functions?: FunctionDefinition[];
47
+ /** "auto" | "none" */
48
+ function_call?: string;
49
+ }
50
+ export interface ResponseChoice {
51
+ message?: ChatMessage;
52
+ index: number;
53
+ finish_reason?: string;
54
+ }
55
+ export interface Usage {
56
+ prompt_tokens?: number;
57
+ completion_tokens?: number;
58
+ total_tokens?: number;
59
+ }
60
+ export interface ChatResponse {
61
+ choices: ResponseChoice[];
62
+ model?: string;
63
+ usage?: Usage;
64
+ }
65
+ export interface ModelInfo {
66
+ id: string;
67
+ object?: string;
68
+ owned_by?: string;
69
+ }
70
+ export interface ModelsResponse {
71
+ data: ModelInfo[];
72
+ }
73
+ export interface TokenResponse {
74
+ access_token: string;
75
+ expires_at: number;
76
+ }
77
+ export interface HttpCallTrace {
78
+ id: number;
79
+ timestamp: Date;
80
+ url: string;
81
+ requestJson: string;
82
+ responseJson: string;
83
+ statusCode: number;
84
+ durationMs: number;
85
+ error?: string;
86
+ }
87
+ export declare const HttpCallTraceStore: {
88
+ nextTraceId: () => number;
89
+ record(trace: HttpCallTrace): void;
90
+ getAll(): readonly HttpCallTrace[];
91
+ clear(): void;
92
+ };
93
+ export interface Cancelable {
94
+ isCanceled(): boolean;
95
+ checkCanceled(): void;
96
+ }
97
+ /** Флаг отмены: вызовите `cancel()`, затем `checkCanceled()` прервёт ожидание. */
98
+ export declare class CancelFlag implements Cancelable {
99
+ private _canceled;
100
+ cancel(): void;
101
+ isCanceled(): boolean;
102
+ checkCanceled(): void;
103
+ }
104
+ /** Нормализация base URL, TLS, трассировка, отмена. */
105
+ export declare abstract class ChatApiClient {
106
+ protected readonly baseUrl: string;
107
+ protected readonly cancelable?: Cancelable;
108
+ constructor(baseUrl: string, cancelable?: Cancelable);
109
+ abstract getModels(): Promise<ModelsResponse>;
110
+ abstract postChatCompletions(request: ChatRequest): Promise<ChatResponse>;
111
+ /** Один HTTP-запрос с JSON-ответом, трассировкой и поддержкой {@link tlsFetchInit}. */
112
+ protected fetchJson<T>(url: string, init: RequestInit, signal?: AbortSignal): Promise<T>;
113
+ }
114
+ export declare class HttpError extends Error {
115
+ readonly statusCode: number;
116
+ readonly url: string;
117
+ constructor(message: string, statusCode: number, url: string);
118
+ }
119
+ /** Basic-ключ → OAuth на `ngw`, затем Bearer к `baseUrl`. */
120
+ export declare class OAuthGigaChatClient extends ChatApiClient {
121
+ private readonly oauthBasicSecret;
122
+ private accessToken;
123
+ private accessTokenExpiresAtMs;
124
+ constructor(baseUrl: string, oauthBasicSecret: string, cancelable?: Cancelable);
125
+ private ensureAccessToken;
126
+ getModels(): Promise<ModelsResponse>;
127
+ postChatCompletions(request: ChatRequest): Promise<ChatResponse>;
128
+ }
129
+ /** Тот же токен в `Authorization: Basic` для любого совместимого `baseUrl`. */
130
+ export declare class BasicAuthChatClient extends ChatApiClient {
131
+ private readonly basicAuthToken;
132
+ constructor(baseUrl: string, basicAuthToken: string, cancelable?: Cancelable);
133
+ getModels(): Promise<ModelsResponse>;
134
+ postChatCompletions(request: ChatRequest): Promise<ChatResponse>;
135
+ }
136
+ export interface MtlsChatClientConfig {
137
+ baseUrl: string;
138
+ /** Путь к PEM сертификата или base64-PEM */
139
+ certificatePath?: string;
140
+ privateKeyPath?: string;
141
+ cancelable?: Cancelable;
142
+ }
143
+ /**
144
+ * Доступ по клиентскому сертификату.
145
+ * Реализуйте {@link MtlsChatClient.buildMtlsFetchExtras} (например `dispatcher` с `https.Agent`).
146
+ */
147
+ export declare class MtlsChatClient extends ChatApiClient {
148
+ private readonly certificatePath?;
149
+ private readonly privateKeyPath?;
150
+ constructor(config: MtlsChatClientConfig);
151
+ getModels(): Promise<ModelsResponse>;
152
+ postChatCompletions(request: ChatRequest): Promise<ChatResponse>;
153
+ /** Доп. опции `fetch` для mTLS в Node (сейчас пусто). */
154
+ protected buildMtlsFetchExtras(): Record<string, unknown>;
155
+ }
156
+ /** Параметры для {@link ChatClientFactory.create}. */
157
+ export interface ChatClientFactoryOptions {
158
+ /** Базовый URL API (со слэшем или без). */
159
+ baseUrl: string;
160
+ /**
161
+ * Для официального `…/api/v1/` — секрет для Basic OAuth;
162
+ * для другого URL — значение для `Authorization: Basic`.
163
+ */
164
+ apiToken?: string | null;
165
+ clientCertificatePath?: string | null;
166
+ clientPrivateKeyPath?: string | null;
167
+ }
168
+ /**
169
+ * Официальный `…/api/v1/` + `apiToken` → {@link OAuthGigaChatClient};
170
+ * иной URL + `apiToken` → {@link BasicAuthChatClient};
171
+ * без токена → {@link MtlsChatClient}.
172
+ */
173
+ export declare class ChatClientFactory {
174
+ static create(options: ChatClientFactoryOptions, cancelable?: Cancelable): ChatApiClient;
175
+ }
176
+ //# sourceMappingURL=llm-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"llm-client.d.ts","sourceRoot":"","sources":["../llm-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Клиенты для GigaChat-совместимого API (`/models`, `/chat/completions`).
3
+ *
4
+ * **Реализации:** {@link ChatApiClient} — база; {@link OAuthGigaChatClient} — OAuth (официальный URL + Basic-ключ);
5
+ * {@link BasicAuthChatClient} — `Authorization: Basic` на произвольный URL; {@link MtlsChatClient} — mTLS (см. `buildMtlsFetchExtras`).
6
+ *
7
+ * **TLS:** `GIGACHAT_TLS_CA_FILE`, `GIGACHAT_TLS_INSECURE` — см. {@link tlsFetchInit}.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import tls from "node:tls";
12
+ import { randomUUID } from "node:crypto";
13
+ import { Agent } from "undici";
14
+ let _tlsInsecureAgent;
15
+ let _tlsCaAgent;
16
+ let _tlsCaResolvedPath;
17
+ function envTruthy(v) {
18
+ return v === "1" || v === "true";
19
+ }
20
+ /**
21
+ * Опции TLS для встроенного `fetch`: дополнительный PEM (`GIGACHAT_TLS_CA_FILE`) или отключение проверки (`GIGACHAT_TLS_INSECURE`).
22
+ * Приоритет у CA-файла.
23
+ */
24
+ function tlsFetchInit() {
25
+ const caRaw = process.env.GIGACHAT_TLS_CA_FILE?.trim();
26
+ if (caRaw) {
27
+ const resolved = path.isAbsolute(caRaw) ? caRaw : path.resolve(process.cwd(), caRaw);
28
+ if (_tlsCaAgent && _tlsCaResolvedPath === resolved)
29
+ return { dispatcher: _tlsCaAgent };
30
+ const extra = fs.readFileSync(resolved, "utf8");
31
+ _tlsCaAgent = new Agent({
32
+ connect: { ca: [...tls.rootCertificates, extra], rejectUnauthorized: true },
33
+ });
34
+ _tlsCaResolvedPath = resolved;
35
+ return { dispatcher: _tlsCaAgent };
36
+ }
37
+ if (!envTruthy(process.env.GIGACHAT_TLS_INSECURE))
38
+ return {};
39
+ if (!_tlsInsecureAgent) {
40
+ _tlsInsecureAgent = new Agent({ connect: { rejectUnauthorized: false } });
41
+ }
42
+ return { dispatcher: _tlsInsecureAgent };
43
+ }
44
+ let _traceSeq = 0;
45
+ const _httpTraces = [];
46
+ export const HttpCallTraceStore = {
47
+ nextTraceId: () => ++_traceSeq,
48
+ record(trace) {
49
+ _httpTraces.push(trace);
50
+ if (_httpTraces.length > 500)
51
+ _httpTraces.shift();
52
+ },
53
+ getAll() {
54
+ return _httpTraces;
55
+ },
56
+ clear() {
57
+ _httpTraces.splice(0);
58
+ },
59
+ };
60
+ /** Флаг отмены: вызовите `cancel()`, затем `checkCanceled()` прервёт ожидание. */
61
+ export class CancelFlag {
62
+ _canceled = false;
63
+ cancel() { this._canceled = true; }
64
+ isCanceled() { return this._canceled; }
65
+ checkCanceled() {
66
+ if (this._canceled)
67
+ throw new Error("Operation was canceled");
68
+ }
69
+ }
70
+ // --- Базовый клиент ---
71
+ /** Нормализация base URL, TLS, трассировка, отмена. */
72
+ export class ChatApiClient {
73
+ baseUrl;
74
+ cancelable;
75
+ constructor(baseUrl, cancelable) {
76
+ this.baseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
77
+ this.cancelable = cancelable;
78
+ }
79
+ /** Один HTTP-запрос с JSON-ответом, трассировкой и поддержкой {@link tlsFetchInit}. */
80
+ async fetchJson(url, init, signal) {
81
+ this.cancelable?.checkCanceled();
82
+ const traceId = HttpCallTraceStore.nextTraceId();
83
+ const startedAt = new Date();
84
+ const requestJson = init.body ? formatJsonIfPossible(init.body.toString()) : "";
85
+ try {
86
+ const controller = new AbortController();
87
+ const mergedSignal = signal ?? controller.signal;
88
+ const cancelPoll = setInterval(() => {
89
+ if (this.cancelable?.isCanceled())
90
+ controller.abort();
91
+ }, 500);
92
+ let response;
93
+ try {
94
+ response = await fetch(url, { ...tlsFetchInit(), ...init, signal: mergedSignal });
95
+ }
96
+ finally {
97
+ clearInterval(cancelPoll);
98
+ }
99
+ this.cancelable?.checkCanceled();
100
+ const bodyText = await response.text();
101
+ const durationMs = Date.now() - startedAt.getTime();
102
+ HttpCallTraceStore.record({
103
+ id: traceId,
104
+ timestamp: startedAt,
105
+ url,
106
+ requestJson,
107
+ responseJson: formatJsonIfPossible(bodyText),
108
+ statusCode: response.status,
109
+ durationMs,
110
+ });
111
+ if (response.ok) {
112
+ return JSON.parse(bodyText);
113
+ }
114
+ throw new HttpError(bodyText, response.status, url);
115
+ }
116
+ catch (err) {
117
+ if (err instanceof HttpError)
118
+ throw err;
119
+ const durationMs = Date.now() - startedAt.getTime();
120
+ const message = err instanceof Error ? err.message : String(err);
121
+ HttpCallTraceStore.record({
122
+ id: traceId,
123
+ timestamp: startedAt,
124
+ url,
125
+ requestJson,
126
+ responseJson: "",
127
+ statusCode: 0,
128
+ durationMs,
129
+ error: message,
130
+ });
131
+ throw new Error(`HTTP request failed: ${message}`, { cause: err });
132
+ }
133
+ }
134
+ }
135
+ export class HttpError extends Error {
136
+ statusCode;
137
+ url;
138
+ constructor(message, statusCode, url) {
139
+ super(message);
140
+ this.name = "HttpError";
141
+ this.statusCode = statusCode;
142
+ this.url = url;
143
+ }
144
+ }
145
+ function formatJsonIfPossible(text) {
146
+ try {
147
+ return JSON.stringify(JSON.parse(text), null, 2);
148
+ }
149
+ catch {
150
+ return text;
151
+ }
152
+ }
153
+ // --- OAuth: официальный облачный GigaChat ---
154
+ const SBER_OAUTH_TOKEN_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth";
155
+ /** Basic-ключ → OAuth на `ngw`, затем Bearer к `baseUrl`. */
156
+ export class OAuthGigaChatClient extends ChatApiClient {
157
+ oauthBasicSecret;
158
+ accessToken = "";
159
+ accessTokenExpiresAtMs = 0;
160
+ constructor(baseUrl, oauthBasicSecret, cancelable) {
161
+ super(baseUrl, cancelable);
162
+ this.oauthBasicSecret = oauthBasicSecret;
163
+ }
164
+ async ensureAccessToken() {
165
+ if (this.accessTokenExpiresAtMs > Date.now())
166
+ return;
167
+ const formBody = new URLSearchParams({ scope: "GIGACHAT_API_PERS" });
168
+ const requestId = randomUUID();
169
+ const response = await fetch(SBER_OAUTH_TOKEN_URL, {
170
+ ...tlsFetchInit(),
171
+ method: "POST",
172
+ headers: {
173
+ "Content-Type": "application/x-www-form-urlencoded",
174
+ Accept: "application/json",
175
+ RqUID: requestId,
176
+ Authorization: `Basic ${this.oauthBasicSecret}`,
177
+ },
178
+ body: formBody.toString(),
179
+ });
180
+ if (!response.ok) {
181
+ throw new HttpError(await response.text(), response.status, SBER_OAUTH_TOKEN_URL);
182
+ }
183
+ const token = await response.json();
184
+ this.accessToken = token.access_token;
185
+ this.accessTokenExpiresAtMs = token.expires_at;
186
+ }
187
+ async getModels() {
188
+ await this.ensureAccessToken();
189
+ return this.fetchJson(this.baseUrl + "models", {
190
+ method: "GET",
191
+ headers: {
192
+ Accept: "application/json",
193
+ Authorization: `Bearer ${this.accessToken}`,
194
+ },
195
+ });
196
+ }
197
+ async postChatCompletions(request) {
198
+ await this.ensureAccessToken();
199
+ return this.fetchJson(this.baseUrl + "chat/completions", {
200
+ method: "POST",
201
+ headers: {
202
+ Accept: "application/json",
203
+ "Content-Type": "application/json",
204
+ Authorization: `Bearer ${this.accessToken}`,
205
+ },
206
+ body: serializeChatRequestBody(request),
207
+ });
208
+ }
209
+ }
210
+ // --- Basic Auth на произвольный хост ---
211
+ /** Тот же токен в `Authorization: Basic` для любого совместимого `baseUrl`. */
212
+ export class BasicAuthChatClient extends ChatApiClient {
213
+ basicAuthToken;
214
+ constructor(baseUrl, basicAuthToken, cancelable) {
215
+ super(baseUrl, cancelable);
216
+ this.basicAuthToken = basicAuthToken;
217
+ }
218
+ async getModels() {
219
+ return this.fetchJson(this.baseUrl + "models", {
220
+ method: "GET",
221
+ headers: {
222
+ Accept: "application/json",
223
+ Authorization: `Basic ${this.basicAuthToken}`,
224
+ },
225
+ });
226
+ }
227
+ async postChatCompletions(request) {
228
+ return this.fetchJson(this.baseUrl + "chat/completions", {
229
+ method: "POST",
230
+ headers: {
231
+ Accept: "application/json",
232
+ "Content-Type": "application/json",
233
+ Authorization: `Basic ${this.basicAuthToken}`,
234
+ },
235
+ body: serializeChatRequestBody(request),
236
+ });
237
+ }
238
+ }
239
+ /**
240
+ * Доступ по клиентскому сертификату.
241
+ * Реализуйте {@link MtlsChatClient.buildMtlsFetchExtras} (например `dispatcher` с `https.Agent`).
242
+ */
243
+ export class MtlsChatClient extends ChatApiClient {
244
+ certificatePath;
245
+ privateKeyPath;
246
+ constructor(config) {
247
+ super(config.baseUrl, config.cancelable);
248
+ this.certificatePath = config.certificatePath;
249
+ this.privateKeyPath = config.privateKeyPath;
250
+ }
251
+ async getModels() {
252
+ return this.fetchJson(this.baseUrl + "models", {
253
+ method: "GET",
254
+ headers: { Accept: "application/json" },
255
+ ...this.buildMtlsFetchExtras(),
256
+ });
257
+ }
258
+ async postChatCompletions(request) {
259
+ return this.fetchJson(this.baseUrl + "chat/completions", {
260
+ method: "POST",
261
+ headers: {
262
+ Accept: "application/json",
263
+ "Content-Type": "application/json",
264
+ },
265
+ body: serializeChatRequestBody(request),
266
+ ...this.buildMtlsFetchExtras(),
267
+ });
268
+ }
269
+ /** Доп. опции `fetch` для mTLS в Node (сейчас пусто). */
270
+ buildMtlsFetchExtras() {
271
+ return {};
272
+ }
273
+ }
274
+ // --- Тело запроса (GigaChat: `arguments` — объект, `content` может быть `null`) ---
275
+ function serializeChatMessageForApi(msg) {
276
+ const out = {
277
+ role: msg.role,
278
+ content: msg.content ?? null,
279
+ };
280
+ if (msg.function_call) {
281
+ out.function_call = {
282
+ name: msg.function_call.name,
283
+ arguments: parseFunctionArgumentsJson(msg.function_call.arguments),
284
+ };
285
+ }
286
+ if (msg.name != null)
287
+ out.name = msg.name;
288
+ return out;
289
+ }
290
+ function parseFunctionArgumentsJson(args) {
291
+ try {
292
+ return JSON.parse(args);
293
+ }
294
+ catch {
295
+ return {};
296
+ }
297
+ }
298
+ function serializeChatRequestBody(request) {
299
+ const body = {
300
+ model: request.model,
301
+ messages: request.messages.map(serializeChatMessageForApi),
302
+ temperature: request.temperature,
303
+ max_tokens: request.max_tokens,
304
+ };
305
+ if (request.functions != null)
306
+ body.functions = request.functions;
307
+ if (request.function_call != null)
308
+ body.function_call = request.function_call;
309
+ return JSON.stringify(body);
310
+ }
311
+ // --- Фабрика ---
312
+ const OFFICIAL_GIGACHAT_API_BASE = "https://gigachat.devices.sberbank.ru/api/v1/";
313
+ /**
314
+ * Официальный `…/api/v1/` + `apiToken` → {@link OAuthGigaChatClient};
315
+ * иной URL + `apiToken` → {@link BasicAuthChatClient};
316
+ * без токена → {@link MtlsChatClient}.
317
+ */
318
+ export class ChatClientFactory {
319
+ static create(options, cancelable) {
320
+ const hasToken = Boolean(options.apiToken);
321
+ const normalizedBase = options.baseUrl.endsWith("/")
322
+ ? options.baseUrl
323
+ : options.baseUrl + "/";
324
+ if (hasToken && normalizedBase === OFFICIAL_GIGACHAT_API_BASE) {
325
+ return new OAuthGigaChatClient(normalizedBase, options.apiToken, cancelable);
326
+ }
327
+ if (hasToken) {
328
+ return new BasicAuthChatClient(normalizedBase, options.apiToken, cancelable);
329
+ }
330
+ return new MtlsChatClient({
331
+ baseUrl: normalizedBase,
332
+ certificatePath: options.clientCertificatePath ?? undefined,
333
+ privateKeyPath: options.clientPrivateKeyPath ?? undefined,
334
+ cancelable,
335
+ });
336
+ }
337
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "gigachat-openai-client",
3
+ "version": "0.1.0",
4
+ "description": "GigaChat API client for Node.js (OpenAI-compatible /v1/models and /v1/chat/completions): OAuth, Basic auth, mTLS hooks, TLS helpers",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/llm-client.js",
8
+ "types": "./dist/llm-client.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/llm-client.d.ts",
12
+ "import": "./dist/llm-client.js",
13
+ "default": "./dist/llm-client.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "sideEffects": false,
21
+ "engines": {
22
+ "node": ">=20.10.0"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.build.json",
26
+ "prepublishOnly": "npm run build",
27
+ "typecheck": "tsc --noEmit",
28
+ "test": "node --experimental-transform-types llm-test.ts",
29
+ "pack:dry": "npm pack --dry-run"
30
+ },
31
+ "keywords": [
32
+ "gigachat",
33
+ "openai",
34
+ "openai-compatible",
35
+ "sber",
36
+ "llm",
37
+ "chat"
38
+ ],
39
+ "dependencies": {
40
+ "dotenv": "^17.3.1",
41
+ "undici": "^7.24.5"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^25.5.0",
45
+ "typescript": "^5.9.3"
46
+ }
47
+ }