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:
co-authored by
Claude Opus 5.5
parent
6ccb5dd46b
commit
1cd1ec33a7
+172
@@ -136,6 +136,132 @@ export function imageOf(messages) {
|
||||
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 text = JSON.stringify(body);
|
||||
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' });
|
||||
}
|
||||
|
||||
// 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') {
|
||||
|
||||
Reference in New Issue
Block a user