chore: форматирование модулей + план по шагам
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
# Пошаговый план реализации AI-ассистента документации
|
||||
|
||||
Дата: 2026-10-07
|
||||
Кому: субагент DeepSeek Flash (исполнитель механических шагов)
|
||||
Кто отвечает за интеграцию/проверку: ассистент (я)
|
||||
Контекст: см. `BRIEF_docs_ai_assistant.md` (решения), `reviews/` (рецензия + диалог).
|
||||
Рабочий каталог кода: `~/TF/tf_docs` (Node.js, без внешних зависимостей, node >= 18).
|
||||
|
||||
> КАЖДЫЙ шаг ниже — отдельная порция для субагента Flash.
|
||||
> Flash делает механику, я (ассистент) принимаю/интегрирую/проверяю.
|
||||
> После каждого шага — коммит осмысленным сообщением, push только по команде владельца.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 0. Блокеры и рекомендации (утвердить у владельца)
|
||||
|
||||
**Не код. Нужны решения владельца до начала.**
|
||||
- Блокеры: 7.1 (путь `/<stand>/_api/assist`), 7.2 (политика отправки данных в ЛЛМ), 7.3 (`X-Forwarded-For`/`ASSIST_TRUSTED_PROXY_CIDR`), 7.5 (не снят — уточнить), ротация ключа `api.aillm.ru`.
|
||||
- Рекомендации одним списком: 7.4, 7.6, 7.7, 7.8, 7.9, 7.11.
|
||||
- Приёмник: `llm_project/DECISIONS_v0.md` (фиксируем принятое).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 1. Live-проверка `api.aillm.ru`
|
||||
|
||||
**Задача Flash:** написать одноразовый скрипт-пробник (НЕ часть `assist.js`) и вернуть факты.
|
||||
Файл: `llm_project/probe/probe_aillm.js` (запуск вручную, результат — в `llm_project/probe/RESULTS.md`).
|
||||
|
||||
Проверить и зафиксировать:
|
||||
1. `response_format: {type: "json_object"}` — поддерживается? (даёт ли валидный JSON)
|
||||
2. `stream: true` — какой формат чанков (`data:` SSE)? нужен ли нам вообще (v1 — нет).
|
||||
3. `reasoning_effort: "low"` — принимается? есть ли `reasoning_content` в ответе?
|
||||
4. Ответ на 429 — какие заголовки (`Retry-After`? `X-RateLimit-*`?), тело.
|
||||
5. Размер контекста — до скольки токенов принимает запрос (увеличивая промпт, найти предел/ошибку).
|
||||
6. Формат ошибки при пустом `content` / обрыве.
|
||||
7. Сколько длится этап 1-типа запрос (засечь 3 образца) — для дедлайна 40 с.
|
||||
|
||||
**Критерий готово:** `RESULTS.md` содержит таблицу ответов по всем 7 пунктам + пример сырого ответа (JSON) для этапов 1 и 2.
|
||||
|
||||
**После шага 1:** финализируем `llmChat` (структуру разбора ответа).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2. Каркас `assist.js`
|
||||
|
||||
**Задача Flash:** создать `assist.js` (новый модуль), НЕ трогая `server.js`.
|
||||
Модули по брифу:
|
||||
- `validateInput(req)` — Content-Type `application/json`, Content-Length > 2048 → 413, чтение потока со счётчиком байт (> 2 КБ → 413), разбор JSON, поле `question` (непустая строка).
|
||||
- rate-limit — локальная таблица ключ=IP (или IP/stand), периодическая чистка; лимит делится на число реплик (`ASSIST_REPLICAS`).
|
||||
- семафор `ASSIST_MAX_CONCURRENT` = 4; сверх — 503 + `Retry-After: 5`, тело `{"error":"busy","message":"..."}`.
|
||||
- объединение одинаковых запросов (ключ = `stand` + нормализованный запрос): два счётчика («ожидающие клиенты» / «держащие вызов»); обрыв клиента не отменяет общий вызов, пока есть держащие; отмена ЛЛМ только когда отключились все.
|
||||
- `llmChat(messages, {max_tokens, controller})` — POST на `api.aillm.ru`, `AbortController`, разбор только `content`.
|
||||
- общий дедлайн `ASSIST_TOTAL_DEADLINE_MS` = 40000; повтор этапа 1 только если запас > 15 с; `req.on('close')` → abort.
|
||||
- кэш ответов (ключ = `stand` + нормализованный запрос), TTL.
|
||||
- логирование: длина вопроса, `stand`, время этапов, статус, кэш, токены. Текст запросов — НЕ логировать.
|
||||
|
||||
**Критерий готово:** модуль экспортирует обработчик, собирается без ошибок (`node -c assist.js`), покрыт беглыми юнит-пробами в `test/` (validateInput, семафор, дедуп-ключ).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 3. Эталонный набор вопросов
|
||||
|
||||
**Задача Flash:** создать `llm_project/golden/golden_questions.json` — 20–30 вопросов.
|
||||
Формат каждой записи:
|
||||
```json
|
||||
{"question": "...", "expected_pages": ["path/на_страницу"], "resource_type": "nubes_..."}
|
||||
```
|
||||
Вопросы: по разным ресурсам (`nubes_*`), включая подресурсы (`_user`, `_database`, `_topic`), негативные (вопрос не про ресурс, вопрос с выдуманным ресурсом вроде `nubes_instance`).
|
||||
**Критерий готово:** файл валиден, вопросы реалистичны (взяты из опубликованной документации стенда `nubes-test`).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 4. Индекс и поиск
|
||||
|
||||
**Задача Flash:** реализовать загрузку и лексический поиск по `search_index.json`.
|
||||
- Источник: `VM_DOCS_BASE_URL/<stand>/search/search_index.json` (только этот путь).
|
||||
- Снять формат индекса с реального стенда (см. шаг 7/8 верификации): структура записи, поля `location` (вид `page/#anchor`), `title`, `text` (HTML → удалить).
|
||||
- Лекс. поиск: токенизация, BM25-подобный скор, top-K разделов, группировка по страницам.
|
||||
- Собрать список реальных `nubes_*` из имён страниц (для этапа 1).
|
||||
- Порог «не найдено» = «совпало >= N уникальных ключевых слов», N подобрать по эталону (шаг 3).
|
||||
**Критерий готово:** на эталонном наборе поиск выдаёт ожидаемые страницы с приемлемой точностью (записать метрику).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 5. Этап 1 (извлечение `resource_type`)
|
||||
|
||||
**Задача Flash:** реализовать этап 1.
|
||||
- Системный промпт (рус.) со списком реальных `nubes_*` из индекса.
|
||||
- Ответ: `resource_type`; если не в списке — отбрасывается (нет ресурса).
|
||||
- Падение/таймаут этапа 1 — продолжить без него (только токен-поиск).
|
||||
**Критерий готово:** на эталоне корректно извлекает/отбрасывает `resource_type`; выдуманные (`nubes_instance`) — отбрасываются.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 6. Объединение + этап 2
|
||||
|
||||
**Задача Flash:** реализовать объединение (П.8) и этап 2.
|
||||
Объединение:
|
||||
- Поиск по токенам исходного запроса — наравне с поиском после этапа 1.
|
||||
- Страница ресурса — всегда первой в контекст (если `resource_type` в списке).
|
||||
- Кандидаты объединяются, повторы по `location`, оценка = max из двух; контекст до K без повторов; страница ресурса входит в K.
|
||||
- Падение этапа 1 — только токен-поиск, без обязательной страницы.
|
||||
Этап 2:
|
||||
- Промпт со строгим шаблоном «Роль/Задача/Контекст/Требования/Ограничения».
|
||||
- Ответ без заголовков → 502 `bad_llm_output`.
|
||||
- Жёсткий `max_tokens`.
|
||||
- `sources` = `/<stand>/` + `location`; отбрасывать `//`, `:`, `..`.
|
||||
**Критерий готово:** полный сквозной ответ на эталоне, корректные `sources`.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 7. Маршрут в `server.js` + Dockerfile + README
|
||||
|
||||
**Задача Flash (под контролем):** в `server.js` добавить POST-ветку `/<stand>/_api/assist` **до** проверки 405 (строки 72–75).
|
||||
- `stand` через `STAND_RE`.
|
||||
- `assist.js` грузить в try/catch; ошибки → 500 JSON.
|
||||
- Поведение статики и падение без `VM_DOCS_BASE_URL` — НЕ менять.
|
||||
- Dockerfile: скопировать `assist.js` (+ при необходимости `lib/`).
|
||||
- README: описать эндпоинт и переменные окружения (`ASSIST_*`, ключ).
|
||||
**Критерий готово:** `node -c`, статика работает как раньше, POST отвечает JSON (200/4xx/5xx).
|
||||
|
||||
---
|
||||
|
||||
## Шаг 8. Виджет (фронтенд)
|
||||
|
||||
**Задача Flash:** написать виджет (встраиваемый JS/CSS/HTML или шаблон).
|
||||
- `stand` из первого сегмента `location.pathname`; нет сегмента — не показывать.
|
||||
- Плавающая кнопка на всех страницах.
|
||||
- Под полем ввода: «не вводите ключи и пароли».
|
||||
- Обращение к `/<stand>/_api/assist`.
|
||||
- Обработка 503 (`Retry-After`) — показ «занято, повторите».
|
||||
**Критерий готово:** виджет открывается, шлёт запрос, показывает ответ и `sources`-ссылки.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 9. Итоговая оценка качества
|
||||
|
||||
**Задача Flash:** прогнать эталонный набор (шаг 3) через полный пайплайн, собрать метрики.
|
||||
- Точность `resource_type`, точность страниц, корректность `sources`, % 502/ошибок, задержки.
|
||||
**Критерий готово:** отчёт `llm_project/golden/EVAL_*.md` с таблицей.
|
||||
|
||||
---
|
||||
|
||||
## Шаг 10. Деплой — ТОЛЬКО по команде владельца. Ассистент сам НЕ деплоит.
|
||||
|
||||
## Шаг 11. Документация (README финал, переменные, эксплуатация).
|
||||
|
||||
---
|
||||
|
||||
## Порядок выполнения и зависимости
|
||||
|
||||
1. Шаг 0 (утверждение) → блокер, ждать владельца.
|
||||
2. Шаг 1 и Шаг 2 — параллельно (каркас не зависит от результата шага 1; `llmChat` финализируется после шага 1).
|
||||
3. Шаг 3 → 4 → 5 → 6 (зависимы).
|
||||
4. Шаг 7 → 8 (после 6).
|
||||
5. Шаг 9 (оценка), Шаг 10 (деплой, по команде), Шаг 11 (документация).
|
||||
|
||||
## Непроверенные допущения (снять на стенде)
|
||||
- `proxy-read-timeout` nginx-ingress (шаг 1/верификация).
|
||||
- Формат `search_index.json` (шаг 4).
|
||||
- Пропускает ли ingress POST (`curl -X POST` на стенде) — отдельная верификация.
|
||||
- Поддержка `reasoning_effort`/`response_format`/`stream`/429 у `api.aillm.ru` (шаг 1).
|
||||
@@ -1,30 +1,197 @@
|
||||
[
|
||||
{"question": "Как создать виртуальную машину в облаке Nubes?", "expected_pages": ["vc_vm_v3"], "resource_type": "nubes_vc_vm_v3"},
|
||||
{"question": "Как настроить PostgreSQL с автоматическим бэкапом?", "expected_pages": ["postgres"], "resource_type": "nubes_postgres"},
|
||||
{"question": "Как создать базу данных и пользователя в PostgreSQL?", "expected_pages": ["postgres"], "resource_type": "nubes_postgres_database"},
|
||||
{"question": "Как выдать виртуальной машине белый IP-адрес?", "expected_pages": ["vcexternalip"], "resource_type": "nubes_vcexternalip"},
|
||||
{"question": "Как поднять MongoDB с ежедневным бэкапом?", "expected_pages": ["mongodb"], "resource_type": "nubes_mongodb"},
|
||||
{"question": "Как завести пользователя и пароль для MongoDB?", "expected_pages": ["mongodb"], "resource_type": "nubes_mongodb_user"},
|
||||
{"question": "Как развернуть Redis для кэширования?", "expected_pages": ["redis"], "resource_type": "nubes_redis"},
|
||||
{"question": "Как создать MariaDB и привязать к ней отдельную базу?", "expected_pages": ["mariadb"], "resource_type": "nubes_mariadb"},
|
||||
{"question": "Как поднять ClickHouse и создать в нём таблицу?", "expected_pages": ["clickhouse"], "resource_type": "nubes_clickhouse_database"},
|
||||
{"question": "Как развернуть Kafka-кластер и создать топик?", "expected_pages": ["kafka"], "resource_type": "nubes_kafka_topic"},
|
||||
{"question": "Как завести пользователя Kafka с правами на конкретный топик?", "expected_pages": ["kafka"], "resource_type": "nubes_kafka_user"},
|
||||
{"question": "Как развернуть RabbitMQ для очередей задач?", "expected_pages": ["rabbitmq"], "resource_type": "nubes_rabbitmq"},
|
||||
{"question": "Как создать S3-бакет для хранения файлов?", "expected_pages": ["s3bucket", "s3"], "resource_type": "nubes_s3bucket"},
|
||||
{"question": "Как создать S3-хранилище и сублюзеров с ограниченными ключами?", "expected_pages": ["s3"], "resource_type": "nubes_s3_sub_user"},
|
||||
{"question": "Как развернуть Kubernetes-кластер в Shturval?", "expected_pages": ["k8s_sthutrval_cluster"], "resource_type": "nubes_k8s_sthutrval_cluster"},
|
||||
{"question": "Как установить Velero для бэкапа Kubernetes?", "expected_pages": ["k8s_velero"], "resource_type": "nubes_k8s_velero"},
|
||||
{"question": "Как развернуть OpenBao для хранения секретов в кластере?", "expected_pages": ["k8s_openbao"], "resource_type": "nubes_k8s_openbao"},
|
||||
{"question": "Как создать DNS-запись для своего домена?", "expected_pages": ["dnsrecord", "zones_v2"], "resource_type": "nubes_dnsrecord"},
|
||||
{"question": "Как создать организацию и vDC в VMware-облаке?", "expected_pages": ["vc_org", "vc_vdc"], "resource_type": "nubes_vc_vdc"},
|
||||
{"question": "Как настроить SNAT в NSX-T?", "expected_pages": ["vc_nsxt"], "resource_type": "nubes_vc_nsxt_snat"},
|
||||
{"question": "Как развернуть LLM-модель с секретами и командой?", "expected_pages": ["llm_ai"], "resource_type": "nubes_llm_ai"},
|
||||
{"question": "Как развернуть Nextcloud и настроить бэкап?", "expected_pages": ["nextcloud"], "resource_type": "nubes_nextcloud"},
|
||||
{"question": "Как развернуть Node.js приложение и сделать передеплой?", "expected_pages": ["nodejs"], "resource_type": "nubes_nodejs"},
|
||||
{"question": "Как задеплоить Flask-приложение и получить его URL?", "expected_pages": ["flask"], "resource_type": "nubes_flask"},
|
||||
{"question": "Как развернуть pgAdmin для управления базами?", "expected_pages": ["pgadmin"], "resource_type": "nubes_pgadmin"},
|
||||
{"question": "Как подключиться к API Terraform-провайдера Nubes?", "expected_pages": [], "resource_type": ""},
|
||||
{"question": "Какие версии провайдера сейчас доступны и как их обновлять?", "expected_pages": ["template"], "resource_type": ""},
|
||||
{"question": "Как создать nubes_instance с плавающим IP floating_ip?", "expected_pages": [], "resource_type": ""}
|
||||
]
|
||||
{
|
||||
"question": "Как создать виртуальную машину в облаке Nubes?",
|
||||
"expected_pages": [
|
||||
"vc_vm_v3"
|
||||
],
|
||||
"resource_type": "nubes_vc_vm_v3"
|
||||
},
|
||||
{
|
||||
"question": "Как настроить PostgreSQL с автоматическим бэкапом?",
|
||||
"expected_pages": [
|
||||
"postgres"
|
||||
],
|
||||
"resource_type": "nubes_postgres"
|
||||
},
|
||||
{
|
||||
"question": "Как создать базу данных и пользователя в PostgreSQL?",
|
||||
"expected_pages": [
|
||||
"postgres"
|
||||
],
|
||||
"resource_type": "nubes_postgres_database"
|
||||
},
|
||||
{
|
||||
"question": "Как выдать виртуальной машине белый IP-адрес?",
|
||||
"expected_pages": [
|
||||
"vcexternalip"
|
||||
],
|
||||
"resource_type": "nubes_vcexternalip"
|
||||
},
|
||||
{
|
||||
"question": "Как поднять MongoDB с ежедневным бэкапом?",
|
||||
"expected_pages": [
|
||||
"mongodb"
|
||||
],
|
||||
"resource_type": "nubes_mongodb"
|
||||
},
|
||||
{
|
||||
"question": "Как завести пользователя и пароль для MongoDB?",
|
||||
"expected_pages": [
|
||||
"mongodb"
|
||||
],
|
||||
"resource_type": "nubes_mongodb_user"
|
||||
},
|
||||
{
|
||||
"question": "Как развернуть Redis для кэширования?",
|
||||
"expected_pages": [
|
||||
"redis"
|
||||
],
|
||||
"resource_type": "nubes_redis"
|
||||
},
|
||||
{
|
||||
"question": "Как создать MariaDB и привязать к ней отдельную базу?",
|
||||
"expected_pages": [
|
||||
"mariadb"
|
||||
],
|
||||
"resource_type": "nubes_mariadb"
|
||||
},
|
||||
{
|
||||
"question": "Как поднять ClickHouse и создать в нём таблицу?",
|
||||
"expected_pages": [
|
||||
"clickhouse"
|
||||
],
|
||||
"resource_type": "nubes_clickhouse_database"
|
||||
},
|
||||
{
|
||||
"question": "Как развернуть Kafka-кластер и создать топик?",
|
||||
"expected_pages": [
|
||||
"kafka"
|
||||
],
|
||||
"resource_type": "nubes_kafka_topic"
|
||||
},
|
||||
{
|
||||
"question": "Как завести пользователя Kafka с правами на конкретный топик?",
|
||||
"expected_pages": [
|
||||
"kafka"
|
||||
],
|
||||
"resource_type": "nubes_kafka_user"
|
||||
},
|
||||
{
|
||||
"question": "Как развернуть RabbitMQ для очередей задач?",
|
||||
"expected_pages": [
|
||||
"rabbitmq"
|
||||
],
|
||||
"resource_type": "nubes_rabbitmq"
|
||||
},
|
||||
{
|
||||
"question": "Как создать S3-бакет для хранения файлов?",
|
||||
"expected_pages": [
|
||||
"s3bucket",
|
||||
"s3"
|
||||
],
|
||||
"resource_type": "nubes_s3bucket"
|
||||
},
|
||||
{
|
||||
"question": "Как создать S3-хранилище и сублюзеров с ограниченными ключами?",
|
||||
"expected_pages": [
|
||||
"s3"
|
||||
],
|
||||
"resource_type": "nubes_s3_sub_user"
|
||||
},
|
||||
{
|
||||
"question": "Как развернуть Kubernetes-кластер в Shturval?",
|
||||
"expected_pages": [
|
||||
"k8s_sthutrval_cluster"
|
||||
],
|
||||
"resource_type": "nubes_k8s_sthutrval_cluster"
|
||||
},
|
||||
{
|
||||
"question": "Как установить Velero для бэкапа Kubernetes?",
|
||||
"expected_pages": [
|
||||
"k8s_velero"
|
||||
],
|
||||
"resource_type": "nubes_k8s_velero"
|
||||
},
|
||||
{
|
||||
"question": "Как развернуть OpenBao для хранения секретов в кластере?",
|
||||
"expected_pages": [
|
||||
"k8s_openbao"
|
||||
],
|
||||
"resource_type": "nubes_k8s_openbao"
|
||||
},
|
||||
{
|
||||
"question": "Как создать DNS-запись для своего домена?",
|
||||
"expected_pages": [
|
||||
"dnsrecord",
|
||||
"zones_v2"
|
||||
],
|
||||
"resource_type": "nubes_dnsrecord"
|
||||
},
|
||||
{
|
||||
"question": "Как создать организацию и vDC в VMware-облаке?",
|
||||
"expected_pages": [
|
||||
"vc_org",
|
||||
"vc_vdc"
|
||||
],
|
||||
"resource_type": "nubes_vc_vdc"
|
||||
},
|
||||
{
|
||||
"question": "Как настроить SNAT в NSX-T?",
|
||||
"expected_pages": [
|
||||
"vc_nsxt"
|
||||
],
|
||||
"resource_type": "nubes_vc_nsxt_snat"
|
||||
},
|
||||
{
|
||||
"question": "Как развернуть LLM-модель с секретами и командой?",
|
||||
"expected_pages": [
|
||||
"llm_ai"
|
||||
],
|
||||
"resource_type": "nubes_llm_ai"
|
||||
},
|
||||
{
|
||||
"question": "Как развернуть Nextcloud и настроить бэкап?",
|
||||
"expected_pages": [
|
||||
"nextcloud"
|
||||
],
|
||||
"resource_type": "nubes_nextcloud"
|
||||
},
|
||||
{
|
||||
"question": "Как развернуть Node.js приложение и сделать передеплой?",
|
||||
"expected_pages": [
|
||||
"nodejs"
|
||||
],
|
||||
"resource_type": "nubes_nodejs"
|
||||
},
|
||||
{
|
||||
"question": "Как задеплоить Flask-приложение и получить его URL?",
|
||||
"expected_pages": [
|
||||
"flask"
|
||||
],
|
||||
"resource_type": "nubes_flask"
|
||||
},
|
||||
{
|
||||
"question": "Как развернуть pgAdmin для управления базами?",
|
||||
"expected_pages": [
|
||||
"pgadmin"
|
||||
],
|
||||
"resource_type": "nubes_pgadmin"
|
||||
},
|
||||
{
|
||||
"question": "Как подключиться к API Terraform-провайдера Nubes?",
|
||||
"expected_pages": [],
|
||||
"resource_type": ""
|
||||
},
|
||||
{
|
||||
"question": "Какие версии провайдера сейчас доступны и как их обновлять?",
|
||||
"expected_pages": [
|
||||
"template"
|
||||
],
|
||||
"resource_type": ""
|
||||
},
|
||||
{
|
||||
"question": "Как создать nubes_instance с плавающим IP floating_ip?",
|
||||
"expected_pages": [],
|
||||
"resource_type": ""
|
||||
}
|
||||
]
|
||||
+134
-134
@@ -24,163 +24,163 @@ const MODEL = process.env.LLM_MODEL || 'gpt-oss-120b';
|
||||
|
||||
// ── поиск ключа: первая строка, начинающаяся с sk-, после строки, содержащей 'https' ──
|
||||
function findApiKey() {
|
||||
const lines = fs.readFileSync(KEY_FILE, 'utf8').split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const t = lines[i].trim();
|
||||
if (t.toLowerCase().includes('https') || t.toLowerCase().includes('aillm')) {
|
||||
// дальше ищем ближайший sk-... в следующих 5 строках
|
||||
for (let j = i + 1; j < Math.min(lines.length, i + 6); j++) {
|
||||
const k = lines[j].trim();
|
||||
if (k.startsWith('sk-')) return k;
|
||||
}
|
||||
const lines = fs.readFileSync(KEY_FILE, 'utf8').split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const t = lines[i].trim();
|
||||
if (t.toLowerCase().includes('https') || t.toLowerCase().includes('aillm')) {
|
||||
// дальше ищем ближайший sk-... в следующих 5 строках
|
||||
for (let j = i + 1; j < Math.min(lines.length, i + 6); j++) {
|
||||
const k = lines[j].trim();
|
||||
if (k.startsWith('sk-')) return k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback: первый sk-* в файле
|
||||
for (const l of lines) {
|
||||
const t = l.trim();
|
||||
if (t.startsWith('sk-')) return t;
|
||||
}
|
||||
return null;
|
||||
// fallback: первый sk-* в файле
|
||||
for (const l of lines) {
|
||||
const t = l.trim();
|
||||
if (t.startsWith('sk-')) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const API_KEY = findApiKey();
|
||||
|
||||
function log(label, obj) {
|
||||
console.log(`\n=== ${label} ===`);
|
||||
console.log(typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2));
|
||||
console.log(`\n=== ${label} ===`);
|
||||
console.log(typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
async function chat(body, { timeoutMs = 60000 } = {}) {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const resp = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
const text = await resp.text();
|
||||
let json = null;
|
||||
try { json = JSON.parse(text); } catch { json = { _raw: text.slice(0, 500) }; }
|
||||
return { status: resp.status, headers: Object.fromEntries(resp.headers), json };
|
||||
} catch (e) {
|
||||
return { status: 'ERR', error: e.name + ': ' + e.message };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const resp = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
const text = await resp.text();
|
||||
let json = null;
|
||||
try { json = JSON.parse(text); } catch { json = { _raw: text.slice(0, 500) }; }
|
||||
return { status: resp.status, headers: Object.fromEntries(resp.headers), json };
|
||||
} catch (e) {
|
||||
return { status: 'ERR', error: e.name + ': ' + e.message };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function t() {
|
||||
const s = Date.now();
|
||||
return () => Date.now() - s;
|
||||
const s = Date.now();
|
||||
return () => Date.now() - s;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!API_KEY) {
|
||||
console.error('Ключ не найден в ' + KEY_FILE);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('API_KEY: ' + API_KEY.slice(0, 6) + '*** (маска)');
|
||||
console.log('BASE_URL: ' + BASE_URL);
|
||||
console.log('MODEL: ' + MODEL);
|
||||
if (!API_KEY) {
|
||||
console.error('Ключ не найден в ' + KEY_FILE);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('API_KEY: ' + API_KEY.slice(0, 6) + '*** (маска)');
|
||||
console.log('BASE_URL: ' + BASE_URL);
|
||||
console.log('MODEL: ' + MODEL);
|
||||
|
||||
// 1. response_format json_object
|
||||
log('1. response_format json_object', await chat({
|
||||
model: MODEL,
|
||||
messages: [{ role: 'user', content: 'Верни JSON {"resource_type":"nubes_vc_vdc"} и ничего больше.' }],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0,
|
||||
}));
|
||||
|
||||
// 3. reasoning_effort low
|
||||
log('3. reasoning_effort low', await chat({
|
||||
model: MODEL,
|
||||
messages: [{ role: 'user', content: 'Одним словом: что такое vDC?' }],
|
||||
reasoning_effort: 'low',
|
||||
temperature: 0,
|
||||
}));
|
||||
|
||||
// 7. задержка этапа 1 (3 замера)
|
||||
log('7. задержка (3 замера этап-1-подобного)', await (async () => {
|
||||
const results = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = t();
|
||||
await chat({
|
||||
// 1. response_format json_object
|
||||
log('1. response_format json_object', await chat({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: 'Ты ассистент документации. Ответь строгим JSON: {"resource_type":"..."}.' },
|
||||
{ role: 'user', content: 'как создать виртуальный датацентр' },
|
||||
],
|
||||
messages: [{ role: 'user', content: 'Верни JSON {"resource_type":"nubes_vc_vdc"} и ничего больше.' }],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0,
|
||||
});
|
||||
results.push(d());
|
||||
}
|
||||
return results;
|
||||
})());
|
||||
}));
|
||||
|
||||
// 5. размер контекста — увеличиваем промпт
|
||||
log('5. контекст (пробуем ~8k и ~16k токенов)', await (async () => {
|
||||
const mk = (words) => Array(words).fill('слово').join(' ');
|
||||
const r = {};
|
||||
for (const words of [2000, 8000, 16000]) {
|
||||
const d = t();
|
||||
const res = await chat({
|
||||
// 3. reasoning_effort low
|
||||
log('3. reasoning_effort low', await chat({
|
||||
model: MODEL,
|
||||
messages: [{ role: 'user', content: `Повтори одно слово "ок". ${mk(words)}` }],
|
||||
max_tokens: 10,
|
||||
});
|
||||
r[words] = { ms: d(), status: res.status, err: res.error || (res.json && res.json.error ? res.json.error.message : null) };
|
||||
}
|
||||
return r;
|
||||
})());
|
||||
messages: [{ role: 'user', content: 'Одним словом: что такое vDC?' }],
|
||||
reasoning_effort: 'low',
|
||||
temperature: 0,
|
||||
}));
|
||||
|
||||
// 6. пустой content (маленький max_tokens + reasoning)
|
||||
log('6. маленький max_tokens (пустой content?)', await chat({
|
||||
model: MODEL,
|
||||
messages: [{ role: 'user', content: 'Распиши подробно архитектуру Terraform провайдера Nubes.' }],
|
||||
max_tokens: 1,
|
||||
temperature: 0,
|
||||
}));
|
||||
// 7. задержка этапа 1 (3 замера)
|
||||
log('7. задержка (3 замера этап-1-подобного)', await (async () => {
|
||||
const results = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = t();
|
||||
await chat({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: 'Ты ассистент документации. Ответь строгим JSON: {"resource_type":"..."}.' },
|
||||
{ role: 'user', content: 'как создать виртуальный датацентр' },
|
||||
],
|
||||
temperature: 0,
|
||||
});
|
||||
results.push(d());
|
||||
}
|
||||
return results;
|
||||
})());
|
||||
|
||||
// 2. stream
|
||||
log('2. stream:true (первые байты)', await (async () => {
|
||||
try {
|
||||
const resp = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: 'Скажи "привет"' }], stream: true }),
|
||||
});
|
||||
const reader = resp.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = '';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
}
|
||||
return { status: resp.status, firstChunks: buf.slice(0, 800) };
|
||||
} catch (e) {
|
||||
return { error: e.name + ': ' + e.message };
|
||||
}
|
||||
})());
|
||||
// 5. размер контекста — увеличиваем промпт
|
||||
log('5. контекст (пробуем ~8k и ~16k токенов)', await (async () => {
|
||||
const mk = (words) => Array(words).fill('слово').join(' ');
|
||||
const r = {};
|
||||
for (const words of [2000, 8000, 16000]) {
|
||||
const d = t();
|
||||
const res = await chat({
|
||||
model: MODEL,
|
||||
messages: [{ role: 'user', content: `Повтори одно слово "ок". ${mk(words)}` }],
|
||||
max_tokens: 10,
|
||||
});
|
||||
r[words] = { ms: d(), status: res.status, err: res.error || (res.json && res.json.error ? res.json.error.message : null) };
|
||||
}
|
||||
return r;
|
||||
})());
|
||||
|
||||
// 4. 429 — пытаемся получить, посылая запрос с заведомо неверным (но тем же) ключом не будем; отдельно проверим /v1/models
|
||||
log('4. /v1/models (проверка доступности)', await (async () => {
|
||||
try {
|
||||
const resp = await fetch(`${BASE_URL}/v1/models`, {
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
});
|
||||
const text = await resp.text().catch(() => '');
|
||||
let m = null; try { m = JSON.parse(text).data.map(x => x.id); } catch { m = text.slice(0, 500); }
|
||||
return { status: resp.status, models: m };
|
||||
} catch (e) {
|
||||
return { error: e.name + ': ' + e.message };
|
||||
}
|
||||
})());
|
||||
// 6. пустой content (маленький max_tokens + reasoning)
|
||||
log('6. маленький max_tokens (пустой content?)', await chat({
|
||||
model: MODEL,
|
||||
messages: [{ role: 'user', content: 'Распиши подробно архитектуру Terraform провайдера Nubes.' }],
|
||||
max_tokens: 1,
|
||||
temperature: 0,
|
||||
}));
|
||||
|
||||
// 2. stream
|
||||
log('2. stream:true (первые байты)', await (async () => {
|
||||
try {
|
||||
const resp = await fetch(`${BASE_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: MODEL, messages: [{ role: 'user', content: 'Скажи "привет"' }], stream: true }),
|
||||
});
|
||||
const reader = resp.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = '';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
}
|
||||
return { status: resp.status, firstChunks: buf.slice(0, 800) };
|
||||
} catch (e) {
|
||||
return { error: e.name + ': ' + e.message };
|
||||
}
|
||||
})());
|
||||
|
||||
// 4. 429 — пытаемся получить, посылая запрос с заведомо неверным (но тем же) ключом не будем; отдельно проверим /v1/models
|
||||
log('4. /v1/models (проверка доступности)', await (async () => {
|
||||
try {
|
||||
const resp = await fetch(`${BASE_URL}/v1/models`, {
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
});
|
||||
const text = await resp.text().catch(() => '');
|
||||
let m = null; try { m = JSON.parse(text).data.map(x => x.id); } catch { m = text.slice(0, 500); }
|
||||
return { status: resp.status, models: m };
|
||||
} catch (e) {
|
||||
return { error: e.name + ': ' + e.message };
|
||||
}
|
||||
})());
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('FATAL', e); process.exit(1); });
|
||||
|
||||
Reference in New Issue
Block a user