From 1cd1ec33a79bf57a6b3ecde379bb9e9bcf44810e Mon Sep 17 00:00:00 2001 From: Alex Cube Date: Fri, 25 Sep 2026 23:35:43 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20OpenAI=20/v1=20=D1=81=20=D1=8D=D0=BC?= =?UTF-8?q?=D1=83=D0=BB=D1=8F=D1=86=D0=B8=D0=B5=D0=B9=20=D0=B2=D1=8B=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены POST /v1/chat/completions и GET /v1/models. Описания инструментов из поля tools вклеиваются в текст промпта, DeepSeek просят звать их блоком {json}, а ответ разбирается обратно в OpenAI tool_calls. Парсятся два формата: наш и родной DeepSeek invoke/parameter. Позволяет подключать агентов на OpenAI-протоколе (например, Hermes) с рабочими вызовами инструментов поверх веб-чата DeepSeek. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011W17JPwr6FwxMAKAHtgEwT --- server.mjs | 172 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/server.mjs b/server.mjs index f10f293..b90bdee 100644 --- a/server.mjs +++ b/server.mjs @@ -136,6 +136,132 @@ export function imageOf(messages) { return null; } +// ──────────────────── OpenAI /v1 с эмуляцией вызова инструментов ──────────── +// Агенты (Hermes и пр.) говорят по OpenAI /v1/chat/completions и передают +// инструменты в поле tools, ожидая обратно tool_calls. У веб-чата DeepSeek нет +// ни ролей, ни функций — поэтому инструменты вклеиваем в текст промпта, просим +// модель звать их блоком {...}, а ответ разбираем обратно +// в tool_calls. Ради этого вся затея: DeepSeek держит формат надёжно, а слабые +// локальные модели через настоящий tool-calling его ломали. + +export const TOOL_OPEN = ''; +export const TOOL_CLOSE = ''; + +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":"","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 не всегда слушается инструкции про {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 }; +} + +// Достаём вызовы инструментов из ответа модели: сперва наш {json}, +// затем, если пусто, — invoke/parameter-формат DeepSeek. +export function parseToolCalls(text) { + const re = /\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') {