mobygate 0.8.4 → 0.9.4

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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * OpenAI Chat Completions translation layer.
3
+ *
4
+ * Mirrors lib/anthropic.js but for the OpenAI shape on POST /v1/chat/completions.
5
+ * Translates between the OpenAI Chat Completions wire format and the
6
+ * Claude Agent SDK's `query()` shape used internally by mobygate.
7
+ *
8
+ * Surface differences vs Anthropic (lib/anthropic.js):
9
+ * - tools: OpenAI uses `tools[].function.{name, description, parameters}`;
10
+ * Anthropic uses `tools[].{name, description, input_schema}`.
11
+ * - tool results: OpenAI uses message.role === 'tool'; Anthropic uses
12
+ * a `tool_result` content block on a user message.
13
+ * - response shape: OpenAI uses `{choices: [{message: {role, content}}]}`;
14
+ * Anthropic uses `{content: [{type: 'text', text}]}` (or tool_use blocks).
15
+ *
16
+ * Both surfaces ultimately drive the same SDK query() — see
17
+ * lib/inference-runner.js for the unified inference loop.
18
+ */
19
+
20
+ import { toolMessagesToText } from './tool-bridge.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Content extraction
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export function extractContent(content) {
27
+ if (typeof content === 'string') return content;
28
+ if (Array.isArray(content)) {
29
+ return content
30
+ .map((part) => {
31
+ if (typeof part === 'string') return part;
32
+ if (part.type === 'text') return part.text;
33
+ if (part.type === 'image_url') return ''; // images carried separately; drop from text
34
+ return JSON.stringify(part);
35
+ })
36
+ .filter(Boolean)
37
+ .join('\n');
38
+ }
39
+ if (content && typeof content === 'object') return JSON.stringify(content);
40
+ return String(content || '');
41
+ }
42
+
43
+ // Convert an OpenAI message.content array into Anthropic image content blocks.
44
+ // Supports both data: URLs (base64) and remote https URLs.
45
+ export function extractImageBlocks(content) {
46
+ if (!Array.isArray(content)) return [];
47
+ const blocks = [];
48
+ for (const part of content) {
49
+ if (!part || part.type !== 'image_url') continue;
50
+ const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url;
51
+ if (!url) continue;
52
+ const dataMatch = /^data:([^;]+);base64,(.+)$/.exec(url);
53
+ if (dataMatch) {
54
+ blocks.push({ type: 'image', source: { type: 'base64', media_type: dataMatch[1], data: dataMatch[2] } });
55
+ } else {
56
+ blocks.push({ type: 'image', source: { type: 'url', url } });
57
+ }
58
+ }
59
+ return blocks;
60
+ }
61
+
62
+ // Collect images from the LAST user message (OpenAI only attaches images
63
+ // to the latest turn).
64
+ export function collectImages(messages) {
65
+ for (let i = messages.length - 1; i >= 0; i--) {
66
+ if (messages[i].role === 'user') return extractImageBlocks(messages[i].content);
67
+ }
68
+ return [];
69
+ }
70
+
71
+ export function hasTools(body) {
72
+ return Array.isArray(body?.tools) && body.tools.length > 0;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Request translation: OpenAI messages → SDK prompt string
77
+ // ---------------------------------------------------------------------------
78
+ // Mirrors lib/anthropic.js#anthropicMessagesToPrompt — see that doc for
79
+ // why tool definitions are NOT injected into the prompt (the runner
80
+ // registers them with the SDK as MCP tools instead).
81
+
82
+ export function messagesToPrompt(messages, { resuming = false } = {}) {
83
+ if (resuming) {
84
+ // Walk backwards from the end, collecting trailing tool messages and
85
+ // the most recent user text.
86
+ const trailingToolMessages = [];
87
+ let userText = '';
88
+ for (let i = messages.length - 1; i >= 0; i--) {
89
+ const msg = messages[i];
90
+ if (msg.role === 'tool') {
91
+ trailingToolMessages.unshift(msg);
92
+ } else if (msg.role === 'user') {
93
+ userText = extractContent(msg.content);
94
+ break;
95
+ } else {
96
+ break;
97
+ }
98
+ }
99
+ const toolResultsText = toolMessagesToText(trailingToolMessages);
100
+ if (!userText && !toolResultsText) {
101
+ return {
102
+ promptText: '',
103
+ 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') + '".',
104
+ };
105
+ }
106
+ const parts = [];
107
+ if (toolResultsText) parts.push(toolResultsText);
108
+ if (userText) parts.push(userText);
109
+ return { promptText: parts.join('\n\n') };
110
+ }
111
+
112
+ // Fresh request: serialize visible history as XML-wrapped text.
113
+ const parts = [];
114
+ for (const msg of messages) {
115
+ switch (msg.role) {
116
+ case 'system':
117
+ parts.push(`<system>\n${extractContent(msg.content)}\n</system>\n`);
118
+ break;
119
+ case 'user':
120
+ parts.push(extractContent(msg.content));
121
+ break;
122
+ case 'assistant': {
123
+ const text = extractContent(msg.content);
124
+ if (text) parts.push(`<previous_response>\n${text}\n</previous_response>\n`);
125
+ break;
126
+ }
127
+ case 'tool': {
128
+ const text = toolMessagesToText([msg]);
129
+ if (text) parts.push(text);
130
+ break;
131
+ }
132
+ }
133
+ }
134
+ return { promptText: parts.join('\n').trim() };
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Normalize model name for OpenAI response format
139
+ // ---------------------------------------------------------------------------
140
+
141
+ export function normalizeModelName(model) {
142
+ if (model?.includes('opus')) return 'claude-opus-4';
143
+ if (model?.includes('sonnet')) return 'claude-sonnet-4';
144
+ if (model?.includes('haiku')) return 'claude-haiku-4';
145
+ return model || 'claude-sonnet-4';
146
+ }
package/lib/quiet.js ADDED
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Quiet mode — scrubs identifying terms (third-party agent harness names,
3
+ * proxy names, etc.) from outbound request payloads before the SDK forwards
4
+ * them to Anthropic. Used by the /quiet/v1/messages route.
5
+ *
6
+ * Why: community reports (X) show that Anthropic appears to scan request
7
+ * bodies for known third-party agent harness names (e.g. having "openclaw"
8
+ * in package.json triggers "extra usage" billing flags even when the harness
9
+ * isn't actually invoked). Quiet mode strips those substrings so the request
10
+ * body looks like a vanilla Anthropic-shape call.
11
+ *
12
+ * This is best-effort. Tool results / file content can still leak names —
13
+ * see CHANGELOG and README for the limits.
14
+ *
15
+ * Configurable via ~/.mobygate/quiet-words.txt (one term:replacement per
16
+ * line, separated by `=`). Falls back to DEFAULT_MAP.
17
+ */
18
+ import { readFileSync, existsSync } from 'fs';
19
+ import { homedir } from 'os';
20
+ import { join } from 'path';
21
+
22
+ const QUIET_WORDS_FILE = join(homedir(), '.mobygate', 'quiet-words.txt');
23
+
24
+ // Defaults. Each key is a brand/identifier we expect Anthropic might match;
25
+ // each value is a neutral replacement that preserves grammar/structure so
26
+ // the model's understanding isn't broken.
27
+ const DEFAULT_MAP = {
28
+ 'openclaw': 'orchestrator',
29
+ 'open-claw': 'orchestrator',
30
+ 'hermes': 'assistant',
31
+ 'mobius': 'bot',
32
+ 'möbius': 'bot',
33
+ 'mobygate': 'proxy',
34
+ 'moby-gate': 'proxy',
35
+ 'moby': 'proxy',
36
+ 'nous': 'lab',
37
+ 'nousresearch': 'lab',
38
+ 'claude-max-proxy': 'proxy',
39
+ };
40
+
41
+ let cachedMap = null;
42
+ let cachedRegex = null;
43
+
44
+ function loadMap() {
45
+ if (cachedMap) return cachedMap;
46
+ const map = { ...DEFAULT_MAP };
47
+ if (existsSync(QUIET_WORDS_FILE)) {
48
+ try {
49
+ const text = readFileSync(QUIET_WORDS_FILE, 'utf8');
50
+ for (const raw of text.split('\n')) {
51
+ const line = raw.trim();
52
+ if (!line || line.startsWith('#')) continue;
53
+ const eq = line.indexOf('=');
54
+ if (eq > 0) {
55
+ const k = line.slice(0, eq).trim().toLowerCase();
56
+ const v = line.slice(eq + 1).trim();
57
+ if (k) map[k] = v;
58
+ } else {
59
+ map[line.toLowerCase()] = 'redacted';
60
+ }
61
+ }
62
+ } catch (e) {
63
+ console.warn(`[quiet] could not read ${QUIET_WORDS_FILE}: ${e.message}`);
64
+ }
65
+ }
66
+ cachedMap = map;
67
+ return map;
68
+ }
69
+
70
+ function buildRegex() {
71
+ if (cachedRegex) return cachedRegex;
72
+ const map = loadMap();
73
+ const keys = Object.keys(map).sort((a, b) => b.length - a.length);
74
+ if (!keys.length) return null;
75
+ // Word-boundary-aware on the right (don't match `claude-max-proxy` inside
76
+ // a longer hyphenated word), but lenient on the left to catch
77
+ // package.json-style "openclaw":"..." entries. Case-insensitive.
78
+ const escaped = keys.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
79
+ cachedRegex = new RegExp(`(${escaped.join('|')})`, 'gi');
80
+ return cachedRegex;
81
+ }
82
+
83
+ /**
84
+ * Reset internal caches. Useful for tests or when the words file changes.
85
+ */
86
+ export function resetQuietCache() {
87
+ cachedMap = null;
88
+ cachedRegex = null;
89
+ }
90
+
91
+ /**
92
+ * Scrub a single string. Returns the scrubbed string, or the input unchanged
93
+ * if no matches (so callers can detect dirty/clean cheaply).
94
+ */
95
+ export function scrubString(input) {
96
+ if (typeof input !== 'string' || !input) return input;
97
+ const regex = buildRegex();
98
+ if (!regex) return input;
99
+ const map = loadMap();
100
+ return input.replace(regex, (match) => map[match.toLowerCase()] ?? match);
101
+ }
102
+
103
+ /**
104
+ * Scrub a single content block (Anthropic shape: { type, text, ... }).
105
+ */
106
+ function scrubBlock(block) {
107
+ if (!block || typeof block !== 'object') return block;
108
+ if (block.type === 'text' && typeof block.text === 'string') {
109
+ return { ...block, text: scrubString(block.text) };
110
+ }
111
+ if (block.type === 'tool_result' && block.content) {
112
+ if (typeof block.content === 'string') {
113
+ return { ...block, content: scrubString(block.content) };
114
+ }
115
+ if (Array.isArray(block.content)) {
116
+ return { ...block, content: block.content.map(scrubBlock) };
117
+ }
118
+ }
119
+ return block;
120
+ }
121
+
122
+ /**
123
+ * Scrub the body of an Anthropic-shape `/v1/messages` request in place.
124
+ * Mutates and returns the same body for ergonomic chaining at the call site.
125
+ *
126
+ * Scrubs: system (string or block array), messages[].content (string or
127
+ * blocks), and metadata.user_id. Does NOT touch tool definitions (rename
128
+ * is a future feature) or model name.
129
+ */
130
+ export function scrubAnthropicBody(body) {
131
+ if (!body || typeof body !== 'object') return body;
132
+
133
+ // system can be a string or an array of {type:'text', text} blocks.
134
+ if (typeof body.system === 'string') {
135
+ body.system = scrubString(body.system);
136
+ } else if (Array.isArray(body.system)) {
137
+ body.system = body.system.map(scrubBlock);
138
+ }
139
+
140
+ if (Array.isArray(body.messages)) {
141
+ body.messages = body.messages.map((m) => {
142
+ if (!m || typeof m !== 'object') return m;
143
+ if (typeof m.content === 'string') {
144
+ return { ...m, content: scrubString(m.content) };
145
+ }
146
+ if (Array.isArray(m.content)) {
147
+ return { ...m, content: m.content.map(scrubBlock) };
148
+ }
149
+ return m;
150
+ });
151
+ }
152
+
153
+ // Strip identifying metadata.user_id (it's the most common detection
154
+ // vector after content matching).
155
+ if (body.metadata && typeof body.metadata === 'object') {
156
+ if (body.metadata.user_id) {
157
+ body.metadata = { ...body.metadata, user_id: undefined };
158
+ delete body.metadata.user_id;
159
+ }
160
+ }
161
+
162
+ // Tool definitions: scrub description fields (free text the model reads
163
+ // but doesn't echo in tool_use blocks). Recurse into input_schema to
164
+ // catch nested property descriptions. Tool *names* are NOT renamed —
165
+ // that requires bidirectional mapping for tool_use responses, which is
166
+ // a future feature. Rename your tool names client-side if they include
167
+ // brand strings.
168
+ if (Array.isArray(body.tools)) {
169
+ body.tools = body.tools.map((t) => {
170
+ if (!t || typeof t !== 'object') return t;
171
+ const out = { ...t };
172
+ if (typeof out.description === 'string') {
173
+ out.description = scrubString(out.description);
174
+ }
175
+ if (out.input_schema && typeof out.input_schema === 'object') {
176
+ out.input_schema = scrubSchemaDescriptions(out.input_schema);
177
+ }
178
+ return out;
179
+ });
180
+ }
181
+
182
+ return body;
183
+ }
184
+
185
+ /**
186
+ * Recursively scrub `description` string fields anywhere in a JSON Schema.
187
+ * Leaves `type`, `enum`, `properties.<name>` keys, etc. alone — only
188
+ * touches fields named `description` whose value is a string.
189
+ */
190
+ function scrubSchemaDescriptions(node) {
191
+ if (Array.isArray(node)) return node.map(scrubSchemaDescriptions);
192
+ if (!node || typeof node !== 'object') return node;
193
+ const out = {};
194
+ for (const [k, v] of Object.entries(node)) {
195
+ if (k === 'description' && typeof v === 'string') {
196
+ out[k] = scrubString(v);
197
+ } else {
198
+ out[k] = scrubSchemaDescriptions(v);
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+
204
+ /**
205
+ * Diagnostic — returns counts of substitutions a scrub would make,
206
+ * without actually mutating. Useful for capture summaries.
207
+ */
208
+ export function quietDiagnose(body) {
209
+ const regex = buildRegex();
210
+ if (!regex) return { matches: 0, words: [] };
211
+ const seen = new Map();
212
+ const visit = (s) => {
213
+ if (typeof s !== 'string') return;
214
+ for (const m of s.matchAll(regex)) {
215
+ const k = m[0].toLowerCase();
216
+ seen.set(k, (seen.get(k) || 0) + 1);
217
+ }
218
+ };
219
+ if (typeof body?.system === 'string') visit(body.system);
220
+ if (Array.isArray(body?.system)) for (const b of body.system) if (b?.text) visit(b.text);
221
+ if (Array.isArray(body?.messages)) {
222
+ for (const msg of body.messages) {
223
+ if (typeof msg?.content === 'string') visit(msg.content);
224
+ if (Array.isArray(msg?.content)) for (const b of msg.content) {
225
+ if (b?.text) visit(b.text);
226
+ if (typeof b?.content === 'string') visit(b.content);
227
+ }
228
+ }
229
+ }
230
+ // Tool descriptions + nested schema descriptions
231
+ if (Array.isArray(body?.tools)) {
232
+ for (const t of body.tools) {
233
+ if (typeof t?.description === 'string') visit(t.description);
234
+ const walk = (n) => {
235
+ if (Array.isArray(n)) n.forEach(walk);
236
+ else if (n && typeof n === 'object') {
237
+ for (const [k, v] of Object.entries(n)) {
238
+ if (k === 'description' && typeof v === 'string') visit(v);
239
+ else walk(v);
240
+ }
241
+ }
242
+ };
243
+ walk(t?.input_schema);
244
+ }
245
+ }
246
+ let total = 0;
247
+ for (const n of seen.values()) total += n;
248
+ return { matches: total, words: [...seen.entries()].map(([w, n]) => ({ word: w, count: n })) };
249
+ }
@@ -30,6 +30,7 @@
30
30
  */
31
31
 
32
32
  import { writeFile, mkdir, appendFile, readdir, unlink, stat } from 'fs/promises';
33
+ import { indexCapture, updateCaptureResponse } from './captures-index.js';
33
34
  import { existsSync } from 'fs';
34
35
  import { join } from 'path';
35
36
  import { homedir } from 'os';
@@ -241,6 +242,19 @@ export async function captureRequest({ path, body, requestId, sessionKey, sessio
241
242
  // Remember the summary path so captureResponse() can append to it.
242
243
  inFlightSummaries.set(requestId, summaryPath);
243
244
 
245
+ // Mirror to SQLite index for `mobygate captures query`. Best-effort
246
+ // — swallows errors internally so a broken index never breaks proxying.
247
+ indexCapture({
248
+ requestId,
249
+ ts: new Date().toISOString(),
250
+ path,
251
+ body,
252
+ sessionKey,
253
+ sessionSource: sessionKeySource,
254
+ jsonPath,
255
+ summaryPath,
256
+ }).catch(() => {});
257
+
244
258
  // Best-effort prune to stay under the cap. Don't await — let it run
245
259
  // alongside the next request.
246
260
  pruneOldCaptures().catch(() => {});
@@ -310,6 +324,16 @@ export async function captureResponse({ requestId, usage, durationMs, status, st
310
324
  }
311
325
 
312
326
  await appendFile(summaryPath, lines.join('\n') + '\n', 'utf8');
327
+
328
+ // Mirror response-side fields to the SQLite index.
329
+ updateCaptureResponse({
330
+ requestId,
331
+ usage,
332
+ durationMs,
333
+ status,
334
+ stopReason,
335
+ model,
336
+ }).catch(() => {});
313
337
  } catch (e) {
314
338
  console.warn(`[capture] response append failed for ${requestId}: ${e.message}`);
315
339
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobygate",
3
- "version": "0.8.4",
3
+ "version": "0.9.4",
4
4
  "description": "OpenAI-compatible local proxy for Claude Max. The Möbius-strip gateway: OpenAI shape in, Claude Max out.",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -11,6 +11,7 @@
11
11
  "start": "node server.js",
12
12
  "dev": "node --watch server.js",
13
13
  "up": "npm install && node server.js",
14
+ "test:smoke": "node --test test/smoke.test.mjs",
14
15
  "auth:status": "node scripts/auth-status.js",
15
16
  "auth:status:quick": "node scripts/auth-status.js --quick",
16
17
  "auth:refresh": "node scripts/auth-refresh.js",
@@ -18,6 +19,7 @@
18
19
  },
19
20
  "dependencies": {
20
21
  "@anthropic-ai/claude-agent-sdk": "^0.2.112",
22
+ "better-sqlite3": "^12.9.0",
21
23
  "express": "^5.1.0",
22
24
  "js-yaml": "^4.1.1",
23
25
  "uuid": "^11.1.0"
@@ -58,6 +60,7 @@
58
60
  "launchd",
59
61
  "server.js",
60
62
  "index.html",
63
+ "dashboard.css",
61
64
  "inspector.html",
62
65
  "mcp-inspect.mjs",
63
66
  "README.md",