mixdog 0.7.17 → 0.7.18

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.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/UNINSTALL.md +7 -4
  4. package/bin/statusline-lib.mjs +29 -6
  5. package/bin/statusline-route.mjs +273 -0
  6. package/bin/statusline.mjs +31 -8
  7. package/commands/model.md +61 -0
  8. package/hooks/session-start.cjs +15 -1
  9. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  10. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  11. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  12. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  13. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  14. package/package.json +1 -1
  15. package/rules/lead/01-general.md +3 -0
  16. package/scripts/gateway-model.mjs +596 -0
  17. package/scripts/lib/gateway-inventory.mjs +178 -0
  18. package/scripts/lib/gateway-settings.mjs +78 -0
  19. package/scripts/run-mcp.mjs +26 -1
  20. package/scripts/statusline-launcher-smoke.mjs +155 -2
  21. package/scripts/uninstall.mjs +44 -7
  22. package/server-main.mjs +252 -11
  23. package/src/agent/index.mjs +96 -5
  24. package/src/agent/orchestrator/bridge-trace.mjs +18 -0
  25. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +4 -1
  26. package/src/agent/orchestrator/providers/anthropic.mjs +6 -2
  27. package/src/agent/orchestrator/session/loop.mjs +145 -4
  28. package/src/agent/orchestrator/session/trim.mjs +1 -1
  29. package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +62 -6
  30. package/src/agent/orchestrator/tools/graph-manifest.json +11 -11
  31. package/src/agent/orchestrator/tools/patch-manifest.json +7 -7
  32. package/src/agent/tool-defs.mjs +10 -3
  33. package/src/channels/lib/runtime-paths.mjs +39 -4
  34. package/src/gateway/claude-current.mjs +255 -0
  35. package/src/gateway/oauth-usage.mjs +598 -0
  36. package/src/gateway/route-meta.mjs +629 -0
  37. package/src/gateway/server.mjs +713 -0
  38. package/src/shared/llm/cost.mjs +1 -1
  39. package/src/shared/seed.mjs +25 -0
  40. package/tools.json +21 -3
@@ -0,0 +1,713 @@
1
+ /**
2
+ * mixdog — Anthropic-compatible local gateway.
3
+ *
4
+ * A loopback-only HTTP server that speaks the Anthropic Messages API
5
+ * (`POST /v1/messages`) so an external Claude Code can point
6
+ * ANTHROPIC_BASE_URL at it and have its MAIN model routed to ANY mixdog
7
+ * provider via the unified provider registry:
8
+ *
9
+ * Anthropic /v1/messages request
10
+ * → toCommon() messages
11
+ * → getProvider(name).send(messages, model, tools, opts)
12
+ * → Anthropic JSON (non-stream) OR Anthropic SSE (stream)
13
+ *
14
+ * The SSE sequence (message_start → content_block_start →
15
+ * content_block_delta{text_delta} → content_block_stop → message_delta →
16
+ * message_stop) is the exact shape parseSSEStream() in
17
+ * src/agent/orchestrator/providers/anthropic-oauth.mjs consumes.
18
+ *
19
+ * send() returns { content, toolCalls, stopReason, usage }; onStreamDelta
20
+ * is heartbeat-only (no token text) today, so streaming is a PSEUDO-stream:
21
+ * the full reply is assembled, then chunked back out as SSE. tool_use is
22
+ * fully mapped (request tools → send(); toolCalls → tool_use blocks /
23
+ * input_json_delta SSE). Real token-level streaming is a LATER step.
24
+ *
25
+ * Two ways to run:
26
+ * 1. Imported: `startGatewayServer({ provider, model, port, log })` —
27
+ * kept as a named export for tests / ad-hoc in-process hosting.
28
+ * 2. Forked directly via `fork('src/gateway/server.mjs')` — config is
29
+ * read from the agent config + MIXDOG_GATEWAY_* env. The parent kills
30
+ * this process at shutdown.
31
+ *
32
+ * Module is OFF by default (modules.gateway.enabled !== true) so existing
33
+ * installs are unaffected.
34
+ */
35
+
36
+ import http from 'http';
37
+ import os from 'os';
38
+ import path from 'path';
39
+ import { pathToFileURL } from 'url';
40
+ import { readSection } from '../shared/config.mjs';
41
+ import { updateJsonAtomicSync } from '../shared/atomic-file.mjs';
42
+ import { isInclusiveProvider } from '../shared/llm/cost.mjs';
43
+ import {
44
+ buildGatewayLimits,
45
+ gatewayAdvertFields,
46
+ gatewaySendOptions,
47
+ prepareGatewayMessages,
48
+ readGatewayRouteInfo,
49
+ recordGatewayUsageEvent,
50
+ summarizeGatewayUsage,
51
+ } from './route-meta.mjs';
52
+ import {
53
+ fetchOAuthUsageSnapshot,
54
+ readCachedOAuthUsageSnapshot,
55
+ } from './oauth-usage.mjs';
56
+ import {
57
+ CLAUDE_CURRENT_MODE,
58
+ readClaudeCodeCurrentRoute,
59
+ } from './claude-current.mjs';
60
+
61
+ // Fixed default port so an external Claude Code's ANTHROPIC_BASE_URL can be
62
+ // a stable URL. Overridable via env (MIXDOG_GATEWAY_PORT) or config
63
+ // (gateway.port). Kept off the ephemeral-port path the status server uses —
64
+ // that server is discovered via an advert file, whereas this one is typed
65
+ // into a client env var. 3468 sits just ABOVE the channels proxy range
66
+ // (PROXY_PORT_MIN=3460 / PROXY_PORT_MAX=3467 in src/channels/index.mjs) and
67
+ // clear of setup's 3458, so the default never collides.
68
+ export const DEFAULT_GATEWAY_PORT = 3468;
69
+ const GATEWAY_HOST = '127.0.0.1';
70
+
71
+ const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
72
+ ? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
73
+ : path.join(os.tmpdir(), 'mixdog');
74
+ const ACTIVE_INSTANCE_FILE = path.join(RUNTIME_ROOT, 'active-instance.json');
75
+
76
+ function parsePositivePid(value) {
77
+ const pid = Number(value);
78
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
79
+ }
80
+
81
+ // process.kill(pid, 0) probes existence without delivering a signal: it
82
+ // throws ESRCH for a dead pid and EPERM for a live one we can't signal
83
+ // (so EPERM counts as alive). Mirrors _isPidAliveLocal in src/memory/index.mjs.
84
+ function isPidAlive(pid) {
85
+ const n = parsePositivePid(pid);
86
+ if (!n) return false;
87
+ try { process.kill(n, 0); return true; }
88
+ catch (err) { return err && err.code === 'EPERM'; }
89
+ }
90
+
91
+ const GATEWAY_SERVER_PID = parsePositivePid(process.pid);
92
+
93
+ // ── Config ───────────────────────────────────────────────────────────
94
+ // Mirrors how src/agent/orchestrator/config.mjs reads provider config:
95
+ // read a top-level section from mixdog-config.json (readSection returns {}
96
+ // when absent / malformed) and overlay env. Every field is optional and
97
+ // resolves to a safe, disabled-friendly default so an enabled-but-unconfigured
98
+ // module is a no-op (refuses to route) rather than a crash.
99
+ //
100
+ // gateway.defaultProvider → routing target provider name (registry key)
101
+ // gateway.defaultModel → model id passed to provider.send()
102
+ // gateway.port → listen port (default 3468)
103
+ //
104
+ // Env overrides take precedence so a launch script / step-4 setup can pin
105
+ // values without touching config:
106
+ // MIXDOG_GATEWAY_PROVIDER, MIXDOG_GATEWAY_MODEL, MIXDOG_GATEWAY_PORT
107
+ export function readGatewayConfig() {
108
+ let section = {};
109
+ try { section = readSection('gateway') || {}; } catch { section = {}; }
110
+ const mode = (typeof section.mode === 'string' ? section.mode.trim() : '') || null;
111
+ const inherited = mode === CLAUDE_CURRENT_MODE ? readClaudeCodeCurrentRoute() : null;
112
+ const provider = process.env.MIXDOG_GATEWAY_PROVIDER
113
+ || (inherited?.provider || '')
114
+ || (typeof section.defaultProvider === 'string' ? section.defaultProvider : '')
115
+ || null;
116
+ const model = process.env.MIXDOG_GATEWAY_MODEL
117
+ || (inherited?.model || '')
118
+ || (typeof section.defaultModel === 'string' ? section.defaultModel : '')
119
+ || null;
120
+ const portRaw = process.env.MIXDOG_GATEWAY_PORT || section.port;
121
+ const portNum = Number(portRaw);
122
+ const port = Number.isFinite(portNum) && portNum > 0 ? portNum : DEFAULT_GATEWAY_PORT;
123
+ return { provider, model, port, mode };
124
+ }
125
+
126
+ // ── Anthropic <-> common message conversion ──────────────────────────
127
+ // The common shape consumed by provider.send() (see toOpenAIMessages in
128
+ // providers/openai-compat.mjs and toAnthropicMessages in anthropic.mjs /
129
+ // anthropic-oauth.mjs):
130
+ // • plain turn → { role, content } (content is a string)
131
+ // • assistant w/ tools → { role:'assistant', content, toolCalls:[{id,name,arguments}] }
132
+ // • tool result → { role:'tool', toolCallId, content }
133
+ // Anthropic packs tool_result blocks inside a USER message and tool_use blocks
134
+ // inside an ASSISTANT message; we unpack both into the per-turn common shape.
135
+
136
+ // Anthropic tool_result.content is either a string or an array of content
137
+ // blocks ({type:'text',text}, images, …). Collapse to text — the common
138
+ // `tool` message carries a string body.
139
+ function toolResultText(content) {
140
+ if (typeof content === 'string') return content;
141
+ if (Array.isArray(content)) {
142
+ return content.map(b => (b && b.type === 'text' ? (b.text || '') : '')).join('');
143
+ }
144
+ return '';
145
+ }
146
+
147
+ // Join the text blocks of an Anthropic content array into a single string.
148
+ function textBlocksToString(blocks) {
149
+ return blocks.map(b => (b && b.type === 'text' ? (b.text || '') : '')).join('');
150
+ }
151
+
152
+ function toCommon(body) {
153
+ const out = [];
154
+ if (body.system) {
155
+ out.push({
156
+ role: 'system',
157
+ content: typeof body.system === 'string'
158
+ ? body.system
159
+ : (body.system || []).map(b => b.text || '').join('\n'),
160
+ });
161
+ }
162
+ for (const m of body.messages || []) {
163
+ // String content: pass straight through as a plain turn.
164
+ if (typeof m.content === 'string') {
165
+ out.push({ role: m.role, content: m.content });
166
+ continue;
167
+ }
168
+ if (!Array.isArray(m.content)) {
169
+ out.push({ role: m.role, content: '' });
170
+ continue;
171
+ }
172
+ if (m.role === 'assistant') {
173
+ // Collapse text blocks; lift tool_use blocks into toolCalls. send()
174
+ // expects toolCalls[].arguments as a PARSED object (block.input already is).
175
+ const text = textBlocksToString(m.content);
176
+ const toolCalls = m.content
177
+ .filter(b => b && b.type === 'tool_use')
178
+ .map(b => ({ id: b.id || '', name: b.name || '', arguments: b.input ?? {} }));
179
+ out.push({
180
+ role: 'assistant',
181
+ content: text,
182
+ ...(toolCalls.length ? { toolCalls } : {}),
183
+ });
184
+ continue;
185
+ }
186
+ // role === 'user' (or anything else with array content): emit each
187
+ // tool_result block as its own common `tool` message (provider formats
188
+ // demand a distinct role:'tool'/tool_result entry per result), then a
189
+ // user text message for any remaining text blocks.
190
+ const toolResults = m.content.filter(b => b && b.type === 'tool_result');
191
+ for (const b of toolResults) {
192
+ out.push({ role: 'tool', toolCallId: b.tool_use_id || '', content: toolResultText(b.content) });
193
+ }
194
+ const text = textBlocksToString(m.content);
195
+ if (text || toolResults.length === 0) {
196
+ out.push({ role: m.role, content: text });
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+
202
+ // Anthropic tool DEFINITIONS ({name, description, input_schema}) → the common
203
+ // tools shape send() expects ({name, description, inputSchema}); the provider
204
+ // then re-maps via toOpenAITools / toAnthropicTools. Skips client-side tools
205
+ // without a name. Returns undefined when there are no usable tools so send()
206
+ // gets `undefined` (its no-tools sentinel), unchanged from step 1.
207
+ function toCommonTools(tools) {
208
+ if (!Array.isArray(tools) || tools.length === 0) return undefined;
209
+ const mapped = tools
210
+ .filter(t => t && typeof t.name === 'string' && t.name)
211
+ .map(t => ({
212
+ name: t.name,
213
+ description: typeof t.description === 'string' ? t.description : '',
214
+ inputSchema: t.input_schema ?? t.inputSchema ?? { type: 'object', properties: {} },
215
+ }));
216
+ return mapped.length ? mapped : undefined;
217
+ }
218
+
219
+ // mixdog stopReason → Anthropic stop_reason.
220
+ const STOP = { stop: 'end_turn', length: 'max_tokens', tool_calls: 'tool_use' };
221
+
222
+ const sse = (res, ev, data) => res.write(`event: ${ev}\ndata: ${JSON.stringify(data)}\n\n`);
223
+
224
+ function anthropicUsageFromProviderUsage(provider, usage = {}, outputOverride = null) {
225
+ const input = Number(usage?.inputTokens || 0) || 0;
226
+ const output = Number(outputOverride ?? usage?.outputTokens ?? 0) || 0;
227
+ const cacheRead = Number(usage?.cachedTokens ?? usage?.cacheReadTokens ?? 0) || 0;
228
+ const cacheWrite = Number(usage?.cacheWriteTokens || 0) || 0;
229
+ // Gateway responses speak Anthropic usage semantics. OpenAI/Codex/Gemini
230
+ // report input as an inclusive prompt total, so subtract cache slots before
231
+ // exposing cache_read/cache_creation; otherwise Claude-side context math
232
+ // would double-count cached tokens.
233
+ const inputTokens = isInclusiveProvider(provider)
234
+ ? Math.max(input - cacheRead - cacheWrite, 0)
235
+ : input;
236
+ return {
237
+ input_tokens: inputTokens,
238
+ output_tokens: output,
239
+ cache_read_input_tokens: cacheRead,
240
+ cache_creation_input_tokens: cacheWrite,
241
+ };
242
+ }
243
+
244
+ function readBody(req) {
245
+ return new Promise((resolve, reject) => {
246
+ let b = '';
247
+ let overflow = false;
248
+ // Cap request body. Claude Code conversation payloads can be large
249
+ // (full context window), so allow a generous 32 MiB before aborting.
250
+ const MAX_BODY_BYTES = 32 * 1024 * 1024;
251
+ req.on('data', (c) => {
252
+ if (overflow) return;
253
+ b += c;
254
+ if (b.length > MAX_BODY_BYTES) { overflow = true; reject(new Error('request body too large')); req.destroy(); }
255
+ });
256
+ req.on('end', () => { if (!overflow) resolve(b); });
257
+ req.on('error', reject);
258
+ });
259
+ }
260
+
261
+ // ── Per-request routing resolution (runtime model switching) ─────────
262
+ // The routing target (provider + model) is re-read from config on each
263
+ // /v1/messages request so editing gateway.defaultProvider / defaultModel
264
+ // takes effect on the NEXT request WITHOUT restarting the gateway. A short
265
+ // in-process TTL cache avoids hitting disk on every request under load.
266
+ // MIXDOG_GATEWAY_PROVIDER / MIXDOG_GATEWAY_MODEL env still win (the pin is
267
+ // applied inside readGatewayConfig). `seed` is the start-time routing, used
268
+ // only as a fallback when a live re-read throws (transient FS error).
269
+ const ROUTING_CACHE_TTL_MS = 1500;
270
+ let _routingCache = null;
271
+ let _routingCacheAt = 0;
272
+ function getRoutingCached(seed, log = () => {}) {
273
+ const now = Date.now();
274
+ if (_routingCache && (now - _routingCacheAt) < ROUTING_CACHE_TTL_MS) return _routingCache;
275
+ let routing;
276
+ try {
277
+ const { provider, model, mode } = readGatewayConfig();
278
+ routing = { provider, model, mode };
279
+ } catch (e) {
280
+ log(`routing re-read failed, keeping previous: ${e?.message || e}`);
281
+ routing = _routingCache || { provider: seed?.provider ?? null, model: seed?.model ?? null, mode: seed?.mode ?? null };
282
+ }
283
+ _routingCache = routing;
284
+ _routingCacheAt = now;
285
+ return routing;
286
+ }
287
+
288
+ // Lazy provider init: if the routing target isn't registered in THIS process
289
+ // yet (e.g. the user just switched to a provider that wasn't enabled at start),
290
+ // re-run initProviders from the current agent config — which overlays keychain
291
+ // keys and registers every enabled provider — then resolve again. A per-name
292
+ // cooldown bounds repeated initProviders calls for a genuinely missing /
293
+ // misconfigured provider so an invalid target can't thrash disk every request.
294
+ const INIT_RETRY_COOLDOWN_MS = 2000;
295
+ let _lastInitMissKey = null;
296
+ let _lastInitMissAt = 0;
297
+ async function ensureProviderInited(name, { getProviderFn, initProvidersFn, loadConfigFn, log = () => {} }) {
298
+ if (!name) return null;
299
+ let p = getProviderFn(name);
300
+ if (p) return p;
301
+ // Cooldown: don't re-init for the same missing provider within the window.
302
+ const now = Date.now();
303
+ if (_lastInitMissKey === name && (now - _lastInitMissAt) < INIT_RETRY_COOLDOWN_MS) {
304
+ return null;
305
+ }
306
+ try {
307
+ const config = loadConfigFn();
308
+ await initProvidersFn(config.providers);
309
+ p = getProviderFn(name);
310
+ } catch (e) {
311
+ log(`lazy provider init failed for "${name}": ${e?.message || e}`);
312
+ p = null;
313
+ }
314
+ if (!p) { _lastInitMissKey = name; _lastInitMissAt = now; }
315
+ return p;
316
+ }
317
+
318
+ // ── Server ───────────────────────────────────────────────────────────
319
+ // `resolveRouting` (per-request { provider, model }) and `ensureProvider`
320
+ // (lazy registry resolver) are injected so this handler stays testable
321
+ // without the registry.
322
+ function makeRequestHandler({ resolveRouting, ensureProvider, log }) {
323
+ return async (req, res) => {
324
+ // Loopback-only — belt-and-braces on top of binding to 127.0.0.1.
325
+ const remote = req.socket?.remoteAddress || '';
326
+ const isLoopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
327
+ if (!isLoopback) {
328
+ res.writeHead(403, { 'Content-Type': 'application/json' });
329
+ res.end(JSON.stringify({ type: 'error', error: { type: 'permission_error', message: 'forbidden' } }));
330
+ return;
331
+ }
332
+ try {
333
+ // count_tokens: cheap char/4 estimate (no model round-trip). Must be
334
+ // matched BEFORE the bare /v1/messages prefix.
335
+ if (req.method === 'POST' && req.url.startsWith('/v1/messages/count_tokens')) {
336
+ const parsed = JSON.parse((await readBody(req)) || '{}');
337
+ const text = JSON.stringify(parsed.messages || [])
338
+ + (typeof parsed.system === 'string' ? parsed.system : '');
339
+ res.writeHead(200, { 'Content-Type': 'application/json' });
340
+ res.end(JSON.stringify({ input_tokens: Math.ceil(text.length / 4) }));
341
+ return;
342
+ }
343
+ if (req.method === 'POST' && req.url.startsWith('/v1/messages')) {
344
+ // Re-read routing per request (runtime model switching) and lazily
345
+ // init the selected provider if it wasn't registered at start.
346
+ const routing = resolveRouting();
347
+ if (!routing.provider) {
348
+ res.writeHead(503, { 'Content-Type': 'application/json' });
349
+ res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: `gateway not configured (provider=${routing.provider || 'none'} model=${routing.model || 'none'})` } }));
350
+ return;
351
+ }
352
+ const provider = await ensureProvider(routing.provider);
353
+ if (!provider) {
354
+ res.writeHead(503, { 'Content-Type': 'application/json' });
355
+ res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: `gateway provider "${routing.provider}" not found or not enabled` } }));
356
+ return;
357
+ }
358
+ const parsed = JSON.parse((await readBody(req)) || '{}');
359
+ const requestRouting = routing.mode === CLAUDE_CURRENT_MODE
360
+ ? { ...routing, ...readClaudeCodeCurrentRoute({ request: parsed }) }
361
+ : routing;
362
+ if (!requestRouting.provider || !requestRouting.model) {
363
+ res.writeHead(503, { 'Content-Type': 'application/json' });
364
+ res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: `gateway not configured (provider=${requestRouting.provider || 'none'} model=${requestRouting.model || 'none'})` } }));
365
+ return;
366
+ }
367
+ const routeInfo = readGatewayRouteInfo(requestRouting, provider);
368
+ const messages = toCommon(parsed);
369
+ const sendTools = toCommonTools(parsed.tools);
370
+ const prepared = prepareGatewayMessages(messages, sendTools, routeInfo, log);
371
+ const usageSnapshotP = fetchOAuthUsageSnapshot(routeInfo, provider, log).catch(() => null);
372
+ if (prepared.trim?.overflow === true) {
373
+ updateGatewayAdvert(
374
+ routeInfo,
375
+ {
376
+ at: Date.now(),
377
+ provider: routeInfo.provider,
378
+ model: routeInfo.model,
379
+ responseModel: routeInfo.model,
380
+ inputTokens: 0,
381
+ outputTokens: 0,
382
+ cacheReadTokens: 0,
383
+ cacheWriteTokens: 0,
384
+ promptTokens: prepared.trim.beforeTokens || 0,
385
+ costUsd: 0,
386
+ costSource: 'none',
387
+ contextUsedPct: routeInfo.contextWindow ? Math.round((prepared.trim.beforeTokens || 0) * 10000 / routeInfo.contextWindow) / 100 : null,
388
+ trim: prepared.trim,
389
+ durationMs: 0,
390
+ rawUsageKeys: [],
391
+ },
392
+ null,
393
+ log,
394
+ );
395
+ res.writeHead(413, { 'Content-Type': 'application/json' });
396
+ res.end(JSON.stringify({
397
+ type: 'error',
398
+ error: {
399
+ type: 'invalid_request_error',
400
+ message: `gateway target context overflow for ${routeInfo.provider}/${routeInfo.model}: estimated ${prepared.trim.beforeTokens} tokens exceeds safe budget ${prepared.trim.budgetTokens}`,
401
+ },
402
+ }));
403
+ return;
404
+ }
405
+ const wantStream = parsed.stream === true;
406
+ const id = 'msg_' + Date.now().toString(36);
407
+ const startedAt = Date.now();
408
+ const out = await provider.send(prepared.messages, routeInfo.model || routing.model, sendTools, gatewaySendOptions(routeInfo));
409
+ const responseModel = out.model || routeInfo.model || routing.model;
410
+ const textOut = out.content || '';
411
+ // send() returns toolCalls as [{ id, name, arguments }] (arguments is
412
+ // a PARSED object — see parseToolCalls in openai-compat.mjs and the
413
+ // tool_use assembly in anthropic-oauth.mjs parseSSEStream). Map each
414
+ // into an Anthropic tool_use block ({ type, id, name, input }).
415
+ const toolCalls = Array.isArray(out.toolCalls) ? out.toolCalls : [];
416
+ // If the model emitted tool calls but the provider didn't flag the
417
+ // stop reason as tool_calls, force tool_use so the client knows to run
418
+ // them and continue the turn.
419
+ const stop = toolCalls.length ? 'tool_use' : (STOP[out.stopReason] || 'end_turn');
420
+ const usage = anthropicUsageFromProviderUsage(routeInfo.provider, out.usage);
421
+ const usageSummary = summarizeGatewayUsage(routeInfo, out, prepared.trim, Date.now() - startedAt);
422
+ recordGatewayUsageEvent(usageSummary);
423
+ const usageSnapshot = await usageSnapshotP;
424
+ const limits = buildGatewayLimits(routeInfo, out, usageSnapshot);
425
+ updateGatewayAdvert(routeInfo, usageSummary, limits, log);
426
+ log(`route -> ${routeInfo.provider}/${routeInfo.model} ${textOut.length}chars tools=${sendTools ? sendTools.length : 0} toolCalls=${toolCalls.length} stream=${wantStream} model=${responseModel}`);
427
+ const routeHeaders = {
428
+ 'x-mixdog-provider': routeInfo.provider || '',
429
+ 'x-mixdog-model': routeInfo.model || '',
430
+ };
431
+ if (!wantStream) {
432
+ // content = optional leading text block, then one tool_use block per call.
433
+ const content = [];
434
+ if (textOut) content.push({ type: 'text', text: textOut });
435
+ for (const tc of toolCalls) {
436
+ content.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.arguments ?? {} });
437
+ }
438
+ if (content.length === 0) content.push({ type: 'text', text: '' });
439
+ res.writeHead(200, { 'Content-Type': 'application/json', ...routeHeaders });
440
+ res.end(JSON.stringify({
441
+ id, type: 'message', role: 'assistant',
442
+ model: responseModel,
443
+ content,
444
+ stop_reason: stop, stop_sequence: null, usage,
445
+ }));
446
+ return;
447
+ }
448
+ // Pseudo-stream: assembled reply chunked back as Anthropic SSE.
449
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', ...routeHeaders });
450
+ sse(res, 'message_start', { type: 'message_start', message: { id, type: 'message', role: 'assistant', model: responseModel, content: [], stop_reason: null, stop_sequence: null, usage: anthropicUsageFromProviderUsage(routeInfo.provider, out.usage, 0) } });
451
+ // Block 0: text (always emitted, even when empty, so the index space
452
+ // is stable). Chunk the assembled text as text_delta — pseudo-stream.
453
+ let blockIndex = 0;
454
+ sse(res, 'content_block_start', { type: 'content_block_start', index: blockIndex, content_block: { type: 'text', text: '' } });
455
+ const ch = Math.max(1, Math.ceil(textOut.length / 6));
456
+ for (let i = 0; i < textOut.length; i += ch) {
457
+ sse(res, 'content_block_delta', { type: 'content_block_delta', index: blockIndex, delta: { type: 'text_delta', text: textOut.slice(i, i + ch) } });
458
+ }
459
+ sse(res, 'content_block_stop', { type: 'content_block_stop', index: blockIndex });
460
+ // One tool_use block per call: content_block_start{tool_use} →
461
+ // input_json_delta{partial_json:<full json at once>} → content_block_stop.
462
+ // This is the reverse of anthropic-oauth.mjs parseSSEStream's tool_use
463
+ // assembly (which concatenates input_json_delta into inputJson then
464
+ // JSON.parses at content_block_stop).
465
+ for (const tc of toolCalls) {
466
+ blockIndex += 1;
467
+ sse(res, 'content_block_start', { type: 'content_block_start', index: blockIndex, content_block: { type: 'tool_use', id: tc.id, name: tc.name, input: {} } });
468
+ sse(res, 'content_block_delta', { type: 'content_block_delta', index: blockIndex, delta: { type: 'input_json_delta', partial_json: JSON.stringify(tc.arguments ?? {}) } });
469
+ sse(res, 'content_block_stop', { type: 'content_block_stop', index: blockIndex });
470
+ }
471
+ sse(res, 'message_delta', { type: 'message_delta', delta: { stop_reason: stop, stop_sequence: null }, usage: { output_tokens: usage.output_tokens } });
472
+ sse(res, 'message_stop', { type: 'message_stop' });
473
+ res.end();
474
+ return;
475
+ }
476
+ // HEAD / and any other path: best-effort empty JSON so client
477
+ // bootstrap probes (models, capabilities, …) don't error out.
478
+ await readBody(req).catch(() => {});
479
+ res.writeHead(200, { 'Content-Type': 'application/json' });
480
+ res.end('{}');
481
+ } catch (e) {
482
+ log(`ERROR ${e?.message || e}`);
483
+ if (!res.headersSent) {
484
+ res.writeHead(500, { 'Content-Type': 'application/json' });
485
+ res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: String(e?.message || e) } }));
486
+ } else {
487
+ try { res.end(); } catch {}
488
+ }
489
+ }
490
+ };
491
+ }
492
+
493
+ /**
494
+ * Start the gateway HTTP server.
495
+ *
496
+ * @param {object} opts
497
+ * @param {string} opts.provider routing target provider (registry key)
498
+ * @param {string} opts.model model id passed to provider.send()
499
+ * @param {number} [opts.port] listen port (default 3468)
500
+ * @param {string} [opts.host] bind host (default 127.0.0.1)
501
+ * @param {function} [opts.getProviderFn] provider resolver (defaults to registry.getProvider)
502
+ * @param {function} [opts.initProvidersFn] (re)init registry for lazy provider load (defaults to registry.initProviders)
503
+ * @param {function} [opts.loadConfigFn] agent config loader for lazy init (defaults to config.loadConfig)
504
+ * @param {function} [opts.log] line logger
505
+ * @returns {Promise<{ port:number, close:()=>Promise<void> }>}
506
+ */
507
+ export async function startGatewayServer({ provider, model, mode = null, port = DEFAULT_GATEWAY_PORT, host = GATEWAY_HOST, getProviderFn, initProvidersFn, loadConfigFn, log = () => {} } = {}) {
508
+ // Lazily import the registry/config so import-only consumers (tests) that
509
+ // inject their own fns never pull the provider tree in.
510
+ if (!getProviderFn || !initProvidersFn || !loadConfigFn) {
511
+ const reg = await import('../agent/orchestrator/providers/registry.mjs');
512
+ const cfg = await import('../agent/orchestrator/config.mjs');
513
+ getProviderFn = getProviderFn || reg.getProvider;
514
+ initProvidersFn = initProvidersFn || reg.initProviders;
515
+ loadConfigFn = loadConfigFn || cfg.loadConfig;
516
+ }
517
+ // Seed routing from the start-time args; the handler re-reads config per
518
+ // request via getRoutingCached (runtime model switching) and falls back to
519
+ // this seed only when a live re-read throws.
520
+ const seed = { provider, model, mode };
521
+ const resolveRouting = () => getRoutingCached(seed, log);
522
+ const ensureProvider = (name) => ensureProviderInited(name, { getProviderFn, initProvidersFn, loadConfigFn, log });
523
+ const server = http.createServer(makeRequestHandler({ resolveRouting, ensureProvider, log }));
524
+ await new Promise((resolve, reject) => {
525
+ server.once('error', reject);
526
+ server.listen(port, host, () => { server.removeListener('error', reject); resolve(); });
527
+ });
528
+ const address = server.address();
529
+ const boundPort = typeof address === 'object' && address ? address.port : port;
530
+ log(`listening on ${host}:${boundPort} provider=${provider} model=${model}`);
531
+ return {
532
+ port: boundPort,
533
+ close: () => new Promise((resolve) => server.close(() => resolve())),
534
+ };
535
+ }
536
+
537
+ // ── active-instance.json advertisement ───────────────────────────────
538
+ // Mirrors advertiseMemoryPort() in src/memory/index.mjs: atomically merge
539
+ // gateway_port + gateway_server_pid into the shared active-instance.json so
540
+ // hooks / setup / status discovery can find the gateway without a hard-coded
541
+ // port. A periodic re-advertise (every 30s) recovers from external file
542
+ // truncation, matching the memory worker's behavior.
543
+ let _periodicAdvertiseInstalled = false;
544
+ let _currentAdvertisedPort = null;
545
+ function cleanGatewayFields(cur) {
546
+ return Object.fromEntries(
547
+ Object.entries(cur || {}).filter(([key]) => !key.startsWith('gateway_'))
548
+ );
549
+ }
550
+
551
+ function currentRouteAdvertFields(boundPort, log = () => {}) {
552
+ try {
553
+ const routeInfo = readGatewayRouteInfo(readGatewayConfig());
554
+ const usageSnapshot = readCachedOAuthUsageSnapshot(routeInfo);
555
+ return gatewayAdvertFields(routeInfo, null, buildGatewayLimits(routeInfo, null, usageSnapshot));
556
+ } catch (e) {
557
+ log(`gateway metadata read failed: ${e?.message || e}`);
558
+ return {};
559
+ }
560
+ }
561
+
562
+ function updateGatewayAdvert(routeInfo, usageSummary = null, limits = null, log = () => {}) {
563
+ if (!Number.isFinite(_currentAdvertisedPort) || _currentAdvertisedPort <= 0) return;
564
+ try {
565
+ const fields = gatewayAdvertFields(routeInfo, usageSummary, limits);
566
+ updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
567
+ const cur = curRaw ?? {};
568
+ const curGwPid = parsePositivePid(cur?.gateway_server_pid);
569
+ if (curGwPid != null && curGwPid !== GATEWAY_SERVER_PID && isPidAlive(curGwPid)) {
570
+ return undefined;
571
+ }
572
+ return {
573
+ ...cur,
574
+ gateway_port: _currentAdvertisedPort,
575
+ ...(GATEWAY_SERVER_PID ? { gateway_server_pid: GATEWAY_SERVER_PID } : {}),
576
+ ...fields,
577
+ updatedAt: Date.now(),
578
+ };
579
+ }, { compact: true, fsyncDir: true });
580
+ } catch (e) {
581
+ log(`active-instance gateway metadata update failed: ${e?.message || e}`);
582
+ }
583
+ }
584
+
585
+ function advertiseGatewayPort(boundPort, attempt = 0, log = () => {}) {
586
+ if (!Number.isFinite(boundPort) || boundPort <= 0) return;
587
+ _currentAdvertisedPort = boundPort;
588
+ try {
589
+ const routeFields = currentRouteAdvertFields(boundPort, log);
590
+ updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
591
+ const cur = curRaw ?? {};
592
+ // Owner guard (symmetric with unadvertiseGatewayPort): if a DIFFERENT
593
+ // gateway_server_pid is already advertised AND that process is still
594
+ // alive, yield to the live owner rather than clobbering its entry.
595
+ // Mirrors the memory_port conflict check in server-main.mjs.
596
+ const curGwPort = Number(cur?.gateway_port);
597
+ const curGwPid = parsePositivePid(cur?.gateway_server_pid);
598
+ const portConflict = Number.isFinite(curGwPort) && curGwPort > 0 && curGwPort !== boundPort;
599
+ const otherOwnerAlive = curGwPid != null && curGwPid !== GATEWAY_SERVER_PID && isPidAlive(curGwPid);
600
+ if (portConflict && otherOwnerAlive) {
601
+ log(`skip gateway_port advertise port=${boundPort} curGwPort=${curGwPort} curGwPid=${curGwPid} gatewayServerPid=${GATEWAY_SERVER_PID || 'none'}`);
602
+ return undefined;
603
+ }
604
+ return {
605
+ ...cur,
606
+ gateway_port: boundPort,
607
+ ...(GATEWAY_SERVER_PID ? { gateway_server_pid: GATEWAY_SERVER_PID } : {}),
608
+ ...routeFields,
609
+ updatedAt: Date.now(),
610
+ };
611
+ }, { compact: true, fsyncDir: true });
612
+ if (!_periodicAdvertiseInstalled) {
613
+ _periodicAdvertiseInstalled = true;
614
+ setInterval(() => {
615
+ try { if (_currentAdvertisedPort != null) advertiseGatewayPort(_currentAdvertisedPort, 0, log); } catch {}
616
+ }, 30_000).unref?.();
617
+ }
618
+ } catch (e) {
619
+ const transient = e?.code === 'EPERM' || e?.code === 'EBUSY' || e?.code === 'EACCES';
620
+ if (transient && attempt < 3) {
621
+ setTimeout(() => advertiseGatewayPort(boundPort, attempt + 1, log), 50 * (attempt + 1));
622
+ return;
623
+ }
624
+ log(`active-instance gateway_port advertise failed: ${e?.message || e}`);
625
+ }
626
+ }
627
+
628
+ function unadvertiseGatewayPort(log = () => {}) {
629
+ try {
630
+ updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
631
+ const cur = curRaw ?? {};
632
+ // Only retract OUR advertisement. A different live gateway owner that
633
+ // raced in between must not have its port clobbered to null.
634
+ if (parsePositivePid(cur.gateway_server_pid) && parsePositivePid(cur.gateway_server_pid) !== GATEWAY_SERVER_PID) {
635
+ return undefined;
636
+ }
637
+ const rest = cleanGatewayFields(cur);
638
+ return { ...rest, updatedAt: Date.now() };
639
+ }, { compact: true, fsyncDir: true });
640
+ } catch (e) {
641
+ log(`active-instance gateway_port retract failed: ${e?.message || e}`);
642
+ }
643
+ }
644
+
645
+ // ── Child-process entry point ─────────────────────────────────────────
646
+ // When forked directly (`node src/gateway/server.mjs`), read config from
647
+ // the agent config + MIXDOG_GATEWAY_* env, init the provider registry, bind
648
+ // the fixed port, and advertise it. Parent signals shutdown via IPC
649
+ // disconnect or SIGTERM.
650
+ const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
651
+ if (isMain) {
652
+ const log = (m) => process.stdout.write(`[gateway] ${m}\n`);
653
+ const { provider, model, port, mode } = readGatewayConfig();
654
+ if (!provider || !model) {
655
+ // Enabled-but-unconfigured: no-op. Exit cleanly so the parent does not
656
+ // treat it as a crash to respawn forever.
657
+ log(`no defaultProvider/defaultModel configured (provider=${provider || 'none'} model=${model || 'none'}) — gateway idle, exiting`);
658
+ process.exit(0);
659
+ }
660
+
661
+ let handle = null;
662
+ let shuttingDown = false;
663
+ const shutdown = async (reason) => {
664
+ if (shuttingDown) return;
665
+ shuttingDown = true;
666
+ log(`shutdown (${reason})`);
667
+ try { unadvertiseGatewayPort(log); } catch {}
668
+ try { if (handle) await handle.close(); } catch {}
669
+ process.exit(0);
670
+ };
671
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
672
+ process.on('SIGINT', () => shutdown('SIGINT'));
673
+ process.on('disconnect', () => shutdown('parent-disconnect'));
674
+ process.on('message', (msg) => { if (msg?.type === 'shutdown') shutdown('ipc-shutdown'); });
675
+
676
+ try {
677
+ // Init the provider registry IN THIS PROCESS — the gateway child is
678
+ // separate from the MCP parent where the agent module runs. loadConfig()
679
+ // overlays keychain API keys onto the provider config, exactly like the
680
+ // agent module's reloadAgentConfig() path.
681
+ const { loadConfig } = await import('../agent/orchestrator/config.mjs');
682
+ const { initProviders, getProvider } = await import('../agent/orchestrator/providers/registry.mjs');
683
+ const config = loadConfig();
684
+ await initProviders(config.providers);
685
+ if (!getProvider(provider)) {
686
+ log(`provider "${provider}" not enabled in agent config — gateway idle, exiting`);
687
+ process.exit(0);
688
+ }
689
+ // Pass the registry/config fns so the handler's per-request routing
690
+ // re-read + lazy provider init (runtime model switching) reuse the same
691
+ // already-imported singletons rather than re-importing per request.
692
+ handle = await startGatewayServer({
693
+ provider, model, port, mode,
694
+ getProviderFn: getProvider,
695
+ initProvidersFn: initProviders,
696
+ loadConfigFn: loadConfig,
697
+ log,
698
+ });
699
+ advertiseGatewayPort(handle.port, 0, log);
700
+ try { process.send?.({ type: 'gateway_ready', port: handle.port }); } catch {}
701
+ } catch (e) {
702
+ // First-binder-wins: EADDRINUSE means another server-main already hosts
703
+ // the gateway on this port (multi-instance). Yield as idle (exit 0) so the
704
+ // parent's exit handler does NOT crash-restart loop. Other listen/init
705
+ // errors are real failures → exit 1 (parent restarts with backoff).
706
+ if (e && e.code === 'EADDRINUSE') {
707
+ log(`port ${port} already in use — another gateway owner is active; idle, exiting`);
708
+ process.exit(0);
709
+ }
710
+ log(`failed to start: ${e && (e.stack || e.message) || e}`);
711
+ process.exit(1);
712
+ }
713
+ }