mobygate 0.5.2 → 0.6.0
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 +122 -0
- package/index.html +172 -7
- package/lib/config.js +9 -0
- package/lib/tool-bridge.js +257 -0
- package/lib/updater.js +275 -0
- package/package.json +4 -1
- package/server.js +226 -183
package/server.js
CHANGED
|
@@ -53,11 +53,30 @@ 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);
|
|
59
74
|
|
|
60
75
|
const PORT = parseInt(process.env.PORT || '3456', 10);
|
|
76
|
+
// Bind to loopback only by default — no LAN exposure. Users who intentionally
|
|
77
|
+
// want to share the proxy on a network can set bind: 0.0.0.0 (or a specific
|
|
78
|
+
// interface) in ~/.mobygate/config.yaml, but should add auth in front of it.
|
|
79
|
+
const BIND = process.env.BIND || '127.0.0.1';
|
|
61
80
|
const DEFAULT_MODEL = process.env.DEFAULT_MODEL || 'claude-opus-4-7[1m]';
|
|
62
81
|
const SESSION_TTL_MS = parseInt(process.env.SESSION_TTL_MS || String(60 * 60 * 1000), 10); // 1 hour default
|
|
63
82
|
|
|
@@ -210,114 +229,45 @@ function collectImages(messages) {
|
|
|
210
229
|
}
|
|
211
230
|
|
|
212
231
|
// ---------------------------------------------------------------------------
|
|
213
|
-
// Tool calling (
|
|
232
|
+
// Tool calling (Phase 1: native MCP tools — no more <tool_call> text hack)
|
|
214
233
|
// ---------------------------------------------------------------------------
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
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.
|
|
221
240
|
|
|
222
241
|
function hasTools(body) {
|
|
223
242
|
return Array.isArray(body?.tools) && body.tools.length > 0;
|
|
224
243
|
}
|
|
225
244
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
for (const t of tools) {
|
|
243
|
-
if (t?.type !== 'function' || !t.function) continue;
|
|
244
|
-
const fn = t.function;
|
|
245
|
-
lines.push(`<tool name="${fn.name}">`);
|
|
246
|
-
if (fn.description) lines.push(` <description>${fn.description}</description>`);
|
|
247
|
-
lines.push(` <parameters>${JSON.stringify(fn.parameters || { type: 'object', properties: {} })}</parameters>`);
|
|
248
|
-
lines.push('</tool>');
|
|
249
|
-
}
|
|
250
|
-
return lines.join('\n');
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function formatAssistantForReplay(msg) {
|
|
254
|
-
const parts = [];
|
|
255
|
-
const text = extractContent(msg.content);
|
|
256
|
-
if (text) parts.push(text);
|
|
257
|
-
if (Array.isArray(msg.tool_calls)) {
|
|
258
|
-
for (const tc of msg.tool_calls) {
|
|
259
|
-
if (tc?.type === 'function' && tc.function) {
|
|
260
|
-
let args = {};
|
|
261
|
-
try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
|
|
262
|
-
parts.push(`<tool_call>${JSON.stringify({ name: tc.function.name, arguments: args })}</tool_call>`);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
return parts.join('\n');
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function formatToolResult(msg) {
|
|
270
|
-
const content = extractContent(msg.content);
|
|
271
|
-
const id = msg.tool_call_id || 'unknown';
|
|
272
|
-
const name = msg.name || '';
|
|
273
|
-
return `<tool_result id="${id}" name="${name}">\n${content}\n</tool_result>`;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Parse the model's text output for <tool_call> tags. Returns
|
|
277
|
-
// { toolCalls: [{id, name, arguments}], textBefore: string }
|
|
278
|
-
// when at least one valid call is found, else null.
|
|
279
|
-
function parseToolCalls(text) {
|
|
280
|
-
if (!text || !text.includes('<tool_call>')) return null;
|
|
281
|
-
const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
|
|
282
|
-
const calls = [];
|
|
283
|
-
let firstIdx = -1;
|
|
284
|
-
let m;
|
|
285
|
-
while ((m = re.exec(text)) !== null) {
|
|
286
|
-
if (firstIdx === -1) firstIdx = m.index;
|
|
287
|
-
try {
|
|
288
|
-
const obj = JSON.parse(m[1]);
|
|
289
|
-
if (obj && typeof obj.name === 'string') {
|
|
290
|
-
calls.push({
|
|
291
|
-
id: `call_${uuidv4().replace(/-/g, '').slice(0, 20)}`,
|
|
292
|
-
name: obj.name,
|
|
293
|
-
arguments: JSON.stringify(obj.arguments ?? {}),
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
} catch {
|
|
297
|
-
// ignore malformed tool_call blocks
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
if (!calls.length) return null;
|
|
301
|
-
return { toolCalls: calls, textBefore: text.slice(0, firstIdx).trim() };
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Detect whether the running text contains a COMPLETE <tool_call>...</tool_call>
|
|
305
|
-
// pair — used to abort the SDK early once a call has been emitted.
|
|
306
|
-
function hasCompleteToolCall(text) {
|
|
307
|
-
return /<tool_call>\s*[\s\S]*?<\/tool_call>/.test(text);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function messagesToPrompt(messages, { resuming = false, tools = null } = {}) {
|
|
311
|
-
// When resuming, the SDK already has full history. Only send the new tail:
|
|
312
|
-
// tool_results (if the client is replying with tool outputs) and/or a fresh
|
|
313
|
-
// 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 } = {}) {
|
|
314
261
|
if (resuming) {
|
|
315
|
-
|
|
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 = [];
|
|
316
266
|
let userText = '';
|
|
317
267
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
318
268
|
const msg = messages[i];
|
|
319
269
|
if (msg.role === 'tool') {
|
|
320
|
-
|
|
270
|
+
trailingToolMessages.unshift(msg);
|
|
321
271
|
} else if (msg.role === 'user') {
|
|
322
272
|
userText = extractContent(msg.content);
|
|
323
273
|
break;
|
|
@@ -325,39 +275,20 @@ function messagesToPrompt(messages, { resuming = false, tools = null } = {}) {
|
|
|
325
275
|
break;
|
|
326
276
|
}
|
|
327
277
|
}
|
|
278
|
+
const toolResultsText = toolMessagesToText(trailingToolMessages);
|
|
328
279
|
const parts = [];
|
|
329
|
-
if (
|
|
330
|
-
parts.push(`<tool_results>\n${toolResults.join('\n')}\n</tool_results>`);
|
|
331
|
-
// The model sometimes treats a bare <tool_results> block as "just data"
|
|
332
|
-
// and returns empty. A short nudge keeps the turn productive without
|
|
333
|
-
// biasing what comes next.
|
|
334
|
-
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.');
|
|
335
|
-
}
|
|
280
|
+
if (toolResultsText) parts.push(toolResultsText);
|
|
336
281
|
if (userText) parts.push(userText);
|
|
337
|
-
return
|
|
282
|
+
return {
|
|
283
|
+
promptText: parts.join('\n\n') || extractContent(messages[messages.length - 1]?.content || ''),
|
|
284
|
+
};
|
|
338
285
|
}
|
|
339
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).
|
|
340
290
|
const parts = [];
|
|
341
|
-
// Tool instructions prepended once at the top of the system context.
|
|
342
|
-
if (tools && tools.length) {
|
|
343
|
-
parts.push(`<system>\n${buildToolInstructions(tools)}\n</system>\n`);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Group consecutive tool-role messages so they emit as one <tool_results> block.
|
|
347
|
-
let toolBuffer = [];
|
|
348
|
-
const flushTools = () => {
|
|
349
|
-
if (toolBuffer.length) {
|
|
350
|
-
parts.push(`<tool_results>\n${toolBuffer.join('\n')}\n</tool_results>\n`);
|
|
351
|
-
toolBuffer = [];
|
|
352
|
-
}
|
|
353
|
-
};
|
|
354
|
-
|
|
355
291
|
for (const msg of messages) {
|
|
356
|
-
if (msg.role === 'tool') {
|
|
357
|
-
toolBuffer.push(formatToolResult(msg));
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
|
-
flushTools();
|
|
361
292
|
switch (msg.role) {
|
|
362
293
|
case 'system':
|
|
363
294
|
parts.push(`<system>\n${extractContent(msg.content)}\n</system>\n`);
|
|
@@ -365,18 +296,34 @@ function messagesToPrompt(messages, { resuming = false, tools = null } = {}) {
|
|
|
365
296
|
case 'user':
|
|
366
297
|
parts.push(extractContent(msg.content));
|
|
367
298
|
break;
|
|
368
|
-
case 'assistant':
|
|
369
|
-
|
|
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);
|
|
370
312
|
break;
|
|
313
|
+
}
|
|
371
314
|
}
|
|
372
315
|
}
|
|
373
|
-
|
|
374
|
-
|
|
316
|
+
return {
|
|
317
|
+
promptText: parts.join('\n').trim(),
|
|
318
|
+
};
|
|
375
319
|
}
|
|
376
320
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
+
*/
|
|
380
327
|
function buildQueryPrompt(promptText, imageBlocks) {
|
|
381
328
|
if (!imageBlocks.length) return promptText;
|
|
382
329
|
const content = [
|
|
@@ -439,12 +386,15 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
|
|
|
439
386
|
const existing = getSession(sessionKey);
|
|
440
387
|
const resuming = !!existing?.sdkSessionId;
|
|
441
388
|
const toolsEnabled = hasTools(body);
|
|
442
|
-
const promptText = messagesToPrompt(body.messages, { resuming
|
|
389
|
+
const { promptText } = messagesToPrompt(body.messages, { resuming });
|
|
443
390
|
const images = collectImages(body.messages);
|
|
444
391
|
const prompt = buildQueryPrompt(promptText, images);
|
|
445
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;
|
|
446
396
|
if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
|
|
447
|
-
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`);
|
|
448
398
|
|
|
449
399
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
450
400
|
res.setHeader('Cache-Control', 'no-cache');
|
|
@@ -469,11 +419,17 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
|
|
|
469
419
|
console.log(` [session] resuming: ${sessionKey} → sdk=${existing.sdkSessionId} (msgs=${existing.messageCount})`);
|
|
470
420
|
}
|
|
471
421
|
|
|
472
|
-
|
|
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()
|
|
473
428
|
|
|
474
429
|
const runQuery = async () => {
|
|
475
430
|
// Reset per-attempt state so a 401 retry starts clean
|
|
476
431
|
bufferedText = '';
|
|
432
|
+
collectedToolCalls = [];
|
|
477
433
|
isFirst = true;
|
|
478
434
|
resolvedModel = model;
|
|
479
435
|
capturedSessionId = existing?.sdkSessionId || null;
|
|
@@ -486,7 +442,18 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
|
|
|
486
442
|
permissionMode: 'bypassPermissions',
|
|
487
443
|
allowDangerouslySkipPermissions: true,
|
|
488
444
|
abortController,
|
|
489
|
-
|
|
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
|
+
: {}),
|
|
490
457
|
...(resuming ? { resume: existing.sdkSessionId } : {}),
|
|
491
458
|
...(sessionKey && !resuming ? { persistSession: true } : {}),
|
|
492
459
|
},
|
|
@@ -528,15 +495,25 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
|
|
|
528
495
|
throw new AuthFailureInResultText(turnText);
|
|
529
496
|
}
|
|
530
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
|
+
|
|
531
512
|
if (turnText) {
|
|
532
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.
|
|
533
516
|
bufferedText += turnText;
|
|
534
|
-
// Abort early once we see a complete <tool_call>...</tool_call>
|
|
535
|
-
if (hasCompleteToolCall(bufferedText)) {
|
|
536
|
-
console.log(' [tools] complete tool_call detected — aborting SDK');
|
|
537
|
-
abortController.abort();
|
|
538
|
-
break;
|
|
539
|
-
}
|
|
540
517
|
} else {
|
|
541
518
|
sendSSE(res, makeChunk(requestId, resolvedModel, turnText, isFirst ? 'assistant' : undefined, null));
|
|
542
519
|
isFirst = false;
|
|
@@ -582,9 +559,8 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
|
|
|
582
559
|
// Tools mode: emit the buffered response as a single chunk with either
|
|
583
560
|
// tool_calls (+ finish_reason: tool_calls) or plain text (+ stop).
|
|
584
561
|
if (toolsEnabled && !res.writableEnded) {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
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)`);
|
|
588
564
|
const chunk = {
|
|
589
565
|
id: `chatcmpl-${requestId}`,
|
|
590
566
|
object: 'chat.completion.chunk',
|
|
@@ -594,8 +570,8 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
|
|
|
594
570
|
index: 0,
|
|
595
571
|
delta: {
|
|
596
572
|
role: 'assistant',
|
|
597
|
-
content:
|
|
598
|
-
tool_calls:
|
|
573
|
+
content: bufferedText.trim() || null,
|
|
574
|
+
tool_calls: collectedToolCalls.map((tc, i) => ({
|
|
599
575
|
index: i,
|
|
600
576
|
id: tc.id,
|
|
601
577
|
type: 'function',
|
|
@@ -630,14 +606,16 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
|
|
|
630
606
|
const existing = getSession(sessionKey);
|
|
631
607
|
const resuming = !!existing?.sdkSessionId;
|
|
632
608
|
const toolsEnabled = hasTools(body);
|
|
633
|
-
const promptText = messagesToPrompt(body.messages, { resuming
|
|
609
|
+
const { promptText } = messagesToPrompt(body.messages, { resuming });
|
|
634
610
|
const images = collectImages(body.messages);
|
|
635
611
|
const prompt = buildQueryPrompt(promptText, images);
|
|
636
612
|
const model = resolveModel(body.model);
|
|
613
|
+
const clientToolsServer = toolsEnabled ? buildClientToolsServer(body.tools) : null;
|
|
637
614
|
if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
|
|
638
|
-
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`);
|
|
639
616
|
|
|
640
617
|
let resultText = '';
|
|
618
|
+
let collectedToolCalls = [];
|
|
641
619
|
let resolvedModel = model;
|
|
642
620
|
let inputTokens = 0;
|
|
643
621
|
let outputTokens = 0;
|
|
@@ -651,6 +629,7 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
|
|
|
651
629
|
const runQuery = async () => {
|
|
652
630
|
// Reset per-attempt state so a 401 retry starts clean
|
|
653
631
|
resultText = '';
|
|
632
|
+
collectedToolCalls = [];
|
|
654
633
|
resolvedModel = model;
|
|
655
634
|
inputTokens = 0;
|
|
656
635
|
outputTokens = 0;
|
|
@@ -664,7 +643,14 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
|
|
|
664
643
|
permissionMode: 'bypassPermissions',
|
|
665
644
|
allowDangerouslySkipPermissions: true,
|
|
666
645
|
abortController,
|
|
667
|
-
...(
|
|
646
|
+
...(clientToolsServer
|
|
647
|
+
? {
|
|
648
|
+
mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
|
|
649
|
+
allowedTools: [`${MCP_TOOL_PREFIX}*`],
|
|
650
|
+
}
|
|
651
|
+
: toolsEnabled
|
|
652
|
+
? { allowedTools: [] }
|
|
653
|
+
: {}),
|
|
668
654
|
...(resuming ? { resume: existing.sdkSessionId } : {}),
|
|
669
655
|
...(sessionKey && !resuming ? { persistSession: true } : {}),
|
|
670
656
|
},
|
|
@@ -692,11 +678,15 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
|
|
|
692
678
|
abortController.abort();
|
|
693
679
|
throw new AuthFailureInResultText(resultText);
|
|
694
680
|
}
|
|
695
|
-
//
|
|
696
|
-
if (toolsEnabled &&
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
+
}
|
|
700
690
|
}
|
|
701
691
|
}
|
|
702
692
|
|
|
@@ -736,32 +726,29 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
|
|
|
736
726
|
if (sessionKey) responseHeaders['X-Session-Id'] = sessionKey;
|
|
737
727
|
|
|
738
728
|
// Tool-calling response shape
|
|
739
|
-
if (toolsEnabled) {
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
|
-
// 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
|
|
765
752
|
}
|
|
766
753
|
|
|
767
754
|
res.set(responseHeaders).json({
|
|
@@ -1086,18 +1073,74 @@ app.get('/dashboard/logs', async (req, res) => {
|
|
|
1086
1073
|
}
|
|
1087
1074
|
});
|
|
1088
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
|
+
|
|
1089
1132
|
// ---------------------------------------------------------------------------
|
|
1090
1133
|
// Start
|
|
1091
1134
|
// ---------------------------------------------------------------------------
|
|
1092
1135
|
|
|
1093
|
-
app.listen(PORT, async () => {
|
|
1136
|
+
app.listen(PORT, BIND, async () => {
|
|
1094
1137
|
const ttlMin = Math.round(SESSION_TTL_MS / 60000);
|
|
1095
1138
|
const meta = await loadBuildMeta();
|
|
1096
1139
|
console.log(banner({ version: meta.version }));
|
|
1097
|
-
console.log(`
|
|
1140
|
+
console.log(` bind ${BIND}:${PORT}${BIND === '127.0.0.1' ? ' (loopback only)' : ' (⚠ network-reachable — add auth)'}`);
|
|
1098
1141
|
console.log(` model ${DEFAULT_MODEL}`);
|
|
1099
1142
|
console.log(` session TTL ${ttlMin} min`);
|
|
1100
1143
|
console.log(` dashboard http://localhost:${PORT}`);
|
|
1101
1144
|
console.log('');
|
|
1102
|
-
dashboardBus.emitEvent({ type: 'server.boot', port: PORT, defaultModel: DEFAULT_MODEL });
|
|
1145
|
+
dashboardBus.emitEvent({ type: 'server.boot', port: PORT, bind: BIND, defaultModel: DEFAULT_MODEL });
|
|
1103
1146
|
});
|