diff --git a/llm_project/probe/RESULTS.md b/llm_project/probe/RESULTS.md new file mode 100644 index 0000000..669d9e3 --- /dev/null +++ b/llm_project/probe/RESULTS.md @@ -0,0 +1,57 @@ +# Результаты live-проверки api.aillm.ru (шаг 1) + +Дата: 2026-10-07 +Модель: `gpt-oss-120b` (vLLM 0.22.0, tp4), прокси litellm 1.82.3 +Base URL: `https://api.aillm.ru` +Ключ: из `/home/naeel/nubes/llm.key` (первый `sk-*` после метки `https`) + +## Итоги по пунктам + +| # | Параметр | Результат | Что делать | +|---|---|---|---| +| 1 | `response_format: {type:"json_object"}` | ✅ поддерживается, валидный JSON в `content` | использовать для этапа 1 | +| 2 | `stream: true` | ✅ SSE `data:`-чанки; сначала `reasoning_content`, потом `content` | v1 стрим НЕ нужен (обычный JSON-ответ) | +| 3 | `reasoning_effort: "low"` | ❌ 400 `UnsupportedParamsError` | НЕ передавать этот параметр | +| 3b | `reasoning_content` | ✅ есть в ответе (дублируется в `provider_specific_fields`) | игнорировать, брать только `content` | +| 4 | `/v1/models` | ✅ 200, модели: gpt-oss-120b, qwen3.8-27b-fp8(-rtx6000), user-bge-m3, bge-reranker-v2-m3, whisper | — | +| 5 | размер контекста | ✅ 2000/8000/16000 слов принято без ошибки (200) | лимит контекста не упёрся, дефолт max_tokens с запасом | +| 6 | пустой content | ⚠️ при `max_tokens:1` и `finish_reason:"length"` → `content:null` | обрабатывать пустой content как ошибку + повтор с бо́льшим max_tokens | +| 7 | задержка этап-1 | ~1.0–1.3 с (3 замера: 1339/1081/1096 ms; повторно 723-1013ms на контекст-тесте) | укладываемся в `ASSIST_TOTAL_DEADLINE_MS=40000` с большим запасом | + +## Детали + +### response_format (п.1) +Запрос с `response_format:{type:"json_object"}` → `content` = `{"resource_type":"nubes_vc_vdc"}` (валидный JSON, без markdown-ограждений). + +### reasoning_effort (п.3) +Ошибка 400: +``` +litellm.UnsupportedParamsError: openai does not support parameters: ['reasoning_effort'], +for model=gpt-oss-120b-113 +``` +→ В `llmChat` параметр `reasoning_effort` НЕ слать. + +### reasoning_content (п.3b) +В ответе присутствуют одновременно: +- `choices[0].message.content` — основной ответ +- `choices[0].message.reasoning_content` — рассуждения (по-английски) +- `choices[0].message.provider_specific_fields.reasoning_content` — дубль + +→ Разбор строится ТОЛЬКО на `content`. + +### контекст (п.5) +16000 повторяющихся слов (~16k токенов) принято, 200 OK, ~1.7 с. Лимит запроса не упёрся. + +### пустой content (п.6) +`max_tokens:1` → `finish_reason:"length"`, `content:null`, `completion_tokens:1` (ушло в reasoning). +→ `llmChat` уже выбрасывает `empty_content`; на уровне этапа 1 — делать повтор с бо́льшим `max_tokens`. + +### stream (п.2) +SSE чанки вида `data: {...}` / `data: [DONE]`. Первые дельты — `delta.reasoning_content`. + +## Выводы для реализации +1. Этап 1: `response_format json_object`, `temperature 0`, брать `content`. +2. Этап 2: без `response_format` (текстовый промпт со строгим шаблоном), `max_tokens` с запасом (не 1). +3. НЕ использовать `reasoning_effort` нигде. +4. Пустой `content` → ошибка `empty_content` → один повтор с `max_tokens` x2. +5. Дедлайн 40 с достаточен с 20+x запасом; можно уменьшить `ASSIST_LLM_TIMEOUT_MS`, но оставить 25 с безопасно. diff --git a/llm_project/probe/probe_aillm.js b/llm_project/probe/probe_aillm.js new file mode 100644 index 0000000..e491805 --- /dev/null +++ b/llm_project/probe/probe_aillm.js @@ -0,0 +1,186 @@ +'use strict'; + +/** + * probe_aillm.js — live-пробник api.aillm.ru (шаг 1 брифа). + * + * Проверяет: + * 1. response_format {type:"json_object"} — поддерживается? + * 2. stream:true — формат чанков + * 3. reasoning_effort:"low" — принимается? есть ли reasoning_content? + * 4. ответ на 429 (имитируем/смотрим заголовки при ошибках) + * 5. размер контекста (увеличиваем промпт до ошибки/предела) + * 6. пустой content / обрыв + * 7. задержка этапа-1-подобного запроса (3 замера) + * + * Ключ читается из файла (первый ключ sk-* после метки с "https"/"aillm"). + * Аргументы командной строки с ключом НЕ принимаются. + */ + +const fs = require('fs'); + +const KEY_FILE = process.env.LLM_KEY_FILE || '/home/naeel/nubes/llm.key'; +const BASE_URL = process.env.LLM_BASE_URL || 'https://api.aillm.ru'; +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; + } + } + } + // 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)); +} + +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); + } +} + +function t() { + 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); + + // 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({ + model: MODEL, + messages: [ + { role: 'system', content: 'Ты ассистент документации. Ответь строгим JSON: {"resource_type":"..."}.' }, + { role: 'user', content: 'как создать виртуальный датацентр' }, + ], + 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({ + 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; + })()); + + // 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); });