mobygate 0.8.3 → 0.9.2
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 +511 -0
- package/bin/mobygate.js +214 -0
- package/inspector.html +200 -3
- package/lib/anthropic.js +6 -1
- package/lib/captures-index.js +524 -0
- package/lib/inference-runner.js +753 -0
- package/lib/openai-translation.js +146 -0
- package/lib/quiet.js +249 -0
- package/lib/request-capture.js +24 -0
- package/package.json +3 -1
- package/server.js +335 -1116
package/server.js
CHANGED
|
@@ -70,16 +70,14 @@ import {
|
|
|
70
70
|
getCurrentVersion,
|
|
71
71
|
} from './lib/updater.js';
|
|
72
72
|
import {
|
|
73
|
-
anthropicMessagesToPrompt,
|
|
74
73
|
collectAnthropicImages,
|
|
75
|
-
buildAnthropicResponse,
|
|
76
|
-
makeStreamTranslator,
|
|
77
74
|
hasAnthropicTools,
|
|
78
|
-
mapStopReason,
|
|
79
|
-
extractSdkUsage,
|
|
80
75
|
} from './lib/anthropic.js';
|
|
76
|
+
import { hasTools, collectImages } from './lib/openai-translation.js';
|
|
77
|
+
import { runInference, openaiSurface, anthropicSurface } from './lib/inference-runner.js';
|
|
81
78
|
import { resolveSessionKey } from './lib/session-derive.js';
|
|
82
79
|
import { captureRequest, captureResponse, isCaptureEnabled, CAPTURE_DIR_PATH } from './lib/request-capture.js';
|
|
80
|
+
import { scrubAnthropicBody, quietDiagnose } from './lib/quiet.js';
|
|
83
81
|
|
|
84
82
|
const __filename = fileURLToPath(import.meta.url);
|
|
85
83
|
const __dirname = dirname(__filename);
|
|
@@ -90,7 +88,32 @@ const PORT = parseInt(process.env.PORT || '3456', 10);
|
|
|
90
88
|
// interface) in ~/.mobygate/config.yaml, but should add auth in front of it.
|
|
91
89
|
const BIND = process.env.BIND || '127.0.0.1';
|
|
92
90
|
const DEFAULT_MODEL = process.env.DEFAULT_MODEL || 'claude-opus-4-7[1m]';
|
|
93
|
-
|
|
91
|
+
// SESSION_TTL_MS: how long mobygate holds onto an idle SDK session before
|
|
92
|
+
// expiring it from its in-memory + on-disk session store. v0.8.5 raises
|
|
93
|
+
// the default from 1h → 4h based on real-world usage data: most multi-
|
|
94
|
+
// channel users (Discord agents serving 20+ channels) revisit channels
|
|
95
|
+
// every few hours, and a 1h TTL forced a fresh `query()` (full prompt
|
|
96
|
+
// re-send) every time. With 4h, mobygate retains the SDK session ID for
|
|
97
|
+
// half a day, so the next request resumes via session-id rather than
|
|
98
|
+
// reissuing the entire prompt.
|
|
99
|
+
//
|
|
100
|
+
// Caveat — this only solves SDK-side session continuity. Anthropic's
|
|
101
|
+
// wire-side prompt cache (5 min default, 1h with the
|
|
102
|
+
// `extended-cache-ttl-2025-04-11` beta) is unaffected; the SDK doesn't
|
|
103
|
+
// currently expose that beta to callers, so cache-creation tax on idle
|
|
104
|
+
// channels still applies. The TTL bump is a partial mitigation, not a
|
|
105
|
+
// fix.
|
|
106
|
+
//
|
|
107
|
+
// Override: SESSION_TTL_MS=14400000 (env, in milliseconds)
|
|
108
|
+
// or MOBY_SESSION_TTL_HOURS=4 (more readable, also accepted)
|
|
109
|
+
const SESSION_TTL_MS = (() => {
|
|
110
|
+
if (process.env.SESSION_TTL_MS) return parseInt(process.env.SESSION_TTL_MS, 10);
|
|
111
|
+
if (process.env.MOBY_SESSION_TTL_HOURS) {
|
|
112
|
+
const h = parseFloat(process.env.MOBY_SESSION_TTL_HOURS);
|
|
113
|
+
if (h > 0) return Math.round(h * 60 * 60 * 1000);
|
|
114
|
+
}
|
|
115
|
+
return 4 * 60 * 60 * 1000; // 4h default (was 1h pre-v0.8.5)
|
|
116
|
+
})();
|
|
94
117
|
|
|
95
118
|
// ---------------------------------------------------------------------------
|
|
96
119
|
// Session store — maps client keys → SDK session IDs (persisted to disk)
|
|
@@ -186,14 +209,22 @@ const MODEL_MAP = {
|
|
|
186
209
|
'claude-opus-4-7[1m]': 'claude-opus-4-7[1m]',
|
|
187
210
|
'claude-opus-4-7-1m': 'claude-opus-4-7[1m]',
|
|
188
211
|
'claude-opus-4-7-200k': 'claude-opus-4-7',
|
|
189
|
-
|
|
212
|
+
// Sonnet 1M context note: unlike Opus 1M (included in Claude Max),
|
|
213
|
+
// Sonnet 1M context requires paid "extra usage" on Max plans —
|
|
214
|
+
// routing all sonnet calls through [1m] gates them on extra-usage
|
|
215
|
+
// billing, so users without it see silent failures. v0.8.4 fix:
|
|
216
|
+
// default sonnet routes to plain `claude-sonnet-4-6` (200k, included);
|
|
217
|
+
// explicit 1M opt-in via the new `claude-sonnet-4-6-1m` alias.
|
|
218
|
+
'claude-sonnet-4': 'claude-sonnet-4-6', // current latest sonnet, 200k (Max-included)
|
|
190
219
|
'claude-sonnet-4-5': 'claude-sonnet-4-5-20250929', // explicit request for older 4-5
|
|
191
|
-
'claude-sonnet-4-6': 'claude-sonnet-4-6
|
|
192
|
-
'claude-sonnet-4-6-
|
|
220
|
+
'claude-sonnet-4-6': 'claude-sonnet-4-6', // 200k context (Max-included)
|
|
221
|
+
'claude-sonnet-4-6-1m': 'claude-sonnet-4-6[1m]', // explicit 1M opt-in (requires Max extra-usage)
|
|
222
|
+
'claude-sonnet-4-6-200k': 'claude-sonnet-4-6', // explicit 200k alias (redundant, kept for clarity)
|
|
193
223
|
'claude-haiku-4': 'claude-haiku-4-5-20251001',
|
|
194
224
|
'claude-haiku-4-5': 'claude-haiku-4-5-20251001',
|
|
195
|
-
'opus': 'claude-opus-4-7[1m]',
|
|
196
|
-
'sonnet': 'claude-sonnet-4-6
|
|
225
|
+
'opus': 'claude-opus-4-7[1m]', // Opus 1M is Max-included
|
|
226
|
+
'sonnet': 'claude-sonnet-4-6', // 200k default; use 'sonnet-1m' for explicit 1M
|
|
227
|
+
'sonnet-1m': 'claude-sonnet-4-6[1m]', // alias for 'sonnet' + explicit 1M opt-in
|
|
197
228
|
'haiku': 'claude-haiku-4-5-20251001',
|
|
198
229
|
};
|
|
199
230
|
|
|
@@ -204,1101 +235,6 @@ function resolveModel(model) {
|
|
|
204
235
|
return MODEL_MAP[stripped] || MODEL_MAP[model] || DEFAULT_MODEL;
|
|
205
236
|
}
|
|
206
237
|
|
|
207
|
-
// ---------------------------------------------------------------------------
|
|
208
|
-
// OpenAI messages → single prompt string
|
|
209
|
-
// ---------------------------------------------------------------------------
|
|
210
|
-
|
|
211
|
-
function extractContent(content) {
|
|
212
|
-
if (typeof content === 'string') return content;
|
|
213
|
-
if (Array.isArray(content)) {
|
|
214
|
-
return content
|
|
215
|
-
.map((part) => {
|
|
216
|
-
if (typeof part === 'string') return part;
|
|
217
|
-
if (part.type === 'text') return part.text;
|
|
218
|
-
if (part.type === 'image_url') return ''; // images carried separately; drop from text
|
|
219
|
-
return JSON.stringify(part);
|
|
220
|
-
})
|
|
221
|
-
.filter(Boolean)
|
|
222
|
-
.join('\n');
|
|
223
|
-
}
|
|
224
|
-
if (content && typeof content === 'object') return JSON.stringify(content);
|
|
225
|
-
return String(content || '');
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Convert an OpenAI message.content array into Anthropic image content blocks.
|
|
229
|
-
// Supports both data: URLs (base64) and remote https URLs.
|
|
230
|
-
function extractImageBlocks(content) {
|
|
231
|
-
if (!Array.isArray(content)) return [];
|
|
232
|
-
const blocks = [];
|
|
233
|
-
for (const part of content) {
|
|
234
|
-
if (!part || part.type !== 'image_url') continue;
|
|
235
|
-
const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url;
|
|
236
|
-
if (!url) continue;
|
|
237
|
-
const dataMatch = /^data:([^;]+);base64,(.+)$/.exec(url);
|
|
238
|
-
if (dataMatch) {
|
|
239
|
-
blocks.push({ type: 'image', source: { type: 'base64', media_type: dataMatch[1], data: dataMatch[2] } });
|
|
240
|
-
} else {
|
|
241
|
-
blocks.push({ type: 'image', source: { type: 'url', url } });
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return blocks;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// Collect images from the LAST user message (OpenAI only attaches images to the latest turn).
|
|
248
|
-
function collectImages(messages) {
|
|
249
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
250
|
-
if (messages[i].role === 'user') return extractImageBlocks(messages[i].content);
|
|
251
|
-
}
|
|
252
|
-
return [];
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ---------------------------------------------------------------------------
|
|
256
|
-
// Tool calling (Phase 1: native MCP tools — no more <tool_call> text hack)
|
|
257
|
-
// ---------------------------------------------------------------------------
|
|
258
|
-
// Client-provided OpenAI tools are registered with the SDK as in-process MCP
|
|
259
|
-
// tools (see lib/tool-bridge.js). The model emits **native** tool_use content
|
|
260
|
-
// blocks in its assistant messages; we abort the SDK on the first one and
|
|
261
|
-
// return OpenAI tool_calls to the client. When the client replies with tool
|
|
262
|
-
// results, we send them back as Anthropic tool_result content blocks inside
|
|
263
|
-
// a single SDKUserMessage — round-tripping cleanly through the SDK session.
|
|
264
|
-
|
|
265
|
-
function hasTools(body) {
|
|
266
|
-
return Array.isArray(body?.tools) && body.tools.length > 0;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Build the prompt text from the OpenAI messages array.
|
|
271
|
-
*
|
|
272
|
-
* Returns `{ promptText }` — a single string ready for the SDK. Tool
|
|
273
|
-
* results are spliced in as <tool_results> XML when present (see
|
|
274
|
-
* lib/tool-bridge.js#toolMessagesToText for why we don't use native
|
|
275
|
-
* tool_result content blocks yet).
|
|
276
|
-
*
|
|
277
|
-
* Resuming vs fresh:
|
|
278
|
-
* - Resuming: SDK has full history. We only send the new tail —
|
|
279
|
-
* trailing tool results plus the most recent user text, if any.
|
|
280
|
-
* - Fresh: SDK starts cold. We serialize the visible history with
|
|
281
|
-
* <system>/<previous_response>/<tool_results> tags. No tool-
|
|
282
|
-
* instruction injection — the SDK MCP registration handles that.
|
|
283
|
-
*/
|
|
284
|
-
function messagesToPrompt(messages, { resuming = false } = {}) {
|
|
285
|
-
if (resuming) {
|
|
286
|
-
// Walk backwards from the end, collecting trailing tool messages and
|
|
287
|
-
// the most recent user text. Tool results are formatted as a text
|
|
288
|
-
// block (see lib/tool-bridge.js#toolMessagesToText for the rationale).
|
|
289
|
-
const trailingToolMessages = [];
|
|
290
|
-
let userText = '';
|
|
291
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
292
|
-
const msg = messages[i];
|
|
293
|
-
if (msg.role === 'tool') {
|
|
294
|
-
trailingToolMessages.unshift(msg);
|
|
295
|
-
} else if (msg.role === 'user') {
|
|
296
|
-
userText = extractContent(msg.content);
|
|
297
|
-
break;
|
|
298
|
-
} else {
|
|
299
|
-
break;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
const toolResultsText = toolMessagesToText(trailingToolMessages);
|
|
303
|
-
if (!userText && !toolResultsText) {
|
|
304
|
-
// Earlier code fell back to extracting whatever was at messages[-1],
|
|
305
|
-
// which on an assistant-terminated history sent the assistant's own
|
|
306
|
-
// previous reply back to the SDK as the new user prompt — and the
|
|
307
|
-
// model would "respond to its own reply." Catch this clearly instead.
|
|
308
|
-
return {
|
|
309
|
-
promptText: '',
|
|
310
|
-
error: 'Resume mode requires the request to end with a user message or tool result. Last message has role "' + (messages[messages.length - 1]?.role || 'unknown') + '".',
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
const parts = [];
|
|
314
|
-
if (toolResultsText) parts.push(toolResultsText);
|
|
315
|
-
if (userText) parts.push(userText);
|
|
316
|
-
return { promptText: parts.join('\n\n') };
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Fresh request: serialize visible history as XML-wrapped text. No
|
|
320
|
-
// tool-instruction injection (the model learns about tools via the SDK
|
|
321
|
-
// MCP registration, not the prompt).
|
|
322
|
-
const parts = [];
|
|
323
|
-
for (const msg of messages) {
|
|
324
|
-
switch (msg.role) {
|
|
325
|
-
case 'system':
|
|
326
|
-
parts.push(`<system>\n${extractContent(msg.content)}\n</system>\n`);
|
|
327
|
-
break;
|
|
328
|
-
case 'user':
|
|
329
|
-
parts.push(extractContent(msg.content));
|
|
330
|
-
break;
|
|
331
|
-
case 'assistant': {
|
|
332
|
-
// Best-effort replay. tool_calls in non-resume history are dropped;
|
|
333
|
-
// the model can usually infer continuity from the surrounding text.
|
|
334
|
-
const text = extractContent(msg.content);
|
|
335
|
-
if (text) parts.push(`<previous_response>\n${text}\n</previous_response>\n`);
|
|
336
|
-
break;
|
|
337
|
-
}
|
|
338
|
-
case 'tool': {
|
|
339
|
-
// Tool messages on a fresh turn (rare — clients normally use
|
|
340
|
-
// session keys). Splice as text since there's no preceding
|
|
341
|
-
// tool_use turn we can bind to natively.
|
|
342
|
-
const text = toolMessagesToText([msg]);
|
|
343
|
-
if (text) parts.push(text);
|
|
344
|
-
break;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
return {
|
|
349
|
-
promptText: parts.join('\n').trim(),
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Wrap promptText + optional image blocks into the form query() expects.
|
|
355
|
-
* Returns a string for the fast path (text-only, no images), or an
|
|
356
|
-
* async iterable yielding one SDKUserMessage with multi-part content
|
|
357
|
-
* when there are images.
|
|
358
|
-
*/
|
|
359
|
-
function buildQueryPrompt(promptText, imageBlocks) {
|
|
360
|
-
if (!imageBlocks.length) return promptText;
|
|
361
|
-
const content = [
|
|
362
|
-
{ type: 'text', text: promptText || '' },
|
|
363
|
-
...imageBlocks,
|
|
364
|
-
];
|
|
365
|
-
async function* gen() {
|
|
366
|
-
yield {
|
|
367
|
-
type: 'user',
|
|
368
|
-
message: { role: 'user', content },
|
|
369
|
-
parent_tool_use_id: null,
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
return gen();
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// ---------------------------------------------------------------------------
|
|
376
|
-
// Normalize model name for OpenAI response format
|
|
377
|
-
// ---------------------------------------------------------------------------
|
|
378
|
-
|
|
379
|
-
function normalizeModelName(model) {
|
|
380
|
-
if (model?.includes('opus')) return 'claude-opus-4';
|
|
381
|
-
if (model?.includes('sonnet')) return 'claude-sonnet-4';
|
|
382
|
-
if (model?.includes('haiku')) return 'claude-haiku-4';
|
|
383
|
-
return model || 'claude-sonnet-4';
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// ---------------------------------------------------------------------------
|
|
387
|
-
// SSE helpers
|
|
388
|
-
// ---------------------------------------------------------------------------
|
|
389
|
-
|
|
390
|
-
function makeChunk(requestId, model, content, role, finishReason) {
|
|
391
|
-
return {
|
|
392
|
-
id: `chatcmpl-${requestId}`,
|
|
393
|
-
object: 'chat.completion.chunk',
|
|
394
|
-
created: Math.floor(Date.now() / 1000),
|
|
395
|
-
model: normalizeModelName(model),
|
|
396
|
-
choices: [{
|
|
397
|
-
index: 0,
|
|
398
|
-
delta: {
|
|
399
|
-
...(role ? { role } : {}),
|
|
400
|
-
...(content !== undefined ? { content } : {}),
|
|
401
|
-
},
|
|
402
|
-
finish_reason: finishReason || null,
|
|
403
|
-
}],
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function sendSSE(res, data) {
|
|
408
|
-
if (!res.writableEnded) {
|
|
409
|
-
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// ---------------------------------------------------------------------------
|
|
414
|
-
// POST /v1/chat/completions — streaming
|
|
415
|
-
// ---------------------------------------------------------------------------
|
|
416
|
-
|
|
417
|
-
async function handleStreaming(req, res, body, requestId, sessionKey) {
|
|
418
|
-
const existing = getSession(sessionKey);
|
|
419
|
-
const resuming = !!existing?.sdkSessionId;
|
|
420
|
-
const toolsEnabled = hasTools(body);
|
|
421
|
-
const { promptText, error: promptError } = messagesToPrompt(body.messages, { resuming });
|
|
422
|
-
if (promptError) {
|
|
423
|
-
return res.status(400).json({
|
|
424
|
-
error: { message: promptError, type: 'invalid_request_error', code: 'invalid_resume_messages' },
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
const images = collectImages(body.messages);
|
|
428
|
-
// NOTE: `prompt` is built inside runQuery (not here) when images are
|
|
429
|
-
// present, because buildQueryPrompt returns a single-use async iterator
|
|
430
|
-
// for multimodal requests. If we built it here and the SDK call hit a
|
|
431
|
-
// 401, runWithAuthRetry would invoke runQuery a second time with the
|
|
432
|
-
// same exhausted iterator → SDK gets an empty user message → silent
|
|
433
|
-
// empty response. Lazy construction inside runQuery rebuilds the
|
|
434
|
-
// iterator per attempt.
|
|
435
|
-
const model = resolveModel(body.model);
|
|
436
|
-
// Build the in-process MCP server exposing client tools to the SDK.
|
|
437
|
-
// null when toolsEnabled is false (or all tools are malformed).
|
|
438
|
-
const clientToolsServer = toolsEnabled ? buildClientToolsServer(body.tools) : null;
|
|
439
|
-
// System-prompt append: tells the model exactly which tools are
|
|
440
|
-
// available and that Claude Code's built-ins (Bash, Grep, Read, etc.)
|
|
441
|
-
// are NOT in this environment. Without this, the model trained-in
|
|
442
|
-
// priors lead it to call Grep/Bash, get blocked by allowedTools, and
|
|
443
|
-
// refuse the task instead of falling back to client tools. ~150 tokens.
|
|
444
|
-
const toolsGuidance = clientToolsServer ? buildToolUsageGuidance(body.tools) : null;
|
|
445
|
-
if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
|
|
446
|
-
if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s) registered as MCP`);
|
|
447
|
-
|
|
448
|
-
res.setHeader('Content-Type', 'text/event-stream');
|
|
449
|
-
res.setHeader('Cache-Control', 'no-cache');
|
|
450
|
-
res.setHeader('Connection', 'keep-alive');
|
|
451
|
-
res.setHeader('X-Request-Id', requestId);
|
|
452
|
-
if (sessionKey) res.setHeader('X-Session-Id', sessionKey);
|
|
453
|
-
res.flushHeaders();
|
|
454
|
-
res.write(':ok\n\n');
|
|
455
|
-
|
|
456
|
-
const abortController = new AbortController();
|
|
457
|
-
let isFirst = true;
|
|
458
|
-
let resolvedModel = model;
|
|
459
|
-
let capturedSessionId = existing?.sdkSessionId || null;
|
|
460
|
-
let clientDisconnected = false;
|
|
461
|
-
let inputTokens = 0;
|
|
462
|
-
let outputTokens = 0;
|
|
463
|
-
let cacheReadTokens = 0;
|
|
464
|
-
let cacheCreateTokens = 0;
|
|
465
|
-
|
|
466
|
-
res.on('close', () => {
|
|
467
|
-
clientDisconnected = true;
|
|
468
|
-
abortController.abort();
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
if (resuming) {
|
|
472
|
-
console.log(` [session] resuming: ${sessionKey} → sdk=${existing.sdkSessionId} (msgs=${existing.messageCount})`);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// Tools-mode buffers text and collects native tool_use blocks. If the
|
|
476
|
-
// model emits text first then a tool_use, we want both: textBefore as
|
|
477
|
-
// the assistant content, plus the tool_calls. (Most clients display the
|
|
478
|
-
// text and then act on the tool_calls.)
|
|
479
|
-
let bufferedText = '';
|
|
480
|
-
let collectedToolCalls = []; // [{id, name, arguments}] from extractToolUses()
|
|
481
|
-
|
|
482
|
-
const runQuery = async () => {
|
|
483
|
-
// Reset per-attempt state so a 401 retry starts clean
|
|
484
|
-
bufferedText = '';
|
|
485
|
-
collectedToolCalls = [];
|
|
486
|
-
isFirst = true;
|
|
487
|
-
resolvedModel = model;
|
|
488
|
-
capturedSessionId = existing?.sdkSessionId || null;
|
|
489
|
-
|
|
490
|
-
// Build the prompt lazily on each attempt — multimodal returns a
|
|
491
|
-
// single-use async iterator. Keeps 401 auth-retries safe.
|
|
492
|
-
const prompt = buildQueryPrompt(promptText, images);
|
|
493
|
-
for await (const message of query({
|
|
494
|
-
prompt,
|
|
495
|
-
options: {
|
|
496
|
-
model,
|
|
497
|
-
maxTurns: toolsEnabled ? 5 : 200,
|
|
498
|
-
permissionMode: 'bypassPermissions',
|
|
499
|
-
allowDangerouslySkipPermissions: true,
|
|
500
|
-
abortController,
|
|
501
|
-
// Tools-mode: register client tools as an in-process MCP server
|
|
502
|
-
// and allow only those (no Bash/Read/etc. — the SDK's built-ins
|
|
503
|
-
// would pollute the session and leak through to the model).
|
|
504
|
-
...(clientToolsServer
|
|
505
|
-
? {
|
|
506
|
-
mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
|
|
507
|
-
allowedTools: [`${MCP_TOOL_PREFIX}*`],
|
|
508
|
-
systemPrompt: { type: 'preset', preset: 'claude_code', append: toolsGuidance },
|
|
509
|
-
}
|
|
510
|
-
: toolsEnabled
|
|
511
|
-
// Tools were requested but none were valid — disable all tools.
|
|
512
|
-
? { allowedTools: [] }
|
|
513
|
-
: {}),
|
|
514
|
-
...(resuming ? { resume: existing.sdkSessionId } : {}),
|
|
515
|
-
...(sessionKey && !resuming ? { persistSession: true } : {}),
|
|
516
|
-
},
|
|
517
|
-
})) {
|
|
518
|
-
if (clientDisconnected) break;
|
|
519
|
-
|
|
520
|
-
const msgPreview = message.type === 'assistant'
|
|
521
|
-
? `content_keys=${Object.keys(message).join(',')}`
|
|
522
|
-
: message.type === 'result'
|
|
523
|
-
? `result=${(message.result || '').slice(0, 60)}`
|
|
524
|
-
: message.subtype || '';
|
|
525
|
-
console.log(` [msg] type=${message.type} ${msgPreview}`);
|
|
526
|
-
|
|
527
|
-
if (message.type === 'system' && message.subtype === 'init' && message.model) {
|
|
528
|
-
resolvedModel = message.model;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
if (message.type === 'assistant' && message.session_id && !capturedSessionId) {
|
|
532
|
-
capturedSessionId = message.session_id;
|
|
533
|
-
console.log(` [session] captured sdk session: ${capturedSessionId}`);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// Extract text from this assistant message
|
|
537
|
-
let turnText = '';
|
|
538
|
-
if (message.type === 'assistant' && message.message?.content) {
|
|
539
|
-
const content = message.message.content;
|
|
540
|
-
if (Array.isArray(content)) {
|
|
541
|
-
for (const b of content) if (b.type === 'text' && b.text) turnText += b.text;
|
|
542
|
-
} else if (typeof content === 'string') {
|
|
543
|
-
turnText = content;
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// Detect auth failure surfaced inline (common on long-running proxies
|
|
548
|
-
// where the SDK's cached creds expire). Throw so runWithAuthRetry
|
|
549
|
-
// treats it like a real 401 exception.
|
|
550
|
-
if (turnText && isAuthFailureText(turnText) && isFirst) {
|
|
551
|
-
abortController.abort();
|
|
552
|
-
throw new AuthFailureInResultText(turnText);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Tools-mode: check for native tool_use content blocks. The moment
|
|
556
|
-
// we see one, abort the SDK — we don't want our stub handler to
|
|
557
|
-
// hang waiting on an execution that's actually happening client-side.
|
|
558
|
-
if (toolsEnabled && message.type === 'assistant' && hasToolUse(message)) {
|
|
559
|
-
const calls = extractToolUses(message);
|
|
560
|
-
if (calls.length) {
|
|
561
|
-
collectedToolCalls.push(...calls);
|
|
562
|
-
if (turnText) bufferedText += turnText;
|
|
563
|
-
console.log(` [tools] ${calls.length} native tool_use block(s) — aborting SDK`);
|
|
564
|
-
abortController.abort();
|
|
565
|
-
break;
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
if (turnText) {
|
|
570
|
-
if (toolsEnabled) {
|
|
571
|
-
// Buffer text in case it precedes a tool_use, or ends up as the
|
|
572
|
-
// final response when the model decides not to call any tools.
|
|
573
|
-
bufferedText += turnText;
|
|
574
|
-
} else {
|
|
575
|
-
sendSSE(res, makeChunk(requestId, resolvedModel, turnText, isFirst ? 'assistant' : undefined, null));
|
|
576
|
-
isFirst = false;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
if (message.type === 'result') {
|
|
581
|
-
if (message.result && isAuthFailureText(message.result) && isFirst) {
|
|
582
|
-
throw new AuthFailureInResultText(message.result);
|
|
583
|
-
}
|
|
584
|
-
if (!toolsEnabled && message.result && isFirst) {
|
|
585
|
-
sendSSE(res, makeChunk(requestId, resolvedModel, message.result, 'assistant', null));
|
|
586
|
-
isFirst = false;
|
|
587
|
-
}
|
|
588
|
-
if (toolsEnabled && !bufferedText && message.result) bufferedText = message.result;
|
|
589
|
-
const usage = extractSdkUsage(message);
|
|
590
|
-
inputTokens = usage.input_tokens;
|
|
591
|
-
outputTokens = usage.output_tokens;
|
|
592
|
-
cacheReadTokens = usage.cache_read_input_tokens;
|
|
593
|
-
cacheCreateTokens = usage.cache_creation_input_tokens;
|
|
594
|
-
console.log(` [model-billed] requested=${resolvedModel} modelUsage=${JSON.stringify(usage.modelUsage || '(none)')}`);
|
|
595
|
-
break;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
};
|
|
599
|
-
|
|
600
|
-
try {
|
|
601
|
-
await runWithAuthRetry({
|
|
602
|
-
attempt: runQuery,
|
|
603
|
-
// Only retry if we haven't written a real chunk yet. In tools mode we
|
|
604
|
-
// buffer internally so any retry is safe regardless.
|
|
605
|
-
bailIfStarted: () => !toolsEnabled && !isFirst,
|
|
606
|
-
onRefreshing: (err) => console.warn(`[auth] 401 on stream — refreshing (${err.message?.slice(0, 80)})`),
|
|
607
|
-
onRetry: (r) => console.log(`[auth] refreshed in ${r.durationMs}ms — retrying stream`),
|
|
608
|
-
});
|
|
609
|
-
} catch (err) {
|
|
610
|
-
// Abort from tool-call detection surfaces as an abort error — not a real failure
|
|
611
|
-
const isAbort = err?.name === 'AbortError' || /aborted/i.test(err?.message || '');
|
|
612
|
-
if (!clientDisconnected && !(toolsEnabled && isAbort)) {
|
|
613
|
-
console.error('[stream] SDK error:', err.message);
|
|
614
|
-
sendSSE(res, { error: { message: err.message, type: 'server_error', code: null } });
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
if (sessionKey && capturedSessionId) {
|
|
619
|
-
upsertSession(sessionKey, capturedSessionId, resolvedModel);
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// Tools mode: emit the buffered response as a single chunk with either
|
|
623
|
-
// tool_calls (+ finish_reason: tool_calls) or plain text (+ stop).
|
|
624
|
-
if (toolsEnabled && !res.writableEnded) {
|
|
625
|
-
if (collectedToolCalls.length > 0) {
|
|
626
|
-
console.log(` [tools] emitting ${collectedToolCalls.length} tool_call(s)`);
|
|
627
|
-
const chunk = {
|
|
628
|
-
id: `chatcmpl-${requestId}`,
|
|
629
|
-
object: 'chat.completion.chunk',
|
|
630
|
-
created: Math.floor(Date.now() / 1000),
|
|
631
|
-
model: normalizeModelName(resolvedModel),
|
|
632
|
-
choices: [{
|
|
633
|
-
index: 0,
|
|
634
|
-
delta: {
|
|
635
|
-
role: 'assistant',
|
|
636
|
-
content: bufferedText.trim() || null,
|
|
637
|
-
tool_calls: collectedToolCalls.map((tc, i) => ({
|
|
638
|
-
index: i,
|
|
639
|
-
id: tc.id,
|
|
640
|
-
type: 'function',
|
|
641
|
-
function: { name: tc.name, arguments: tc.arguments },
|
|
642
|
-
})),
|
|
643
|
-
},
|
|
644
|
-
finish_reason: 'tool_calls',
|
|
645
|
-
}],
|
|
646
|
-
};
|
|
647
|
-
sendSSE(res, chunk);
|
|
648
|
-
} else {
|
|
649
|
-
sendSSE(res, makeChunk(requestId, resolvedModel, bufferedText, 'assistant', null));
|
|
650
|
-
sendSSE(res, makeChunk(requestId, resolvedModel, undefined, undefined, 'stop'));
|
|
651
|
-
}
|
|
652
|
-
res.write('data: [DONE]\n\n');
|
|
653
|
-
res.end();
|
|
654
|
-
captureResponse({
|
|
655
|
-
requestId,
|
|
656
|
-
usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens, cache_creation_input_tokens: cacheCreateTokens },
|
|
657
|
-
status: 'ok',
|
|
658
|
-
stopReason: collectedToolCalls.length > 0 ? 'tool_use' : 'end_turn',
|
|
659
|
-
model: resolvedModel,
|
|
660
|
-
});
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
if (!res.writableEnded) {
|
|
665
|
-
sendSSE(res, makeChunk(requestId, resolvedModel, undefined, undefined, 'stop'));
|
|
666
|
-
res.write('data: [DONE]\n\n');
|
|
667
|
-
res.end();
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
captureResponse({
|
|
671
|
-
requestId,
|
|
672
|
-
usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens, cache_creation_input_tokens: cacheCreateTokens },
|
|
673
|
-
status: clientDisconnected ? 'client_disconnect' : 'ok',
|
|
674
|
-
stopReason: 'end_turn',
|
|
675
|
-
model: resolvedModel,
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// ---------------------------------------------------------------------------
|
|
680
|
-
// POST /v1/chat/completions — non-streaming
|
|
681
|
-
// ---------------------------------------------------------------------------
|
|
682
|
-
|
|
683
|
-
async function handleNonStreaming(res, body, requestId, sessionKey) {
|
|
684
|
-
const existing = getSession(sessionKey);
|
|
685
|
-
const resuming = !!existing?.sdkSessionId;
|
|
686
|
-
const toolsEnabled = hasTools(body);
|
|
687
|
-
const { promptText, error: promptError } = messagesToPrompt(body.messages, { resuming });
|
|
688
|
-
if (promptError) {
|
|
689
|
-
return res.status(400).json({
|
|
690
|
-
error: { message: promptError, type: 'invalid_request_error', code: 'invalid_resume_messages' },
|
|
691
|
-
});
|
|
692
|
-
}
|
|
693
|
-
const images = collectImages(body.messages);
|
|
694
|
-
// NOTE: `prompt` is built inside runQuery (not here) when images are
|
|
695
|
-
// present, because buildQueryPrompt returns a single-use async iterator
|
|
696
|
-
// for multimodal requests. If we built it here and the SDK call hit a
|
|
697
|
-
// 401, runWithAuthRetry would invoke runQuery a second time with the
|
|
698
|
-
// same exhausted iterator → SDK gets an empty user message → silent
|
|
699
|
-
// empty response. Lazy construction inside runQuery rebuilds the
|
|
700
|
-
// iterator per attempt.
|
|
701
|
-
const model = resolveModel(body.model);
|
|
702
|
-
const clientToolsServer = toolsEnabled ? buildClientToolsServer(body.tools) : null;
|
|
703
|
-
const toolsGuidance = clientToolsServer ? buildToolUsageGuidance(body.tools) : null;
|
|
704
|
-
if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
|
|
705
|
-
if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s) registered as MCP`);
|
|
706
|
-
|
|
707
|
-
let resultText = '';
|
|
708
|
-
let collectedToolCalls = [];
|
|
709
|
-
let resolvedModel = model;
|
|
710
|
-
let inputTokens = 0;
|
|
711
|
-
let outputTokens = 0;
|
|
712
|
-
let cacheReadTokens = 0;
|
|
713
|
-
let cacheCreateTokens = 0;
|
|
714
|
-
let stopReason = 'end_turn';
|
|
715
|
-
let capturedSessionId = existing?.sdkSessionId || null;
|
|
716
|
-
const abortController = new AbortController();
|
|
717
|
-
|
|
718
|
-
if (resuming) {
|
|
719
|
-
console.log(` [session] resuming: ${sessionKey} → sdk=${existing.sdkSessionId} (msgs=${existing.messageCount})`);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
const runQuery = async () => {
|
|
723
|
-
// Reset per-attempt state so a 401 retry starts clean
|
|
724
|
-
resultText = '';
|
|
725
|
-
collectedToolCalls = [];
|
|
726
|
-
resolvedModel = model;
|
|
727
|
-
inputTokens = 0;
|
|
728
|
-
outputTokens = 0;
|
|
729
|
-
capturedSessionId = existing?.sdkSessionId || null;
|
|
730
|
-
|
|
731
|
-
// Build the prompt lazily on each attempt — multimodal returns a
|
|
732
|
-
// single-use async iterator. Keeps 401 auth-retries safe.
|
|
733
|
-
const prompt = buildQueryPrompt(promptText, images);
|
|
734
|
-
for await (const message of query({
|
|
735
|
-
prompt,
|
|
736
|
-
options: {
|
|
737
|
-
model,
|
|
738
|
-
maxTurns: toolsEnabled ? 5 : 200,
|
|
739
|
-
permissionMode: 'bypassPermissions',
|
|
740
|
-
allowDangerouslySkipPermissions: true,
|
|
741
|
-
abortController,
|
|
742
|
-
...(clientToolsServer
|
|
743
|
-
? {
|
|
744
|
-
mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
|
|
745
|
-
allowedTools: [`${MCP_TOOL_PREFIX}*`],
|
|
746
|
-
systemPrompt: { type: 'preset', preset: 'claude_code', append: toolsGuidance },
|
|
747
|
-
}
|
|
748
|
-
: toolsEnabled
|
|
749
|
-
? { allowedTools: [] }
|
|
750
|
-
: {}),
|
|
751
|
-
...(resuming ? { resume: existing.sdkSessionId } : {}),
|
|
752
|
-
...(sessionKey && !resuming ? { persistSession: true } : {}),
|
|
753
|
-
},
|
|
754
|
-
})) {
|
|
755
|
-
if (message.type === 'system' && message.subtype === 'init' && message.model) {
|
|
756
|
-
resolvedModel = message.model;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
if (message.type === 'assistant' && message.session_id && !capturedSessionId) {
|
|
760
|
-
capturedSessionId = message.session_id;
|
|
761
|
-
console.log(` [session] captured sdk session: ${capturedSessionId}`);
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
if (message.type === 'assistant' && message.message?.content) {
|
|
765
|
-
const content = message.message.content;
|
|
766
|
-
if (Array.isArray(content)) {
|
|
767
|
-
for (const block of content) {
|
|
768
|
-
if (block.type === 'text') resultText += block.text || '';
|
|
769
|
-
}
|
|
770
|
-
} else if (typeof content === 'string') {
|
|
771
|
-
resultText += content;
|
|
772
|
-
}
|
|
773
|
-
// Detect auth failure surfaced inline (long-running proxy, cached creds)
|
|
774
|
-
if (isAuthFailureText(resultText)) {
|
|
775
|
-
abortController.abort();
|
|
776
|
-
throw new AuthFailureInResultText(resultText);
|
|
777
|
-
}
|
|
778
|
-
// Native tool_use detection — abort the moment a tool_use lands.
|
|
779
|
-
if (toolsEnabled && hasToolUse(message)) {
|
|
780
|
-
const calls = extractToolUses(message);
|
|
781
|
-
if (calls.length) {
|
|
782
|
-
collectedToolCalls.push(...calls);
|
|
783
|
-
console.log(` [tools] ${calls.length} native tool_use block(s) — aborting SDK`);
|
|
784
|
-
abortController.abort();
|
|
785
|
-
break;
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
if (message.type === 'result') {
|
|
791
|
-
if (message.result && !resultText) resultText = message.result;
|
|
792
|
-
if (isAuthFailureText(resultText)) {
|
|
793
|
-
throw new AuthFailureInResultText(resultText);
|
|
794
|
-
}
|
|
795
|
-
const usage = extractSdkUsage(message);
|
|
796
|
-
inputTokens = usage.input_tokens;
|
|
797
|
-
outputTokens = usage.output_tokens;
|
|
798
|
-
cacheReadTokens = usage.cache_read_input_tokens;
|
|
799
|
-
cacheCreateTokens = usage.cache_creation_input_tokens;
|
|
800
|
-
console.log(` [model-billed] requested=${resolvedModel} modelUsage=${JSON.stringify(usage.modelUsage || '(none)')}`);
|
|
801
|
-
if (message.subtype) stopReason = message.subtype;
|
|
802
|
-
break;
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
};
|
|
806
|
-
|
|
807
|
-
try {
|
|
808
|
-
await runWithAuthRetry({
|
|
809
|
-
attempt: runQuery,
|
|
810
|
-
// Non-streaming never writes to res until the end — retry is always safe
|
|
811
|
-
bailIfStarted: () => false,
|
|
812
|
-
onRefreshing: (err) => console.warn(`[auth] 401 on sync call — refreshing (${err.message?.slice(0, 80)})`),
|
|
813
|
-
onRetry: (r) => console.log(`[auth] refreshed in ${r.durationMs}ms — retrying sync call`),
|
|
814
|
-
});
|
|
815
|
-
} catch (err) {
|
|
816
|
-
const isAbort = err?.name === 'AbortError' || /aborted/i.test(err?.message || '');
|
|
817
|
-
if (!(toolsEnabled && isAbort)) {
|
|
818
|
-
console.error('[non-stream] SDK error:', err.message);
|
|
819
|
-
return res.status(500).json({ error: { message: err.message, type: 'server_error', code: null } });
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
if (sessionKey && capturedSessionId) {
|
|
824
|
-
upsertSession(sessionKey, capturedSessionId, resolvedModel);
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
const responseHeaders = {};
|
|
828
|
-
if (sessionKey) responseHeaders['X-Session-Id'] = sessionKey;
|
|
829
|
-
|
|
830
|
-
// Tool-calling response shape
|
|
831
|
-
if (toolsEnabled && collectedToolCalls.length > 0) {
|
|
832
|
-
console.log(` [tools] emitting ${collectedToolCalls.length} tool_call(s)`);
|
|
833
|
-
return res.set(responseHeaders).json({
|
|
834
|
-
id: `chatcmpl-${requestId}`,
|
|
835
|
-
object: 'chat.completion',
|
|
836
|
-
created: Math.floor(Date.now() / 1000),
|
|
837
|
-
model: normalizeModelName(resolvedModel),
|
|
838
|
-
choices: [{
|
|
839
|
-
index: 0,
|
|
840
|
-
message: {
|
|
841
|
-
role: 'assistant',
|
|
842
|
-
content: resultText.trim() || null,
|
|
843
|
-
tool_calls: collectedToolCalls.map((tc) => ({
|
|
844
|
-
id: tc.id,
|
|
845
|
-
type: 'function',
|
|
846
|
-
function: { name: tc.name, arguments: tc.arguments },
|
|
847
|
-
})),
|
|
848
|
-
},
|
|
849
|
-
finish_reason: 'tool_calls',
|
|
850
|
-
}],
|
|
851
|
-
usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens },
|
|
852
|
-
});
|
|
853
|
-
// No tool_use blocks → fall through to normal text response
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
res.set(responseHeaders).json({
|
|
857
|
-
id: `chatcmpl-${requestId}`,
|
|
858
|
-
object: 'chat.completion',
|
|
859
|
-
created: Math.floor(Date.now() / 1000),
|
|
860
|
-
model: normalizeModelName(resolvedModel),
|
|
861
|
-
choices: [{
|
|
862
|
-
index: 0,
|
|
863
|
-
message: { role: 'assistant', content: resultText },
|
|
864
|
-
finish_reason: 'stop',
|
|
865
|
-
}],
|
|
866
|
-
usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens },
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
captureResponse({
|
|
870
|
-
requestId,
|
|
871
|
-
usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens, cache_creation_input_tokens: cacheCreateTokens },
|
|
872
|
-
status: 'ok',
|
|
873
|
-
stopReason,
|
|
874
|
-
model: resolvedModel,
|
|
875
|
-
});
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
// ---------------------------------------------------------------------------
|
|
879
|
-
// POST /v1/messages — Anthropic-native surface (non-streaming + streaming)
|
|
880
|
-
// ---------------------------------------------------------------------------
|
|
881
|
-
// The dual-surface architecture: Hermes uses /v1/chat/completions
|
|
882
|
-
// (OpenAI shape), OpenClaw uses /v1/messages (Anthropic shape). Both
|
|
883
|
-
// translate to the SAME underlying SDK query() — the surfaces are pure
|
|
884
|
-
// translators over a single inference engine.
|
|
885
|
-
//
|
|
886
|
-
// Tool calling: reuses Phase 1's native MCP path from lib/tool-bridge.js.
|
|
887
|
-
// No prompt-injected tool definitions, no <tool_call> text parsing.
|
|
888
|
-
// Inbound tool_results still spliced as text on resume (see anthropic.js
|
|
889
|
-
// docstring for why — Phase 1 limitation, not lifted here).
|
|
890
|
-
|
|
891
|
-
async function handleAnthropicNonStreaming(res, body, requestId, sessionKey) {
|
|
892
|
-
const existing = getSession(sessionKey);
|
|
893
|
-
const resuming = !!existing?.sdkSessionId;
|
|
894
|
-
const toolsEnabled = hasAnthropicTools(body);
|
|
895
|
-
const { promptText, error: promptError } = anthropicMessagesToPrompt(body, { resuming });
|
|
896
|
-
if (promptError) {
|
|
897
|
-
return res.status(400).json({
|
|
898
|
-
type: 'error',
|
|
899
|
-
error: { type: 'invalid_request_error', message: promptError },
|
|
900
|
-
});
|
|
901
|
-
}
|
|
902
|
-
const images = collectAnthropicImages(body.messages || []);
|
|
903
|
-
// See note in handleStreaming — `prompt` is built lazily inside runQuery
|
|
904
|
-
// because the multimodal path returns a single-use async iterator that
|
|
905
|
-
// a 401-retry would exhaust on the first attempt.
|
|
906
|
-
const model = resolveModel(body.model);
|
|
907
|
-
// Translate Anthropic tool defs → OpenAI shape that buildClientToolsServer
|
|
908
|
-
// expects. Both go through the same JSON-Schema → Zod path on the way to
|
|
909
|
-
// MCP; the wrapper shape difference is just `function:{name, parameters}`
|
|
910
|
-
// vs `{name, input_schema}`.
|
|
911
|
-
const toolsForBridge = toolsEnabled
|
|
912
|
-
? body.tools.map((t) => ({
|
|
913
|
-
type: 'function',
|
|
914
|
-
function: { name: t.name, description: t.description || '', parameters: t.input_schema || {} },
|
|
915
|
-
}))
|
|
916
|
-
: null;
|
|
917
|
-
const clientToolsServer = toolsForBridge ? buildClientToolsServer(toolsForBridge) : null;
|
|
918
|
-
const toolsGuidance = clientToolsServer ? buildToolUsageGuidance(toolsForBridge) : null;
|
|
919
|
-
|
|
920
|
-
if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
|
|
921
|
-
if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s) registered as MCP`);
|
|
922
|
-
|
|
923
|
-
let resultText = '';
|
|
924
|
-
let collectedToolCalls = [];
|
|
925
|
-
let resolvedModel = model;
|
|
926
|
-
let inputTokens = 0;
|
|
927
|
-
let outputTokens = 0;
|
|
928
|
-
let cacheReadTokens = 0;
|
|
929
|
-
let cacheCreateTokens = 0;
|
|
930
|
-
let capturedSessionId = existing?.sdkSessionId || null;
|
|
931
|
-
let stopReason = 'end_turn';
|
|
932
|
-
const abortController = new AbortController();
|
|
933
|
-
|
|
934
|
-
if (resuming) {
|
|
935
|
-
console.log(` [session] resuming: ${sessionKey} → sdk=${existing.sdkSessionId} (msgs=${existing.messageCount})`);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
const runQuery = async () => {
|
|
939
|
-
resultText = '';
|
|
940
|
-
collectedToolCalls = [];
|
|
941
|
-
resolvedModel = model;
|
|
942
|
-
inputTokens = 0;
|
|
943
|
-
outputTokens = 0;
|
|
944
|
-
capturedSessionId = existing?.sdkSessionId || null;
|
|
945
|
-
stopReason = 'end_turn';
|
|
946
|
-
|
|
947
|
-
// Build the prompt lazily on each attempt — multimodal returns a
|
|
948
|
-
// single-use async iterator. Keeps 401 auth-retries safe.
|
|
949
|
-
const prompt = buildQueryPrompt(promptText, images);
|
|
950
|
-
for await (const message of query({
|
|
951
|
-
prompt,
|
|
952
|
-
options: {
|
|
953
|
-
model,
|
|
954
|
-
maxTurns: toolsEnabled ? 5 : 200,
|
|
955
|
-
permissionMode: 'bypassPermissions',
|
|
956
|
-
allowDangerouslySkipPermissions: true,
|
|
957
|
-
abortController,
|
|
958
|
-
...(clientToolsServer
|
|
959
|
-
? {
|
|
960
|
-
mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
|
|
961
|
-
allowedTools: [`${MCP_TOOL_PREFIX}*`],
|
|
962
|
-
systemPrompt: { type: 'preset', preset: 'claude_code', append: toolsGuidance },
|
|
963
|
-
}
|
|
964
|
-
: toolsEnabled
|
|
965
|
-
? { allowedTools: [] }
|
|
966
|
-
: {}),
|
|
967
|
-
...(resuming ? { resume: existing.sdkSessionId } : {}),
|
|
968
|
-
...(sessionKey && !resuming ? { persistSession: true } : {}),
|
|
969
|
-
},
|
|
970
|
-
})) {
|
|
971
|
-
if (message.type === 'system' && message.subtype === 'init' && message.model) {
|
|
972
|
-
resolvedModel = message.model;
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
if (message.type === 'assistant' && message.session_id && !capturedSessionId) {
|
|
976
|
-
capturedSessionId = message.session_id;
|
|
977
|
-
console.log(` [session] captured sdk session: ${capturedSessionId}`);
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
if (message.type === 'assistant' && message.message?.content) {
|
|
981
|
-
const content = message.message.content;
|
|
982
|
-
if (Array.isArray(content)) {
|
|
983
|
-
for (const block of content) {
|
|
984
|
-
if (block.type === 'text') resultText += block.text || '';
|
|
985
|
-
}
|
|
986
|
-
} else if (typeof content === 'string') {
|
|
987
|
-
resultText += content;
|
|
988
|
-
}
|
|
989
|
-
if (isAuthFailureText(resultText)) {
|
|
990
|
-
abortController.abort();
|
|
991
|
-
throw new AuthFailureInResultText(resultText);
|
|
992
|
-
}
|
|
993
|
-
if (toolsEnabled && hasToolUse(message)) {
|
|
994
|
-
const calls = extractToolUses(message);
|
|
995
|
-
if (calls.length) {
|
|
996
|
-
collectedToolCalls.push(...calls);
|
|
997
|
-
stopReason = 'tool_use';
|
|
998
|
-
console.log(` [tools] ${calls.length} native tool_use block(s) — aborting SDK`);
|
|
999
|
-
abortController.abort();
|
|
1000
|
-
break;
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
if (message.type === 'result') {
|
|
1006
|
-
if (message.result && !resultText) resultText = message.result;
|
|
1007
|
-
if (isAuthFailureText(resultText)) {
|
|
1008
|
-
throw new AuthFailureInResultText(resultText);
|
|
1009
|
-
}
|
|
1010
|
-
const usage = extractSdkUsage(message);
|
|
1011
|
-
inputTokens = usage.input_tokens;
|
|
1012
|
-
outputTokens = usage.output_tokens;
|
|
1013
|
-
cacheReadTokens = usage.cache_read_input_tokens;
|
|
1014
|
-
cacheCreateTokens = usage.cache_creation_input_tokens;
|
|
1015
|
-
console.log(` [model-billed] requested=${resolvedModel} modelUsage=${JSON.stringify(usage.modelUsage || '(none)')}`);
|
|
1016
|
-
stopReason = mapStopReason(message);
|
|
1017
|
-
break;
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
};
|
|
1021
|
-
|
|
1022
|
-
try {
|
|
1023
|
-
await runWithAuthRetry({
|
|
1024
|
-
attempt: runQuery,
|
|
1025
|
-
bailIfStarted: () => false,
|
|
1026
|
-
onRefreshing: (err) => console.warn(`[auth] 401 on /v1/messages — refreshing (${err.message?.slice(0, 80)})`),
|
|
1027
|
-
onRetry: (r) => console.log(`[auth] refreshed in ${r.durationMs}ms — retrying /v1/messages`),
|
|
1028
|
-
});
|
|
1029
|
-
} catch (err) {
|
|
1030
|
-
const isAbort = err?.name === 'AbortError' || /aborted/i.test(err?.message || '');
|
|
1031
|
-
if (!(toolsEnabled && isAbort)) {
|
|
1032
|
-
console.error('[/v1/messages] SDK error:', err.message);
|
|
1033
|
-
return res.status(500).json({
|
|
1034
|
-
type: 'error',
|
|
1035
|
-
error: { type: 'api_error', message: err.message },
|
|
1036
|
-
});
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
if (sessionKey && capturedSessionId) {
|
|
1041
|
-
upsertSession(sessionKey, capturedSessionId, resolvedModel);
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
if (sessionKey) res.setHeader('X-Session-Id', sessionKey);
|
|
1045
|
-
|
|
1046
|
-
res.json(buildAnthropicResponse({
|
|
1047
|
-
rawText: resultText.trim(),
|
|
1048
|
-
toolUses: collectedToolCalls,
|
|
1049
|
-
model: resolvedModel,
|
|
1050
|
-
usage: { input_tokens: inputTokens, output_tokens: outputTokens },
|
|
1051
|
-
requestId,
|
|
1052
|
-
stopReason,
|
|
1053
|
-
}));
|
|
1054
|
-
|
|
1055
|
-
captureResponse({
|
|
1056
|
-
requestId,
|
|
1057
|
-
usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens, cache_creation_input_tokens: cacheCreateTokens },
|
|
1058
|
-
status: 'ok',
|
|
1059
|
-
stopReason,
|
|
1060
|
-
model: resolvedModel,
|
|
1061
|
-
});
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
|
|
1065
|
-
const existing = getSession(sessionKey);
|
|
1066
|
-
const resuming = !!existing?.sdkSessionId;
|
|
1067
|
-
const toolsEnabled = hasAnthropicTools(body);
|
|
1068
|
-
const { promptText, error: promptError } = anthropicMessagesToPrompt(body, { resuming });
|
|
1069
|
-
if (promptError) {
|
|
1070
|
-
return res.status(400).json({
|
|
1071
|
-
type: 'error',
|
|
1072
|
-
error: { type: 'invalid_request_error', message: promptError },
|
|
1073
|
-
});
|
|
1074
|
-
}
|
|
1075
|
-
const images = collectAnthropicImages(body.messages || []);
|
|
1076
|
-
// See note in handleStreaming — `prompt` is built lazily inside runQuery
|
|
1077
|
-
// because the multimodal path returns a single-use async iterator that
|
|
1078
|
-
// a 401-retry would exhaust on the first attempt.
|
|
1079
|
-
const model = resolveModel(body.model);
|
|
1080
|
-
const toolsForBridge = toolsEnabled
|
|
1081
|
-
? body.tools.map((t) => ({
|
|
1082
|
-
type: 'function',
|
|
1083
|
-
function: { name: t.name, description: t.description || '', parameters: t.input_schema || {} },
|
|
1084
|
-
}))
|
|
1085
|
-
: null;
|
|
1086
|
-
const clientToolsServer = toolsForBridge ? buildClientToolsServer(toolsForBridge) : null;
|
|
1087
|
-
const toolsGuidance = clientToolsServer ? buildToolUsageGuidance(toolsForBridge) : null;
|
|
1088
|
-
|
|
1089
|
-
if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
|
|
1090
|
-
if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s) registered as MCP`);
|
|
1091
|
-
|
|
1092
|
-
res.setHeader('Content-Type', 'text/event-stream');
|
|
1093
|
-
res.setHeader('Cache-Control', 'no-cache');
|
|
1094
|
-
res.setHeader('Connection', 'keep-alive');
|
|
1095
|
-
res.setHeader('X-Request-Id', requestId);
|
|
1096
|
-
if (sessionKey) res.setHeader('X-Session-Id', sessionKey);
|
|
1097
|
-
res.flushHeaders();
|
|
1098
|
-
|
|
1099
|
-
const tx = makeStreamTranslator({ res, requestId, model });
|
|
1100
|
-
const abortController = new AbortController();
|
|
1101
|
-
let resolvedModel = model;
|
|
1102
|
-
let capturedSessionId = existing?.sdkSessionId || null;
|
|
1103
|
-
let inputTokens = 0;
|
|
1104
|
-
let outputTokens = 0;
|
|
1105
|
-
let cacheReadTokens = 0;
|
|
1106
|
-
let cacheCreateTokens = 0;
|
|
1107
|
-
let stopReason = 'end_turn';
|
|
1108
|
-
let clientDisconnected = false;
|
|
1109
|
-
let textEmittedSoFar = ''; // dedup against same-message reflow from SDK
|
|
1110
|
-
let toolUseEmitted = false;
|
|
1111
|
-
|
|
1112
|
-
res.on('close', () => {
|
|
1113
|
-
clientDisconnected = true;
|
|
1114
|
-
abortController.abort();
|
|
1115
|
-
});
|
|
1116
|
-
|
|
1117
|
-
if (resuming) {
|
|
1118
|
-
console.log(` [session] resuming: ${sessionKey} → sdk=${existing.sdkSessionId} (msgs=${existing.messageCount})`);
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
const runQuery = async () => {
|
|
1122
|
-
// Reset per-attempt state in case of 401-retry. Note: tx is reused
|
|
1123
|
-
// across retries, so a successful retry that comes after we already
|
|
1124
|
-
// emitted message_start would surface as a confused stream. We bail
|
|
1125
|
-
// out of retry once the translator has started (see bailIfStarted).
|
|
1126
|
-
resolvedModel = model;
|
|
1127
|
-
capturedSessionId = existing?.sdkSessionId || null;
|
|
1128
|
-
inputTokens = 0;
|
|
1129
|
-
outputTokens = 0;
|
|
1130
|
-
stopReason = 'end_turn';
|
|
1131
|
-
textEmittedSoFar = '';
|
|
1132
|
-
toolUseEmitted = false;
|
|
1133
|
-
|
|
1134
|
-
// Build the prompt lazily on each attempt — multimodal returns a
|
|
1135
|
-
// single-use async iterator. Keeps 401 auth-retries safe.
|
|
1136
|
-
const prompt = buildQueryPrompt(promptText, images);
|
|
1137
|
-
for await (const message of query({
|
|
1138
|
-
prompt,
|
|
1139
|
-
options: {
|
|
1140
|
-
model,
|
|
1141
|
-
maxTurns: toolsEnabled ? 5 : 200,
|
|
1142
|
-
permissionMode: 'bypassPermissions',
|
|
1143
|
-
allowDangerouslySkipPermissions: true,
|
|
1144
|
-
abortController,
|
|
1145
|
-
...(clientToolsServer
|
|
1146
|
-
? {
|
|
1147
|
-
mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
|
|
1148
|
-
allowedTools: [`${MCP_TOOL_PREFIX}*`],
|
|
1149
|
-
systemPrompt: { type: 'preset', preset: 'claude_code', append: toolsGuidance },
|
|
1150
|
-
}
|
|
1151
|
-
: toolsEnabled
|
|
1152
|
-
? { allowedTools: [] }
|
|
1153
|
-
: {}),
|
|
1154
|
-
...(resuming ? { resume: existing.sdkSessionId } : {}),
|
|
1155
|
-
...(sessionKey && !resuming ? { persistSession: true } : {}),
|
|
1156
|
-
},
|
|
1157
|
-
})) {
|
|
1158
|
-
if (clientDisconnected) break;
|
|
1159
|
-
|
|
1160
|
-
if (message.type === 'system' && message.subtype === 'init' && message.model) {
|
|
1161
|
-
resolvedModel = message.model;
|
|
1162
|
-
tx.start(resolvedModel, 0);
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
if (message.type === 'assistant' && message.session_id && !capturedSessionId) {
|
|
1166
|
-
capturedSessionId = message.session_id;
|
|
1167
|
-
console.log(` [session] captured sdk session: ${capturedSessionId}`);
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
if (message.type === 'assistant' && message.message?.content) {
|
|
1171
|
-
const content = message.message.content;
|
|
1172
|
-
|
|
1173
|
-
// Auth-failure short-circuit: throw so runWithAuthRetry handles it.
|
|
1174
|
-
// Only safe before any text has been streamed (otherwise we've
|
|
1175
|
-
// already corrupted the SSE stream and can't undo).
|
|
1176
|
-
if (Array.isArray(content)) {
|
|
1177
|
-
let combined = '';
|
|
1178
|
-
for (const b of content) if (b?.type === 'text' && b.text) combined += b.text;
|
|
1179
|
-
if (combined && isAuthFailureText(combined) && !tx.hasStarted) {
|
|
1180
|
-
abortController.abort();
|
|
1181
|
-
throw new AuthFailureInResultText(combined);
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
// Tool_use detection: emit tool_use blocks structurally and abort.
|
|
1186
|
-
// We do this BEFORE streaming text deltas from this message so the
|
|
1187
|
-
// tool_use block is properly framed (after any pending text block
|
|
1188
|
-
// closes). The translator handles the close-text → open-tool-use
|
|
1189
|
-
// sequencing internally.
|
|
1190
|
-
if (toolsEnabled && hasToolUse(message)) {
|
|
1191
|
-
const calls = extractToolUses(message);
|
|
1192
|
-
if (calls.length) {
|
|
1193
|
-
// Emit any text from this same message *before* the tool_use
|
|
1194
|
-
// (Anthropic streams sometimes have text + tool_use in one
|
|
1195
|
-
// assistant message — preserve that ordering).
|
|
1196
|
-
if (Array.isArray(content)) {
|
|
1197
|
-
for (const b of content) {
|
|
1198
|
-
if (b?.type === 'text' && b.text) {
|
|
1199
|
-
// Compute delta vs what we've emitted to avoid duplication
|
|
1200
|
-
// on aggregator-style assistant messages that resend the
|
|
1201
|
-
// whole accumulated text.
|
|
1202
|
-
const delta = b.text.startsWith(textEmittedSoFar)
|
|
1203
|
-
? b.text.slice(textEmittedSoFar.length)
|
|
1204
|
-
: b.text;
|
|
1205
|
-
if (delta) {
|
|
1206
|
-
tx.pushTextDelta(delta);
|
|
1207
|
-
textEmittedSoFar += delta;
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
for (const tu of calls) tx.pushToolUse(tu);
|
|
1213
|
-
toolUseEmitted = true;
|
|
1214
|
-
stopReason = 'tool_use';
|
|
1215
|
-
console.log(` [tools] ${calls.length} native tool_use block(s) — aborting SDK`);
|
|
1216
|
-
abortController.abort();
|
|
1217
|
-
break;
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
// Plain text-only assistant message: stream the delta.
|
|
1222
|
-
if (Array.isArray(content)) {
|
|
1223
|
-
let combined = '';
|
|
1224
|
-
for (const b of content) if (b?.type === 'text' && b.text) combined += b.text;
|
|
1225
|
-
if (combined) {
|
|
1226
|
-
const delta = combined.startsWith(textEmittedSoFar)
|
|
1227
|
-
? combined.slice(textEmittedSoFar.length)
|
|
1228
|
-
: combined;
|
|
1229
|
-
if (delta) {
|
|
1230
|
-
tx.pushTextDelta(delta);
|
|
1231
|
-
textEmittedSoFar += delta;
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
} else if (typeof content === 'string' && content) {
|
|
1235
|
-
const delta = content.startsWith(textEmittedSoFar)
|
|
1236
|
-
? content.slice(textEmittedSoFar.length)
|
|
1237
|
-
: content;
|
|
1238
|
-
if (delta) {
|
|
1239
|
-
tx.pushTextDelta(delta);
|
|
1240
|
-
textEmittedSoFar += delta;
|
|
1241
|
-
}
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
if (message.type === 'result') {
|
|
1246
|
-
if (message.result && !textEmittedSoFar && !toolUseEmitted) {
|
|
1247
|
-
// Some SDK paths only deliver text via the final result message
|
|
1248
|
-
// (no streaming assistant messages). Emit it here as a single
|
|
1249
|
-
// delta — clients see this as "model started + finished in one
|
|
1250
|
-
// chunk", which is valid SSE.
|
|
1251
|
-
tx.pushTextDelta(message.result);
|
|
1252
|
-
}
|
|
1253
|
-
if (isAuthFailureText(message.result || '') && !tx.hasStarted) {
|
|
1254
|
-
throw new AuthFailureInResultText(message.result);
|
|
1255
|
-
}
|
|
1256
|
-
const usage = extractSdkUsage(message);
|
|
1257
|
-
inputTokens = usage.input_tokens;
|
|
1258
|
-
outputTokens = usage.output_tokens;
|
|
1259
|
-
cacheReadTokens = usage.cache_read_input_tokens;
|
|
1260
|
-
cacheCreateTokens = usage.cache_creation_input_tokens;
|
|
1261
|
-
console.log(` [model-billed] requested=${resolvedModel} modelUsage=${JSON.stringify(usage.modelUsage || '(none)')}`);
|
|
1262
|
-
if (!toolUseEmitted) stopReason = mapStopReason(message);
|
|
1263
|
-
break;
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
};
|
|
1267
|
-
|
|
1268
|
-
try {
|
|
1269
|
-
await runWithAuthRetry({
|
|
1270
|
-
attempt: runQuery,
|
|
1271
|
-
// Once we've emitted message_start or any deltas, the SSE stream is
|
|
1272
|
-
// committed — a retry would fragment it. Same logic as the OpenAI
|
|
1273
|
-
// surface (bail once anything has been written).
|
|
1274
|
-
bailIfStarted: () => tx.hasStarted,
|
|
1275
|
-
onRefreshing: (err) => console.warn(`[auth] 401 on /v1/messages stream — refreshing (${err.message?.slice(0, 80)})`),
|
|
1276
|
-
onRetry: (r) => console.log(`[auth] refreshed in ${r.durationMs}ms — retrying /v1/messages stream`),
|
|
1277
|
-
});
|
|
1278
|
-
} catch (err) {
|
|
1279
|
-
const isAbort = err?.name === 'AbortError' || /aborted/i.test(err?.message || '');
|
|
1280
|
-
if (!clientDisconnected && !(toolsEnabled && isAbort)) {
|
|
1281
|
-
console.error('[/v1/messages stream] SDK error:', err.message);
|
|
1282
|
-
tx.error(err);
|
|
1283
|
-
return;
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
if (sessionKey && capturedSessionId) {
|
|
1288
|
-
upsertSession(sessionKey, capturedSessionId, resolvedModel);
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
tx.finish({ stopReason, usage: { output_tokens: outputTokens } });
|
|
1292
|
-
|
|
1293
|
-
captureResponse({
|
|
1294
|
-
requestId,
|
|
1295
|
-
usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_read_input_tokens: cacheReadTokens, cache_creation_input_tokens: cacheCreateTokens },
|
|
1296
|
-
status: 'ok',
|
|
1297
|
-
stopReason,
|
|
1298
|
-
model: resolvedModel,
|
|
1299
|
-
});
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
238
|
// ---------------------------------------------------------------------------
|
|
1303
239
|
// Express app
|
|
1304
240
|
// ---------------------------------------------------------------------------
|
|
@@ -1407,6 +343,19 @@ app.get('/inspector', async (_req, res) => {
|
|
|
1407
343
|
}
|
|
1408
344
|
});
|
|
1409
345
|
|
|
346
|
+
// GET /v1/chat/completions — RFC 9110: 405 with Allow header so probes
|
|
347
|
+
// (e.g. Hermes onboarding) can detect the endpoint exists. Returning 404
|
|
348
|
+
// on GET makes them think the endpoint is missing entirely.
|
|
349
|
+
const methodNotAllowed = (allow) => (_req, res) => {
|
|
350
|
+
res.set('Allow', allow);
|
|
351
|
+
res.status(405).json({
|
|
352
|
+
error: { message: `Method Not Allowed. Use ${allow}.`, type: 'invalid_request_error', code: 'method_not_allowed' },
|
|
353
|
+
});
|
|
354
|
+
};
|
|
355
|
+
app.get('/v1/chat/completions', methodNotAllowed('POST'));
|
|
356
|
+
app.get('/v1/messages', methodNotAllowed('POST'));
|
|
357
|
+
app.get('/quiet/v1/messages', methodNotAllowed('POST'));
|
|
358
|
+
|
|
1410
359
|
// POST /v1/chat/completions
|
|
1411
360
|
app.post('/v1/chat/completions', async (req, res) => {
|
|
1412
361
|
const requestId = uuidv4().replace(/-/g, '').slice(0, 24);
|
|
@@ -1475,11 +424,14 @@ app.post('/v1/chat/completions', async (req, res) => {
|
|
|
1475
424
|
res.on('finish', () => emitEnd());
|
|
1476
425
|
res.on('close', () => { if (!endEmitted) emitEnd({ status: 'error', error: 'client_disconnect' }); });
|
|
1477
426
|
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
427
|
+
await runInference(
|
|
428
|
+
{ req, res, body, requestId, sessionKey },
|
|
429
|
+
openaiSurface,
|
|
430
|
+
{
|
|
431
|
+
mode: body.stream ? 'stream' : 'json',
|
|
432
|
+
deps: { getSession, upsertSession, resolveModel },
|
|
433
|
+
},
|
|
434
|
+
);
|
|
1483
435
|
});
|
|
1484
436
|
|
|
1485
437
|
// POST /v1/messages — Anthropic-native surface (for OpenClaw etc.).
|
|
@@ -1548,11 +500,104 @@ app.post('/v1/messages', async (req, res) => {
|
|
|
1548
500
|
res.on('finish', () => emitEnd());
|
|
1549
501
|
res.on('close', () => { if (!endEmitted) emitEnd({ status: 'error', error: 'client_disconnect' }); });
|
|
1550
502
|
|
|
1551
|
-
|
|
1552
|
-
|
|
503
|
+
await runInference(
|
|
504
|
+
{ req, res, body, requestId, sessionKey },
|
|
505
|
+
anthropicSurface,
|
|
506
|
+
{
|
|
507
|
+
mode: body.stream ? 'stream' : 'json',
|
|
508
|
+
deps: { getSession, upsertSession, resolveModel },
|
|
509
|
+
},
|
|
510
|
+
);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// POST /quiet/v1/messages — Anthropic-shape, but with two changes vs /v1/messages:
|
|
514
|
+
// 1. Body is scrubbed for known third-party agent identifiers
|
|
515
|
+
// (openclaw, hermes, mobius, etc.) before the SDK forwards it.
|
|
516
|
+
// 2. SDK receives an explicit string systemPrompt — disables the
|
|
517
|
+
// claude_code preset that otherwise injects "I am Claude Code…" framing.
|
|
518
|
+
//
|
|
519
|
+
// Use case: clients that don't want their identity to leak into Anthropic's
|
|
520
|
+
// detection heuristics (e.g. "found 'openclaw' in package.json → flag account
|
|
521
|
+
// for extra-usage billing"). Configurable scrub list at ~/.mobygate/quiet-words.txt.
|
|
522
|
+
app.post('/quiet/v1/messages', async (req, res) => {
|
|
523
|
+
const requestId = uuidv4().replace(/-/g, '').slice(0, 24);
|
|
524
|
+
const body = req.body;
|
|
525
|
+
|
|
526
|
+
if (!body?.messages || !Array.isArray(body.messages) || body.messages.length === 0) {
|
|
527
|
+
return res.status(400).json({
|
|
528
|
+
type: 'error',
|
|
529
|
+
error: { type: 'invalid_request_error', message: 'messages is required and must be a non-empty array' },
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Scrub the body in place BEFORE anything else reads it — capture, session
|
|
534
|
+
// derivation, prompt building all see the scrubbed content from here on.
|
|
535
|
+
// Diagnose first so we can log what we stripped (without leaking the values).
|
|
536
|
+
const diag = quietDiagnose(body);
|
|
537
|
+
scrubAnthropicBody(body);
|
|
538
|
+
|
|
539
|
+
const { key: sessionKey, source: sessionKeySource } = resolveSessionKey({
|
|
540
|
+
headerKey: req.headers['x-session-id'],
|
|
541
|
+
bodyKey: body.session_id,
|
|
542
|
+
body,
|
|
543
|
+
});
|
|
544
|
+
const existing = getSession(sessionKey);
|
|
545
|
+
const sessionTag = sessionKey
|
|
546
|
+
? ` | session=${sessionKey}${sessionKeySource === 'auto' ? ' (auto)' : ''}${existing ? ' (resume)' : ' (new)'}`
|
|
547
|
+
: '';
|
|
548
|
+
|
|
549
|
+
console.log(`[${new Date().toISOString()}] anthropic-quiet ${body.stream ? 'stream' : 'sync'} | model=${body.model} → ${resolveModel(body.model)} | msgs=${body.messages.length}${sessionTag}`);
|
|
550
|
+
if (diag.matches > 0) {
|
|
551
|
+
const breakdown = diag.words.map(w => `${w.word}×${w.count}`).join(' ');
|
|
552
|
+
console.log(` [quiet] scrubbed ${diag.matches} occurrence(s): ${breakdown}`);
|
|
1553
553
|
} else {
|
|
1554
|
-
|
|
554
|
+
console.log(` [quiet] payload was already clean (no matches)`);
|
|
1555
555
|
}
|
|
556
|
+
|
|
557
|
+
captureRequest({ path: '/quiet/v1/messages', body, requestId, sessionKey, sessionKeySource });
|
|
558
|
+
|
|
559
|
+
const startedAt = Date.now();
|
|
560
|
+
const imageBlocks = collectAnthropicImages(body.messages || []).length;
|
|
561
|
+
dashboardBus.emitEvent({
|
|
562
|
+
type: 'request.start',
|
|
563
|
+
id: requestId,
|
|
564
|
+
method: 'POST',
|
|
565
|
+
path: '/quiet/v1/messages',
|
|
566
|
+
model: body.model,
|
|
567
|
+
resolvedModel: resolveModel(body.model),
|
|
568
|
+
session: sessionKey,
|
|
569
|
+
stream: !!body.stream,
|
|
570
|
+
tools: hasAnthropicTools(body),
|
|
571
|
+
images: imageBlocks,
|
|
572
|
+
messages: body.messages.length,
|
|
573
|
+
resuming: !!existing,
|
|
574
|
+
quietScrubs: diag.matches,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
let endEmitted = false;
|
|
578
|
+
const emitEnd = (overrides = {}) => {
|
|
579
|
+
if (endEmitted) return;
|
|
580
|
+
endEmitted = true;
|
|
581
|
+
dashboardBus.emitEvent({
|
|
582
|
+
type: 'request.end',
|
|
583
|
+
id: requestId,
|
|
584
|
+
durationMs: Date.now() - startedAt,
|
|
585
|
+
status: res.statusCode < 400 ? 'ok' : 'error',
|
|
586
|
+
httpStatus: res.statusCode,
|
|
587
|
+
...overrides,
|
|
588
|
+
});
|
|
589
|
+
};
|
|
590
|
+
res.on('finish', () => emitEnd());
|
|
591
|
+
res.on('close', () => { if (!endEmitted) emitEnd({ status: 'error', error: 'client_disconnect' }); });
|
|
592
|
+
|
|
593
|
+
await runInference(
|
|
594
|
+
{ req, res, body, requestId, sessionKey },
|
|
595
|
+
anthropicSurface,
|
|
596
|
+
{
|
|
597
|
+
mode: body.stream ? 'stream' : 'json',
|
|
598
|
+
deps: { getSession, upsertSession, resolveModel },
|
|
599
|
+
},
|
|
600
|
+
);
|
|
1556
601
|
});
|
|
1557
602
|
|
|
1558
603
|
// GET /v1/models
|
|
@@ -1564,7 +609,10 @@ app.get('/v1/models', (_req, res) => {
|
|
|
1564
609
|
{ id: 'claude-opus-4-7', object: 'model', owned_by: 'anthropic', created: now, context_length: 1000000 },
|
|
1565
610
|
{ id: 'claude-opus-4-7-200k', object: 'model', owned_by: 'anthropic', created: now, context_length: 200000 },
|
|
1566
611
|
{ id: 'claude-opus-4-6', object: 'model', owned_by: 'anthropic', created: now, context_length: 1000000 },
|
|
1567
|
-
|
|
612
|
+
// Sonnet defaults to 200k (Max-included). Use claude-sonnet-4-6-1m
|
|
613
|
+
// for the 1M variant, which requires paid extra usage on Max.
|
|
614
|
+
{ id: 'claude-sonnet-4-6', object: 'model', owned_by: 'anthropic', created: now, context_length: 200000 },
|
|
615
|
+
{ id: 'claude-sonnet-4-6-1m', object: 'model', owned_by: 'anthropic', created: now, context_length: 1000000 },
|
|
1568
616
|
{ id: 'claude-haiku-4-5', object: 'model', owned_by: 'anthropic', created: now, context_length: 200000 },
|
|
1569
617
|
],
|
|
1570
618
|
});
|
|
@@ -1914,6 +962,176 @@ app.post('/dashboard/captures-toggle', requireLocalOrigin, async (req, res) => {
|
|
|
1914
962
|
}
|
|
1915
963
|
});
|
|
1916
964
|
|
|
965
|
+
// GET /dashboard/session-costs — per-session cost breakdown (v0.8.5)
|
|
966
|
+
//
|
|
967
|
+
// Aggregates the [model-billed] log lines emitted by each handler's SDK
|
|
968
|
+
// result step. Grouped by session_key. Surfaces:
|
|
969
|
+
// - cost_usd total $ across all turns of this session
|
|
970
|
+
// - turns number of completed (non-tool-use-aborted) turns
|
|
971
|
+
// - dollars_per_turn average cost amortization (low = cache working)
|
|
972
|
+
// - models per-model breakdown (opus vs sonnet vs haiku)
|
|
973
|
+
// - first_user first user message (for human-readable identification)
|
|
974
|
+
//
|
|
975
|
+
// This view exists because today's audit found 38.9% of total spend
|
|
976
|
+
// going to "singleton" sessions — channels that fire once, idle past
|
|
977
|
+
// the wire-cache TTL, then pay cache_creation tax on the next turn.
|
|
978
|
+
// The dashboard tab built off this endpoint lets users spot bleeding
|
|
979
|
+
// channels in real time and decide which to keep warm via cron pings.
|
|
980
|
+
app.get('/dashboard/session-costs', requireLocalOrigin, async (_req, res) => {
|
|
981
|
+
try {
|
|
982
|
+
const { readFile, readdir } = await import('fs/promises');
|
|
983
|
+
const { existsSync } = await import('fs');
|
|
984
|
+
const path = await import('path');
|
|
985
|
+
const { homedir } = await import('os');
|
|
986
|
+
|
|
987
|
+
const logPath = join(LOGS_DIR, 'server.log');
|
|
988
|
+
const captureDir = process.env.MOBYGATE_CAPTURE_DIR
|
|
989
|
+
|| join(process.env.MOBYGATE_HOME || join(homedir(), '.mobygate'), 'captures');
|
|
990
|
+
|
|
991
|
+
// Step 1: parse [model-billed] lines from server.log, associating
|
|
992
|
+
// each with the most recently observed session= line above it.
|
|
993
|
+
const sessions = {}; // sk -> { turns, cost_usd, models: {model -> {turns, cost_usd, in_uncached, cache_read, cache_create, out}} }
|
|
994
|
+
let lastSession = null;
|
|
995
|
+
|
|
996
|
+
if (existsSync(logPath)) {
|
|
997
|
+
const raw = await readFile(logPath, 'utf8');
|
|
998
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
999
|
+
const sessMatch = line.match(/session=(auto_\w+)/);
|
|
1000
|
+
if (sessMatch) lastSession = sessMatch[1];
|
|
1001
|
+
const billed = line.match(/\[model-billed\] requested=\S+ modelUsage=(\{.+\})/);
|
|
1002
|
+
if (billed && lastSession) {
|
|
1003
|
+
let mu;
|
|
1004
|
+
try { mu = JSON.parse(billed[1]); } catch { continue; }
|
|
1005
|
+
if (!sessions[lastSession]) {
|
|
1006
|
+
sessions[lastSession] = { turns: 0, cost_usd: 0, models: {} };
|
|
1007
|
+
}
|
|
1008
|
+
const rec = sessions[lastSession];
|
|
1009
|
+
rec.turns += 1;
|
|
1010
|
+
for (const [model, data] of Object.entries(mu)) {
|
|
1011
|
+
const cost = data.costUSD || 0;
|
|
1012
|
+
rec.cost_usd += cost;
|
|
1013
|
+
if (!rec.models[model]) rec.models[model] = { turns: 0, cost_usd: 0, in_uncached: 0, cache_read: 0, cache_create: 0, out: 0 };
|
|
1014
|
+
const m = rec.models[model];
|
|
1015
|
+
m.turns += 1;
|
|
1016
|
+
m.cost_usd += cost;
|
|
1017
|
+
m.in_uncached += data.inputTokens || 0;
|
|
1018
|
+
m.cache_read += data.cacheReadInputTokens || 0;
|
|
1019
|
+
m.cache_create += data.cacheCreationInputTokens || 0;
|
|
1020
|
+
m.out += data.outputTokens || 0;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Step 2: enrich with capture metadata (first user message, model,
|
|
1027
|
+
// path, msg count) for each session_key. Only need to read enough
|
|
1028
|
+
// captures to find one per session.
|
|
1029
|
+
const sessionMeta = {};
|
|
1030
|
+
if (existsSync(captureDir)) {
|
|
1031
|
+
const files = (await readdir(captureDir))
|
|
1032
|
+
.filter(n => n.endsWith('.json'))
|
|
1033
|
+
.sort()
|
|
1034
|
+
.reverse(); // newest first
|
|
1035
|
+
for (const f of files) {
|
|
1036
|
+
const summaryFile = f.replace(/\.json$/, '.summary.txt');
|
|
1037
|
+
if (!existsSync(join(captureDir, summaryFile))) continue;
|
|
1038
|
+
const summary = await readFile(join(captureDir, summaryFile), 'utf8').catch(() => '');
|
|
1039
|
+
const skMatch = summary.match(/^session_key:\s+(auto_\w+)/m);
|
|
1040
|
+
if (!skMatch) continue;
|
|
1041
|
+
const sk = skMatch[1];
|
|
1042
|
+
if (sessionMeta[sk]) continue; // already have meta
|
|
1043
|
+
const modelMatch = summary.match(/^model:\s+(\S+)/m);
|
|
1044
|
+
const pathMatch = summary.match(/^path:\s+(\S+)/m);
|
|
1045
|
+
const msgsMatch = summary.match(/^messages:\s+(\d+)/m);
|
|
1046
|
+
const lastSeen = (await readFile(join(captureDir, summaryFile)).then(b => b.length).catch(()=>0)) ? f.slice(0, 19) : null;
|
|
1047
|
+
|
|
1048
|
+
let firstUser = null;
|
|
1049
|
+
try {
|
|
1050
|
+
const body = JSON.parse(await readFile(join(captureDir, f), 'utf8'));
|
|
1051
|
+
for (const m of (body.messages || []).slice(0, 5)) {
|
|
1052
|
+
if (m.role !== 'user') continue;
|
|
1053
|
+
const c = m.content;
|
|
1054
|
+
let txt = '';
|
|
1055
|
+
if (Array.isArray(c)) {
|
|
1056
|
+
for (const blk of c) {
|
|
1057
|
+
if (blk?.type === 'text' && blk.text) { txt = blk.text; break; }
|
|
1058
|
+
}
|
|
1059
|
+
} else if (typeof c === 'string') {
|
|
1060
|
+
txt = c;
|
|
1061
|
+
}
|
|
1062
|
+
// Skip "OpenClaw runtime context" boilerplate
|
|
1063
|
+
if (txt && !txt.startsWith('OpenClaw runtime context')) {
|
|
1064
|
+
firstUser = txt.slice(0, 80).replace(/\s+/g, ' ');
|
|
1065
|
+
break;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
} catch {}
|
|
1069
|
+
|
|
1070
|
+
sessionMeta[sk] = {
|
|
1071
|
+
model: modelMatch ? modelMatch[1] : null,
|
|
1072
|
+
path: pathMatch ? pathMatch[1] : null,
|
|
1073
|
+
msgs: msgsMatch ? parseInt(msgsMatch[1], 10) : null,
|
|
1074
|
+
lastSeenIso: lastSeen,
|
|
1075
|
+
firstUser,
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Step 3: combine and sort
|
|
1081
|
+
const out = [];
|
|
1082
|
+
for (const [sk, rec] of Object.entries(sessions)) {
|
|
1083
|
+
const meta = sessionMeta[sk] || {};
|
|
1084
|
+
out.push({
|
|
1085
|
+
session_key: sk,
|
|
1086
|
+
turns: rec.turns,
|
|
1087
|
+
cost_usd: Math.round(rec.cost_usd * 10000) / 10000,
|
|
1088
|
+
per_turn_usd: Math.round((rec.cost_usd / Math.max(rec.turns, 1)) * 10000) / 10000,
|
|
1089
|
+
bucket: rec.turns === 1 ? 'singleton' : rec.turns <= 3 ? 'short' : rec.turns <= 10 ? 'medium' : 'warm',
|
|
1090
|
+
model: meta.model || null,
|
|
1091
|
+
path: meta.path || null,
|
|
1092
|
+
msgs: meta.msgs || null,
|
|
1093
|
+
last_seen: meta.lastSeenIso || null,
|
|
1094
|
+
first_user: meta.firstUser || null,
|
|
1095
|
+
models: Object.fromEntries(
|
|
1096
|
+
Object.entries(rec.models).map(([m, d]) => [m, {
|
|
1097
|
+
turns: d.turns,
|
|
1098
|
+
cost_usd: Math.round(d.cost_usd * 10000) / 10000,
|
|
1099
|
+
in_uncached: d.in_uncached,
|
|
1100
|
+
cache_read: d.cache_read,
|
|
1101
|
+
cache_create: d.cache_create,
|
|
1102
|
+
out: d.out,
|
|
1103
|
+
}]),
|
|
1104
|
+
),
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
out.sort((a, b) => b.cost_usd - a.cost_usd);
|
|
1108
|
+
|
|
1109
|
+
// Step 4: aggregate stats
|
|
1110
|
+
const totalCost = out.reduce((s, r) => s + r.cost_usd, 0);
|
|
1111
|
+
const totalTurns = out.reduce((s, r) => s + r.turns, 0);
|
|
1112
|
+
const buckets = { singleton: { sessions: 0, cost: 0 }, short: { sessions: 0, cost: 0 }, medium: { sessions: 0, cost: 0 }, warm: { sessions: 0, cost: 0 } };
|
|
1113
|
+
for (const r of out) {
|
|
1114
|
+
buckets[r.bucket].sessions += 1;
|
|
1115
|
+
buckets[r.bucket].cost += r.cost_usd;
|
|
1116
|
+
}
|
|
1117
|
+
for (const k of Object.keys(buckets)) {
|
|
1118
|
+
buckets[k].cost = Math.round(buckets[k].cost * 100) / 100;
|
|
1119
|
+
buckets[k].pct_of_total = totalCost > 0 ? Math.round((buckets[k].cost / totalCost) * 1000) / 10 : 0;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
res.json({
|
|
1123
|
+
generatedAt: new Date().toISOString(),
|
|
1124
|
+
total_cost_usd: Math.round(totalCost * 100) / 100,
|
|
1125
|
+
total_turns: totalTurns,
|
|
1126
|
+
session_count: out.length,
|
|
1127
|
+
buckets,
|
|
1128
|
+
sessions: out,
|
|
1129
|
+
});
|
|
1130
|
+
} catch (e) {
|
|
1131
|
+
res.status(500).json({ error: e.message });
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1917
1135
|
// ---------------------------------------------------------------------------
|
|
1918
1136
|
// Updater — dashboard-driven "update available → update now" flow
|
|
1919
1137
|
// ---------------------------------------------------------------------------
|
|
@@ -1976,11 +1194,12 @@ app.get('/update/status', (req, res) => {
|
|
|
1976
1194
|
|
|
1977
1195
|
app.listen(PORT, BIND, async () => {
|
|
1978
1196
|
const ttlMin = Math.round(SESSION_TTL_MS / 60000);
|
|
1197
|
+
const ttlHours = (SESSION_TTL_MS / 3600000).toFixed(1);
|
|
1979
1198
|
const meta = await loadBuildMeta();
|
|
1980
1199
|
console.log(banner({ version: meta.version }));
|
|
1981
1200
|
console.log(` bind ${BIND}:${PORT}${BIND === '127.0.0.1' ? ' (loopback only)' : ' (⚠ network-reachable — add auth)'}`);
|
|
1982
1201
|
console.log(` model ${DEFAULT_MODEL}`);
|
|
1983
|
-
console.log(` session TTL ${ttlMin} min`);
|
|
1202
|
+
console.log(` session TTL ${ttlMin} min (${ttlHours}h)`);
|
|
1984
1203
|
console.log(` dashboard http://localhost:${PORT}`);
|
|
1985
1204
|
if (isCaptureEnabled()) {
|
|
1986
1205
|
console.log(` capture ON → ${CAPTURE_DIR_PATH.replace(process.env.HOME || '', '~')}`);
|