routstrd 0.4.8 → 0.4.9
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/README.md +96 -6
- package/SKILL.md +341 -0
- package/dist/daemon/index.js +598 -390
- package/dist/index.js +162637 -14155
- package/package.json +9 -4
- package/src/cli.ts +279 -110
- package/src/daemon/args.ts +4 -5
- package/src/daemon/http/index.ts +37 -2
- package/src/daemon/index.ts +94 -33
- package/src/daemon/wallet/auto-refill.ts +1 -1
- package/src/daemon/wallet/coco-client.ts +205 -3
- package/src/daemon/wallet/cocod-client.ts +19 -3
- package/src/daemon/wallet/index.ts +1 -1
- package/src/index.ts +9 -1
- package/src/runtime.ts +55 -0
- package/src/start-daemon.ts +4 -8
- package/src/utils/clients.ts +74 -1
- package/src/utils/config.ts +14 -0
- package/src/utils/daemon-stop.ts +125 -0
- package/src/utils/standalone-update.ts +214 -0
- package/src/utils/update-checker.ts +81 -18
- package/src/version.ts +3 -0
- package/src/cli.test.ts +0 -192
- package/src/daemon/fatal-error.test.ts +0 -104
- package/src/daemon/http/usage-summary.test.ts +0 -272
- package/src/daemon/models.test.ts +0 -81
- package/src/daemon/wallet/cleanup.test.ts +0 -168
- package/src/daemon/wallet/coco-client.npc.test.ts +0 -252
- package/src/daemon/wallet/coco-client.test.ts +0 -669
- package/src/daemon/wallet/diagnostics.test.ts +0 -378
- package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
- package/src/daemon/wallet/migration.test.ts +0 -171
- package/src/daemon/wallet/receive-dedup.test.ts +0 -238
package/src/cli.test.ts
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
|
|
3
|
-
import { tmpdir } from "os";
|
|
4
|
-
import { join } from "path";
|
|
5
|
-
import {
|
|
6
|
-
collectRecentRequestsFromLines,
|
|
7
|
-
initializeWallet,
|
|
8
|
-
parseStructuredLogLine,
|
|
9
|
-
} from "./cli";
|
|
10
|
-
|
|
11
|
-
const tempDirs: string[] = [];
|
|
12
|
-
|
|
13
|
-
function makeTempDir(): string {
|
|
14
|
-
const dir = mkdtempSync(join(tmpdir(), "routstrd-wallet-test-"));
|
|
15
|
-
tempDirs.push(dir);
|
|
16
|
-
return dir;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function permissions(path: string): number {
|
|
20
|
-
return statSync(path).mode & 0o777;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
afterEach(() => {
|
|
24
|
-
for (const dir of tempDirs.splice(0)) {
|
|
25
|
-
rmSync(dir, { recursive: true, force: true });
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe("parseStructuredLogLine", () => {
|
|
30
|
-
test("extracts timestamp, level, request id, model, and routing flag", () => {
|
|
31
|
-
const line =
|
|
32
|
-
"[2026-08-27T13:00:00.000Z] [INFO] [req:a1b2c3d4] [model:gpt-5] Routing request with path: /v1/chat/completions";
|
|
33
|
-
|
|
34
|
-
expect(parseStructuredLogLine(line)).toEqual({
|
|
35
|
-
timestamp: "2026-08-27T13:00:00.000Z",
|
|
36
|
-
level: "INFO",
|
|
37
|
-
requestId: "a1b2c3d4",
|
|
38
|
-
modelId: "gpt-5",
|
|
39
|
-
isRouting: true,
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("parses SDK child lines without marking them as routing lines", () => {
|
|
44
|
-
const line =
|
|
45
|
-
"[2026-08-27T13:00:01.000Z] [DEBUG] [req:a1b2c3d4] [model:gpt-5] [BalanceManager] token stuff";
|
|
46
|
-
|
|
47
|
-
expect(parseStructuredLogLine(line)).toEqual({
|
|
48
|
-
timestamp: "2026-08-27T13:00:01.000Z",
|
|
49
|
-
level: "DEBUG",
|
|
50
|
-
requestId: "a1b2c3d4",
|
|
51
|
-
modelId: "gpt-5",
|
|
52
|
-
isRouting: false,
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("handles legacy lines that have a request id but no model tag", () => {
|
|
57
|
-
const line =
|
|
58
|
-
"[2026-08-27T13:00:02.000Z] [INFO] [req:deadbeef] Routing request with path: /v1/responses";
|
|
59
|
-
|
|
60
|
-
expect(parseStructuredLogLine(line)).toEqual({
|
|
61
|
-
timestamp: "2026-08-27T13:00:02.000Z",
|
|
62
|
-
level: "INFO",
|
|
63
|
-
requestId: "deadbeef",
|
|
64
|
-
modelId: undefined,
|
|
65
|
-
isRouting: true,
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("extracts the bare request id from colon-joined legacy child tags", () => {
|
|
70
|
-
const line =
|
|
71
|
-
"[2026-08-27T13:00:04.000Z] [INFO] [req:deadbeef:RoutstrClient] [RoutstrClient] generic request pricing input {\"modelId\":\"deepseek-v4-flash-0731\"}";
|
|
72
|
-
|
|
73
|
-
const parsed = parseStructuredLogLine(line);
|
|
74
|
-
expect(parsed.requestId).toBe("deadbeef");
|
|
75
|
-
expect(parsed.modelId).toBe("deepseek-v4-flash-0731");
|
|
76
|
-
expect(parsed.isRouting).toBe(false);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test("falls back to the JSON modelId when no model tag is present", () => {
|
|
80
|
-
const line =
|
|
81
|
-
"[2026-08-27T13:00:05.000Z] [INFO] [req:deadbeef:RoutstrClient] [RoutstrClient] generic request pricing input {\"modelId\":\"deepseek-v4-pro-0813\",\"messageCount\":121}";
|
|
82
|
-
|
|
83
|
-
expect(parseStructuredLogLine(line).modelId).toBe("deepseek-v4-pro-0813");
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("returns an empty record for plain non-request lines", () => {
|
|
87
|
-
const line = "[2026-08-27T13:00:03.000Z] [WARN] Some startup message";
|
|
88
|
-
|
|
89
|
-
expect(parseStructuredLogLine(line)).toEqual({
|
|
90
|
-
timestamp: "2026-08-27T13:00:03.000Z",
|
|
91
|
-
level: "WARN",
|
|
92
|
-
requestId: undefined,
|
|
93
|
-
modelId: undefined,
|
|
94
|
-
isRouting: false,
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
describe("collectRecentRequestsFromLines", () => {
|
|
100
|
-
const lines = [
|
|
101
|
-
"[2026-08-27T13:00:00.000Z] [INFO] [req:11111111] [model:gpt-5] Routing request with path: /a",
|
|
102
|
-
"[2026-08-27T13:00:01.000Z] [DEBUG] [req:11111111] [model:gpt-5] [BalanceManager] token",
|
|
103
|
-
"[2026-08-27T13:00:02.000Z] [INFO] [req:22222222] [model:claude-sonnet-4.5] Routing request with path: /b",
|
|
104
|
-
"[2026-08-27T13:00:03.000Z] [DEBUG] [req:22222222] [model:claude-sonnet-4.5] [ModelManager] refresh",
|
|
105
|
-
];
|
|
106
|
-
|
|
107
|
-
test("returns one record per request, newest first", () => {
|
|
108
|
-
expect(collectRecentRequestsFromLines(lines, 10)).toEqual([
|
|
109
|
-
{
|
|
110
|
-
requestId: "22222222",
|
|
111
|
-
modelId: "claude-sonnet-4.5",
|
|
112
|
-
timestamp: "2026-08-27T13:00:02.000Z",
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
requestId: "11111111",
|
|
116
|
-
modelId: "gpt-5",
|
|
117
|
-
timestamp: "2026-08-27T13:00:00.000Z",
|
|
118
|
-
},
|
|
119
|
-
]);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test("respects the limit", () => {
|
|
123
|
-
expect(collectRecentRequestsFromLines(lines, 1)).toEqual([
|
|
124
|
-
{
|
|
125
|
-
requestId: "22222222",
|
|
126
|
-
modelId: "claude-sonnet-4.5",
|
|
127
|
-
timestamp: "2026-08-27T13:00:02.000Z",
|
|
128
|
-
},
|
|
129
|
-
]);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("defaults the model to unknown for legacy routing lines", () => {
|
|
133
|
-
const legacy = [
|
|
134
|
-
"[2026-08-27T13:00:00.000Z] [INFO] [req:deadbeef] Routing request with path: /a",
|
|
135
|
-
];
|
|
136
|
-
|
|
137
|
-
expect(collectRecentRequestsFromLines(legacy, 10)).toEqual([
|
|
138
|
-
{
|
|
139
|
-
requestId: "deadbeef",
|
|
140
|
-
modelId: "unknown",
|
|
141
|
-
timestamp: "2026-08-27T13:00:00.000Z",
|
|
142
|
-
},
|
|
143
|
-
]);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("enriches the model from a sibling pricing line in legacy logs", () => {
|
|
147
|
-
const legacy = [
|
|
148
|
-
"[2026-08-27T13:00:00.000Z] [INFO] [req:deadbeef] Routing request with path: /a",
|
|
149
|
-
"[2026-08-27T13:00:01.000Z] [INFO] [req:deadbeef:RoutstrClient] [RoutstrClient] generic request pricing input {\"modelId\":\"deepseek-v4-flash-0731\"}",
|
|
150
|
-
];
|
|
151
|
-
|
|
152
|
-
expect(collectRecentRequestsFromLines(legacy, 10)).toEqual([
|
|
153
|
-
{
|
|
154
|
-
requestId: "deadbeef",
|
|
155
|
-
modelId: "deepseek-v4-flash-0731",
|
|
156
|
-
timestamp: "2026-08-27T13:00:00.000Z",
|
|
157
|
-
},
|
|
158
|
-
]);
|
|
159
|
-
});
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
describe("initializeWallet", () => {
|
|
163
|
-
test("creates the wallet directory and config with restrictive permissions", () => {
|
|
164
|
-
const walletDir = join(makeTempDir(), "wallet");
|
|
165
|
-
|
|
166
|
-
initializeWallet(walletDir);
|
|
167
|
-
|
|
168
|
-
const walletConfig = join(walletDir, "config.json");
|
|
169
|
-
expect(permissions(walletDir)).toBe(0o700);
|
|
170
|
-
expect(permissions(walletConfig)).toBe(0o600);
|
|
171
|
-
|
|
172
|
-
const config = JSON.parse(readFileSync(walletConfig, "utf8"));
|
|
173
|
-
expect(config.encrypted).toBe(false);
|
|
174
|
-
expect(typeof config.mnemonic).toBe("string");
|
|
175
|
-
expect(config.mnemonic.length).toBeGreaterThan(0);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("repairs permissions without replacing an existing wallet", () => {
|
|
179
|
-
const walletDir = join(makeTempDir(), "wallet");
|
|
180
|
-
const walletConfig = join(walletDir, "config.json");
|
|
181
|
-
const existingConfig = JSON.stringify({ mnemonic: "existing seed" });
|
|
182
|
-
|
|
183
|
-
mkdirSync(walletDir, { mode: 0o755 });
|
|
184
|
-
writeFileSync(walletConfig, existingConfig, { mode: 0o644 });
|
|
185
|
-
|
|
186
|
-
initializeWallet(walletDir);
|
|
187
|
-
|
|
188
|
-
expect(readFileSync(walletConfig, "utf8")).toBe(existingConfig);
|
|
189
|
-
expect(permissions(walletDir)).toBe(0o700);
|
|
190
|
-
expect(permissions(walletConfig)).toBe(0o600);
|
|
191
|
-
});
|
|
192
|
-
});
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { unlinkSync } from "fs";
|
|
3
|
-
import { tmpdir } from "os";
|
|
4
|
-
import { join } from "path";
|
|
5
|
-
import { exitOnUncaughtException } from "./fatal-error";
|
|
6
|
-
|
|
7
|
-
const fatalErrorModule = JSON.stringify(join(import.meta.dir, "fatal-error.ts"));
|
|
8
|
-
|
|
9
|
-
const fixturePaths: string[] = [];
|
|
10
|
-
|
|
11
|
-
afterEach(() => {
|
|
12
|
-
while (fixturePaths.length > 0) {
|
|
13
|
-
try {
|
|
14
|
-
unlinkSync(fixturePaths.pop()!);
|
|
15
|
-
} catch {
|
|
16
|
-
// Best effort cleanup of temp fixtures.
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
/** Run a fixture script in a real subprocess so process.exit is for real. */
|
|
22
|
-
async function runFixture(source: string): Promise<{
|
|
23
|
-
exitCode: number;
|
|
24
|
-
stdout: string;
|
|
25
|
-
stderr: string;
|
|
26
|
-
}> {
|
|
27
|
-
const scriptPath = join(
|
|
28
|
-
tmpdir(),
|
|
29
|
-
`routstrd-fatal-error-fixture-${crypto.randomUUID()}.ts`,
|
|
30
|
-
);
|
|
31
|
-
fixturePaths.push(scriptPath);
|
|
32
|
-
await Bun.write(scriptPath, source);
|
|
33
|
-
const proc = Bun.spawn(["bun", scriptPath], {
|
|
34
|
-
stdout: "pipe",
|
|
35
|
-
stderr: "pipe",
|
|
36
|
-
});
|
|
37
|
-
const [stdout, stderr] = await Promise.all([
|
|
38
|
-
new Response(proc.stdout).text(),
|
|
39
|
-
new Response(proc.stderr).text(),
|
|
40
|
-
]);
|
|
41
|
-
const exitCode = await proc.exited;
|
|
42
|
-
return { exitCode, stdout, stderr };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
describe("exitOnUncaughtException", () => {
|
|
46
|
-
test("reports the error and exits with code 1", () => {
|
|
47
|
-
let exitCode: number | undefined;
|
|
48
|
-
const stderrLines: string[] = [];
|
|
49
|
-
const originalConsoleError = console.error;
|
|
50
|
-
console.error = (...args: unknown[]) => {
|
|
51
|
-
stderrLines.push(args.map(String).join(" "));
|
|
52
|
-
};
|
|
53
|
-
try {
|
|
54
|
-
exitOnUncaughtException(new Error("boom"), (code) => {
|
|
55
|
-
exitCode = code;
|
|
56
|
-
});
|
|
57
|
-
} finally {
|
|
58
|
-
console.error = originalConsoleError;
|
|
59
|
-
}
|
|
60
|
-
expect(exitCode).toBe(1);
|
|
61
|
-
expect(stderrLines.join("\n")).toContain("boom");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("handles non-Error thrown values", () => {
|
|
65
|
-
let exitCode: number | undefined;
|
|
66
|
-
const originalConsoleError = console.error;
|
|
67
|
-
console.error = () => {};
|
|
68
|
-
try {
|
|
69
|
-
exitOnUncaughtException("string failure", (code) => {
|
|
70
|
-
exitCode = code;
|
|
71
|
-
});
|
|
72
|
-
} finally {
|
|
73
|
-
console.error = originalConsoleError;
|
|
74
|
-
}
|
|
75
|
-
expect(exitCode).toBe(1);
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe("installGlobalErrorHandlers (subprocess)", () => {
|
|
80
|
-
test("an uncaught exception exits the process with code 1", async () => {
|
|
81
|
-
const { exitCode, stderr } = await runFixture(`
|
|
82
|
-
import { installGlobalErrorHandlers } from ${fatalErrorModule};
|
|
83
|
-
installGlobalErrorHandlers();
|
|
84
|
-
setTimeout(() => {
|
|
85
|
-
throw new Error("boom-uncaught");
|
|
86
|
-
}, 10);
|
|
87
|
-
`);
|
|
88
|
-
expect(exitCode).toBe(1);
|
|
89
|
-
expect(stderr).toContain("boom-uncaught");
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("an unhandled rejection is logged without exiting", async () => {
|
|
93
|
-
const { exitCode, stdout } = await runFixture(`
|
|
94
|
-
import { installGlobalErrorHandlers } from ${fatalErrorModule};
|
|
95
|
-
installGlobalErrorHandlers();
|
|
96
|
-
Promise.reject(new Error("boom-rejection"));
|
|
97
|
-
setTimeout(() => {
|
|
98
|
-
console.log("STILL-ALIVE");
|
|
99
|
-
}, 50);
|
|
100
|
-
`);
|
|
101
|
-
expect(exitCode).toBe(0);
|
|
102
|
-
expect(stdout).toContain("STILL-ALIVE");
|
|
103
|
-
});
|
|
104
|
-
});
|
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
-
import { createMemoryUsageTrackingDriver } from "@routstr/sdk/storage";
|
|
3
|
-
import type { UsageTrackingEntry } from "@routstr/sdk/storage";
|
|
4
|
-
import type { ClientEntry } from "../../utils/clients";
|
|
5
|
-
import { getUsageSummary, __resetUsageSummaryCacheForTest } from "./usage-summary";
|
|
6
|
-
|
|
7
|
-
// ─── Test fixtures ────────────────────────────────────────────────────────────
|
|
8
|
-
//
|
|
9
|
-
// All timestamps are computed relative to "now" so the fixtures never age
|
|
10
|
-
// out of getUsageSummary's 30-day rolling window. We anchor to a recent UTC
|
|
11
|
-
// midnight ~10 days ago and keep the same hour-of-day structure the
|
|
12
|
-
// tz-bucketing assertions depend on.
|
|
13
|
-
//
|
|
14
|
-
// tz = 300 (UTC-5 / EST). After shifting by -300min, UTC dates become:
|
|
15
|
-
// d1: <anchor> 10:00Z → local <anchor> 05:00 → local day "<anchor>"
|
|
16
|
-
// d2: <anchor+1d> 03:00Z → local <anchor> 22:00 → local day "<anchor>"
|
|
17
|
-
// d3: <anchor+1d> 06:00Z → local <anchor+1d> 01:00 → local day "<anchor+1d>"
|
|
18
|
-
|
|
19
|
-
const _BASE_UTC_MIDNIGHT = Math.floor(Date.now() / 86_400_000) * 86_400_000;
|
|
20
|
-
const _ANCHOR_MS = _BASE_UTC_MIDNIGHT - 10 * 86_400_000; // 10 days ago UTC
|
|
21
|
-
const D1 = _ANCHOR_MS + 10 * 3_600_000; // 10:00Z → local day <anchor>
|
|
22
|
-
const D2 = _ANCHOR_MS + 1 * 86_400_000 + 3 * 3_600_000; // 03:00Z next day → local day <anchor>
|
|
23
|
-
const D3 = _ANCHOR_MS + 1 * 86_400_000 + 6 * 3_600_000; // 06:00Z next day → local day <anchor+1d>
|
|
24
|
-
|
|
25
|
-
/** Compute the local-day date string the same way the SDK's day grouping does. */
|
|
26
|
-
function localDayDateStr(ts: number, tzOffsetMinutes: number): string {
|
|
27
|
-
const d = new Date(ts - tzOffsetMinutes * 60_000);
|
|
28
|
-
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const BASE_ENTRY: Omit<UsageTrackingEntry, "id" | "timestamp" | "modelId" | "client" | "totalTokens"> = {
|
|
32
|
-
baseUrl: "https://api.openai.com/",
|
|
33
|
-
requestId: "req-1",
|
|
34
|
-
cost: 0.01,
|
|
35
|
-
satsCost: 10,
|
|
36
|
-
promptTokens: 100,
|
|
37
|
-
completionTokens: 50,
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
function makeEntry(
|
|
41
|
-
id: string,
|
|
42
|
-
overrides: Partial<UsageTrackingEntry> & Pick<UsageTrackingEntry, "id" | "timestamp" | "modelId" | "totalTokens">,
|
|
43
|
-
): UsageTrackingEntry {
|
|
44
|
-
return {
|
|
45
|
-
...BASE_ENTRY,
|
|
46
|
-
...overrides,
|
|
47
|
-
id,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Seed entries:
|
|
52
|
-
// - 2 entries for model-a, client-1 (owned by npub1)
|
|
53
|
-
// - 1 entry for model-b, client-2 (no npub)
|
|
54
|
-
// - 1 entry for model-a, no client (null client)
|
|
55
|
-
// - totalTokens spread across tiny (< 1000) and small (1000-10000) buckets
|
|
56
|
-
|
|
57
|
-
const ENTRIES: UsageTrackingEntry[] = [
|
|
58
|
-
// model-a, client-1, day 2026-05-20 (D1), totalTokens=500 (tiny)
|
|
59
|
-
makeEntry("e1", {
|
|
60
|
-
id: "e1",
|
|
61
|
-
timestamp: D1,
|
|
62
|
-
modelId: "model-a",
|
|
63
|
-
client: "client-1",
|
|
64
|
-
totalTokens: 500,
|
|
65
|
-
promptTokens: 350,
|
|
66
|
-
completionTokens: 150,
|
|
67
|
-
satsCost: 10,
|
|
68
|
-
cost: 0.01,
|
|
69
|
-
}),
|
|
70
|
-
// model-a, client-1, day 2026-05-20 (D2), totalTokens=2000 (small)
|
|
71
|
-
makeEntry("e2", {
|
|
72
|
-
id: "e2",
|
|
73
|
-
timestamp: D2,
|
|
74
|
-
modelId: "model-a",
|
|
75
|
-
client: "client-1",
|
|
76
|
-
totalTokens: 2000,
|
|
77
|
-
promptTokens: 1400,
|
|
78
|
-
completionTokens: 600,
|
|
79
|
-
satsCost: 20,
|
|
80
|
-
cost: 0.02,
|
|
81
|
-
}),
|
|
82
|
-
// model-b, client-2, day 2026-05-21 (D3), totalTokens=800 (tiny)
|
|
83
|
-
makeEntry("e3", {
|
|
84
|
-
id: "e3",
|
|
85
|
-
timestamp: D3,
|
|
86
|
-
modelId: "model-b",
|
|
87
|
-
client: "client-2",
|
|
88
|
-
totalTokens: 800,
|
|
89
|
-
promptTokens: 600,
|
|
90
|
-
completionTokens: 200,
|
|
91
|
-
satsCost: 8,
|
|
92
|
-
cost: 0.008,
|
|
93
|
-
}),
|
|
94
|
-
// model-a, no client, day 2026-05-21 (D3), totalTokens=5000 (small)
|
|
95
|
-
makeEntry("e4", {
|
|
96
|
-
id: "e4",
|
|
97
|
-
timestamp: D3 + 1000,
|
|
98
|
-
modelId: "model-a",
|
|
99
|
-
client: undefined,
|
|
100
|
-
totalTokens: 5000,
|
|
101
|
-
promptTokens: 3500,
|
|
102
|
-
completionTokens: 1500,
|
|
103
|
-
satsCost: 50,
|
|
104
|
-
cost: 0.05,
|
|
105
|
-
}),
|
|
106
|
-
];
|
|
107
|
-
|
|
108
|
-
const CLIENTS: ClientEntry[] = [
|
|
109
|
-
{
|
|
110
|
-
clientId: "client-1",
|
|
111
|
-
name: "Client One",
|
|
112
|
-
apiKey: "sk-client1",
|
|
113
|
-
createdAt: D1 - 86400000,
|
|
114
|
-
ownerNpub: "npub1abc",
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
clientId: "client-2",
|
|
118
|
-
name: "Client Two",
|
|
119
|
-
apiKey: "sk-client2",
|
|
120
|
-
createdAt: D1 - 86400000,
|
|
121
|
-
// no ownerNpub
|
|
122
|
-
},
|
|
123
|
-
];
|
|
124
|
-
|
|
125
|
-
const TZ = 300; // UTC-5 / EST
|
|
126
|
-
|
|
127
|
-
describe("getUsageSummary", () => {
|
|
128
|
-
let driver: ReturnType<typeof createMemoryUsageTrackingDriver>;
|
|
129
|
-
|
|
130
|
-
beforeEach(async () => {
|
|
131
|
-
// Reset the module-level memo cache explicitly so each test starts cold.
|
|
132
|
-
// Without this, tests 2-10 would share the cached object from test 1
|
|
133
|
-
// (all use the same fixtures → identical cache key → same TTL window).
|
|
134
|
-
__resetUsageSummaryCacheForTest();
|
|
135
|
-
driver = createMemoryUsageTrackingDriver(ENTRIES);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("returns correct totals", async () => {
|
|
139
|
-
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
140
|
-
|
|
141
|
-
expect(summary.totals.requests).toBe(4);
|
|
142
|
-
expect(summary.totals.totalTokens).toBe(500 + 2000 + 800 + 5000);
|
|
143
|
-
expect(summary.totals.promptTokens).toBe(350 + 1400 + 600 + 3500);
|
|
144
|
-
expect(summary.totals.completionTokens).toBe(150 + 600 + 200 + 1500);
|
|
145
|
-
expect(summary.totals.satsCost).toBe(10 + 20 + 8 + 50);
|
|
146
|
-
expect(summary.totals.cost).toBeCloseTo(0.01 + 0.02 + 0.008 + 0.05, 5);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("returns models sorted desc by satsCost", async () => {
|
|
150
|
-
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
151
|
-
const { models } = summary;
|
|
152
|
-
|
|
153
|
-
// model-a: 3 entries (e1, e2, e4) total satsCost = 10+20+50 = 80
|
|
154
|
-
// model-b: 1 entry (e3) satsCost = 8
|
|
155
|
-
expect(models).toHaveLength(2);
|
|
156
|
-
expect(models[0]!.modelId).toBe("model-a");
|
|
157
|
-
expect(models[0]!.satsCost).toBe(80);
|
|
158
|
-
expect(models[0]!.requests).toBe(3);
|
|
159
|
-
expect(models[1]!.modelId).toBe("model-b");
|
|
160
|
-
expect(models[1]!.satsCost).toBe(8);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("returns days most-recent-first with correct tz-bucketing", async () => {
|
|
164
|
-
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
165
|
-
const { days } = summary;
|
|
166
|
-
|
|
167
|
-
// D1 (10:00Z) and D2 (next day 03:00Z) both bucket to local day <anchor>;
|
|
168
|
-
// D3 (next day 06:00Z) buckets to local day <anchor+1d>.
|
|
169
|
-
// So: day <anchor> has e1+e2, day <anchor+1d> has e3+e4
|
|
170
|
-
expect(days.length).toBeGreaterThanOrEqual(2);
|
|
171
|
-
|
|
172
|
-
// Most-recent-first: <anchor+1d> first
|
|
173
|
-
expect(days[0]!.date).toBe(localDayDateStr(D3, TZ));
|
|
174
|
-
expect(days[0]!.requests).toBe(2); // e3, e4
|
|
175
|
-
expect(days[0]!.satsCost).toBe(8 + 50);
|
|
176
|
-
|
|
177
|
-
expect(days[1]!.date).toBe(localDayDateStr(D1, TZ));
|
|
178
|
-
expect(days[1]!.requests).toBe(2); // e1, e2
|
|
179
|
-
expect(days[1]!.satsCost).toBe(10 + 20);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("returns empty hoursToday (test entries are not today)", async () => {
|
|
183
|
-
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
184
|
-
// Test entries are ~10 days ago, not today
|
|
185
|
-
expect(summary.hoursToday).toHaveLength(0);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("returns correct sizeBuckets", async () => {
|
|
189
|
-
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
190
|
-
const { sizeBuckets } = summary;
|
|
191
|
-
|
|
192
|
-
// tiny [0, 1000): e1 (500) + e3 (800) = 2 entries
|
|
193
|
-
expect(sizeBuckets.tiny.count).toBe(2);
|
|
194
|
-
expect(sizeBuckets.tiny.cost).toBe(10 + 8);
|
|
195
|
-
|
|
196
|
-
// small [1000, 10000): e2 (2000) + e4 (5000) = 2 entries
|
|
197
|
-
expect(sizeBuckets.small.count).toBe(2);
|
|
198
|
-
expect(sizeBuckets.small.cost).toBe(20 + 50);
|
|
199
|
-
|
|
200
|
-
// medium, large, huge should be 0
|
|
201
|
-
expect(sizeBuckets.medium.count).toBe(0);
|
|
202
|
-
expect(sizeBuckets.large.count).toBe(0);
|
|
203
|
-
expect(sizeBuckets.huge.count).toBe(0);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it("returns per-client topModels for top non-null clients", async () => {
|
|
207
|
-
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
208
|
-
const { clients } = summary;
|
|
209
|
-
|
|
210
|
-
// client-1: e1 + e2 (model-a), satsCost = 30
|
|
211
|
-
// client-2: e3 (model-b), satsCost = 8
|
|
212
|
-
// unknown (null client): e4 (model-a), satsCost = 50
|
|
213
|
-
// Sorted desc by satsCost: unknown (50), client-1 (30), client-2 (8)
|
|
214
|
-
|
|
215
|
-
const c1 = clients.find((c) => c.client === "client-1");
|
|
216
|
-
expect(c1).toBeDefined();
|
|
217
|
-
expect(c1!.requests).toBe(2);
|
|
218
|
-
expect(c1!.satsCost).toBe(30);
|
|
219
|
-
// client-1 is in top 3 non-null clients, should have topModels
|
|
220
|
-
expect(c1!.topModels.length).toBeGreaterThan(0);
|
|
221
|
-
expect(c1!.topModels[0]!.modelId).toBe("model-a");
|
|
222
|
-
|
|
223
|
-
const c2 = clients.find((c) => c.client === "client-2");
|
|
224
|
-
expect(c2).toBeDefined();
|
|
225
|
-
expect(c2!.requests).toBe(1);
|
|
226
|
-
expect(c2!.satsCost).toBe(8);
|
|
227
|
-
|
|
228
|
-
// unknown client (null group) should have empty topModels
|
|
229
|
-
const unknown = clients.find((c) => c.client === "unknown");
|
|
230
|
-
expect(unknown).toBeDefined();
|
|
231
|
-
expect(unknown!.topModels).toHaveLength(0);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it("folds client rows into npubs correctly", async () => {
|
|
235
|
-
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
236
|
-
const { npubs } = summary;
|
|
237
|
-
|
|
238
|
-
// Only client-1 has ownerNpub = "npub1abc"
|
|
239
|
-
// npub1abc: e1 + e2 = requests 2, satsCost 30
|
|
240
|
-
expect(npubs).toHaveLength(1);
|
|
241
|
-
expect(npubs[0]!.npub).toBe("npub1abc");
|
|
242
|
-
expect(npubs[0]!.requests).toBe(2);
|
|
243
|
-
expect(npubs[0]!.satsCost).toBe(30);
|
|
244
|
-
// Top models for npub1abc (clients: ["client-1"]): only model-a
|
|
245
|
-
expect(npubs[0]!.topModels.length).toBeGreaterThan(0);
|
|
246
|
-
expect(npubs[0]!.topModels[0]!.modelId).toBe("model-a");
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it("returns recent entries (up to 50)", async () => {
|
|
250
|
-
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
251
|
-
expect(summary.recent).toHaveLength(4); // only 4 entries in driver
|
|
252
|
-
// Should be sorted desc by timestamp
|
|
253
|
-
expect(summary.recent[0]!.timestamp).toBeGreaterThanOrEqual(summary.recent[1]!.timestamp);
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it("returns providers correctly", async () => {
|
|
257
|
-
const summary = await getUsageSummary(driver, CLIENTS, TZ);
|
|
258
|
-
const { providers } = summary;
|
|
259
|
-
|
|
260
|
-
// All entries use the same baseUrl
|
|
261
|
-
expect(providers).toHaveLength(1);
|
|
262
|
-
expect(providers[0]!.baseUrl).toBe("https://api.openai.com/");
|
|
263
|
-
expect(providers[0]!.requests).toBe(4);
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it("caches results for same key within TTL", async () => {
|
|
267
|
-
const summary1 = await getUsageSummary(driver, CLIENTS, TZ);
|
|
268
|
-
const summary2 = await getUsageSummary(driver, CLIENTS, TZ);
|
|
269
|
-
// Same object reference means it was served from cache
|
|
270
|
-
expect(summary1).toBe(summary2);
|
|
271
|
-
});
|
|
272
|
-
});
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import type { SdkStore } from "@routstr/sdk";
|
|
3
|
-
import { createModelService } from "./models";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* `ensureProvidersBootstrapped` must mirror the review-disabled provider set
|
|
7
|
-
* (kind 38425) into the SdkStore. The SDK applies review disables to the
|
|
8
|
-
* discovery adapter during bootstrap, but `providers list` and per-model
|
|
9
|
-
* provider views read the store — if the store is not kept in sync, a fresh
|
|
10
|
-
* install reports "0 disabled" while routing silently excludes them.
|
|
11
|
-
*/
|
|
12
|
-
describe("createModelService.ensureProvidersBootstrapped", () => {
|
|
13
|
-
function makeStore(initialBaseUrls: string[] = []) {
|
|
14
|
-
const disabledCalls: string[][] = [];
|
|
15
|
-
const state: Record<string, unknown> = {
|
|
16
|
-
baseUrlsList: initialBaseUrls,
|
|
17
|
-
setBaseUrlsList: (urls: string[]) => {
|
|
18
|
-
state.baseUrlsList = urls;
|
|
19
|
-
},
|
|
20
|
-
disabledProviders: [],
|
|
21
|
-
setDisabledProviders: (urls: string[]) => {
|
|
22
|
-
state.disabledProviders = urls;
|
|
23
|
-
disabledCalls.push(urls);
|
|
24
|
-
},
|
|
25
|
-
manuallyDisabledProviders: [],
|
|
26
|
-
manuallyEnabledProviders: [],
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
store: { getState: () => state } as unknown as SdkStore,
|
|
31
|
-
state,
|
|
32
|
-
disabledCalls,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function makeModelManager(
|
|
37
|
-
providers: string[],
|
|
38
|
-
reviewedDisabled: string[] | null,
|
|
39
|
-
) {
|
|
40
|
-
return {
|
|
41
|
-
bootstrapProviders: async () => providers,
|
|
42
|
-
syncReviewedProvidersFromNostr: async () => reviewedDisabled,
|
|
43
|
-
} as never;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
it("mirrors review-disabled providers into the store after bootstrap", async () => {
|
|
47
|
-
const { store, state, disabledCalls } = makeStore();
|
|
48
|
-
const modelManager = makeModelManager(
|
|
49
|
-
["https://ok.example/", "https://bad.example/"],
|
|
50
|
-
["https://bad.example/"],
|
|
51
|
-
);
|
|
52
|
-
const service = createModelService(
|
|
53
|
-
modelManager as never,
|
|
54
|
-
{} as never,
|
|
55
|
-
store,
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
await service.ensureProvidersBootstrapped();
|
|
59
|
-
|
|
60
|
-
expect(disabledCalls).toEqual([["https://bad.example/"]]);
|
|
61
|
-
expect(state.disabledProviders).toEqual(["https://bad.example/"]);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("leaves the store unchanged when review sync reports no change (null)", async () => {
|
|
65
|
-
const { store, state, disabledCalls } = makeStore(["https://ok.example/"]);
|
|
66
|
-
const modelManager = makeModelManager(
|
|
67
|
-
["https://ok.example/", "https://new.example/"],
|
|
68
|
-
null,
|
|
69
|
-
);
|
|
70
|
-
const service = createModelService(
|
|
71
|
-
modelManager as never,
|
|
72
|
-
{} as never,
|
|
73
|
-
store,
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
await service.ensureProvidersBootstrapped();
|
|
77
|
-
|
|
78
|
-
expect(disabledCalls).toEqual([]);
|
|
79
|
-
expect(state.disabledProviders).toEqual([]);
|
|
80
|
-
});
|
|
81
|
-
});
|