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.
- package/CHANGELOG.md +207 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/bin/mobygate.js +443 -0
- package/index.html +805 -0
- package/launchd/ai.mobygate.auth-refresh.plist +83 -0
- package/lib/ascii.js +108 -0
- package/lib/config.js +131 -0
- package/lib/dashboard-bus.js +158 -0
- package/lib/platform.js +584 -0
- package/lib/session-store.js +112 -0
- package/mcp-inspect.mjs +186 -0
- package/package.json +62 -0
- package/scripts/auth-helper.js +198 -0
- package/scripts/auth-refresh.js +41 -0
- package/scripts/auth-status.js +36 -0
- package/server.js +1076 -0
|
@@ -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();
|