feat: OpenAI /v1 с эмуляцией вызова инструментов

Добавлены POST /v1/chat/completions и GET /v1/models. Описания инструментов
из поля tools вклеиваются в текст промпта, DeepSeek просят звать их блоком
<tool_call>{json}</tool_call>, а ответ разбирается обратно в OpenAI tool_calls.
Парсятся два формата: наш <tool_call> и родной DeepSeek invoke/parameter.

Позволяет подключать агентов на OpenAI-протоколе (например, Hermes) с рабочими
вызовами инструментов поверх веб-чата DeepSeek.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011W17JPwr6FwxMAKAHtgEwT
This commit is contained in:
Alex Cube
2026-09-25 23:35:43 +03:00
co-authored by Claude Opus 5.5
parent 6ccb5dd46b
commit 1cd1ec33a7
+172
View File
@@ -136,6 +136,132 @@ export function imageOf(messages) {
return null; return null;
} }
// ──────────────────── OpenAI /v1 с эмуляцией вызова инструментов ────────────
// Агенты (Hermes и пр.) говорят по OpenAI /v1/chat/completions и передают
// инструменты в поле tools, ожидая обратно tool_calls. У веб-чата DeepSeek нет
// ни ролей, ни функций — поэтому инструменты вклеиваем в текст промпта, просим
// модель звать их блоком <tool_call>{...}</tool_call>, а ответ разбираем обратно
// в tool_calls. Ради этого вся затея: DeepSeek держит формат надёжно, а слабые
// локальные модели через настоящий tool-calling его ломали.
export const TOOL_OPEN = '<tool_call>';
export const TOOL_CLOSE = '</tool_call>';
function toolInstructions(tools) {
const list = tools.map(t => {
const f = t.function || t;
return `- ${f.name}: ${f.description || ''}\n parameters (JSON Schema): ${JSON.stringify(f.parameters || {})}`;
}).join('\n');
return [
'You have tools. Available tools:', list, '',
`To call tools, output ONLY tool-call blocks and nothing else, one per line:`,
`${TOOL_OPEN}{"name":"<tool>","arguments":{ ... }}${TOOL_CLOSE}`,
'Rules:',
'- A tool call MUST be exactly that block; arguments MUST be valid JSON.',
'- When calling tools, output no other text — no prose, no markdown fences.',
'- After tool results arrive, either call more tools or give the final answer.',
'- Give a plain-text answer (no blocks) only when the task is finished.',
].join('\n');
}
// content OpenAI бывает строкой или массивом частей (текст/картинка).
function textOf(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.map(p => (typeof p === 'string' ? p : (p?.text || ''))).filter(Boolean).join('\n');
}
return String(content ?? '');
}
function safeArgs(a) {
if (a && typeof a === 'object') return a;
try { return JSON.parse(a || '{}'); } catch { return {}; }
}
// Разворачиваем OpenAI-историю (роли, tool_calls ассистента, результаты role:tool)
// в один текст для веб-чата.
export function renderMessages(messages) {
const parts = [];
for (const m of messages || []) {
const role = m.role || 'user';
if (role === 'system') { parts.push(`# System\n${textOf(m.content)}`); continue; }
if (role === 'tool') {
const id = m.tool_call_id || m.name || '';
parts.push(`# Tool result${id ? ` (${id})` : ''}\n${textOf(m.content)}`); continue;
}
if (role === 'assistant') {
let s = '# Assistant\n';
if (m.content) s += `${textOf(m.content)}\n`;
for (const tc of m.tool_calls || []) {
const fn = tc.function || {};
s += `${TOOL_OPEN}${JSON.stringify({ name: fn.name, arguments: safeArgs(fn.arguments) })}${TOOL_CLOSE}\n`;
}
parts.push(s.trimEnd()); continue;
}
parts.push(`# User\n${textOf(m.content)}`);
}
return parts.join('\n\n');
}
const mkCall = (name, args) => ({
id: 'call_' + Math.random().toString(36).slice(2, 10),
type: 'function',
function: { name, arguments: JSON.stringify(args ?? {}) },
});
// DeepSeek не всегда слушается инструкции про <tool_call>{json}: иногда отдаёт
// свой invoke/parameter-формат (<||DSML|| invoke name="t"> с параметрами-тегами).
// Разбираем и его — иначе вызов ушёл бы наружу текстом. Толерантно к любому
// мусору вокруг слов invoke/parameter (спецсимволы-разделители игнорируем).
function parseInvokeStyle(text) {
if (!/invoke\s+name=/i.test(text)) return { calls: [], clean: text };
const calls = [];
// Каждый invoke-блок: имя + его параметры до закрытия invoke.
const blockRe = /invoke\s+name="([^"]+)"([\s\S]*?)(?=invoke\s+name="|$)/gi;
const paramRe = /parameter\s+name="([^"]+)"([^>]*)>([\s\S]*?)<\/[^>]*?parameter/gi;
let b;
while ((b = blockRe.exec(text))) {
const name = b[1];
const args = {};
let p;
while ((p = paramRe.exec(b[2]))) {
const key = p[1], meta = p[2] || '', raw = p[3].trim();
// string="true" — брать как строку; иначе пробуем распознать число/bool/JSON.
if (/string="true"/i.test(meta)) args[key] = raw;
else { try { args[key] = JSON.parse(raw); } catch { args[key] = raw; } }
}
calls.push(mkCall(name, args));
}
// Вырезаем всё, что похоже на разметку вызова, из «чистого» текста.
const clean = text.replace(/<[^>]*?(invoke|parameter|calls)[^>]*?>/gi, '')
.replace(/<\/[^>]*?(invoke|parameter|calls)[^>]*?>/gi, '').trim();
return { calls, clean };
}
// Достаём вызовы инструментов из ответа модели: сперва наш <tool_call>{json},
// затем, если пусто, — invoke/parameter-формат DeepSeek.
export function parseToolCalls(text) {
const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
const calls = [];
let m;
while ((m = re.exec(text))) {
let obj;
try { obj = JSON.parse(m[1]); } catch { continue; }
if (!obj || !obj.name) continue;
calls.push(mkCall(obj.name, obj.arguments ?? obj.parameters ?? {}));
}
if (calls.length) return { calls, clean: text.replace(re, '').trim() };
return parseInvokeStyle(text);
}
function sse(res, chunks) {
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache', Connection: 'keep-alive' });
for (const c of chunks) res.write(`data: ${JSON.stringify(c)}\n\n`);
res.write('data: [DONE]\n\n');
res.end();
}
const json = (res, code, body) => { const json = (res, code, body) => {
const text = JSON.stringify(body); const text = JSON.stringify(body);
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8',
@@ -264,6 +390,52 @@ const server = createServer(async (req, res) => {
{ ...head, response: '', done: true, done_reason: 'stop' }); { ...head, response: '', done: true, done_reason: 'stop' });
} }
// OpenAI-совместимый чат с инструментами. Именно сюда ходит Hermes.
if (req.method === 'POST' && url.pathname === '/v1/chat/completions') {
const body = await readBody(req);
const tools = body.tools || [];
let prompt = renderMessages(body.messages);
if (tools.length) prompt = `${toolInstructions(tools)}\n\n${prompt}`;
if (!prompt.trim()) return json(res, 400, { error: { message: 'пустой запрос' } });
const imageBytes = imageOf(body.messages);
const started = Date.now();
console.log(`v1 запрос ${prompt.length} симв., инструментов ${tools.length}`);
const answer = await serial(() => track(() => ask({
prompt, imageBytes, timeout: cfg.timeout, log: m => console.log(` ${m}`),
})));
const { calls, clean } = tools.length ? parseToolCalls(answer) : { calls: [], clean: answer };
console.log(`v1 ответ ${answer.length} симв., вызовов ${calls.length}, за ${
Math.round((Date.now() - started) / 1000)} с`);
const model = body.model || MODEL;
const created = Math.floor(Date.now() / 1000);
const id = `chatcmpl-${created}`;
const message = calls.length
? { role: 'assistant', content: clean || null, tool_calls: calls }
: { role: 'assistant', content: answer };
const finish = calls.length ? 'tool_calls' : 'stop';
if (body.stream) {
const base = { id, object: 'chat.completion.chunk', created, model };
const delta = calls.length
? { role: 'assistant', content: clean || '', tool_calls: calls.map((c, i) => ({ index: i, ...c })) }
: { role: 'assistant', content: answer };
return sse(res, [
{ ...base, choices: [{ index: 0, delta, finish_reason: null }] },
{ ...base, choices: [{ index: 0, delta: {}, finish_reason: finish }] },
]);
}
return json(res, 200, {
id, object: 'chat.completion', created, model,
choices: [{ index: 0, message, finish_reason: finish }],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
});
}
// Список моделей в формате OpenAI — некоторые клиенты дёргают его при старте.
if (req.method === 'GET' && url.pathname === '/v1/models') {
return json(res, 200, { object: 'list',
data: [{ id: MODEL, object: 'model', created: 0, owned_by: 'deepseek' }] });
}
// Эмбеддинги веб-чат не отдаёт в принципе. Честный отказ лучше пустого // Эмбеддинги веб-чат не отдаёт в принципе. Честный отказ лучше пустого
// вектора: клиент увидит причину, а не молча испорченный поиск. // вектора: клиент увидит причину, а не молча испорченный поиск.
if (url.pathname === '/api/embeddings' || url.pathname === '/api/embed') { if (url.pathname === '/api/embeddings' || url.pathname === '/api/embed') {