mobygate 0.5.3 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/bin/mobygate.js +9 -4
- package/index.html +154 -0
- package/lib/tool-bridge.js +257 -0
- package/lib/updater.js +275 -0
- package/package.json +1 -1
- package/server.js +219 -180
package/server.js
CHANGED
|
@@ -53,6 +53,21 @@ import { banner } from './lib/ascii.js';
|
|
|
53
53
|
import { bus as dashboardBus } from './lib/dashboard-bus.js';
|
|
54
54
|
import { loadSessions, saveSessions, flushSessionsNow } from './lib/session-store.js';
|
|
55
55
|
import { LOGS_DIR } from './lib/config.js';
|
|
56
|
+
import {
|
|
57
|
+
buildClientToolsServer,
|
|
58
|
+
extractToolUses,
|
|
59
|
+
hasToolUse,
|
|
60
|
+
toolMessagesToText,
|
|
61
|
+
MCP_SERVER_NAME,
|
|
62
|
+
MCP_TOOL_PREFIX,
|
|
63
|
+
} from './lib/tool-bridge.js';
|
|
64
|
+
import {
|
|
65
|
+
getUpdateCheck,
|
|
66
|
+
applyUpdate,
|
|
67
|
+
readUpdateState,
|
|
68
|
+
readUpdateLogTail,
|
|
69
|
+
getCurrentVersion,
|
|
70
|
+
} from './lib/updater.js';
|
|
56
71
|
|
|
57
72
|
const __filename = fileURLToPath(import.meta.url);
|
|
58
73
|
const __dirname = dirname(__filename);
|
|
@@ -214,114 +229,45 @@ function collectImages(messages) {
|
|
|
214
229
|
}
|
|
215
230
|
|
|
216
231
|
// ---------------------------------------------------------------------------
|
|
217
|
-
// Tool calling (
|
|
232
|
+
// Tool calling (Phase 1: native MCP tools — no more <tool_call> text hack)
|
|
218
233
|
// ---------------------------------------------------------------------------
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
234
|
+
// Client-provided OpenAI tools are registered with the SDK as in-process MCP
|
|
235
|
+
// tools (see lib/tool-bridge.js). The model emits **native** tool_use content
|
|
236
|
+
// blocks in its assistant messages; we abort the SDK on the first one and
|
|
237
|
+
// return OpenAI tool_calls to the client. When the client replies with tool
|
|
238
|
+
// results, we send them back as Anthropic tool_result content blocks inside
|
|
239
|
+
// a single SDKUserMessage — round-tripping cleanly through the SDK session.
|
|
225
240
|
|
|
226
241
|
function hasTools(body) {
|
|
227
242
|
return Array.isArray(body?.tools) && body.tools.length > 0;
|
|
228
243
|
}
|
|
229
244
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
for (const t of tools) {
|
|
247
|
-
if (t?.type !== 'function' || !t.function) continue;
|
|
248
|
-
const fn = t.function;
|
|
249
|
-
lines.push(`<tool name="${fn.name}">`);
|
|
250
|
-
if (fn.description) lines.push(` <description>${fn.description}</description>`);
|
|
251
|
-
lines.push(` <parameters>${JSON.stringify(fn.parameters || { type: 'object', properties: {} })}</parameters>`);
|
|
252
|
-
lines.push('</tool>');
|
|
253
|
-
}
|
|
254
|
-
return lines.join('\n');
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function formatAssistantForReplay(msg) {
|
|
258
|
-
const parts = [];
|
|
259
|
-
const text = extractContent(msg.content);
|
|
260
|
-
if (text) parts.push(text);
|
|
261
|
-
if (Array.isArray(msg.tool_calls)) {
|
|
262
|
-
for (const tc of msg.tool_calls) {
|
|
263
|
-
if (tc?.type === 'function' && tc.function) {
|
|
264
|
-
let args = {};
|
|
265
|
-
try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
|
|
266
|
-
parts.push(`<tool_call>${JSON.stringify({ name: tc.function.name, arguments: args })}</tool_call>`);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
return parts.join('\n');
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function formatToolResult(msg) {
|
|
274
|
-
const content = extractContent(msg.content);
|
|
275
|
-
const id = msg.tool_call_id || 'unknown';
|
|
276
|
-
const name = msg.name || '';
|
|
277
|
-
return `<tool_result id="${id}" name="${name}">\n${content}\n</tool_result>`;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Parse the model's text output for <tool_call> tags. Returns
|
|
281
|
-
// { toolCalls: [{id, name, arguments}], textBefore: string }
|
|
282
|
-
// when at least one valid call is found, else null.
|
|
283
|
-
function parseToolCalls(text) {
|
|
284
|
-
if (!text || !text.includes('<tool_call>')) return null;
|
|
285
|
-
const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
|
|
286
|
-
const calls = [];
|
|
287
|
-
let firstIdx = -1;
|
|
288
|
-
let m;
|
|
289
|
-
while ((m = re.exec(text)) !== null) {
|
|
290
|
-
if (firstIdx === -1) firstIdx = m.index;
|
|
291
|
-
try {
|
|
292
|
-
const obj = JSON.parse(m[1]);
|
|
293
|
-
if (obj && typeof obj.name === 'string') {
|
|
294
|
-
calls.push({
|
|
295
|
-
id: `call_${uuidv4().replace(/-/g, '').slice(0, 20)}`,
|
|
296
|
-
name: obj.name,
|
|
297
|
-
arguments: JSON.stringify(obj.arguments ?? {}),
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
} catch {
|
|
301
|
-
// ignore malformed tool_call blocks
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
if (!calls.length) return null;
|
|
305
|
-
return { toolCalls: calls, textBefore: text.slice(0, firstIdx).trim() };
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Detect whether the running text contains a COMPLETE <tool_call>...</tool_call>
|
|
309
|
-
// pair — used to abort the SDK early once a call has been emitted.
|
|
310
|
-
function hasCompleteToolCall(text) {
|
|
311
|
-
return /<tool_call>\s*[\s\S]*?<\/tool_call>/.test(text);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function messagesToPrompt(messages, { resuming = false, tools = null } = {}) {
|
|
315
|
-
// When resuming, the SDK already has full history. Only send the new tail:
|
|
316
|
-
// tool_results (if the client is replying with tool outputs) and/or a fresh
|
|
317
|
-
// user message.
|
|
245
|
+
/**
|
|
246
|
+
* Build the prompt text from the OpenAI messages array.
|
|
247
|
+
*
|
|
248
|
+
* Returns `{ promptText }` — a single string ready for the SDK. Tool
|
|
249
|
+
* results are spliced in as <tool_results> XML when present (see
|
|
250
|
+
* lib/tool-bridge.js#toolMessagesToText for why we don't use native
|
|
251
|
+
* tool_result content blocks yet).
|
|
252
|
+
*
|
|
253
|
+
* Resuming vs fresh:
|
|
254
|
+
* - Resuming: SDK has full history. We only send the new tail —
|
|
255
|
+
* trailing tool results plus the most recent user text, if any.
|
|
256
|
+
* - Fresh: SDK starts cold. We serialize the visible history with
|
|
257
|
+
* <system>/<previous_response>/<tool_results> tags. No tool-
|
|
258
|
+
* instruction injection — the SDK MCP registration handles that.
|
|
259
|
+
*/
|
|
260
|
+
function messagesToPrompt(messages, { resuming = false } = {}) {
|
|
318
261
|
if (resuming) {
|
|
319
|
-
|
|
262
|
+
// Walk backwards from the end, collecting trailing tool messages and
|
|
263
|
+
// the most recent user text. Tool results are formatted as a text
|
|
264
|
+
// block (see lib/tool-bridge.js#toolMessagesToText for the rationale).
|
|
265
|
+
const trailingToolMessages = [];
|
|
320
266
|
let userText = '';
|
|
321
267
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
322
268
|
const msg = messages[i];
|
|
323
269
|
if (msg.role === 'tool') {
|
|
324
|
-
|
|
270
|
+
trailingToolMessages.unshift(msg);
|
|
325
271
|
} else if (msg.role === 'user') {
|
|
326
272
|
userText = extractContent(msg.content);
|
|
327
273
|
break;
|
|
@@ -329,39 +275,20 @@ function messagesToPrompt(messages, { resuming = false, tools = null } = {}) {
|
|
|
329
275
|
break;
|
|
330
276
|
}
|
|
331
277
|
}
|
|
278
|
+
const toolResultsText = toolMessagesToText(trailingToolMessages);
|
|
332
279
|
const parts = [];
|
|
333
|
-
if (
|
|
334
|
-
parts.push(`<tool_results>\n${toolResults.join('\n')}\n</tool_results>`);
|
|
335
|
-
// The model sometimes treats a bare <tool_results> block as "just data"
|
|
336
|
-
// and returns empty. A short nudge keeps the turn productive without
|
|
337
|
-
// biasing what comes next.
|
|
338
|
-
if (!userText) parts.push('Use the tool results above to continue toward the final answer. If more tool calls are needed, emit them; otherwise respond directly.');
|
|
339
|
-
}
|
|
280
|
+
if (toolResultsText) parts.push(toolResultsText);
|
|
340
281
|
if (userText) parts.push(userText);
|
|
341
|
-
return
|
|
282
|
+
return {
|
|
283
|
+
promptText: parts.join('\n\n') || extractContent(messages[messages.length - 1]?.content || ''),
|
|
284
|
+
};
|
|
342
285
|
}
|
|
343
286
|
|
|
287
|
+
// Fresh request: serialize visible history as XML-wrapped text. No
|
|
288
|
+
// tool-instruction injection (the model learns about tools via the SDK
|
|
289
|
+
// MCP registration, not the prompt).
|
|
344
290
|
const parts = [];
|
|
345
|
-
// Tool instructions prepended once at the top of the system context.
|
|
346
|
-
if (tools && tools.length) {
|
|
347
|
-
parts.push(`<system>\n${buildToolInstructions(tools)}\n</system>\n`);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Group consecutive tool-role messages so they emit as one <tool_results> block.
|
|
351
|
-
let toolBuffer = [];
|
|
352
|
-
const flushTools = () => {
|
|
353
|
-
if (toolBuffer.length) {
|
|
354
|
-
parts.push(`<tool_results>\n${toolBuffer.join('\n')}\n</tool_results>\n`);
|
|
355
|
-
toolBuffer = [];
|
|
356
|
-
}
|
|
357
|
-
};
|
|
358
|
-
|
|
359
291
|
for (const msg of messages) {
|
|
360
|
-
if (msg.role === 'tool') {
|
|
361
|
-
toolBuffer.push(formatToolResult(msg));
|
|
362
|
-
continue;
|
|
363
|
-
}
|
|
364
|
-
flushTools();
|
|
365
292
|
switch (msg.role) {
|
|
366
293
|
case 'system':
|
|
367
294
|
parts.push(`<system>\n${extractContent(msg.content)}\n</system>\n`);
|
|
@@ -369,18 +296,34 @@ function messagesToPrompt(messages, { resuming = false, tools = null } = {}) {
|
|
|
369
296
|
case 'user':
|
|
370
297
|
parts.push(extractContent(msg.content));
|
|
371
298
|
break;
|
|
372
|
-
case 'assistant':
|
|
373
|
-
|
|
299
|
+
case 'assistant': {
|
|
300
|
+
// Best-effort replay. tool_calls in non-resume history are dropped;
|
|
301
|
+
// the model can usually infer continuity from the surrounding text.
|
|
302
|
+
const text = extractContent(msg.content);
|
|
303
|
+
if (text) parts.push(`<previous_response>\n${text}\n</previous_response>\n`);
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
case 'tool': {
|
|
307
|
+
// Tool messages on a fresh turn (rare — clients normally use
|
|
308
|
+
// session keys). Splice as text since there's no preceding
|
|
309
|
+
// tool_use turn we can bind to natively.
|
|
310
|
+
const text = toolMessagesToText([msg]);
|
|
311
|
+
if (text) parts.push(text);
|
|
374
312
|
break;
|
|
313
|
+
}
|
|
375
314
|
}
|
|
376
315
|
}
|
|
377
|
-
|
|
378
|
-
|
|
316
|
+
return {
|
|
317
|
+
promptText: parts.join('\n').trim(),
|
|
318
|
+
};
|
|
379
319
|
}
|
|
380
320
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Wrap promptText + optional image blocks into the form query() expects.
|
|
323
|
+
* Returns a string for the fast path (text-only, no images), or an
|
|
324
|
+
* async iterable yielding one SDKUserMessage with multi-part content
|
|
325
|
+
* when there are images.
|
|
326
|
+
*/
|
|
384
327
|
function buildQueryPrompt(promptText, imageBlocks) {
|
|
385
328
|
if (!imageBlocks.length) return promptText;
|
|
386
329
|
const content = [
|
|
@@ -443,12 +386,15 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
|
|
|
443
386
|
const existing = getSession(sessionKey);
|
|
444
387
|
const resuming = !!existing?.sdkSessionId;
|
|
445
388
|
const toolsEnabled = hasTools(body);
|
|
446
|
-
const promptText = messagesToPrompt(body.messages, { resuming
|
|
389
|
+
const { promptText } = messagesToPrompt(body.messages, { resuming });
|
|
447
390
|
const images = collectImages(body.messages);
|
|
448
391
|
const prompt = buildQueryPrompt(promptText, images);
|
|
449
392
|
const model = resolveModel(body.model);
|
|
393
|
+
// Build the in-process MCP server exposing client tools to the SDK.
|
|
394
|
+
// null when toolsEnabled is false (or all tools are malformed).
|
|
395
|
+
const clientToolsServer = toolsEnabled ? buildClientToolsServer(body.tools) : null;
|
|
450
396
|
if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
|
|
451
|
-
if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s)
|
|
397
|
+
if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s) registered as MCP`);
|
|
452
398
|
|
|
453
399
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
454
400
|
res.setHeader('Cache-Control', 'no-cache');
|
|
@@ -473,11 +419,17 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
|
|
|
473
419
|
console.log(` [session] resuming: ${sessionKey} → sdk=${existing.sdkSessionId} (msgs=${existing.messageCount})`);
|
|
474
420
|
}
|
|
475
421
|
|
|
476
|
-
|
|
422
|
+
// Tools-mode buffers text and collects native tool_use blocks. If the
|
|
423
|
+
// model emits text first then a tool_use, we want both: textBefore as
|
|
424
|
+
// the assistant content, plus the tool_calls. (Most clients display the
|
|
425
|
+
// text and then act on the tool_calls.)
|
|
426
|
+
let bufferedText = '';
|
|
427
|
+
let collectedToolCalls = []; // [{id, name, arguments}] from extractToolUses()
|
|
477
428
|
|
|
478
429
|
const runQuery = async () => {
|
|
479
430
|
// Reset per-attempt state so a 401 retry starts clean
|
|
480
431
|
bufferedText = '';
|
|
432
|
+
collectedToolCalls = [];
|
|
481
433
|
isFirst = true;
|
|
482
434
|
resolvedModel = model;
|
|
483
435
|
capturedSessionId = existing?.sdkSessionId || null;
|
|
@@ -490,7 +442,18 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
|
|
|
490
442
|
permissionMode: 'bypassPermissions',
|
|
491
443
|
allowDangerouslySkipPermissions: true,
|
|
492
444
|
abortController,
|
|
493
|
-
|
|
445
|
+
// Tools-mode: register client tools as an in-process MCP server
|
|
446
|
+
// and allow only those (no Bash/Read/etc. — the SDK's built-ins
|
|
447
|
+
// would pollute the session and leak through to the model).
|
|
448
|
+
...(clientToolsServer
|
|
449
|
+
? {
|
|
450
|
+
mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
|
|
451
|
+
allowedTools: [`${MCP_TOOL_PREFIX}*`],
|
|
452
|
+
}
|
|
453
|
+
: toolsEnabled
|
|
454
|
+
// Tools were requested but none were valid — disable all tools.
|
|
455
|
+
? { allowedTools: [] }
|
|
456
|
+
: {}),
|
|
494
457
|
...(resuming ? { resume: existing.sdkSessionId } : {}),
|
|
495
458
|
...(sessionKey && !resuming ? { persistSession: true } : {}),
|
|
496
459
|
},
|
|
@@ -532,15 +495,25 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
|
|
|
532
495
|
throw new AuthFailureInResultText(turnText);
|
|
533
496
|
}
|
|
534
497
|
|
|
498
|
+
// Tools-mode: check for native tool_use content blocks. The moment
|
|
499
|
+
// we see one, abort the SDK — we don't want our stub handler to
|
|
500
|
+
// hang waiting on an execution that's actually happening client-side.
|
|
501
|
+
if (toolsEnabled && message.type === 'assistant' && hasToolUse(message)) {
|
|
502
|
+
const calls = extractToolUses(message);
|
|
503
|
+
if (calls.length) {
|
|
504
|
+
collectedToolCalls.push(...calls);
|
|
505
|
+
if (turnText) bufferedText += turnText;
|
|
506
|
+
console.log(` [tools] ${calls.length} native tool_use block(s) — aborting SDK`);
|
|
507
|
+
abortController.abort();
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
535
512
|
if (turnText) {
|
|
536
513
|
if (toolsEnabled) {
|
|
514
|
+
// Buffer text in case it precedes a tool_use, or ends up as the
|
|
515
|
+
// final response when the model decides not to call any tools.
|
|
537
516
|
bufferedText += turnText;
|
|
538
|
-
// Abort early once we see a complete <tool_call>...</tool_call>
|
|
539
|
-
if (hasCompleteToolCall(bufferedText)) {
|
|
540
|
-
console.log(' [tools] complete tool_call detected — aborting SDK');
|
|
541
|
-
abortController.abort();
|
|
542
|
-
break;
|
|
543
|
-
}
|
|
544
517
|
} else {
|
|
545
518
|
sendSSE(res, makeChunk(requestId, resolvedModel, turnText, isFirst ? 'assistant' : undefined, null));
|
|
546
519
|
isFirst = false;
|
|
@@ -586,9 +559,8 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
|
|
|
586
559
|
// Tools mode: emit the buffered response as a single chunk with either
|
|
587
560
|
// tool_calls (+ finish_reason: tool_calls) or plain text (+ stop).
|
|
588
561
|
if (toolsEnabled && !res.writableEnded) {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
console.log(` [tools] emitting ${parsed.toolCalls.length} tool_call(s)`);
|
|
562
|
+
if (collectedToolCalls.length > 0) {
|
|
563
|
+
console.log(` [tools] emitting ${collectedToolCalls.length} tool_call(s)`);
|
|
592
564
|
const chunk = {
|
|
593
565
|
id: `chatcmpl-${requestId}`,
|
|
594
566
|
object: 'chat.completion.chunk',
|
|
@@ -598,8 +570,8 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
|
|
|
598
570
|
index: 0,
|
|
599
571
|
delta: {
|
|
600
572
|
role: 'assistant',
|
|
601
|
-
content:
|
|
602
|
-
tool_calls:
|
|
573
|
+
content: bufferedText.trim() || null,
|
|
574
|
+
tool_calls: collectedToolCalls.map((tc, i) => ({
|
|
603
575
|
index: i,
|
|
604
576
|
id: tc.id,
|
|
605
577
|
type: 'function',
|
|
@@ -634,14 +606,16 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
|
|
|
634
606
|
const existing = getSession(sessionKey);
|
|
635
607
|
const resuming = !!existing?.sdkSessionId;
|
|
636
608
|
const toolsEnabled = hasTools(body);
|
|
637
|
-
const promptText = messagesToPrompt(body.messages, { resuming
|
|
609
|
+
const { promptText } = messagesToPrompt(body.messages, { resuming });
|
|
638
610
|
const images = collectImages(body.messages);
|
|
639
611
|
const prompt = buildQueryPrompt(promptText, images);
|
|
640
612
|
const model = resolveModel(body.model);
|
|
613
|
+
const clientToolsServer = toolsEnabled ? buildClientToolsServer(body.tools) : null;
|
|
641
614
|
if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
|
|
642
|
-
if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s)`);
|
|
615
|
+
if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s) registered as MCP`);
|
|
643
616
|
|
|
644
617
|
let resultText = '';
|
|
618
|
+
let collectedToolCalls = [];
|
|
645
619
|
let resolvedModel = model;
|
|
646
620
|
let inputTokens = 0;
|
|
647
621
|
let outputTokens = 0;
|
|
@@ -655,6 +629,7 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
|
|
|
655
629
|
const runQuery = async () => {
|
|
656
630
|
// Reset per-attempt state so a 401 retry starts clean
|
|
657
631
|
resultText = '';
|
|
632
|
+
collectedToolCalls = [];
|
|
658
633
|
resolvedModel = model;
|
|
659
634
|
inputTokens = 0;
|
|
660
635
|
outputTokens = 0;
|
|
@@ -668,7 +643,14 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
|
|
|
668
643
|
permissionMode: 'bypassPermissions',
|
|
669
644
|
allowDangerouslySkipPermissions: true,
|
|
670
645
|
abortController,
|
|
671
|
-
...(
|
|
646
|
+
...(clientToolsServer
|
|
647
|
+
? {
|
|
648
|
+
mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
|
|
649
|
+
allowedTools: [`${MCP_TOOL_PREFIX}*`],
|
|
650
|
+
}
|
|
651
|
+
: toolsEnabled
|
|
652
|
+
? { allowedTools: [] }
|
|
653
|
+
: {}),
|
|
672
654
|
...(resuming ? { resume: existing.sdkSessionId } : {}),
|
|
673
655
|
...(sessionKey && !resuming ? { persistSession: true } : {}),
|
|
674
656
|
},
|
|
@@ -696,11 +678,15 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
|
|
|
696
678
|
abortController.abort();
|
|
697
679
|
throw new AuthFailureInResultText(resultText);
|
|
698
680
|
}
|
|
699
|
-
//
|
|
700
|
-
if (toolsEnabled &&
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
681
|
+
// Native tool_use detection — abort the moment a tool_use lands.
|
|
682
|
+
if (toolsEnabled && hasToolUse(message)) {
|
|
683
|
+
const calls = extractToolUses(message);
|
|
684
|
+
if (calls.length) {
|
|
685
|
+
collectedToolCalls.push(...calls);
|
|
686
|
+
console.log(` [tools] ${calls.length} native tool_use block(s) — aborting SDK`);
|
|
687
|
+
abortController.abort();
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
704
690
|
}
|
|
705
691
|
}
|
|
706
692
|
|
|
@@ -740,32 +726,29 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
|
|
|
740
726
|
if (sessionKey) responseHeaders['X-Session-Id'] = sessionKey;
|
|
741
727
|
|
|
742
728
|
// Tool-calling response shape
|
|
743
|
-
if (toolsEnabled) {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
});
|
|
767
|
-
}
|
|
768
|
-
// No tool_call tags → fall through to normal text response
|
|
729
|
+
if (toolsEnabled && collectedToolCalls.length > 0) {
|
|
730
|
+
console.log(` [tools] emitting ${collectedToolCalls.length} tool_call(s)`);
|
|
731
|
+
return res.set(responseHeaders).json({
|
|
732
|
+
id: `chatcmpl-${requestId}`,
|
|
733
|
+
object: 'chat.completion',
|
|
734
|
+
created: Math.floor(Date.now() / 1000),
|
|
735
|
+
model: normalizeModelName(resolvedModel),
|
|
736
|
+
choices: [{
|
|
737
|
+
index: 0,
|
|
738
|
+
message: {
|
|
739
|
+
role: 'assistant',
|
|
740
|
+
content: resultText.trim() || null,
|
|
741
|
+
tool_calls: collectedToolCalls.map((tc) => ({
|
|
742
|
+
id: tc.id,
|
|
743
|
+
type: 'function',
|
|
744
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
745
|
+
})),
|
|
746
|
+
},
|
|
747
|
+
finish_reason: 'tool_calls',
|
|
748
|
+
}],
|
|
749
|
+
usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens },
|
|
750
|
+
});
|
|
751
|
+
// No tool_use blocks → fall through to normal text response
|
|
769
752
|
}
|
|
770
753
|
|
|
771
754
|
res.set(responseHeaders).json({
|
|
@@ -1090,6 +1073,62 @@ app.get('/dashboard/logs', async (req, res) => {
|
|
|
1090
1073
|
}
|
|
1091
1074
|
});
|
|
1092
1075
|
|
|
1076
|
+
// ---------------------------------------------------------------------------
|
|
1077
|
+
// Updater — dashboard-driven "update available → update now" flow
|
|
1078
|
+
// ---------------------------------------------------------------------------
|
|
1079
|
+
|
|
1080
|
+
// GET /update/check — is there a newer mobygate on npm?
|
|
1081
|
+
// Response: { current, latest, updateAvailable, installMode, canApply, cached, error }
|
|
1082
|
+
// Safe to poll: the npm registry call is cached for 15 min in-process.
|
|
1083
|
+
app.get('/update/check', async (req, res) => {
|
|
1084
|
+
try {
|
|
1085
|
+
const force = req.query.force === '1' || req.query.force === 'true';
|
|
1086
|
+
const info = await getUpdateCheck({ force });
|
|
1087
|
+
res.json(info);
|
|
1088
|
+
} catch (e) {
|
|
1089
|
+
res.status(500).json({ error: e.message });
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// POST /update/apply — fire the update in a detached child process.
|
|
1094
|
+
// We return immediately with { started, pid }. The child runs
|
|
1095
|
+
// `npm install -g mobygate@latest` (or `git pull && npm install`), then
|
|
1096
|
+
// restarts the service — which kills us. The dashboard polls
|
|
1097
|
+
// /update/status to show progress and reconnects once the new server is up.
|
|
1098
|
+
app.post('/update/apply', (_req, res) => {
|
|
1099
|
+
try {
|
|
1100
|
+
const result = applyUpdate({});
|
|
1101
|
+
const status = result.started ? 202 : 409;
|
|
1102
|
+
res.status(status).json({ ...result, currentVersion: getCurrentVersion() });
|
|
1103
|
+
if (result.started) {
|
|
1104
|
+
dashboardBus.emitEvent({ type: 'update.started', pid: result.pid, mode: result.mode });
|
|
1105
|
+
}
|
|
1106
|
+
} catch (e) {
|
|
1107
|
+
res.status(500).json({ started: false, error: e.message });
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
// GET /update/status — progress for a running (or just-finished) update.
|
|
1112
|
+
// The dashboard polls this during apply. `running` is determined by
|
|
1113
|
+
// PID liveness, so even if our process is the one getting restarted,
|
|
1114
|
+
// the new one answers correctly.
|
|
1115
|
+
app.get('/update/status', (req, res) => {
|
|
1116
|
+
const state = readUpdateState();
|
|
1117
|
+
let running = false;
|
|
1118
|
+
if (state.pid) {
|
|
1119
|
+
try { process.kill(state.pid, 0); running = true; } catch {}
|
|
1120
|
+
}
|
|
1121
|
+
const lines = Math.min(1000, parseInt(req.query.lines || '200', 10));
|
|
1122
|
+
res.json({
|
|
1123
|
+
running,
|
|
1124
|
+
pid: state.pid || null,
|
|
1125
|
+
startedAt: state.startedAt || null,
|
|
1126
|
+
mode: state.mode || null,
|
|
1127
|
+
lines: readUpdateLogTail({ lines }),
|
|
1128
|
+
currentVersion: getCurrentVersion(),
|
|
1129
|
+
});
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1093
1132
|
// ---------------------------------------------------------------------------
|
|
1094
1133
|
// Start
|
|
1095
1134
|
// ---------------------------------------------------------------------------
|