feat(probe): live-проверка api.aillm.ru (response_format/stream/reasoning_effort/контекст/пустой content)
This commit is contained in:
@@ -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 с безопасно.
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user