offrouter-core 0.0.0 → 0.2.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/package.json +3 -3
- package/src/auth/credential-chain.test.ts +124 -0
- package/src/auth/credential-chain.ts +60 -6
- package/src/auth/index.ts +14 -1
- package/src/auth/keychain.test.ts +191 -1
- package/src/auth/keychain.ts +131 -2
- 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 +66 -2
- package/src/config.ts +22 -1
- package/src/index.ts +20 -1
- package/src/policy.ts +2 -0
- package/src/providers/adapter.ts +4 -0
- package/src/router.test.ts +137 -0
- package/src/router.ts +34 -5
- package/src/schemas.test.ts +51 -0
- package/src/schemas.ts +5 -0
- package/src/types.ts +6 -0
- package/src/usage-file.test.ts +243 -0
- package/src/usage-file.ts +435 -0
- package/src/usage.test.ts +52 -0
- package/src/usage.ts +190 -0
|
@@ -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
|
@@ -157,7 +157,7 @@ enabled = true
|
|
|
157
157
|
expect(config.policyDefaults.subscriptionFirst).toBe(true);
|
|
158
158
|
expect(config.policyDefaults.allowApiKeyFallback).toBe(true);
|
|
159
159
|
expect(config.providers).toEqual({
|
|
160
|
-
claude: { id: "claude", enabled: true },
|
|
160
|
+
claude: { id: "claude", enabled: true, accounts: [{ id: "claude" }] },
|
|
161
161
|
});
|
|
162
162
|
|
|
163
163
|
const allowed = evaluatePolicy(
|
|
@@ -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(
|
|
@@ -323,7 +339,7 @@ enabled = true
|
|
|
323
339
|
"project-enabled",
|
|
324
340
|
]);
|
|
325
341
|
expect(trusted.providers).toEqual({
|
|
326
|
-
"project-agent": { id: "project-agent", enabled: true },
|
|
342
|
+
"project-agent": { id: "project-agent", enabled: true, accounts: [{ id: "project-agent" }] },
|
|
327
343
|
});
|
|
328
344
|
expect(trusted.sources).toContain(
|
|
329
345
|
join(project, ".offrouter", "config.toml"),
|
|
@@ -412,4 +428,52 @@ weird_flag = 1
|
|
|
412
428
|
expect(config.sources).toEqual([]);
|
|
413
429
|
expect(config.warnings).toEqual([]);
|
|
414
430
|
});
|
|
431
|
+
|
|
432
|
+
describe("multi-account provider config", () => {
|
|
433
|
+
it("parses TOML with explicit accounts array", async () => {
|
|
434
|
+
const home = await trackDir(makeHome);
|
|
435
|
+
await writeFile(
|
|
436
|
+
join(home, "config.toml"),
|
|
437
|
+
`
|
|
438
|
+
allowlisted_profiles = ["claude-personal"]
|
|
439
|
+
|
|
440
|
+
[[providers.anthropic.accounts]]
|
|
441
|
+
id = "anthropic-1"
|
|
442
|
+
label = "Work Account"
|
|
443
|
+
|
|
444
|
+
[[providers.anthropic.accounts]]
|
|
445
|
+
id = "anthropic-2"
|
|
446
|
+
label = "Personal Account"
|
|
447
|
+
`,
|
|
448
|
+
"utf8",
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
const config = await loadConfig({ env: { OFFROUTER_HOME: home } });
|
|
452
|
+
expect(config.providers.anthropic).toBeDefined();
|
|
453
|
+
expect(config.providers.anthropic.accounts).toEqual([
|
|
454
|
+
{ id: "anthropic-1", label: "Work Account" },
|
|
455
|
+
{ id: "anthropic-2", label: "Personal Account" },
|
|
456
|
+
]);
|
|
457
|
+
expect(config.providers.anthropic.enabled).toBe(true);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("creates implicit account when no accounts array is present", async () => {
|
|
461
|
+
const home = await trackDir(makeHome);
|
|
462
|
+
await writeFile(
|
|
463
|
+
join(home, "config.toml"),
|
|
464
|
+
`
|
|
465
|
+
allowlisted_profiles = ["codex-personal"]
|
|
466
|
+
|
|
467
|
+
[providers.openai]
|
|
468
|
+
enabled = true
|
|
469
|
+
`,
|
|
470
|
+
"utf8",
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
const config = await loadConfig({ env: { OFFROUTER_HOME: home } });
|
|
474
|
+
expect(config.providers.openai).toBeDefined();
|
|
475
|
+
expect(config.providers.openai.accounts).toEqual([{ id: "openai" }]);
|
|
476
|
+
expect(config.providers.openai.enabled).toBe(true);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
415
479
|
});
|
package/src/config.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface PolicyDefaults {
|
|
|
20
20
|
export interface ConfiguredProvider {
|
|
21
21
|
id: string;
|
|
22
22
|
enabled: boolean;
|
|
23
|
+
accounts: ConfiguredAccount[];
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/** Named profile overlay loaded from profiles/*.toml. */
|
|
@@ -30,6 +31,12 @@ export interface ConfiguredProfile {
|
|
|
30
31
|
subscriptionFirst?: boolean;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** Single configured account within a provider. */
|
|
35
|
+
export interface ConfiguredAccount {
|
|
36
|
+
id: string;
|
|
37
|
+
label?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
33
40
|
export interface ConfigWarning {
|
|
34
41
|
filePath: string;
|
|
35
42
|
path: string;
|
|
@@ -94,6 +101,9 @@ function allowedFromZodShape(shape: z.ZodRawShape): AllowedShape {
|
|
|
94
101
|
|
|
95
102
|
const ProviderConfigShape = {
|
|
96
103
|
enabled: z.boolean().optional(),
|
|
104
|
+
accounts: z
|
|
105
|
+
.array(z.object({ id: z.string().min(1), label: z.string().optional() }).strict())
|
|
106
|
+
.optional(),
|
|
97
107
|
} satisfies z.ZodRawShape;
|
|
98
108
|
|
|
99
109
|
const PolicySectionShape = {
|
|
@@ -101,6 +111,7 @@ const PolicySectionShape = {
|
|
|
101
111
|
allow_api_key_fallback: z.boolean().optional(),
|
|
102
112
|
allow_third_party_subscription_adapters: z.boolean().optional(),
|
|
103
113
|
denied_providers: z.array(z.string().min(1)).optional(),
|
|
114
|
+
near_limit_threshold: z.number().min(0).max(1).optional(),
|
|
104
115
|
} satisfies z.ZodRawShape;
|
|
105
116
|
|
|
106
117
|
const RootConfigShape = {
|
|
@@ -122,7 +133,10 @@ const ProfileFileShape = {
|
|
|
122
133
|
const RootConfigSchema = z.object(RootConfigShape).strict();
|
|
123
134
|
const ProfileFileSchema = z.object(ProfileFileShape).strict();
|
|
124
135
|
|
|
125
|
-
const PROVIDER_ALLOWED: AllowedShape =
|
|
136
|
+
const PROVIDER_ALLOWED: AllowedShape = {
|
|
137
|
+
...allowedFromZodShape(ProviderConfigShape),
|
|
138
|
+
accounts: "leaf",
|
|
139
|
+
};
|
|
126
140
|
const ROOT_ALLOWED: AllowedShape = {
|
|
127
141
|
...allowedFromZodShape(RootConfigShape),
|
|
128
142
|
policy: allowedFromZodShape(PolicySectionShape),
|
|
@@ -355,6 +369,9 @@ function mergeRootInto(
|
|
|
355
369
|
target.policy.allowThirdPartySubscriptionAdapters =
|
|
356
370
|
root.policy.allow_third_party_subscription_adapters;
|
|
357
371
|
}
|
|
372
|
+
if (root.policy.near_limit_threshold !== undefined) {
|
|
373
|
+
target.policy.nearLimitThreshold = root.policy.near_limit_threshold;
|
|
374
|
+
}
|
|
358
375
|
if (root.policy.denied_providers) {
|
|
359
376
|
target.policy.deniedProviders = uniqueStrings([
|
|
360
377
|
...(target.policy.deniedProviders ?? []),
|
|
@@ -365,9 +382,13 @@ function mergeRootInto(
|
|
|
365
382
|
|
|
366
383
|
if (root.providers) {
|
|
367
384
|
for (const [id, provider] of Object.entries(root.providers)) {
|
|
385
|
+
const accounts: ConfiguredAccount[] = provider.accounts
|
|
386
|
+
? provider.accounts.map((a: { id: string; label?: string }) => ({ id: a.id, label: a.label }))
|
|
387
|
+
: [{ id }];
|
|
368
388
|
target.providers[id] = {
|
|
369
389
|
id,
|
|
370
390
|
enabled: provider.enabled ?? true,
|
|
391
|
+
accounts,
|
|
371
392
|
};
|
|
372
393
|
}
|
|
373
394
|
}
|
package/src/index.ts
CHANGED
|
@@ -83,6 +83,7 @@ export {
|
|
|
83
83
|
resolveOffRouterHome,
|
|
84
84
|
} from "./config.js";
|
|
85
85
|
export type {
|
|
86
|
+
ConfiguredAccount,
|
|
86
87
|
ConfiguredProfile,
|
|
87
88
|
ConfiguredProvider,
|
|
88
89
|
ConfigWarning,
|
|
@@ -117,8 +118,16 @@ export type {
|
|
|
117
118
|
MarkCancelledOptions,
|
|
118
119
|
} from "./audit.js";
|
|
119
120
|
|
|
120
|
-
export { InMemoryStateStore, InMemoryUsageStore } from "./usage.js";
|
|
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";
|
|
121
129
|
export type {
|
|
130
|
+
AccountUsageSnapshot,
|
|
122
131
|
InMemoryUsageStoreOptions,
|
|
123
132
|
PaidFallbackExplanation,
|
|
124
133
|
ProviderUsageSnapshot,
|
|
@@ -196,14 +205,21 @@ export type {
|
|
|
196
205
|
// Auth module (credential chain, OAuth PKCE, keychain helpers)
|
|
197
206
|
export {
|
|
198
207
|
Redacted,
|
|
208
|
+
defaultCallbackPort,
|
|
209
|
+
deleteOAuthTokens,
|
|
199
210
|
exchangeCodeForTokens,
|
|
200
211
|
generateCodeChallenge,
|
|
201
212
|
generateCodeVerifier,
|
|
213
|
+
getOAuthTokens,
|
|
202
214
|
getProviderToken,
|
|
203
215
|
isOAuthProvider,
|
|
216
|
+
isOAuthTokenExpired,
|
|
204
217
|
PKCE_CONFIGS,
|
|
205
218
|
refreshTokens,
|
|
219
|
+
resolveClientId,
|
|
206
220
|
resolveCredential,
|
|
221
|
+
runOAuthLogin,
|
|
222
|
+
setOAuthTokens,
|
|
207
223
|
setProviderToken,
|
|
208
224
|
startOAuthFlow,
|
|
209
225
|
} from "./auth/index.js";
|
|
@@ -213,10 +229,13 @@ export type {
|
|
|
213
229
|
OAuthFlowResult,
|
|
214
230
|
OAuthFlowState,
|
|
215
231
|
OAuthProviderConfig,
|
|
232
|
+
OAuthStoredTokens,
|
|
216
233
|
OAuthStartOptions,
|
|
217
234
|
OAuthTokenOptions,
|
|
218
235
|
OAuthTokenResponse,
|
|
219
236
|
ResolveCredentialOptions,
|
|
237
|
+
RunOAuthLoginOptions,
|
|
238
|
+
RunOAuthLoginResult,
|
|
220
239
|
} from "./auth/index.js";
|
|
221
240
|
|
|
222
241
|
// OpenAI Responses-compatible local proxy
|
package/src/policy.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface PolicyConfig {
|
|
|
25
25
|
allowThirdPartySubscriptionAdapters?: boolean;
|
|
26
26
|
/** Optional explicit provider deny list by providerId. */
|
|
27
27
|
deniedProviders?: string[];
|
|
28
|
+
/** Fraction of subscription quota remaining at/under which an account is "near-limit". Default 0.2. */
|
|
29
|
+
nearLimitThreshold?: number;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
const HEALTHY_SUBSCRIPTION_STATUSES: ReadonlySet<SubscriptionStatus> = new Set([
|
package/src/providers/adapter.ts
CHANGED
|
@@ -209,6 +209,7 @@ export interface AuthStatus {
|
|
|
209
209
|
subscriptionStatus?: SubscriptionStatus;
|
|
210
210
|
limitsDiscoverable: boolean;
|
|
211
211
|
health: ProviderHealth;
|
|
212
|
+
accountId?: string;
|
|
212
213
|
detail?: string;
|
|
213
214
|
/**
|
|
214
215
|
* Which credential source satisfied auth (if any).
|
|
@@ -231,6 +232,7 @@ export interface LimitSnapshot {
|
|
|
231
232
|
limit?: number;
|
|
232
233
|
resetAt?: string;
|
|
233
234
|
unit?: string;
|
|
235
|
+
accountId?: string;
|
|
234
236
|
metadata?: Record<string, string | number | boolean | null>;
|
|
235
237
|
}
|
|
236
238
|
|
|
@@ -320,5 +322,7 @@ export function capabilityToCandidate(
|
|
|
320
322
|
contextWindowTokens: capability.contextWindowTokens,
|
|
321
323
|
maxOutputTokens: capability.maxOutputTokens,
|
|
322
324
|
estimatedCostUsd: extras.estimatedCostUsd ?? capability.estimatedCostUsd,
|
|
325
|
+
accountId: extras.accountId,
|
|
326
|
+
subscriptionQuotaPercentRemaining: extras.subscriptionQuotaPercentRemaining,
|
|
323
327
|
};
|
|
324
328
|
}
|
package/src/router.test.ts
CHANGED
|
@@ -559,4 +559,141 @@ describe("routeRequest", () => {
|
|
|
559
559
|
expect(decision.auditSummary).not.toContain("raw user text");
|
|
560
560
|
}
|
|
561
561
|
});
|
|
562
|
+
|
|
563
|
+
describe("multi-account routing", () => {
|
|
564
|
+
it("selects first healthy subscription when two accounts have same rank", () => {
|
|
565
|
+
const decision = routeRequest(
|
|
566
|
+
baseRequest({
|
|
567
|
+
constraints: { subscriptionFirst: true, allowApiKeyFallback: false },
|
|
568
|
+
}),
|
|
569
|
+
[
|
|
570
|
+
candidate({
|
|
571
|
+
providerId: "anthropic",
|
|
572
|
+
modelId: "sonnet",
|
|
573
|
+
authTier: "subscription",
|
|
574
|
+
subscriptionStatus: "active",
|
|
575
|
+
health: "healthy",
|
|
576
|
+
accountId: "anthropic-1",
|
|
577
|
+
estimatedCostUsd: 0,
|
|
578
|
+
}),
|
|
579
|
+
candidate({
|
|
580
|
+
providerId: "anthropic",
|
|
581
|
+
modelId: "sonnet",
|
|
582
|
+
authTier: "subscription",
|
|
583
|
+
subscriptionStatus: "active",
|
|
584
|
+
health: "healthy",
|
|
585
|
+
accountId: "anthropic-2",
|
|
586
|
+
estimatedCostUsd: 0,
|
|
587
|
+
}),
|
|
588
|
+
],
|
|
589
|
+
personalConfig,
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
expect(decision.blocked).toBeFalsy();
|
|
593
|
+
// Same rankKey, stable sort by input order: first candidate wins
|
|
594
|
+
expect(decision.route?.accountId).toBe("anthropic-1");
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("skips exhausted account and picks the next healthy one", () => {
|
|
598
|
+
const decision = routeRequest(
|
|
599
|
+
baseRequest({
|
|
600
|
+
constraints: { subscriptionFirst: true, allowApiKeyFallback: true },
|
|
601
|
+
}),
|
|
602
|
+
[
|
|
603
|
+
candidate({
|
|
604
|
+
providerId: "anthropic",
|
|
605
|
+
modelId: "sonnet",
|
|
606
|
+
authTier: "subscription",
|
|
607
|
+
subscriptionStatus: "exhausted",
|
|
608
|
+
health: "healthy",
|
|
609
|
+
accountId: "anthropic-1",
|
|
610
|
+
estimatedCostUsd: 0,
|
|
611
|
+
}),
|
|
612
|
+
candidate({
|
|
613
|
+
providerId: "anthropic",
|
|
614
|
+
modelId: "sonnet",
|
|
615
|
+
authTier: "subscription",
|
|
616
|
+
subscriptionStatus: "active",
|
|
617
|
+
health: "healthy",
|
|
618
|
+
accountId: "anthropic-2",
|
|
619
|
+
estimatedCostUsd: 0,
|
|
620
|
+
}),
|
|
621
|
+
],
|
|
622
|
+
personalConfig,
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
expect(decision.blocked).toBeFalsy();
|
|
626
|
+
expect(decision.route?.accountId).toBe("anthropic-2");
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("prefers healthy active account over near-limit account", () => {
|
|
630
|
+
const decision = routeRequest(
|
|
631
|
+
baseRequest({
|
|
632
|
+
constraints: { subscriptionFirst: true, allowApiKeyFallback: false },
|
|
633
|
+
}),
|
|
634
|
+
[
|
|
635
|
+
candidate({
|
|
636
|
+
providerId: "anthropic",
|
|
637
|
+
modelId: "sonnet",
|
|
638
|
+
authTier: "subscription",
|
|
639
|
+
subscriptionStatus: "near-limit",
|
|
640
|
+
subscriptionQuotaPercentRemaining: 15,
|
|
641
|
+
health: "healthy",
|
|
642
|
+
accountId: "anthropic-1",
|
|
643
|
+
estimatedCostUsd: 0,
|
|
644
|
+
}),
|
|
645
|
+
candidate({
|
|
646
|
+
providerId: "anthropic",
|
|
647
|
+
modelId: "sonnet",
|
|
648
|
+
authTier: "subscription",
|
|
649
|
+
subscriptionStatus: "active",
|
|
650
|
+
health: "healthy",
|
|
651
|
+
accountId: "anthropic-2",
|
|
652
|
+
estimatedCostUsd: 0,
|
|
653
|
+
}),
|
|
654
|
+
],
|
|
655
|
+
personalConfig,
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
expect(decision.blocked).toBeFalsy();
|
|
659
|
+
expect(decision.route?.accountId).toBe("anthropic-2");
|
|
660
|
+
expect(decision.nearLimitWarning).toBeUndefined();
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("selects near-limit subscription when no healthy alternative exists and warns", () => {
|
|
664
|
+
const decision = routeRequest(
|
|
665
|
+
baseRequest({
|
|
666
|
+
constraints: { subscriptionFirst: true, allowApiKeyFallback: true },
|
|
667
|
+
}),
|
|
668
|
+
[
|
|
669
|
+
candidate({
|
|
670
|
+
providerId: "anthropic",
|
|
671
|
+
modelId: "sonnet",
|
|
672
|
+
authTier: "subscription",
|
|
673
|
+
subscriptionStatus: "near-limit",
|
|
674
|
+
subscriptionQuotaPercentRemaining: 15,
|
|
675
|
+
health: "healthy",
|
|
676
|
+
accountId: "anthropic-1",
|
|
677
|
+
estimatedCostUsd: 0,
|
|
678
|
+
}),
|
|
679
|
+
candidate({
|
|
680
|
+
providerId: "openrouter",
|
|
681
|
+
modelId: "fast",
|
|
682
|
+
authTier: "api-key",
|
|
683
|
+
authScope: "third-party",
|
|
684
|
+
estimatedCostUsd: 0.01,
|
|
685
|
+
}),
|
|
686
|
+
],
|
|
687
|
+
personalConfig,
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
expect(decision.blocked).toBeFalsy();
|
|
691
|
+
expect(decision.route?.accountId).toBe("anthropic-1");
|
|
692
|
+
expect(decision.nearLimitWarning).toBeTruthy();
|
|
693
|
+
expect(decision.nearLimitWarning).toContain("near its limit");
|
|
694
|
+
expect(decision.explanation).toContain("near its limit");
|
|
695
|
+
expect(decision.route?.provider).toBe("anthropic");
|
|
696
|
+
expect(decision.route?.model).toBe("sonnet");
|
|
697
|
+
});
|
|
698
|
+
});
|
|
562
699
|
});
|