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;
|
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') {
|
||||||
|
|||||||
Reference in New Issue
Block a user