offrouter-core 0.1.0 → 0.2.1
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/package.json +3 -3
- package/src/auth/credential-chain.test.ts +124 -0
- package/src/auth/credential-chain.ts +55 -4
- package/src/auth/index.ts +14 -1
- package/src/auth/keychain.test.ts +154 -1
- package/src/auth/keychain.ts +111 -0
- package/src/auth/oauth-pkce.test.ts +147 -2
- package/src/auth/oauth-pkce.ts +175 -13
- package/src/auth/oauth-server.test.ts +83 -0
- package/src/auth/oauth-server.ts +296 -0
- package/src/config.test.ts +16 -0
- package/src/config.ts +4 -0
- package/src/index.ts +17 -0
- package/src/usage-file.test.ts +243 -0
- package/src/usage-file.ts +435 -0
- package/src/usage.ts +44 -17
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local OAuth callback server for OffRouter's live PKCE login flow.
|
|
3
|
+
*
|
|
4
|
+
* Binds to 127.0.0.1 ONLY (never 0.0.0.0) so the provider can redirect back
|
|
5
|
+
* to this machine without exposing the callback to the network. The server
|
|
6
|
+
* captures the authorization `code` + `state` from the redirect and hands them
|
|
7
|
+
* to the caller; it never touches tokens, secrets, or the network beyond the
|
|
8
|
+
* single loopback HTTP socket.
|
|
9
|
+
*/
|
|
10
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
|
11
|
+
import { URL } from "node:url";
|
|
12
|
+
|
|
13
|
+
/** Loopback-only hostname. Hardcoded to prevent accidental 0.0.0.0 binds. */
|
|
14
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
15
|
+
|
|
16
|
+
/** Path the provider redirects to (http://127.0.0.1:PORT/callback). */
|
|
17
|
+
const CALLBACK_PATH = "/callback";
|
|
18
|
+
|
|
19
|
+
export interface CallbackServerOptions {
|
|
20
|
+
/**
|
|
21
|
+
* State value expected from the provider. Validated in waitForCode against
|
|
22
|
+
* the state returned by the redirect; a mismatch rejects the promise.
|
|
23
|
+
* May also be supplied per-call to waitForCode.
|
|
24
|
+
*/
|
|
25
|
+
expectedState?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WaitForCodeOptions {
|
|
29
|
+
/** Overrides the server-level expectedState for this call. */
|
|
30
|
+
expectedState?: string;
|
|
31
|
+
/** How long to wait for a callback before rejecting (default 5 minutes). */
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
/** Optional abort signal to cancel the wait early. */
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CallbackCode {
|
|
38
|
+
code: string;
|
|
39
|
+
state: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CallbackServerHandle {
|
|
43
|
+
/** Full redirect_uri to register with the provider (http://127.0.0.1:PORT/callback). */
|
|
44
|
+
url: string;
|
|
45
|
+
/** Actual bound port (useful when 0 was requested). */
|
|
46
|
+
port: number;
|
|
47
|
+
/** Resolve with {code, state} on a valid callback; reject on timeout/state mismatch. */
|
|
48
|
+
waitForCode(options?: WaitForCodeOptions): Promise<CallbackCode>;
|
|
49
|
+
/** Stop listening and free the port. Idempotent. */
|
|
50
|
+
close(): Promise<void>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Start a loopback HTTP server that captures an OAuth authorization code.
|
|
55
|
+
* Pass port 0 to let the OS choose a free port (read handle.port afterwards).
|
|
56
|
+
*/
|
|
57
|
+
export function startCallbackServer(
|
|
58
|
+
port: number,
|
|
59
|
+
options: CallbackServerOptions = {},
|
|
60
|
+
): Promise<CallbackServerHandle> {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const serverLevelState = options.expectedState;
|
|
63
|
+
|
|
64
|
+
// Deferred for the first valid callback (has a `code` param).
|
|
65
|
+
let captured: CallbackCode | null = null;
|
|
66
|
+
const waiters: Array<{
|
|
67
|
+
resolve: (value: CallbackCode) => void;
|
|
68
|
+
reject: (error: Error) => void;
|
|
69
|
+
}> = [];
|
|
70
|
+
|
|
71
|
+
const httpServer: Server = createServer(
|
|
72
|
+
(req: IncomingMessage, res: ServerResponse) => {
|
|
73
|
+
handleCallback(req, res);
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
function handleCallback(req: IncomingMessage, res: ServerResponse): void {
|
|
78
|
+
// Only respond to GET on the callback path; everything else 404s.
|
|
79
|
+
if (req.method !== "GET") {
|
|
80
|
+
res.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
|
|
81
|
+
res.end("Method Not Allowed");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let parsedUrl: URL;
|
|
86
|
+
try {
|
|
87
|
+
parsedUrl = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}`);
|
|
88
|
+
} catch {
|
|
89
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
|
|
90
|
+
res.end("Bad Request");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const pathname = parsedUrl.pathname;
|
|
95
|
+
const code = parsedUrl.searchParams.get("code");
|
|
96
|
+
const state = parsedUrl.searchParams.get("state");
|
|
97
|
+
const error = parsedUrl.searchParams.get("error");
|
|
98
|
+
|
|
99
|
+
// OAuth error from the provider (e.g. user denied consent).
|
|
100
|
+
if (error) {
|
|
101
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
102
|
+
res.end(authorizationDeniedPage(error));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (pathname !== CALLBACK_PATH) {
|
|
107
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
108
|
+
res.end("Not Found");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Missing code -> provider returned nothing usable.
|
|
113
|
+
if (!code) {
|
|
114
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
115
|
+
res.end(missingCodePage());
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Capture the first valid callback exactly once.
|
|
120
|
+
const callback: CallbackCode = { code, state: state ?? "" };
|
|
121
|
+
if (!captured) {
|
|
122
|
+
captured = callback;
|
|
123
|
+
// Resolve any pending waiters that accept this callback.
|
|
124
|
+
const pending = waiters.splice(0, waiters.length);
|
|
125
|
+
for (const w of pending) {
|
|
126
|
+
w.resolve(callback);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Always show the user a friendly page; security validation happens in
|
|
131
|
+
// waitForCode (state may not be known at request time).
|
|
132
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
133
|
+
res.end(successPage());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
|
137
|
+
reject(
|
|
138
|
+
new Error(
|
|
139
|
+
`Failed to start OAuth callback server on 127.0.0.1:${port}: ${err.message}`,
|
|
140
|
+
{ cause: err },
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
httpServer.listen(port, LOOPBACK_HOST, () => {
|
|
146
|
+
const address = httpServer.address();
|
|
147
|
+
const boundPort =
|
|
148
|
+
address && typeof address === "object" ? address.port : port;
|
|
149
|
+
const redirectUrl = `http://${LOOPBACK_HOST}:${boundPort}${CALLBACK_PATH}`;
|
|
150
|
+
|
|
151
|
+
const handle: CallbackServerHandle = {
|
|
152
|
+
url: redirectUrl,
|
|
153
|
+
port: boundPort,
|
|
154
|
+
waitForCode(waitOpts: WaitForCodeOptions = {}): Promise<CallbackCode> {
|
|
155
|
+
const expected =
|
|
156
|
+
waitOpts.expectedState !== undefined
|
|
157
|
+
? waitOpts.expectedState
|
|
158
|
+
: serverLevelState;
|
|
159
|
+
const timeoutMs = waitOpts.timeoutMs ?? 5 * 60 * 1000;
|
|
160
|
+
|
|
161
|
+
return new Promise<CallbackCode>((resolveWait, rejectWait) => {
|
|
162
|
+
let settled = false;
|
|
163
|
+
|
|
164
|
+
const settle = (
|
|
165
|
+
outcome: CallbackCode | Error,
|
|
166
|
+
isReject: boolean,
|
|
167
|
+
): void => {
|
|
168
|
+
if (settled) return;
|
|
169
|
+
settled = true;
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
waitOpts.signal?.removeEventListener("abort", onAbort);
|
|
172
|
+
if (isReject) {
|
|
173
|
+
rejectWait(outcome as Error);
|
|
174
|
+
} else {
|
|
175
|
+
resolveWait(outcome as CallbackCode);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const onAbort = (): void => {
|
|
180
|
+
settle(
|
|
181
|
+
new Error("OAuth callback wait was aborted."),
|
|
182
|
+
true,
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const timer = setTimeout(() => {
|
|
187
|
+
settle(new Error("OAuth login timed out waiting for callback."), true);
|
|
188
|
+
}, timeoutMs);
|
|
189
|
+
|
|
190
|
+
if (waitOpts.signal?.aborted) {
|
|
191
|
+
settle(new Error("OAuth callback wait was aborted."), true);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
waitOpts.signal?.addEventListener("abort", onAbort);
|
|
195
|
+
|
|
196
|
+
const resolveWith = (callback: CallbackCode): void => {
|
|
197
|
+
if (expected !== undefined && callback.state !== expected) {
|
|
198
|
+
settle(
|
|
199
|
+
new Error(
|
|
200
|
+
`OAuth state mismatch: expected "${redactState(expected)}" ` +
|
|
201
|
+
`but received "${redactState(callback.state)}". ` +
|
|
202
|
+
`This may indicate a CSRF attempt or a stale login tab.`,
|
|
203
|
+
),
|
|
204
|
+
true,
|
|
205
|
+
);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
settle(callback, false);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
if (captured) {
|
|
212
|
+
resolveWith(captured);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
waiters.push({ resolve: resolveWith, reject: rejectWait });
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
close(): Promise<void> {
|
|
220
|
+
return new Promise<void>((resolveClose) => {
|
|
221
|
+
httpServer.close(() => resolveClose());
|
|
222
|
+
// close() waits for in-flight connections; closeAllConnections is
|
|
223
|
+
// available on Node 18+ and speeds shutdown without dropping the
|
|
224
|
+
// response the user's browser is mid-receiving.
|
|
225
|
+
// (Not called here to keep the success page delivery intact.)
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
resolve(handle);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Static HTML pages (ASCII-only, no secret material)
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
function successPage(): string {
|
|
240
|
+
return [
|
|
241
|
+
"<!doctype html>",
|
|
242
|
+
"<html lang=\"en\">",
|
|
243
|
+
"<head><meta charset=\"utf-8\"><title>OffRouter login</title></head>",
|
|
244
|
+
"<body>",
|
|
245
|
+
"<h1>OffRouter login authorized</h1>",
|
|
246
|
+
"<p>You can close this tab and return to your terminal.</p>",
|
|
247
|
+
"</body>",
|
|
248
|
+
"</html>",
|
|
249
|
+
"",
|
|
250
|
+
].join("\n");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function authorizationDeniedPage(error: string): string {
|
|
254
|
+
const safe = escapeHtml(error);
|
|
255
|
+
return [
|
|
256
|
+
"<!doctype html>",
|
|
257
|
+
"<html lang=\"en\">",
|
|
258
|
+
"<head><meta charset=\"utf-8\"><title>OffRouter login</title></head>",
|
|
259
|
+
"<body>",
|
|
260
|
+
"<h1>OffRouter login not completed</h1>",
|
|
261
|
+
`<p>The provider reported: ${safe}</p>`,
|
|
262
|
+
"<p>You can close this tab and try again from your terminal.</p>",
|
|
263
|
+
"</body>",
|
|
264
|
+
"</html>",
|
|
265
|
+
"",
|
|
266
|
+
].join("\n");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function missingCodePage(): string {
|
|
270
|
+
return [
|
|
271
|
+
"<!doctype html>",
|
|
272
|
+
"<html lang=\"en\">",
|
|
273
|
+
"<head><meta charset=\"utf-8\"><title>OffRouter login</title></head>",
|
|
274
|
+
"<body>",
|
|
275
|
+
"<h1>OffRouter login incomplete</h1>",
|
|
276
|
+
"<p>The callback did not include an authorization code.</p>",
|
|
277
|
+
"</body>",
|
|
278
|
+
"</html>",
|
|
279
|
+
"",
|
|
280
|
+
].join("\n");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function escapeHtml(value: string): string {
|
|
284
|
+
return value
|
|
285
|
+
.replace(/&/g, "&")
|
|
286
|
+
.replace(/</g, "<")
|
|
287
|
+
.replace(/>/g, ">")
|
|
288
|
+
.replace(/"/g, """)
|
|
289
|
+
.replace(/'/g, "'");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Truncate a state token for error messages so it is never echoed in full. */
|
|
293
|
+
function redactState(state: string): string {
|
|
294
|
+
if (state.length <= 8) return "***";
|
|
295
|
+
return `${state.slice(0, 4)}...${state.slice(-4)}`;
|
|
296
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -168,6 +168,22 @@ enabled = true
|
|
|
168
168
|
expect(allowed.allowed).toHaveLength(1);
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
it("parses policy.near_limit_threshold into policy.nearLimitThreshold", async () => {
|
|
172
|
+
const home = await trackDir(makeHome);
|
|
173
|
+
await writeFile(
|
|
174
|
+
join(home, "config.toml"),
|
|
175
|
+
`allowlisted_profiles = ["claude-personal"]
|
|
176
|
+
|
|
177
|
+
[policy]
|
|
178
|
+
near_limit_threshold = 0.15
|
|
179
|
+
`,
|
|
180
|
+
"utf8",
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const config = await loadConfig({ env: { OFFROUTER_HOME: home } });
|
|
184
|
+
expect(config.policy.nearLimitThreshold).toBe(0.15);
|
|
185
|
+
});
|
|
186
|
+
|
|
171
187
|
it("loads named profile overlays from profiles/*.toml", async () => {
|
|
172
188
|
const home = await trackDir(makeHome);
|
|
173
189
|
await writeFile(
|
package/src/config.ts
CHANGED
|
@@ -111,6 +111,7 @@ const PolicySectionShape = {
|
|
|
111
111
|
allow_api_key_fallback: z.boolean().optional(),
|
|
112
112
|
allow_third_party_subscription_adapters: z.boolean().optional(),
|
|
113
113
|
denied_providers: z.array(z.string().min(1)).optional(),
|
|
114
|
+
near_limit_threshold: z.number().min(0).max(1).optional(),
|
|
114
115
|
} satisfies z.ZodRawShape;
|
|
115
116
|
|
|
116
117
|
const RootConfigShape = {
|
|
@@ -368,6 +369,9 @@ function mergeRootInto(
|
|
|
368
369
|
target.policy.allowThirdPartySubscriptionAdapters =
|
|
369
370
|
root.policy.allow_third_party_subscription_adapters;
|
|
370
371
|
}
|
|
372
|
+
if (root.policy.near_limit_threshold !== undefined) {
|
|
373
|
+
target.policy.nearLimitThreshold = root.policy.near_limit_threshold;
|
|
374
|
+
}
|
|
371
375
|
if (root.policy.denied_providers) {
|
|
372
376
|
target.policy.deniedProviders = uniqueStrings([
|
|
373
377
|
...(target.policy.deniedProviders ?? []),
|
package/src/index.ts
CHANGED
|
@@ -119,6 +119,13 @@ export type {
|
|
|
119
119
|
} from "./audit.js";
|
|
120
120
|
|
|
121
121
|
export { DEFAULT_NEAR_LIMIT_THRESHOLD, InMemoryStateStore, InMemoryUsageStore } from "./usage.js";
|
|
122
|
+
export {
|
|
123
|
+
FileUsageStore,
|
|
124
|
+
} from "./usage-file.js";
|
|
125
|
+
export type {
|
|
126
|
+
FileUsageStoreFileSystem,
|
|
127
|
+
FileUsageStoreOptions,
|
|
128
|
+
} from "./usage-file.js";
|
|
122
129
|
export type {
|
|
123
130
|
AccountUsageSnapshot,
|
|
124
131
|
InMemoryUsageStoreOptions,
|
|
@@ -198,14 +205,21 @@ export type {
|
|
|
198
205
|
// Auth module (credential chain, OAuth PKCE, keychain helpers)
|
|
199
206
|
export {
|
|
200
207
|
Redacted,
|
|
208
|
+
defaultCallbackPort,
|
|
209
|
+
deleteOAuthTokens,
|
|
201
210
|
exchangeCodeForTokens,
|
|
202
211
|
generateCodeChallenge,
|
|
203
212
|
generateCodeVerifier,
|
|
213
|
+
getOAuthTokens,
|
|
204
214
|
getProviderToken,
|
|
205
215
|
isOAuthProvider,
|
|
216
|
+
isOAuthTokenExpired,
|
|
206
217
|
PKCE_CONFIGS,
|
|
207
218
|
refreshTokens,
|
|
219
|
+
resolveClientId,
|
|
208
220
|
resolveCredential,
|
|
221
|
+
runOAuthLogin,
|
|
222
|
+
setOAuthTokens,
|
|
209
223
|
setProviderToken,
|
|
210
224
|
startOAuthFlow,
|
|
211
225
|
} from "./auth/index.js";
|
|
@@ -215,10 +229,13 @@ export type {
|
|
|
215
229
|
OAuthFlowResult,
|
|
216
230
|
OAuthFlowState,
|
|
217
231
|
OAuthProviderConfig,
|
|
232
|
+
OAuthStoredTokens,
|
|
218
233
|
OAuthStartOptions,
|
|
219
234
|
OAuthTokenOptions,
|
|
220
235
|
OAuthTokenResponse,
|
|
221
236
|
ResolveCredentialOptions,
|
|
237
|
+
RunOAuthLoginOptions,
|
|
238
|
+
RunOAuthLoginResult,
|
|
222
239
|
} from "./auth/index.js";
|
|
223
240
|
|
|
224
241
|
// OpenAI Responses-compatible local proxy
|
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
});
|