mobygate 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <!--
4
+ Proactive Claude Max OAuth refresh cron.
5
+
6
+ Runs scripts/auth-refresh.js every 4 hours via launchd. Anthropic's OAuth
7
+ access tokens last ~8 hours, so a 4h cadence keeps us well inside the
8
+ valid window even if one run fails (then we have another ~4h of buffer).
9
+
10
+ Install:
11
+ cp launchd/ai.mobygate.auth-refresh.plist ~/Library/LaunchAgents/
12
+ launchctl load ~/Library/LaunchAgents/ai.mobygate.auth-refresh.plist
13
+
14
+ Uninstall:
15
+ launchctl unload ~/Library/LaunchAgents/ai.mobygate.auth-refresh.plist
16
+ rm ~/Library/LaunchAgents/ai.mobygate.auth-refresh.plist
17
+
18
+ Before installing, edit WorkingDirectory below to match your checkout path
19
+ if it's not /Users/farhan/openclaude/claude-max-sdk-proxy.
20
+
21
+ Logs go to logs/auth-refresh.log inside the project directory.
22
+ -->
23
+ <plist version="1.0">
24
+ <dict>
25
+ <key>Label</key>
26
+ <string>ai.mobygate.auth-refresh</string>
27
+
28
+ <!--
29
+ launchd does not source your shell's rc files, so it does NOT see the
30
+ PATH set up by fnm/nvm/volta/asdf. Options:
31
+ (a) hardcode an absolute path to node here (simplest, fragile to
32
+ node upgrades that move the binary)
33
+ (b) use `/bin/sh -lc "node scripts/auth-refresh.js"` so a login
34
+ shell sources your profile and sets up fnm/nvm (below, commented)
35
+
36
+ Default uses fnm's stable "default" alias symlink. If you use a
37
+ different node manager or a system install, change the first string
38
+ in ProgramArguments to wherever `which node` resolves to for you.
39
+ -->
40
+ <key>ProgramArguments</key>
41
+ <array>
42
+ <string>/Users/farhan/.local/share/fnm/aliases/default/bin/node</string>
43
+ <string>scripts/auth-refresh.js</string>
44
+ </array>
45
+ <!-- Alternative: let your login shell set up fnm/nvm first.
46
+ <key>ProgramArguments</key>
47
+ <array>
48
+ <string>/bin/sh</string>
49
+ <string>-lc</string>
50
+ <string>node scripts/auth-refresh.js</string>
51
+ </array>
52
+ -->
53
+
54
+
55
+ <key>WorkingDirectory</key>
56
+ <string>/Users/farhan/openclaude/claude-max-sdk-proxy</string>
57
+
58
+ <key>EnvironmentVariables</key>
59
+ <dict>
60
+ <key>PATH</key>
61
+ <string>/Users/farhan/.local/share/fnm/aliases/default/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/Users/farhan/.local/bin</string>
62
+ <key>HOME</key>
63
+ <string>/Users/farhan</string>
64
+ </dict>
65
+
66
+ <!-- Every 4 hours (14400 seconds) -->
67
+ <key>StartInterval</key>
68
+ <integer>14400</integer>
69
+
70
+ <!-- Also run 60 seconds after load so we get an immediate probe -->
71
+ <key>RunAtLoad</key>
72
+ <true/>
73
+
74
+ <key>StandardOutPath</key>
75
+ <string>/Users/farhan/openclaude/claude-max-sdk-proxy/logs/auth-refresh.log</string>
76
+ <key>StandardErrorPath</key>
77
+ <string>/Users/farhan/openclaude/claude-max-sdk-proxy/logs/auth-refresh.err.log</string>
78
+
79
+ <!-- Don't restart on failure — one run per interval is enough. -->
80
+ <key>KeepAlive</key>
81
+ <false/>
82
+ </dict>
83
+ </plist>
package/lib/ascii.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Terminal ASCII art for mobygate.
3
+ *
4
+ * The whale character-art and color palette are lifted directly from the
5
+ * Paper design file (01KPFE5G6MJGMT5E5MGA94DQRF, artboard C1-0) — the
6
+ * same source of truth as the web dashboard. Whale-in-terminal and
7
+ * whale-in-browser should feel like the same product.
8
+ *
9
+ * Palette — 24-bit truecolor ANSI, exact hex match with the web side:
10
+ * #B7E56D primary green (whale, healthy state, title)
11
+ * #E89B2E orange (stars, warning, CTA)
12
+ * #4EA4C4 blue (waterline, stream)
13
+ * #8A9A6A muted olive (tagline, tertiary text)
14
+ * #5A5F54 dim (labels, version)
15
+ * #F3EFE4 warm white (primary text)
16
+ *
17
+ * Fallback: NO_COLOR env var or non-TTY stdout strips all escape codes.
18
+ */
19
+
20
+ const C = {
21
+ reset: '\x1b[0m',
22
+ bold: '\x1b[1m',
23
+ // 24-bit truecolor — matches the Paper palette and web dashboard exactly
24
+ green: '\x1b[38;2;183;229;109m', // #B7E56D
25
+ orange: '\x1b[38;2;232;155;46m', // #E89B2E
26
+ blue: '\x1b[38;2;78;164;196m', // #4EA4C4
27
+ olive: '\x1b[38;2;138;154;106m', // #8A9A6A
28
+ dim: '\x1b[38;2;90;95;84m', // #5A5F54
29
+ text: '\x1b[38;2;243;239;228m', // #F3EFE4
30
+ };
31
+
32
+ // Exact 6-line whale transcribed from Paper C1-0 header. Barnacle cluster
33
+ // (##), spout (.), motion lines (==), mouth (/"""""""\__/), eye (o), tail.
34
+ const WHALE = [
35
+ " ## .",
36
+ " ## ## ## ==",
37
+ " ## ## ## ## ===",
38
+ ' /"""""""""""\\__/ ==',
39
+ " \\______ o __/",
40
+ " \\______\\___,/",
41
+ ];
42
+
43
+ // Sparse starfield above the whale — staggered asterisks and dots so it
44
+ // reads as night sky, not noise.
45
+ const STARS = [
46
+ " . * . * . .",
47
+ " * . . *",
48
+ " . . * . * .",
49
+ ];
50
+
51
+ // One-line waterline under the whale. The ∞∾ alternation nods to the
52
+ // Möbius-loop concept baked into the name without being heavy-handed.
53
+ const WAVES = " ∞∾∞∾∞∾∞∾∞∾∞∾∞∾∞∾∞∾∞∾∞∾∞∾∞";
54
+
55
+ const TITLE = "mobygate";
56
+ const TAG = "OpenAI → Claude Max · local gateway";
57
+
58
+ function useColor() {
59
+ return process.stdout.isTTY && !process.env.NO_COLOR;
60
+ }
61
+
62
+ function paint(s, ...codes) {
63
+ if (!useColor()) return s;
64
+ return `${codes.join('')}${s}${C.reset}`;
65
+ }
66
+
67
+ function padBlock(lines) {
68
+ const w = Math.max(...lines.map((l) => [...l].length));
69
+ return lines.map((l) => l + ' '.repeat(w - [...l].length));
70
+ }
71
+
72
+ /**
73
+ * Full banner — stars / whale / waterline / title / tagline.
74
+ * Used by `mobygate init` and the server startup log.
75
+ *
76
+ * version? appends a dim "v0.X.Y" next to the title.
77
+ */
78
+ export function banner({ version } = {}) {
79
+ const whale = padBlock(WHALE);
80
+ const out = [
81
+ '',
82
+ ...STARS.map((s) => ' ' + paint(s, C.orange)),
83
+ '',
84
+ ...whale.map((l) => ' ' + paint(l, C.green)),
85
+ ' ' + paint(WAVES, C.blue),
86
+ '',
87
+ ' ' + paint(TITLE, C.bold, C.green) + (version ? ' ' + paint('v' + version, C.dim) : ''),
88
+ ' ' + paint(TAG, C.olive),
89
+ '',
90
+ ];
91
+ return out.join('\n');
92
+ }
93
+
94
+ /**
95
+ * Compact one-liner — whale-spout-flanked title. For `mobygate status`
96
+ * and other short CLI surfaces where the full whale is overkill.
97
+ */
98
+ export function compactBanner({ version } = {}) {
99
+ const v = version ? ' ' + paint('v' + version, C.dim) : '';
100
+ return [
101
+ '',
102
+ ' ' + paint('∞∾∞∾', C.blue) + ' ' + paint(TITLE, C.bold, C.green) + ' ' + paint('∾∞∾∞', C.blue) + v,
103
+ ' ' + paint(TAG, C.olive),
104
+ '',
105
+ ].join('\n');
106
+ }
107
+
108
+ export const colors = C;
package/lib/config.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * mobygate config loader.
3
+ *
4
+ * Reads ~/.mobygate/config.yaml if present, merges with env-var overrides
5
+ * and built-in defaults. Written by `mobygate init`, but also editable by
6
+ * hand. Missing file → all defaults (so the proxy works without ever
7
+ * running init, matching the old behavior).
8
+ *
9
+ * Precedence (highest → lowest):
10
+ * 1. Process env vars (PORT, DEFAULT_MODEL, etc.)
11
+ * 2. ~/.mobygate/config.yaml
12
+ * 3. Built-in defaults
13
+ */
14
+
15
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
16
+ import { homedir } from 'os';
17
+ import { join } from 'path';
18
+ import yaml from 'js-yaml';
19
+
20
+ export const CONFIG_DIR = process.env.MOBYGATE_HOME || join(homedir(), '.mobygate');
21
+ export const CONFIG_PATH = join(CONFIG_DIR, 'config.yaml');
22
+ export const STATE_PATH = join(CONFIG_DIR, 'state.json');
23
+ // Logs live alongside config — same across git-clone, npm-global, and
24
+ // per-user installs. Never inside the install dir (npm global installs
25
+ // may be root-owned, and `npm update` wipes non-tarball files).
26
+ export const LOGS_DIR = join(CONFIG_DIR, 'logs');
27
+
28
+ const DEFAULTS = {
29
+ port: 3456,
30
+ default_model: 'claude-opus-4-7[1m]',
31
+ session_ttl_minutes: 60,
32
+ max_concurrent: null, // reserved for future (per-session throttling)
33
+ claude_bin: '', // empty → PATH lookup
34
+ log_level: 'info',
35
+ auth_refresh_interval_hours: 4,
36
+ };
37
+
38
+ /**
39
+ * Parse the config file and merge with defaults. Returns a frozen object.
40
+ * Never throws for a missing file; does throw for invalid YAML.
41
+ */
42
+ export function loadConfig() {
43
+ let fileConfig = {};
44
+ if (existsSync(CONFIG_PATH)) {
45
+ try {
46
+ const raw = readFileSync(CONFIG_PATH, 'utf8');
47
+ fileConfig = yaml.load(raw) || {};
48
+ if (typeof fileConfig !== 'object' || Array.isArray(fileConfig)) {
49
+ console.warn(`[config] ${CONFIG_PATH} did not parse to an object — ignoring`);
50
+ fileConfig = {};
51
+ }
52
+ } catch (e) {
53
+ console.warn(`[config] failed to parse ${CONFIG_PATH}: ${e.message}`);
54
+ fileConfig = {};
55
+ }
56
+ }
57
+
58
+ const merged = {
59
+ port: parseInt(process.env.PORT || String(fileConfig.port ?? DEFAULTS.port), 10),
60
+ default_model: process.env.DEFAULT_MODEL || fileConfig.default_model || DEFAULTS.default_model,
61
+ session_ttl_minutes: parseInt(
62
+ process.env.SESSION_TTL_MINUTES
63
+ || String(fileConfig.session_ttl_minutes ?? DEFAULTS.session_ttl_minutes),
64
+ 10,
65
+ ),
66
+ claude_bin: process.env.CLAUDE_BIN || fileConfig.claude_bin || DEFAULTS.claude_bin,
67
+ log_level: process.env.LOG_LEVEL || fileConfig.log_level || DEFAULTS.log_level,
68
+ auth_refresh_interval_hours: parseInt(
69
+ process.env.AUTH_REFRESH_INTERVAL_HOURS
70
+ || String(fileConfig.auth_refresh_interval_hours ?? DEFAULTS.auth_refresh_interval_hours),
71
+ 10,
72
+ ),
73
+ source: existsSync(CONFIG_PATH) ? CONFIG_PATH : '(defaults)',
74
+ };
75
+ return Object.freeze(merged);
76
+ }
77
+
78
+ /**
79
+ * Write a config file with the given values merged on top of DEFAULTS.
80
+ * Creates the directory if needed. Produces a commented YAML file so
81
+ * users who open it can see what's tunable.
82
+ */
83
+ export function writeConfig(values = {}) {
84
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
85
+ const merged = { ...DEFAULTS, ...values };
86
+ const content = [
87
+ '# mobygate config — generated by `mobygate init`.',
88
+ '# Env vars (PORT, DEFAULT_MODEL, SESSION_TTL_MINUTES, CLAUDE_BIN, etc.)',
89
+ '# override any field here. Safe to hand-edit.',
90
+ '',
91
+ `# HTTP port the proxy listens on.`,
92
+ `port: ${merged.port}`,
93
+ '',
94
+ `# Default Claude model when the client does not specify one.`,
95
+ `# Other aliases (opus, sonnet, haiku) resolve per MODEL_MAP in server.js.`,
96
+ `default_model: ${JSON.stringify(merged.default_model)}`,
97
+ '',
98
+ `# How long a client-provided session key maps to an SDK session.`,
99
+ `session_ttl_minutes: ${merged.session_ttl_minutes}`,
100
+ '',
101
+ `# Explicit path to the \`claude\` CLI binary.`,
102
+ `# Empty string = resolve via PATH. Set this if claude is not on PATH,`,
103
+ `# or if you use a node manager (fnm/nvm) that puts binaries in`,
104
+ `# session-specific directories.`,
105
+ `claude_bin: ${JSON.stringify(merged.claude_bin)}`,
106
+ '',
107
+ `# Log verbosity.`,
108
+ `log_level: ${merged.log_level}`,
109
+ '',
110
+ `# How often the proactive auth-refresh cron runs (hours).`,
111
+ `# Anthropic tokens last ~8h, so 4h cadence keeps you well inside the window.`,
112
+ `auth_refresh_interval_hours: ${merged.auth_refresh_interval_hours}`,
113
+ '',
114
+ ].join('\n');
115
+ writeFileSync(CONFIG_PATH, content);
116
+ return CONFIG_PATH;
117
+ }
118
+
119
+ /** Read the install-metadata sidecar (or null if not yet written). */
120
+ export function readState() {
121
+ if (!existsSync(STATE_PATH)) return null;
122
+ try { return JSON.parse(readFileSync(STATE_PATH, 'utf8')); } catch { return null; }
123
+ }
124
+
125
+ export function writeState(patch) {
126
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
127
+ const current = readState() || {};
128
+ const merged = { ...current, ...patch, updated_at: new Date().toISOString() };
129
+ writeFileSync(STATE_PATH, JSON.stringify(merged, null, 2));
130
+ return merged;
131
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * In-process event bus for the live dashboard.
3
+ *
4
+ * Handlers emit events as requests flow through; the HTTP SSE endpoint
5
+ * (/events) subscribes and pipes each event to connected browsers. A
6
+ * bounded ring buffer keeps the most recent N events so the dashboard
7
+ * can render a populated table on first page load without waiting for
8
+ * new traffic.
9
+ *
10
+ * Intentionally simple — no persistence, no cross-process fan-out, no
11
+ * metrics library. Everything resets when the server restarts. That's
12
+ * appropriate for a local-first dev proxy. If you want historical
13
+ * analytics, scrape /events or the log file.
14
+ */
15
+
16
+ import { EventEmitter } from 'events';
17
+
18
+ const DEFAULT_RING_SIZE = 200;
19
+
20
+ const LATENCY_SAMPLES = 50; // rolling window per variant (stream/sync)
21
+ const TRAFFIC_WINDOW_MIN = 15; // minutes retained for the traffic chart
22
+
23
+ function percentile(sorted, p) {
24
+ if (!sorted.length) return null;
25
+ const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
26
+ return sorted[idx];
27
+ }
28
+
29
+ function minuteKey(d = new Date()) {
30
+ return new Date(d).toISOString().slice(0, 16) + ':00Z'; // YYYY-MM-DDTHH:MM:00Z
31
+ }
32
+
33
+ class DashboardBus extends EventEmitter {
34
+ constructor(ringSize = DEFAULT_RING_SIZE) {
35
+ super();
36
+ this.setMaxListeners(50);
37
+ this.ringSize = ringSize;
38
+ this.recent = [];
39
+ this.startedAt = Date.now();
40
+ this.stats = {
41
+ totalRequests: 0,
42
+ succeeded: 0,
43
+ failed: 0,
44
+ streamingRequests: 0,
45
+ toolRequests: 0,
46
+ multimodalRequests: 0,
47
+ authRefreshes: 0,
48
+ };
49
+ // Rolling latency samples, keyed by stream|sync
50
+ this.latency = { stream: [], sync: [] };
51
+ // Per-minute traffic bucket map: key → count
52
+ this.trafficByMinute = new Map();
53
+ // Track each request's type at start-time so the end-event can route to
54
+ // the right latency bucket.
55
+ this.inflightType = new Map();
56
+ }
57
+
58
+ /**
59
+ * Emit an event to subscribers and store in the ring buffer.
60
+ * Accepted types:
61
+ * 'request.start' { id, method, path, model, session, stream, tools, images, messages }
62
+ * 'request.end' { id, durationMs, status, finishReason, inputTokens, outputTokens, error }
63
+ * 'auth.refresh' { ok, durationMs, error }
64
+ * 'server.boot' { port, defaultModel }
65
+ */
66
+ emitEvent(ev) {
67
+ const event = { ...ev, ts: ev.ts || new Date().toISOString() };
68
+ this.emit('event', event);
69
+
70
+ this.recent.push(event);
71
+ if (this.recent.length > this.ringSize) this.recent.shift();
72
+
73
+ // Stats updates
74
+ if (event.type === 'request.start') {
75
+ this.stats.totalRequests++;
76
+ if (event.stream) this.stats.streamingRequests++;
77
+ if (event.tools) this.stats.toolRequests++;
78
+ if (event.images > 0) this.stats.multimodalRequests++;
79
+
80
+ // Remember each request's kind so end-event routing knows which bucket
81
+ this.inflightType.set(event.id, event.stream ? 'stream' : 'sync');
82
+
83
+ // Traffic bucket — count by the request.start minute
84
+ const k = minuteKey(event.ts);
85
+ this.trafficByMinute.set(k, (this.trafficByMinute.get(k) || 0) + 1);
86
+ this._trimTraffic();
87
+ } else if (event.type === 'request.end') {
88
+ if (event.status === 'ok') this.stats.succeeded++;
89
+ else this.stats.failed++;
90
+
91
+ // Latency sample → rolling window for the right variant
92
+ const kind = this.inflightType.get(event.id) || 'sync';
93
+ this.inflightType.delete(event.id);
94
+ if (typeof event.durationMs === 'number' && event.durationMs >= 0) {
95
+ const arr = this.latency[kind];
96
+ arr.push(event.durationMs);
97
+ if (arr.length > LATENCY_SAMPLES) arr.shift();
98
+ }
99
+ } else if (event.type === 'auth.refresh') {
100
+ this.stats.authRefreshes++;
101
+ }
102
+ }
103
+
104
+ /** Drop traffic buckets older than TRAFFIC_WINDOW_MIN minutes. */
105
+ _trimTraffic() {
106
+ const cutoff = new Date(Date.now() - TRAFFIC_WINDOW_MIN * 60 * 1000);
107
+ for (const k of this.trafficByMinute.keys()) {
108
+ if (new Date(k) < cutoff) this.trafficByMinute.delete(k);
109
+ }
110
+ }
111
+
112
+ /** Compute { p50, p95, recent[], count } for a latency variant. */
113
+ _latencyMetrics(kind) {
114
+ const recent = this.latency[kind];
115
+ const sorted = [...recent].sort((a, b) => a - b);
116
+ return {
117
+ count: recent.length,
118
+ p50: percentile(sorted, 50),
119
+ p95: percentile(sorted, 95),
120
+ recent: [...recent], // chronological order, for sparkline rendering
121
+ };
122
+ }
123
+
124
+ /** Fill-in zeros for missing minutes in the traffic window. */
125
+ _trafficSeries() {
126
+ this._trimTraffic();
127
+ const now = new Date();
128
+ const series = [];
129
+ for (let i = TRAFFIC_WINDOW_MIN - 1; i >= 0; i--) {
130
+ const t = new Date(now.getTime() - i * 60 * 1000);
131
+ const k = minuteKey(t);
132
+ series.push({ minute: k, count: this.trafficByMinute.get(k) || 0 });
133
+ }
134
+ return series;
135
+ }
136
+
137
+ getRecent({ limit = 100 } = {}) {
138
+ const slice = this.recent.slice(-limit);
139
+ return slice.reverse(); // newest first
140
+ }
141
+
142
+ getStats() {
143
+ return {
144
+ ...this.stats,
145
+ uptimeSec: Math.floor((Date.now() - this.startedAt) / 1000),
146
+ startedAt: new Date(this.startedAt).toISOString(),
147
+ ringSize: this.ringSize,
148
+ recentCount: this.recent.length,
149
+ latency: {
150
+ stream: this._latencyMetrics('stream'),
151
+ sync: this._latencyMetrics('sync'),
152
+ },
153
+ traffic: this._trafficSeries(),
154
+ };
155
+ }
156
+ }
157
+
158
+ export const bus = new DashboardBus();