offrouter-core 0.2.0 → 0.2.2
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/dist/audit.d.ts +137 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +320 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth/credential-chain.d.ts +84 -0
- package/dist/auth/credential-chain.d.ts.map +1 -0
- package/dist/auth/credential-chain.js +150 -0
- package/dist/auth/credential-chain.js.map +1 -0
- package/dist/auth/index.d.ts +10 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +7 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/keychain.d.ts +60 -0
- package/dist/auth/keychain.d.ts.map +1 -0
- package/dist/auth/keychain.js +104 -0
- package/dist/auth/keychain.js.map +1 -0
- package/dist/auth/oauth-pkce.d.ts +138 -0
- package/dist/auth/oauth-pkce.d.ts.map +1 -0
- package/dist/auth/oauth-pkce.js +375 -0
- package/dist/auth/oauth-pkce.js.map +1 -0
- package/dist/auth/oauth-server.d.ts +36 -0
- package/dist/auth/oauth-server.d.ts.map +1 -0
- package/dist/auth/oauth-server.js +210 -0
- package/dist/auth/oauth-server.js.map +1 -0
- package/dist/config.d.ts +76 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +355 -0
- package/dist/config.js.map +1 -0
- package/dist/delegation.d.ts +123 -0
- package/dist/delegation.d.ts.map +1 -0
- package/dist/delegation.js +455 -0
- package/dist/delegation.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/policy.d.ts +45 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +318 -0
- package/dist/policy.js.map +1 -0
- package/{src/protocol.ts → dist/protocol.d.ts} +2 -2
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +6 -0
- package/dist/protocol.js.map +1 -0
- package/dist/providers/adapter.d.ts +137 -0
- package/dist/providers/adapter.d.ts.map +1 -0
- package/dist/providers/adapter.js +163 -0
- package/dist/providers/adapter.js.map +1 -0
- package/dist/providers/catalog-data.d.ts +128 -0
- package/dist/providers/catalog-data.d.ts.map +1 -0
- package/dist/providers/catalog-data.js +397 -0
- package/dist/providers/catalog-data.js.map +1 -0
- package/dist/providers/catalog.d.ts +63 -0
- package/dist/providers/catalog.d.ts.map +1 -0
- package/dist/providers/catalog.js +394 -0
- package/dist/providers/catalog.js.map +1 -0
- package/dist/providers/fake.d.ts +46 -0
- package/dist/providers/fake.d.ts.map +1 -0
- package/dist/providers/fake.js +234 -0
- package/dist/providers/fake.js.map +1 -0
- package/dist/providers/openai-compatible.d.ts +65 -0
- package/dist/providers/openai-compatible.d.ts.map +1 -0
- package/dist/providers/openai-compatible.js +434 -0
- package/dist/providers/openai-compatible.js.map +1 -0
- package/dist/proxy/responses-mapper.d.ts +366 -0
- package/dist/proxy/responses-mapper.d.ts.map +1 -0
- package/dist/proxy/responses-mapper.js +517 -0
- package/dist/proxy/responses-mapper.js.map +1 -0
- package/dist/proxy/responses-server.d.ts +29 -0
- package/dist/proxy/responses-server.d.ts.map +1 -0
- package/dist/proxy/responses-server.js +360 -0
- package/dist/proxy/responses-server.js.map +1 -0
- package/dist/router.d.ts +18 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +296 -0
- package/dist/router.js.map +1 -0
- package/dist/schemas.d.ts +560 -0
- package/dist/schemas.d.ts.map +1 -0
- package/{src/schemas.ts → dist/schemas.js} +83 -103
- package/dist/schemas.js.map +1 -0
- package/dist/secrets.d.ts +112 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +326 -0
- package/dist/secrets.js.map +1 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/usage-file.d.ts +59 -0
- package/dist/usage-file.d.ts.map +1 -0
- package/dist/usage-file.js +316 -0
- package/dist/usage-file.js.map +1 -0
- package/dist/usage.d.ts +235 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +517 -0
- package/dist/usage.js.map +1 -0
- package/package.json +9 -4
- package/src/audit.test.ts +0 -302
- package/src/audit.ts +0 -553
- package/src/auth/credential-chain.test.ts +0 -326
- package/src/auth/credential-chain.ts +0 -235
- package/src/auth/index.ts +0 -45
- package/src/auth/keychain.test.ts +0 -265
- package/src/auth/keychain.ts +0 -168
- package/src/auth/oauth-pkce.test.ts +0 -329
- package/src/auth/oauth-pkce.ts +0 -571
- package/src/auth/oauth-server.test.ts +0 -83
- package/src/auth/oauth-server.ts +0 -296
- package/src/config.test.ts +0 -479
- package/src/config.ts +0 -505
- package/src/delegation.test.ts +0 -368
- package/src/delegation.ts +0 -605
- package/src/index.ts +0 -280
- package/src/policy.test.ts +0 -634
- package/src/policy.ts +0 -420
- package/src/providers/adapter.test.ts +0 -293
- package/src/providers/adapter.ts +0 -328
- package/src/providers/catalog-data.test.ts +0 -258
- package/src/providers/catalog-data.ts +0 -498
- package/src/providers/catalog.test.ts +0 -84
- package/src/providers/catalog.ts +0 -483
- package/src/providers/fake.ts +0 -312
- package/src/providers/openai-compatible.test.ts +0 -366
- package/src/providers/openai-compatible.ts +0 -554
- package/src/proxy/responses-mapper.test.ts +0 -290
- package/src/proxy/responses-mapper.ts +0 -736
- package/src/proxy/responses-server.test.ts +0 -322
- package/src/proxy/responses-server.ts +0 -469
- package/src/router.test.ts +0 -699
- package/src/router.ts +0 -352
- package/src/schemas.test.ts +0 -291
- package/src/secrets.test.ts +0 -271
- package/src/secrets.ts +0 -461
- package/src/types.ts +0 -173
- package/src/usage-file.test.ts +0 -243
- package/src/usage-file.ts +0 -435
- package/src/usage.test.ts +0 -335
- package/src/usage.ts +0 -859
- package/tsconfig.json +0 -9
package/src/usage-file.test.ts
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
DEFAULT_NEAR_LIMIT_THRESHOLD,
|
|
7
|
-
FileUsageStore,
|
|
8
|
-
InMemoryUsageStore,
|
|
9
|
-
type UsageStore,
|
|
10
|
-
} from "./usage-file.js";
|
|
11
|
-
|
|
12
|
-
const tempDirs: string[] = [];
|
|
13
|
-
|
|
14
|
-
afterEach(async () => {
|
|
15
|
-
await Promise.all(
|
|
16
|
-
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
|
|
17
|
-
);
|
|
18
|
-
},);
|
|
19
|
-
|
|
20
|
-
async function makeTempDir(): Promise<string> {
|
|
21
|
-
const dir = await mkdtemp(join(tmpdir(), "offrouter-usage-file-"));
|
|
22
|
-
tempDirs.push(dir);
|
|
23
|
-
return dir;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function makeFileStore(
|
|
27
|
-
filePath?: string,
|
|
28
|
-
): Promise<{ dir: string; path: string; store: FileUsageStore }> {
|
|
29
|
-
const dir = await makeTempDir();
|
|
30
|
-
const path = filePath ?? join(dir, "state", "usage.json");
|
|
31
|
-
const store = new FileUsageStore({ filePath: path });
|
|
32
|
-
return { dir, path, store };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
describe("FileUsageStore", () => {
|
|
36
|
-
it("satisfies the UsageStore interface contract", () => {
|
|
37
|
-
const store: UsageStore = new FileUsageStore({
|
|
38
|
-
filePath: "/tmp/offrouter-usage-store-interface.json",
|
|
39
|
-
});
|
|
40
|
-
expect(typeof store.recordUsage).toBe("function");
|
|
41
|
-
expect(typeof store.isNearLimit).toBe("function");
|
|
42
|
-
expect(typeof store.record).toBe("function");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("persists account usage across store instances sharing the same file", async () => {
|
|
46
|
-
const { path, store } = await makeFileStore();
|
|
47
|
-
await store.recordUsage("anthropic", "anthropic-1", {
|
|
48
|
-
used: 42,
|
|
49
|
-
limit: 100,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
const reloaded = new FileUsageStore({ filePath: path });
|
|
53
|
-
const snap = await reloaded.getAccountUsage("anthropic", "anthropic-1");
|
|
54
|
-
expect(snap?.used).toBe(42);
|
|
55
|
-
expect(snap?.limit).toBe(100);
|
|
56
|
-
expect(snap?.remaining).toBe(58);
|
|
57
|
-
|
|
58
|
-
// A fresh instance also sees near-limit state.
|
|
59
|
-
expect(await reloaded.isNearLimit("anthropic", "anthropic-1")).toBe(false);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("marks an account near-limit at 85% consumed (threshold 0.2)", async () => {
|
|
63
|
-
const { store } = await makeFileStore();
|
|
64
|
-
const snap = await store.recordUsage("anthropic", "anthropic-1", {
|
|
65
|
-
used: 85,
|
|
66
|
-
limit: 100,
|
|
67
|
-
});
|
|
68
|
-
expect(snap.remaining).toBe(15);
|
|
69
|
-
expect(snap.percentRemaining).toBe(15);
|
|
70
|
-
expect(snap.nearLimit).toBe(true);
|
|
71
|
-
expect(snap.subscriptionStatus).toBe("near-limit");
|
|
72
|
-
expect(await store.isNearLimit("anthropic", "anthropic-1")).toBe(true);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("does not mark an account near-limit at 50% consumed", async () => {
|
|
76
|
-
const { store } = await makeFileStore();
|
|
77
|
-
const snap = await store.recordUsage("anthropic", "anthropic-1", {
|
|
78
|
-
used: 50,
|
|
79
|
-
limit: 100,
|
|
80
|
-
});
|
|
81
|
-
expect(snap.nearLimit).toBe(false);
|
|
82
|
-
expect(snap.subscriptionStatus).toBe("active");
|
|
83
|
-
expect(await store.isNearLimit("anthropic", "anthropic-1")).toBe(false);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("respects a custom near-limit threshold", async () => {
|
|
87
|
-
const { store } = await makeFileStore();
|
|
88
|
-
await store.recordUsage("anthropic", "anthropic-1", {
|
|
89
|
-
used: 85,
|
|
90
|
-
limit: 100,
|
|
91
|
-
});
|
|
92
|
-
// 15% remaining: near-limit at 0.2 default, not at stricter 0.1.
|
|
93
|
-
expect(await store.isNearLimit("anthropic", "anthropic-1")).toBe(true);
|
|
94
|
-
expect(await store.isNearLimit("anthropic", "anthropic-1", 0.1)).toBe(false);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("marks an account exhausted at 100% consumed with dead health", async () => {
|
|
98
|
-
const { store } = await makeFileStore();
|
|
99
|
-
const snap = await store.recordUsage("anthropic", "anthropic-1", {
|
|
100
|
-
used: 100,
|
|
101
|
-
limit: 100,
|
|
102
|
-
});
|
|
103
|
-
expect(snap.subscriptionStatus).toBe("exhausted");
|
|
104
|
-
expect(snap.nearLimit).toBe(false);
|
|
105
|
-
expect(snap.health).toBe("dead");
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("writes the usage file with mode 0600 on POSIX", async () => {
|
|
109
|
-
if (process.platform === "win32") {
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
const { path, store } = await makeFileStore();
|
|
113
|
-
await store.recordUsage("anthropic", "anthropic-1", {
|
|
114
|
-
used: 1,
|
|
115
|
-
limit: 100,
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
const info = await stat(path);
|
|
119
|
-
expect(info.mode & 0o777).toBe(0o600);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("falls back to an empty state on a corrupt file instead of crashing", async () => {
|
|
123
|
-
const dir = await makeTempDir();
|
|
124
|
-
const path = join(dir, "state", "usage.json");
|
|
125
|
-
await mkdir(join(dir, "state"), { recursive: true });
|
|
126
|
-
await writeFile(path, "{not valid json at all", "utf8");
|
|
127
|
-
|
|
128
|
-
const store = new FileUsageStore({ filePath: path });
|
|
129
|
-
// Reads do not throw; state is empty.
|
|
130
|
-
const snap = await store.getAccountUsage("anthropic", "anthropic-1");
|
|
131
|
-
expect(snap).toBeUndefined();
|
|
132
|
-
expect(await store.listAccountUsage()).toEqual([]);
|
|
133
|
-
|
|
134
|
-
// A subsequent write replaces the corrupt file with a valid document.
|
|
135
|
-
await store.recordUsage("anthropic", "anthropic-1", {
|
|
136
|
-
used: 90,
|
|
137
|
-
limit: 100,
|
|
138
|
-
});
|
|
139
|
-
const raw = JSON.parse(await readFile(path, "utf8")) as {
|
|
140
|
-
version: number;
|
|
141
|
-
accounts: Record<string, unknown>;
|
|
142
|
-
};
|
|
143
|
-
expect(raw.version).toBe(1);
|
|
144
|
-
expect(Object.keys(raw.accounts)).toEqual(["anthropic:anthropic-1"]);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("concurrent writes from two stores on the same file do not corrupt it", async () => {
|
|
148
|
-
const dir = await makeTempDir();
|
|
149
|
-
const path = join(dir, "state", "usage.json");
|
|
150
|
-
const s1 = new FileUsageStore({ filePath: path });
|
|
151
|
-
const s2 = new FileUsageStore({ filePath: path });
|
|
152
|
-
|
|
153
|
-
await Promise.all([
|
|
154
|
-
s1.recordUsage("anthropic", "anthropic-1", { used: 80, limit: 100 }),
|
|
155
|
-
s2.recordUsage("openai", "openai-1", { used: 90, limit: 100 }),
|
|
156
|
-
]);
|
|
157
|
-
|
|
158
|
-
// The file is always valid JSON with the expected schema.
|
|
159
|
-
const raw = JSON.parse(await readFile(path, "utf8")) as {
|
|
160
|
-
version: number;
|
|
161
|
-
accounts: Record<string, { providerId: string }>;
|
|
162
|
-
};
|
|
163
|
-
expect(raw.version).toBe(1);
|
|
164
|
-
// Last-writer-wins is acceptable; corruption (invalid JSON) is not.
|
|
165
|
-
for (const doc of Object.values(raw.accounts)) {
|
|
166
|
-
expect(typeof doc.providerId).toBe("string");
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("writes on every mutation so a new process sees the latest usage", async () => {
|
|
171
|
-
const { path, store } = await makeFileStore();
|
|
172
|
-
await store.recordUsage("anthropic", "anthropic-1", {
|
|
173
|
-
used: 10,
|
|
174
|
-
limit: 100,
|
|
175
|
-
});
|
|
176
|
-
await store.recordUsage("anthropic", "anthropic-1", {
|
|
177
|
-
used: 88,
|
|
178
|
-
limit: 100,
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
const reloaded = new FileUsageStore({ filePath: path });
|
|
182
|
-
const snap = await reloaded.getAccountUsage("anthropic", "anthropic-1");
|
|
183
|
-
expect(snap?.used).toBe(88);
|
|
184
|
-
expect(snap?.nearLimit).toBe(true);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("delegates provider health/quota logic to an in-memory store", async () => {
|
|
188
|
-
const { store } = await makeFileStore();
|
|
189
|
-
await store.setSubscriptionQuota("claude", {
|
|
190
|
-
limit: 10,
|
|
191
|
-
used: 9,
|
|
192
|
-
status: "active",
|
|
193
|
-
});
|
|
194
|
-
const snap = await store.getProvider("claude");
|
|
195
|
-
expect(snap?.subscriptionQuotaRemaining).toBe(1);
|
|
196
|
-
expect(snap?.health).toBe("healthy");
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it("returns near-limit accounts across the persisted set", async () => {
|
|
200
|
-
const { store } = await makeFileStore();
|
|
201
|
-
await store.recordUsage("anthropic", "anthropic-1", {
|
|
202
|
-
used: 85,
|
|
203
|
-
limit: 100,
|
|
204
|
-
});
|
|
205
|
-
await store.recordUsage("anthropic", "anthropic-2", {
|
|
206
|
-
used: 10,
|
|
207
|
-
limit: 100,
|
|
208
|
-
});
|
|
209
|
-
const near = await store.getNearLimitAccounts();
|
|
210
|
-
expect(near).toHaveLength(1);
|
|
211
|
-
expect(near[0]?.accountId).toBe("anthropic-1");
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it("toJSON exposes diagnostics without throwing", async () => {
|
|
215
|
-
const { store } = await makeFileStore();
|
|
216
|
-
await store.recordUsage("anthropic", "anthropic-1", {
|
|
217
|
-
used: 85,
|
|
218
|
-
limit: 100,
|
|
219
|
-
});
|
|
220
|
-
const json = store.toJSON();
|
|
221
|
-
expect(json).toMatchObject({ kind: "file-usage" });
|
|
222
|
-
expect(JSON.stringify(json)).not.toContain("anthropic-1");
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it("uses the default near-limit threshold constant", () => {
|
|
226
|
-
expect(DEFAULT_NEAR_LIMIT_THRESHOLD).toBe(0.2);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("accepts an injected in-memory delegate for provider state", async () => {
|
|
230
|
-
const dir = await makeTempDir();
|
|
231
|
-
const path = join(dir, "state", "usage.json");
|
|
232
|
-
const delegate = new InMemoryUsageStore();
|
|
233
|
-
const wired = new FileUsageStore({ filePath: path, delegate });
|
|
234
|
-
await wired.setSubscriptionQuota("claude", {
|
|
235
|
-
limit: 5,
|
|
236
|
-
used: 1,
|
|
237
|
-
status: "active",
|
|
238
|
-
});
|
|
239
|
-
// Provider state lives on the injected delegate.
|
|
240
|
-
const snap = await delegate.getProvider("claude");
|
|
241
|
-
expect(snap?.subscriptionQuotaRemaining).toBe(4);
|
|
242
|
-
});
|
|
243
|
-
});
|
package/src/usage-file.ts
DELETED
|
@@ -1,435 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File-backed usage store for OffRouter.
|
|
3
|
-
*
|
|
4
|
-
* Wraps an in-memory provider-health/quota engine and adds JSON persistence for
|
|
5
|
-
* per-account quota usage so near-limit detection survives across CLI
|
|
6
|
-
* invocations and MCP server restarts.
|
|
7
|
-
*
|
|
8
|
-
* On-disk layout (`$OFFROUTER_HOME/state/usage.json`):
|
|
9
|
-
* { version: 1, accounts: Record<`${providerId}:${accountId}`, PersistedAccount> }
|
|
10
|
-
*
|
|
11
|
-
* Safety guarantees mirror FileSecretStore:
|
|
12
|
-
* - atomic write via temp file + rename
|
|
13
|
-
* - chmod 0600 on POSIX after every write
|
|
14
|
-
* - missing or corrupt file => graceful fallback to empty state (never throws)
|
|
15
|
-
*
|
|
16
|
-
* Provider health/quota math (sliding-window error budget, cooldown) stays
|
|
17
|
-
* in-memory and process-local; only account quota usage is persisted, which is
|
|
18
|
-
* what near-limit detection and quota reporting depend on across sessions.
|
|
19
|
-
*/
|
|
20
|
-
import { readFileSync } from "node:fs";
|
|
21
|
-
import {
|
|
22
|
-
chmod,
|
|
23
|
-
mkdir,
|
|
24
|
-
readFile,
|
|
25
|
-
rename,
|
|
26
|
-
unlink,
|
|
27
|
-
writeFile,
|
|
28
|
-
} from "node:fs/promises";
|
|
29
|
-
import { dirname } from "node:path";
|
|
30
|
-
import { randomBytes } from "node:crypto";
|
|
31
|
-
import {
|
|
32
|
-
computeAccountSnapshot,
|
|
33
|
-
InMemoryUsageStore,
|
|
34
|
-
type AccountUsageDoc,
|
|
35
|
-
type AccountUsageSnapshot,
|
|
36
|
-
type PaidFallbackExplanation,
|
|
37
|
-
type ProviderUsageSnapshot,
|
|
38
|
-
type RecordUsageResult,
|
|
39
|
-
type UsageEvent,
|
|
40
|
-
type UsageStore,
|
|
41
|
-
} from "./usage.js";
|
|
42
|
-
import type { ProviderHealth } from "./types.js";
|
|
43
|
-
|
|
44
|
-
export { DEFAULT_NEAR_LIMIT_THRESHOLD } from "./usage.js";
|
|
45
|
-
export { InMemoryUsageStore } from "./usage.js";
|
|
46
|
-
export type { UsageStore } from "./usage.js";
|
|
47
|
-
|
|
48
|
-
export interface FileUsageStoreFileSystem {
|
|
49
|
-
mkdir: typeof mkdir;
|
|
50
|
-
readFile: typeof readFile;
|
|
51
|
-
rename: typeof rename;
|
|
52
|
-
unlink: typeof unlink;
|
|
53
|
-
writeFile: typeof writeFile;
|
|
54
|
-
chmod: typeof chmod;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export interface FileUsageStoreOptions {
|
|
58
|
-
/** Absolute path to usage.json (typically `$OFFROUTER_HOME/state/usage.json`). */
|
|
59
|
-
filePath: string;
|
|
60
|
-
/** Injectable file operations for failure-path tests. Defaults to node fs. */
|
|
61
|
-
fileSystem?: Partial<FileUsageStoreFileSystem>;
|
|
62
|
-
/** Injectable clock for tests. */
|
|
63
|
-
now?: () => Date;
|
|
64
|
-
/**
|
|
65
|
-
* In-memory store that owns provider health/quota logic. Account usage is
|
|
66
|
-
* persisted separately and mirrored onto this delegate. Useful for tests.
|
|
67
|
-
*/
|
|
68
|
-
delegate?: UsageStore;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** On-disk schema for FileUsageStore. */
|
|
72
|
-
interface PersistedAccount {
|
|
73
|
-
providerId: string;
|
|
74
|
-
accountId: string;
|
|
75
|
-
used: number;
|
|
76
|
-
limit?: number;
|
|
77
|
-
lastUpdated: string;
|
|
78
|
-
health?: ProviderHealth;
|
|
79
|
-
cooldownUntil?: number;
|
|
80
|
-
recentErrors?: number[];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface PersistedUsageDocument {
|
|
84
|
-
version: 1;
|
|
85
|
-
accounts: Record<string, PersistedAccount>;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const EMPTY_DOC: PersistedUsageDocument = { version: 1, accounts: {} };
|
|
89
|
-
|
|
90
|
-
function accountKey(providerId: string, accountId: string): string {
|
|
91
|
-
return `${providerId}:${accountId}`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Parse a persisted usage document leniently. A missing or corrupt file is
|
|
96
|
-
* treated as empty state rather than a hard failure so a bad write can never
|
|
97
|
-
* wedge the CLI.
|
|
98
|
-
*/
|
|
99
|
-
function parseUsageDocument(raw: string): PersistedUsageDocument {
|
|
100
|
-
let parsed: unknown;
|
|
101
|
-
try {
|
|
102
|
-
parsed = JSON.parse(raw) as unknown;
|
|
103
|
-
} catch {
|
|
104
|
-
// Corrupt JSON: fall back to empty instead of throwing.
|
|
105
|
-
return { ...EMPTY_DOC, accounts: {} };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
109
|
-
return { ...EMPTY_DOC, accounts: {} };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const record = parsed as Record<string, unknown>;
|
|
113
|
-
const accountsRaw = record.accounts;
|
|
114
|
-
if (
|
|
115
|
-
typeof accountsRaw !== "object" ||
|
|
116
|
-
accountsRaw === null ||
|
|
117
|
-
Array.isArray(accountsRaw)
|
|
118
|
-
) {
|
|
119
|
-
return { ...EMPTY_DOC, accounts: {} };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const accounts: Record<string, PersistedAccount> = {};
|
|
123
|
-
for (const [key, value] of Object.entries(
|
|
124
|
-
accountsRaw as Record<string, unknown>,
|
|
125
|
-
)) {
|
|
126
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
const entry = value as Record<string, unknown>;
|
|
130
|
-
if (
|
|
131
|
-
typeof entry.providerId !== "string" ||
|
|
132
|
-
typeof entry.accountId !== "string" ||
|
|
133
|
-
typeof entry.used !== "number"
|
|
134
|
-
) {
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
const doc: PersistedAccount = {
|
|
138
|
-
providerId: entry.providerId,
|
|
139
|
-
accountId: entry.accountId,
|
|
140
|
-
used: entry.used,
|
|
141
|
-
lastUpdated:
|
|
142
|
-
typeof entry.lastUpdated === "string" ? entry.lastUpdated : "",
|
|
143
|
-
};
|
|
144
|
-
if (typeof entry.limit === "number") {
|
|
145
|
-
doc.limit = entry.limit;
|
|
146
|
-
}
|
|
147
|
-
accounts[key] = doc;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return { version: 1, accounts };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* File-backed UsageStore. Persists per-account quota usage atomically and
|
|
155
|
-
* delegates provider health/quota logic to an in-memory engine.
|
|
156
|
-
*/
|
|
157
|
-
export class FileUsageStore implements UsageStore {
|
|
158
|
-
readonly filePath: string;
|
|
159
|
-
readonly #fs: FileUsageStoreFileSystem;
|
|
160
|
-
readonly #now: () => Date;
|
|
161
|
-
readonly #delegate: UsageStore;
|
|
162
|
-
/** In-memory mirror of persisted account docs, hydrated lazily. */
|
|
163
|
-
#accounts: Map<string, AccountUsageDoc> | null = null;
|
|
164
|
-
/** Serializes mutations so concurrent writes never interleave. */
|
|
165
|
-
#chain: Promise<unknown> = Promise.resolve();
|
|
166
|
-
|
|
167
|
-
constructor(options: FileUsageStoreOptions) {
|
|
168
|
-
if (!options?.filePath || typeof options.filePath !== "string") {
|
|
169
|
-
throw new TypeError("FileUsageStore requires options.filePath");
|
|
170
|
-
}
|
|
171
|
-
this.filePath = options.filePath;
|
|
172
|
-
this.#now = options.now ?? (() => new Date());
|
|
173
|
-
this.#delegate =
|
|
174
|
-
options.delegate ?? new InMemoryUsageStore({ now: this.#now });
|
|
175
|
-
this.#fs = {
|
|
176
|
-
mkdir,
|
|
177
|
-
readFile,
|
|
178
|
-
rename,
|
|
179
|
-
unlink,
|
|
180
|
-
writeFile,
|
|
181
|
-
chmod,
|
|
182
|
-
...options.fileSystem,
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
#exclusive<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
187
|
-
const run = this.#chain.then(fn, fn);
|
|
188
|
-
this.#chain = run.then(
|
|
189
|
-
() => undefined,
|
|
190
|
-
() => undefined,
|
|
191
|
-
);
|
|
192
|
-
return run;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** Load the file once into the in-memory account map. Never throws. */
|
|
196
|
-
async #load(): Promise<Map<string, AccountUsageDoc>> {
|
|
197
|
-
if (this.#accounts) {
|
|
198
|
-
return this.#accounts;
|
|
199
|
-
}
|
|
200
|
-
let doc: PersistedUsageDocument;
|
|
201
|
-
try {
|
|
202
|
-
const raw = await this.#fs.readFile(this.filePath, "utf8");
|
|
203
|
-
doc = parseUsageDocument(raw);
|
|
204
|
-
} catch (err) {
|
|
205
|
-
if (
|
|
206
|
-
err &&
|
|
207
|
-
typeof err === "object" &&
|
|
208
|
-
"code" in err &&
|
|
209
|
-
(err as { code?: string }).code === "ENOENT"
|
|
210
|
-
) {
|
|
211
|
-
doc = { ...EMPTY_DOC, accounts: {} };
|
|
212
|
-
} else {
|
|
213
|
-
// Any read failure (including corruption surfaced as a throw) is
|
|
214
|
-
// treated as empty state so the CLI keeps working.
|
|
215
|
-
doc = { ...EMPTY_DOC, accounts: {} };
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
const map = new Map<string, AccountUsageDoc>();
|
|
219
|
-
for (const [key, entry] of Object.entries(doc.accounts)) {
|
|
220
|
-
map.set(key, {
|
|
221
|
-
providerId: entry.providerId,
|
|
222
|
-
accountId: entry.accountId,
|
|
223
|
-
used: entry.used,
|
|
224
|
-
limit: entry.limit,
|
|
225
|
-
updatedAt: entry.lastUpdated,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
this.#accounts = map;
|
|
229
|
-
return map;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/** Synchronous load for toJSON diagnostics; never throws. */
|
|
233
|
-
#loadKnownSync(): Map<string, AccountUsageDoc> {
|
|
234
|
-
if (this.#accounts) {
|
|
235
|
-
return this.#accounts;
|
|
236
|
-
}
|
|
237
|
-
let map = new Map<string, AccountUsageDoc>();
|
|
238
|
-
try {
|
|
239
|
-
const raw = readFileSync(this.filePath, "utf8");
|
|
240
|
-
const doc = parseUsageDocument(raw);
|
|
241
|
-
map = new Map();
|
|
242
|
-
for (const [key, entry] of Object.entries(doc.accounts)) {
|
|
243
|
-
map.set(key, {
|
|
244
|
-
providerId: entry.providerId,
|
|
245
|
-
accountId: entry.accountId,
|
|
246
|
-
used: entry.used,
|
|
247
|
-
limit: entry.limit,
|
|
248
|
-
updatedAt: entry.lastUpdated,
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
} catch {
|
|
252
|
-
map = new Map();
|
|
253
|
-
}
|
|
254
|
-
return map;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
async #persist(accounts: Map<string, AccountUsageDoc>): Promise<void> {
|
|
258
|
-
const persistedAccounts: Record<string, PersistedAccount> = {};
|
|
259
|
-
for (const [key, doc] of accounts) {
|
|
260
|
-
const snap = computeAccountSnapshot(doc);
|
|
261
|
-
const entry: PersistedAccount = {
|
|
262
|
-
providerId: doc.providerId,
|
|
263
|
-
accountId: doc.accountId,
|
|
264
|
-
used: doc.used,
|
|
265
|
-
lastUpdated: doc.updatedAt,
|
|
266
|
-
health: snap.health,
|
|
267
|
-
recentErrors: [],
|
|
268
|
-
};
|
|
269
|
-
if (doc.limit !== undefined) {
|
|
270
|
-
entry.limit = doc.limit;
|
|
271
|
-
}
|
|
272
|
-
persistedAccounts[key] = entry;
|
|
273
|
-
}
|
|
274
|
-
const payload = `${JSON.stringify(
|
|
275
|
-
{ version: 1 as const, accounts: persistedAccounts },
|
|
276
|
-
null,
|
|
277
|
-
2,
|
|
278
|
-
)}\n`;
|
|
279
|
-
|
|
280
|
-
await this.#fs.mkdir(dirname(this.filePath), { recursive: true });
|
|
281
|
-
const tmp = `${this.filePath}.${randomBytes(8).toString("hex")}.tmp`;
|
|
282
|
-
try {
|
|
283
|
-
await this.#fs.writeFile(tmp, payload, {
|
|
284
|
-
encoding: "utf8",
|
|
285
|
-
mode: 0o600,
|
|
286
|
-
});
|
|
287
|
-
await this.#fs.chmod(tmp, 0o600);
|
|
288
|
-
await this.#fs.rename(tmp, this.filePath);
|
|
289
|
-
await this.#fs.chmod(this.filePath, 0o600);
|
|
290
|
-
} catch (err) {
|
|
291
|
-
await this.#fs.unlink(tmp).catch((unlinkErr: unknown) => {
|
|
292
|
-
if (
|
|
293
|
-
unlinkErr &&
|
|
294
|
-
typeof unlinkErr === "object" &&
|
|
295
|
-
"code" in unlinkErr &&
|
|
296
|
-
(unlinkErr as { code?: string }).code === "ENOENT"
|
|
297
|
-
) {
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
throw unlinkErr;
|
|
301
|
-
});
|
|
302
|
-
throw err;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// ---- Provider health/quota: delegated to the in-memory engine ----
|
|
307
|
-
|
|
308
|
-
record(event: UsageEvent): Promise<RecordUsageResult> {
|
|
309
|
-
return this.#delegate.record(event);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
getProvider(providerId: string): Promise<ProviderUsageSnapshot | undefined> {
|
|
313
|
-
return this.#delegate.getProvider(providerId);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
listProviders(): Promise<ProviderUsageSnapshot[]> {
|
|
317
|
-
return this.#delegate.listProviders();
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
setSubscriptionQuota(
|
|
321
|
-
providerId: string,
|
|
322
|
-
input: {
|
|
323
|
-
limit?: number;
|
|
324
|
-
used?: number;
|
|
325
|
-
status?: import("./types.js").SubscriptionStatus;
|
|
326
|
-
modelId?: string;
|
|
327
|
-
at?: Date;
|
|
328
|
-
},
|
|
329
|
-
): Promise<ProviderUsageSnapshot> {
|
|
330
|
-
return this.#delegate.setSubscriptionQuota(providerId, input);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
explainPaidFallback(
|
|
334
|
-
providerId: string,
|
|
335
|
-
options?: { hasApiKeyCandidate?: boolean },
|
|
336
|
-
): Promise<PaidFallbackExplanation> {
|
|
337
|
-
return this.#delegate.explainPaidFallback(providerId, options);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// ---- Per-account quota usage: persisted ----
|
|
341
|
-
|
|
342
|
-
async recordUsage(
|
|
343
|
-
providerId: string,
|
|
344
|
-
accountId: string,
|
|
345
|
-
input: { used?: number; limit?: number; at?: Date },
|
|
346
|
-
): Promise<AccountUsageSnapshot> {
|
|
347
|
-
return this.#exclusive(async () => {
|
|
348
|
-
const accounts = await this.#load();
|
|
349
|
-
const at = input.at ?? this.#now();
|
|
350
|
-
const key = accountKey(providerId, accountId);
|
|
351
|
-
let doc = accounts.get(key);
|
|
352
|
-
if (!doc) {
|
|
353
|
-
doc = {
|
|
354
|
-
providerId,
|
|
355
|
-
accountId,
|
|
356
|
-
used: 0,
|
|
357
|
-
updatedAt: at.toISOString(),
|
|
358
|
-
};
|
|
359
|
-
accounts.set(key, doc);
|
|
360
|
-
}
|
|
361
|
-
if (input.used !== undefined) doc.used = input.used;
|
|
362
|
-
if (input.limit !== undefined) doc.limit = input.limit;
|
|
363
|
-
doc.updatedAt = at.toISOString();
|
|
364
|
-
await this.#persist(accounts);
|
|
365
|
-
return computeAccountSnapshot(doc);
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
async isNearLimit(
|
|
370
|
-
providerId: string,
|
|
371
|
-
accountId: string,
|
|
372
|
-
threshold?: number,
|
|
373
|
-
): Promise<boolean> {
|
|
374
|
-
return this.#exclusive(async () => {
|
|
375
|
-
const accounts = await this.#load();
|
|
376
|
-
const doc = accounts.get(accountKey(providerId, accountId));
|
|
377
|
-
if (!doc) return false;
|
|
378
|
-
return computeAccountSnapshot(doc, threshold).nearLimit;
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
async getNearLimitAccounts(
|
|
383
|
-
threshold?: number,
|
|
384
|
-
): Promise<AccountUsageSnapshot[]> {
|
|
385
|
-
return this.#exclusive(async () => {
|
|
386
|
-
const accounts = await this.#load();
|
|
387
|
-
const out: AccountUsageSnapshot[] = [];
|
|
388
|
-
for (const doc of accounts.values()) {
|
|
389
|
-
const snap = computeAccountSnapshot(doc, threshold);
|
|
390
|
-
if (snap.nearLimit) out.push(snap);
|
|
391
|
-
}
|
|
392
|
-
return out.sort((a, b) => {
|
|
393
|
-
if (a.providerId !== b.providerId)
|
|
394
|
-
return a.providerId.localeCompare(b.providerId);
|
|
395
|
-
return a.accountId.localeCompare(b.accountId);
|
|
396
|
-
});
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
async getAccountUsage(
|
|
401
|
-
providerId: string,
|
|
402
|
-
accountId: string,
|
|
403
|
-
): Promise<AccountUsageSnapshot | undefined> {
|
|
404
|
-
return this.#exclusive(async () => {
|
|
405
|
-
const accounts = await this.#load();
|
|
406
|
-
const doc = accounts.get(accountKey(providerId, accountId));
|
|
407
|
-
if (!doc) return undefined;
|
|
408
|
-
return computeAccountSnapshot(doc);
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
async listAccountUsage(): Promise<AccountUsageSnapshot[]> {
|
|
413
|
-
return this.#exclusive(async () => {
|
|
414
|
-
const accounts = await this.#load();
|
|
415
|
-
const out: AccountUsageSnapshot[] = [];
|
|
416
|
-
for (const doc of accounts.values()) {
|
|
417
|
-
out.push(computeAccountSnapshot(doc));
|
|
418
|
-
}
|
|
419
|
-
return out.sort((a, b) => {
|
|
420
|
-
if (a.providerId !== b.providerId)
|
|
421
|
-
return a.providerId.localeCompare(b.providerId);
|
|
422
|
-
return a.accountId.localeCompare(b.accountId);
|
|
423
|
-
});
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
toJSON(): Record<string, unknown> {
|
|
428
|
-
const accounts = this.#loadKnownSync();
|
|
429
|
-
return {
|
|
430
|
-
kind: "file-usage",
|
|
431
|
-
filePath: this.filePath,
|
|
432
|
-
accountCount: accounts.size,
|
|
433
|
-
};
|
|
434
|
-
}
|
|
435
|
-
}
|