@stage-labs/metro 0.1.0-beta.220 → 0.1.0-beta.222
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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/usage-probe.ts +127 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +7 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +30 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +17 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +40 -11
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +6 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +10 -3
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-lists.ts +57 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +17 -49
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/openrouter.ts +32 -18
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/provider-models.ts +74 -22
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +102 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +115 -11
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.222",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
+
import { agentCommand, agentUser, agentUserExpected, claudeBin } from '../agent-user/user.js';
|
|
5
|
+
import { runningAsRoot } from '../metro-user/privilege.js';
|
|
6
|
+
import { PRIVACY_ENV } from './setup.js';
|
|
7
|
+
|
|
8
|
+
const REQUEST_ID = 'metro-usage';
|
|
9
|
+
const REQUEST = `${JSON.stringify({ type: 'control_request', request_id: REQUEST_ID, request: { subtype: 'get_usage', skip_behaviors: true } })}\n`;
|
|
10
|
+
const ARGS = [
|
|
11
|
+
'-p',
|
|
12
|
+
'--setting-sources',
|
|
13
|
+
'project',
|
|
14
|
+
'--strict-mcp-config',
|
|
15
|
+
'--mcp-config',
|
|
16
|
+
'{"mcpServers":{}}',
|
|
17
|
+
'--no-session-persistence',
|
|
18
|
+
'--input-format',
|
|
19
|
+
'stream-json',
|
|
20
|
+
'--output-format',
|
|
21
|
+
'stream-json',
|
|
22
|
+
'--verbose',
|
|
23
|
+
];
|
|
24
|
+
const BLOCKS_USAGE = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC';
|
|
25
|
+
const PROBE_ENV: Record<string, string> = {
|
|
26
|
+
...Object.fromEntries(Object.entries(PRIVACY_ENV).filter(([name]) => name !== BLOCKS_USAGE)),
|
|
27
|
+
DISABLE_AUTOUPDATER: '1',
|
|
28
|
+
DISABLE_BUG_COMMAND: '1',
|
|
29
|
+
ENABLE_CLAUDEAI_MCP_SERVERS: 'false',
|
|
30
|
+
};
|
|
31
|
+
const TIMEOUT_MS = 20_000;
|
|
32
|
+
const KILL_GRACE_MS = 2_000;
|
|
33
|
+
const LINE_MAX = 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
export interface UsageProbeDeps {
|
|
36
|
+
command?: string[];
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function usageProbeCommand(): string[] | null {
|
|
41
|
+
if (runningAsRoot() || (agentUserExpected() && agentUser() === null)) return null;
|
|
42
|
+
return agentCommand([claudeBin(), ...ARGS], PROBE_ENV);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function answerOf(line: string): Record<string, unknown> | null {
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(line);
|
|
48
|
+
if (!isRecord(parsed) || parsed.type !== 'control_response' || !isRecord(parsed.response)) return null;
|
|
49
|
+
return parsed.response.request_id === REQUEST_ID ? parsed.response : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function limitsOf(answer: Record<string, unknown>): unknown {
|
|
56
|
+
if (answer.subtype !== 'success') throw new Error(`Claude Code refused the usage request: ${typeof answer.error === 'string' ? answer.error : 'no reason given'}`);
|
|
57
|
+
return isRecord(answer.response) ? (answer.response.rate_limits ?? null) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function usageIn(output: string): unknown {
|
|
61
|
+
const answer = output
|
|
62
|
+
.split('\n')
|
|
63
|
+
.map(answerOf)
|
|
64
|
+
.find((found) => found !== null);
|
|
65
|
+
if (answer === undefined || answer === null) throw new Error('Claude Code gave no usage answer');
|
|
66
|
+
return limitsOf(answer);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function readClaudeUsage(deps: UsageProbeDeps = {}): Promise<unknown> {
|
|
70
|
+
const command = deps.command ?? usageProbeCommand();
|
|
71
|
+
if (command === null) return Promise.reject(new Error('Claude Code runs only as the agent user, and this box has none'));
|
|
72
|
+
const [file = '', ...args] = command;
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const child = spawn(file, args, { cwd: '/', stdio: ['pipe', 'pipe', 'ignore'], env: { ...process.env, ...PROBE_ENV } });
|
|
75
|
+
let carry = '';
|
|
76
|
+
let settled = false;
|
|
77
|
+
let hardKill: ReturnType<typeof setTimeout> | undefined;
|
|
78
|
+
const settle = (run: () => void): void => {
|
|
79
|
+
if (settled) return;
|
|
80
|
+
settled = true;
|
|
81
|
+
run();
|
|
82
|
+
};
|
|
83
|
+
const overdue = setTimeout(() => {
|
|
84
|
+
settle(() => {
|
|
85
|
+
reject(new Error('Claude Code did not report the usage in time'));
|
|
86
|
+
});
|
|
87
|
+
child.stdout.destroy();
|
|
88
|
+
child.kill('SIGTERM');
|
|
89
|
+
hardKill = setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS);
|
|
90
|
+
}, deps.timeoutMs ?? TIMEOUT_MS);
|
|
91
|
+
const take = (line: string): void => {
|
|
92
|
+
const answer = answerOf(line);
|
|
93
|
+
if (answer === null) return;
|
|
94
|
+
settle(() => {
|
|
95
|
+
try {
|
|
96
|
+
resolve(limitsOf(answer));
|
|
97
|
+
} catch (err) {
|
|
98
|
+
reject(err instanceof Error ? err : new Error(errMsg(err)));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
103
|
+
const lines = (carry + chunk.toString('utf8')).split('\n');
|
|
104
|
+
carry = lines.pop() ?? '';
|
|
105
|
+
if (carry.length > LINE_MAX) carry = '';
|
|
106
|
+
lines.forEach(take);
|
|
107
|
+
});
|
|
108
|
+
child.stdin.on('error', (err) => {
|
|
109
|
+
log.debug({ err: errMsg(err) }, 'claude-usage: Claude Code closed its input early');
|
|
110
|
+
});
|
|
111
|
+
child.on('error', (err) => {
|
|
112
|
+
clearTimeout(overdue);
|
|
113
|
+
settle(() => {
|
|
114
|
+
reject(err);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
child.on('close', () => {
|
|
118
|
+
clearTimeout(overdue);
|
|
119
|
+
clearTimeout(hardKill);
|
|
120
|
+
take(carry);
|
|
121
|
+
settle(() => {
|
|
122
|
+
reject(new Error('Claude Code gave no usage answer'));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
child.stdin.end(REQUEST);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -10,6 +10,7 @@ export const CODEX_ISSUER = 'https://auth.openai.com';
|
|
|
10
10
|
export const CODEX_REDIRECT = 'http://localhost:1455/auth/callback';
|
|
11
11
|
const SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke';
|
|
12
12
|
const AUTH_CLAIM = 'https://api.openai.com/auth';
|
|
13
|
+
const PROFILE_CLAIM = 'https://api.openai.com/profile';
|
|
13
14
|
const ACCESS_TOKEN_TTL_MS = 55 * 60_000;
|
|
14
15
|
|
|
15
16
|
export interface CodexTokens {
|
|
@@ -76,7 +77,12 @@ function decodeJwtPayload(jwt: string): Record<string, unknown> {
|
|
|
76
77
|
export function claimsOf(idToken: string): { accountId: string | null; email: string | null; plan: string | null } {
|
|
77
78
|
const payload = decodeJwtPayload(idToken);
|
|
78
79
|
const auth = isRecord(payload[AUTH_CLAIM]) ? payload[AUTH_CLAIM] : {};
|
|
79
|
-
|
|
80
|
+
const profile = isRecord(payload[PROFILE_CLAIM]) ? payload[PROFILE_CLAIM] : {};
|
|
81
|
+
return {
|
|
82
|
+
accountId: nonEmpty(auth.chatgpt_account_id),
|
|
83
|
+
email: nonEmpty(payload.email) ?? nonEmpty(profile.email),
|
|
84
|
+
plan: nonEmpty(auth.chatgpt_plan_type),
|
|
85
|
+
};
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
interface TokenResponse {
|
|
@@ -8,11 +8,13 @@ import { ToolNames, toResponsesRequest } from './codex-translate.js';
|
|
|
8
8
|
import { GatewayError, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
|
|
9
9
|
import { parseEvent, SseParser, type SseEvent } from './frames.js';
|
|
10
10
|
import type { Connection } from './model-config.js';
|
|
11
|
+
import { listCache } from './model-lists.js';
|
|
11
12
|
import { answerWhole, currentOf, errorKind, reach, refreshed, relayTranslated, sessionHeader, type TokenSource, type TokenState } from './subscription.js';
|
|
12
|
-
import { noteUsageHeaders } from './usage.js';
|
|
13
|
+
import { codexUsageBody, noteUsageHeaders, type Reported } from './usage.js';
|
|
13
14
|
|
|
14
15
|
const CODEX_BASE = 'https://chatgpt.com/backend-api/codex';
|
|
15
16
|
const INVALID = [400, 404, 422];
|
|
17
|
+
const USAGE_TIMEOUT_MS = 10_000;
|
|
16
18
|
|
|
17
19
|
export interface CodexDeps {
|
|
18
20
|
base?: string;
|
|
@@ -25,6 +27,8 @@ export type CodexState = TokenState<CodexTokens>;
|
|
|
25
27
|
|
|
26
28
|
export const sharedCodexState: CodexState = new Map();
|
|
27
29
|
|
|
30
|
+
const codexLists = listCache<string>('codex');
|
|
31
|
+
|
|
28
32
|
const sourceOf = (deps: CodexDeps): TokenSource<CodexTokens> => ({
|
|
29
33
|
label: 'Codex',
|
|
30
34
|
stale: (tokens) => tokensStale(tokens),
|
|
@@ -107,10 +111,33 @@ export async function codexMessages(
|
|
|
107
111
|
answerWhole(res, frames + translator.close(), conn.id);
|
|
108
112
|
}
|
|
109
113
|
|
|
114
|
+
const jsonHeaders = (tokens: CodexTokens): Record<string, string> => ({ ...headersFor(tokens, randomUUID()), accept: 'application/json' });
|
|
115
|
+
|
|
116
|
+
export const codexUsageUrl = (base = CODEX_BASE): string => `${base.replace(/\/codex\/?$/, '')}/wham/usage`;
|
|
117
|
+
|
|
118
|
+
export async function codexUsageNow(conn: Connection, deps: CodexDeps, state: CodexState): Promise<Reported | null> {
|
|
119
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
120
|
+
const res = await reach(
|
|
121
|
+
() => currentTokens(conn, deps, state),
|
|
122
|
+
(tokens) => refreshed(state, conn.id, tokens, sourceOf(deps)),
|
|
123
|
+
(tokens) => fetchImpl(codexUsageUrl(deps.base), { headers: jsonHeaders(tokens), redirect: 'manual', signal: AbortSignal.timeout(USAGE_TIMEOUT_MS) }),
|
|
124
|
+
);
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
await res.body?.cancel();
|
|
127
|
+
throw new GatewayError(res.status, errorKind(res.status, INVALID), `Codex would not report its usage (${String(res.status)})`);
|
|
128
|
+
}
|
|
129
|
+
return codexUsageBody(await res.json());
|
|
130
|
+
}
|
|
131
|
+
|
|
110
132
|
export async function codexModels(tokens: CodexTokens, deps: Omit<CodexDeps, 'save'>): Promise<string[]> {
|
|
111
133
|
const version = await learnCodexVersion();
|
|
112
|
-
const
|
|
113
|
-
|
|
134
|
+
const base = deps.base ?? CODEX_BASE;
|
|
135
|
+
return codexLists.get(`${base}:${tokens.accountId}:${version}`, () => listCodex(tokens, version, base, deps.fetchImpl ?? fetch));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function listCodex(tokens: CodexTokens, version: string, base: string, fetchImpl: typeof fetch): Promise<string[]> {
|
|
139
|
+
const res = await fetchImpl(`${base}/models?client_version=${version}`, {
|
|
140
|
+
headers: jsonHeaders(tokens),
|
|
114
141
|
redirect: 'manual',
|
|
115
142
|
});
|
|
116
143
|
if (!res.ok) throw new GatewayError(res.status, errorKind(res.status, INVALID), `Codex would not list models (${String(res.status)})`);
|
|
@@ -64,12 +64,28 @@ export function effortToApply(body: Body): string | null {
|
|
|
64
64
|
export const cappedEffort = (effort: string): string => (ABOVE_HIGH.has(effort) ? 'high' : effort);
|
|
65
65
|
|
|
66
66
|
const THINKING_ALWAYS_ON = /claude-(fable|mythos)-5|claude-opus-5-5/;
|
|
67
|
+
const REFUSES_THINKING_OFF = '"thinking.type.disabled" is not supported';
|
|
68
|
+
const thinkingAlwaysOn = new Set<string>();
|
|
69
|
+
|
|
70
|
+
export const thinkingOff = (body: Body): boolean => record(body.thinking).type === 'disabled';
|
|
71
|
+
|
|
72
|
+
const alwaysThinks = (model: string): boolean => THINKING_ALWAYS_ON.test(model) || thinkingAlwaysOn.has(model);
|
|
67
73
|
|
|
68
74
|
export function withThinkingFor(body: Body, model: string): Body {
|
|
69
|
-
if (!
|
|
75
|
+
if (!alwaysThinks(model) || !thinkingOff(body)) return body;
|
|
70
76
|
return Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'thinking'));
|
|
71
77
|
}
|
|
72
78
|
|
|
79
|
+
export function learnAlwaysThinks(model: string, refusal: string): boolean {
|
|
80
|
+
if (!refusal.includes(REFUSES_THINKING_OFF)) return false;
|
|
81
|
+
thinkingAlwaysOn.add(model);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function forgetLearnedThinking(): void {
|
|
86
|
+
thinkingAlwaysOn.clear();
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
export function withBlockBinding(body: Body): Body {
|
|
74
90
|
const thinking = body.thinking;
|
|
75
91
|
if (!isRecord(thinking) || thinking.block_binding !== undefined) return body;
|
|
@@ -9,9 +9,20 @@ import {
|
|
|
9
9
|
freshAdaptations,
|
|
10
10
|
type Adaptations,
|
|
11
11
|
} from './bedrock.js';
|
|
12
|
-
import { addBeta, anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
|
|
13
|
-
import {
|
|
14
|
-
|
|
12
|
+
import { addBeta, anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, upstreamMessage, watchUpstream } from './forward.js';
|
|
13
|
+
import {
|
|
14
|
+
BINDING_BETA,
|
|
15
|
+
cappedEffort,
|
|
16
|
+
effortToApply,
|
|
17
|
+
forgetLearnedThinking,
|
|
18
|
+
learnAlwaysThinks,
|
|
19
|
+
plannedEffort,
|
|
20
|
+
thinkingOff,
|
|
21
|
+
withBlockBinding,
|
|
22
|
+
withEffort,
|
|
23
|
+
withThinkingFor,
|
|
24
|
+
} from './effort.js';
|
|
25
|
+
import { notReady, PASSTHROUGH_ID, readModelConfig, resolveRoute, routeLabel, setCodexAuth, setGeminiAuth, writeModelConfig, type Connection, type ModelConfig, type Route } from './model-config.js';
|
|
15
26
|
import { codexMessages, sharedCodexState } from './codex.js';
|
|
16
27
|
import { geminiMessages, sharedGeminiState } from './gemini.js';
|
|
17
28
|
import { countTokens } from './subscription.js';
|
|
@@ -23,10 +34,10 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
23
34
|
import { forgetServed, noteServed } from './served.js';
|
|
24
35
|
import { forgetUsage, noteUsageHeaders, UsageScanner } from './usage.js';
|
|
25
36
|
import { fitToolSearch } from './tool-search.js';
|
|
37
|
+
import { ANTHROPIC_API, refreshLoginModels } from './provider-models.js';
|
|
26
38
|
import type { CodexTokens } from './codex-auth.js';
|
|
27
39
|
|
|
28
40
|
const GATEWAY_PREFIX = '/gateway';
|
|
29
|
-
const ANTHROPIC_BASE = 'https://api.anthropic.com';
|
|
30
41
|
const MESSAGES = '/v1/messages';
|
|
31
42
|
const COUNT = '/v1/messages/count_tokens';
|
|
32
43
|
const MODELS = '/v1/models';
|
|
@@ -49,6 +60,7 @@ export function resetGatewayState(): void {
|
|
|
49
60
|
forgetUsage();
|
|
50
61
|
learned.fields.clear();
|
|
51
62
|
learned.dropBetas = false;
|
|
63
|
+
forgetLearnedThinking();
|
|
52
64
|
sharedCodexState.clear();
|
|
53
65
|
sharedGeminiState.clear();
|
|
54
66
|
}
|
|
@@ -75,7 +87,7 @@ function modelsBody(cfg: ModelConfig): Record<string, unknown> {
|
|
|
75
87
|
return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
|
|
76
88
|
}
|
|
77
89
|
|
|
78
|
-
const PASSTHROUGH: Connection = { id:
|
|
90
|
+
const PASSTHROUGH: Connection = { id: PASSTHROUGH_ID, provider: 'anthropic', label: 'Claude Code login', model: '', apiKey: '', region: '', zdr: false, codex: null, gemini: null };
|
|
79
91
|
|
|
80
92
|
const passthrough = (body: Record<string, unknown>): Route => ({ connection: PASSTHROUGH, model: requestedModel(body) });
|
|
81
93
|
|
|
@@ -97,8 +109,9 @@ interface Payloads {
|
|
|
97
109
|
}
|
|
98
110
|
|
|
99
111
|
function anthropicPayloads(raw: Buffer, sent: Record<string, unknown>, shaped: Record<string, unknown>, model: string): Payloads {
|
|
100
|
-
const
|
|
101
|
-
const
|
|
112
|
+
const runnable = withThinkingFor(sent, model);
|
|
113
|
+
const rewrite = runnable !== sent || (typeof sent.model === 'string' && sent.model !== model);
|
|
114
|
+
const asSent = rewrite ? Buffer.from(JSON.stringify({ ...runnable, model })) : raw;
|
|
102
115
|
const bound = withBlockBinding(withThinkingFor(shaped, model));
|
|
103
116
|
if (bound === sent) return { metro: asSent, asSent: null, bound: false };
|
|
104
117
|
return { metro: Buffer.from(JSON.stringify(rewrite ? { ...bound, model } : bound)), asSent, bound: bound !== shaped };
|
|
@@ -115,6 +128,20 @@ async function asAnthropicWants(send: Send, base: Record<string, string>, payloa
|
|
|
115
128
|
return send(asSent, base);
|
|
116
129
|
}
|
|
117
130
|
|
|
131
|
+
async function refusedThinkingOff(upstream: Response, shaped: Record<string, unknown>, model: string): Promise<boolean> {
|
|
132
|
+
if (upstream.status !== 400 || !thinkingOff(shaped)) return false;
|
|
133
|
+
if (!learnAlwaysThinks(model, upstreamMessage(await upstream.clone().text(), ''))) return false;
|
|
134
|
+
await upstream.body?.cancel();
|
|
135
|
+
log.info({ model }, 'gateway: this model cannot run with thinking off, so metro leaves that setting out for it from now on');
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function listLoginModels(req: IncomingMessage, anthropicBase: string): void {
|
|
140
|
+
refreshLoginModels(req.headers, anthropicBase).catch((err: unknown) => {
|
|
141
|
+
log.warn({ err: errMsg(err) }, 'gateway: could not list the models of the Claude Code login');
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
118
145
|
async function toAnthropic(
|
|
119
146
|
req: IncomingMessage,
|
|
120
147
|
res: ServerResponse,
|
|
@@ -125,7 +152,8 @@ async function toAnthropic(
|
|
|
125
152
|
deps: GatewayDeps,
|
|
126
153
|
): Promise<void> {
|
|
127
154
|
const conn = route.connection;
|
|
128
|
-
const
|
|
155
|
+
const anthropicBase = deps.anthropicBase ?? ANTHROPIC_API;
|
|
156
|
+
const url = `${anthropicBase}${(req.url ?? '').slice(GATEWAY_PREFIX.length)}`;
|
|
129
157
|
const key = conn.apiKey;
|
|
130
158
|
if (key === '' && standsInFor(req))
|
|
131
159
|
throw new GatewayError(
|
|
@@ -134,10 +162,13 @@ async function toAnthropic(
|
|
|
134
162
|
'Claude Code on this machine has no Anthropic login of its own; choose Bedrock, OpenRouter, Codex or Gemini on the Model page, or sign in on the Claude tab',
|
|
135
163
|
);
|
|
136
164
|
const watch = watchUpstream(res);
|
|
165
|
+
if (key === '') listLoginModels(req, anthropicBase);
|
|
137
166
|
const base = key === '' ? forwardedHeaders(req) : anthropicHeaders(req, key);
|
|
138
167
|
const send = (payload: Buffer, headers: Record<string, string>): Promise<Response> =>
|
|
139
168
|
fetch(url, { method: 'POST', headers, body: new Uint8Array(payload), signal: watch.signal, redirect: 'manual' });
|
|
140
|
-
const
|
|
169
|
+
const attempt = (): Promise<Response> => asAnthropicWants(send, base, anthropicPayloads(raw, sent, shaped, route.model), conn.label, route.model);
|
|
170
|
+
const first = await attempt();
|
|
171
|
+
const upstream = (await refusedThinkingOff(first, shaped, route.model)) ? await attempt() : first;
|
|
141
172
|
noteRefusal(conn.label, route.model, upstream);
|
|
142
173
|
noteUsageHeaders('anthropic', conn.id, upstream.headers);
|
|
143
174
|
const scanner = new UsageScanner(conn.id);
|
|
@@ -177,8 +208,6 @@ async function toOpenRouter(
|
|
|
177
208
|
await pipeResponse(upstream, res, watch, { keepalive: true, ownCredential: true, scanner: new UsageScanner(conn.id) });
|
|
178
209
|
}
|
|
179
210
|
|
|
180
|
-
const thinkingOff = (body: Record<string, unknown>): boolean => isRecord(body.thinking) && body.thinking.type === 'disabled';
|
|
181
|
-
|
|
182
211
|
function openrouterBody(body: Record<string, unknown>, model: string, zdr: boolean): Record<string, unknown> {
|
|
183
212
|
const sent: Record<string, unknown> = { ...body, model };
|
|
184
213
|
if (thinkingOff(body)) delete sent.thinking;
|
|
@@ -7,9 +7,10 @@ import { openrouterModels, openrouterZdrModels } from './openrouter.js';
|
|
|
7
7
|
import { anthropicModels, bedrockModels } from './provider-models.js';
|
|
8
8
|
import { routeOf, syncAvailableModelsQuietly } from '../claude/setup.js';
|
|
9
9
|
import { sessionRunning, stopSession } from '../claude/session.js';
|
|
10
|
-
import { forgetOne } from './usage.js';
|
|
10
|
+
import { forgetOne, forgetReported } from './usage.js';
|
|
11
11
|
import {
|
|
12
12
|
addConnection,
|
|
13
|
+
connectionOf,
|
|
13
14
|
parseModelConfig,
|
|
14
15
|
readModelConfig,
|
|
15
16
|
removeConnection,
|
|
@@ -19,7 +20,8 @@ import {
|
|
|
19
20
|
type ModelConfig,
|
|
20
21
|
} from './model-config.js';
|
|
21
22
|
import { asApiError, BODY_MAX, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
|
|
22
|
-
import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES
|
|
23
|
+
import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES } from './model-signin.js';
|
|
24
|
+
import { refreshUsage } from './usage-refresh.js';
|
|
23
25
|
|
|
24
26
|
const PATH = '/api/model';
|
|
25
27
|
const CONNECTIONS = '/api/model/connections';
|
|
@@ -73,7 +75,9 @@ async function create(req: IncomingMessage, deps: ModelApiDeps, store: Store): P
|
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
async function change(req: IncomingMessage, deps: ModelApiDeps, store: Store, id: string): Promise<unknown> {
|
|
78
|
+
const before = connectionOf(store.read(), id)?.apiKey;
|
|
76
79
|
const next = await withBody(req, (body) => updateConnection(store.read(), id, body));
|
|
80
|
+
if (connectionOf(next, id)?.apiKey !== before) forgetReported(id);
|
|
77
81
|
return kept(store, next, deps, 'model-api: connection changed', { connection: id });
|
|
78
82
|
}
|
|
79
83
|
|
|
@@ -3,7 +3,7 @@ import { newId } from '@metro-labs/core/ids';
|
|
|
3
3
|
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
4
4
|
import { agentsDir } from '../agents/files.js';
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
|
-
import type
|
|
6
|
+
import { claimsOf, type CodexTokens } from './codex-auth.js';
|
|
7
7
|
import { tokensFromDisk as geminiTokensFromDisk, type GeminiTokens } from './gemini-auth.js';
|
|
8
8
|
|
|
9
9
|
export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'gemini'] as const;
|
|
@@ -189,6 +189,14 @@ export function resolveRoute(requested: string, cfg: ModelConfig): Route | null
|
|
|
189
189
|
export const routeLabel = (route: Route): string =>
|
|
190
190
|
route.connection.provider === 'anthropic' ? route.model : `${route.connection.provider}:${route.model}`;
|
|
191
191
|
|
|
192
|
+
export const PASSTHROUGH_ID = 'passthrough';
|
|
193
|
+
|
|
194
|
+
function signedInAs(c: Connection): { account: string | null; plan: string | null } {
|
|
195
|
+
if (c.codex === null) return { account: c.gemini?.email ?? null, plan: c.gemini?.tier ?? null };
|
|
196
|
+
const claims = claimsOf(c.codex.idToken);
|
|
197
|
+
return { account: c.codex.email ?? claims.email, plan: c.codex.plan ?? claims.plan };
|
|
198
|
+
}
|
|
199
|
+
|
|
192
200
|
function publicConnection(c: Connection): Record<string, unknown> {
|
|
193
201
|
return {
|
|
194
202
|
id: c.id,
|
|
@@ -199,8 +207,7 @@ function publicConnection(c: Connection): Record<string, unknown> {
|
|
|
199
207
|
region: c.region,
|
|
200
208
|
zdr: c.zdr,
|
|
201
209
|
signedIn: c.codex !== null || c.gemini !== null,
|
|
202
|
-
|
|
203
|
-
plan: c.codex?.plan ?? c.gemini?.tier ?? null,
|
|
210
|
+
...signedInAs(c),
|
|
204
211
|
};
|
|
205
212
|
}
|
|
206
213
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
|
|
4
|
+
export const LIST_TTL_MS = 60 * 60_000;
|
|
5
|
+
export const RETRY_MS = 5 * 60_000;
|
|
6
|
+
|
|
7
|
+
export interface ListCache<T> {
|
|
8
|
+
get: (key: string, load: () => Promise<T[]>) => Promise<T[]>;
|
|
9
|
+
peek: (key: string) => T[] | null;
|
|
10
|
+
refresh: (key: string, load: () => Promise<T[]>) => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const clears = new Set<() => void>();
|
|
14
|
+
|
|
15
|
+
export function listCache<T>(name: string, now: () => number = Date.now): ListCache<T> {
|
|
16
|
+
const kept = new Map<string, { at: number; list: T[] }>();
|
|
17
|
+
const tried = new Map<string, number>();
|
|
18
|
+
clears.add(() => {
|
|
19
|
+
kept.clear();
|
|
20
|
+
tried.clear();
|
|
21
|
+
});
|
|
22
|
+
const fresh = (key: string): boolean => {
|
|
23
|
+
const hit = kept.get(key);
|
|
24
|
+
return hit !== undefined && now() - hit.at < LIST_TTL_MS;
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
async get(key, load) {
|
|
28
|
+
const hit = kept.get(key);
|
|
29
|
+
if (hit !== undefined && fresh(key)) return hit.list;
|
|
30
|
+
try {
|
|
31
|
+
const list = await load();
|
|
32
|
+
kept.set(key, { at: now(), list });
|
|
33
|
+
return list;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (hit === undefined) throw err;
|
|
36
|
+
log.warn({ list: name, err: errMsg(err) }, 'model-lists: the provider would not list its models, so the last list it gave stands');
|
|
37
|
+
return hit.list;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
peek: (key) => kept.get(key)?.list ?? null,
|
|
41
|
+
async refresh(key, load) {
|
|
42
|
+
if (fresh(key) || now() - (tried.get(key) ?? Number.NEGATIVE_INFINITY) < RETRY_MS) return;
|
|
43
|
+
tried.set(key, now());
|
|
44
|
+
try {
|
|
45
|
+
kept.set(key, { at: now(), list: await load() });
|
|
46
|
+
} catch (err) {
|
|
47
|
+
log.warn({ list: name, err: errMsg(err) }, 'model-lists: the provider would not list its models, so the last list it gave stands');
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function forgetModelLists(): void {
|
|
54
|
+
for (const clear of clears) clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const fingerprint = (...parts: string[]): string => createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 16);
|
|
@@ -2,16 +2,15 @@ import type { IncomingMessage } from 'node:http';
|
|
|
2
2
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
3
|
import { readJsonBody } from '@metro-labs/http/api-http';
|
|
4
4
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
-
import {
|
|
5
|
+
import { log } from '@metro-labs/core/log';
|
|
6
6
|
import { beginLogin, finishLogin, readCodexCliAuth, type CodexTokens } from './codex-auth.js';
|
|
7
7
|
import { beginDeviceLogin, pollDeviceLogin } from './codex-device.js';
|
|
8
8
|
import { codexModels, currentTokens, sharedCodexState } from './codex.js';
|
|
9
9
|
import { beginLogin as beginGeminiLogin, exchangeCode as exchangeGeminiCode, userEmail, type GeminiTokens } from './gemini-auth.js';
|
|
10
10
|
import { onboard, parseGeminiProject } from './gemini-setup.js';
|
|
11
11
|
import { currentGeminiTokens, listGeminiModels, sharedGeminiState, type GeminiDeps } from './gemini.js';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import { geminiUsage, noteUsage, openrouterUsage, usageOf } from './usage.js';
|
|
12
|
+
import { listCache } from './model-lists.js';
|
|
13
|
+
import { forgetReported } from './usage.js';
|
|
15
14
|
import {
|
|
16
15
|
addConnection,
|
|
17
16
|
connectionOf,
|
|
@@ -21,8 +20,7 @@ import {
|
|
|
21
20
|
type ModelConfig,
|
|
22
21
|
type Provider,
|
|
23
22
|
} from './model-config.js';
|
|
24
|
-
import { asApiError, askedConnection, BODY_MAX, connectionFor,
|
|
25
|
-
|
|
23
|
+
import { asApiError, askedConnection, BODY_MAX, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
|
|
26
24
|
|
|
27
25
|
function connectionToFill(store: Store, req: IncomingMessage, provider: Provider): { cfg: ModelConfig; id: string } {
|
|
28
26
|
const asked = askedConnection(req);
|
|
@@ -35,8 +33,9 @@ function connectionToFill(store: Store, req: IncomingMessage, provider: Provider
|
|
|
35
33
|
return { cfg, id: cfg.connections.at(-1)?.id ?? '' };
|
|
36
34
|
}
|
|
37
35
|
|
|
38
|
-
const saved = (store: Store, cfg: ModelConfig, note: string, fields: Record<string, unknown>): unknown => {
|
|
36
|
+
const saved = (store: Store, cfg: ModelConfig, note: string, fields: { connection: string } & Record<string, unknown>): unknown => {
|
|
39
37
|
store.write(cfg);
|
|
38
|
+
forgetReported(fields.connection);
|
|
40
39
|
log.info(fields, note);
|
|
41
40
|
return settingsBody(cfg);
|
|
42
41
|
};
|
|
@@ -53,7 +52,7 @@ async function pollDevice(id: string, req: IncomingMessage, deps: ModelApiDeps,
|
|
|
53
52
|
return { status: 'done', settings: keepCodex(store, req, result.tokens, 'model-api: Codex connected by device code') };
|
|
54
53
|
}
|
|
55
54
|
|
|
56
|
-
const codexDepsFor = (deps: ModelApiDeps, store: Store, id: string): { issuer?: string; fetchImpl?: typeof fetch; base?: string; save: (connId: string, t: CodexTokens) => void } => ({
|
|
55
|
+
export const codexDepsFor = (deps: ModelApiDeps, store: Store, id: string): { issuer?: string; fetchImpl?: typeof fetch; base?: string; save: (connId: string, t: CodexTokens) => void } => ({
|
|
57
56
|
issuer: deps.issuer,
|
|
58
57
|
fetchImpl: deps.fetchImpl,
|
|
59
58
|
base: deps.codexBase,
|
|
@@ -113,7 +112,7 @@ const geminiDeps = (deps: ModelApiDeps, store: Store): GeminiDeps => ({
|
|
|
113
112
|
},
|
|
114
113
|
});
|
|
115
114
|
|
|
116
|
-
async function geminiModelsOf(conn: Connection, deps: ModelApiDeps, store: Store): Promise<Awaited<ReturnType<typeof listGeminiModels>>> {
|
|
115
|
+
export async function geminiModelsOf(conn: Connection, deps: ModelApiDeps, store: Store): Promise<Awaited<ReturnType<typeof listGeminiModels>>> {
|
|
117
116
|
if (conn.gemini === null) throw new ApiError('this connection is not signed in yet', 400);
|
|
118
117
|
const tokens = await currentGeminiTokens(conn, geminiDeps(deps, store), sharedGeminiState);
|
|
119
118
|
return listGeminiModels(tokens, geminiDeps(deps, store));
|
|
@@ -140,54 +139,23 @@ async function connectGemini(req: IncomingMessage, deps: ModelApiDeps, store: St
|
|
|
140
139
|
return saved(store, setGeminiAuth(cfg, id, full), 'model-api: Gemini connected', { connection: id, tier: onboarded.tier });
|
|
141
140
|
}
|
|
142
141
|
|
|
142
|
+
const geminiLists = listCache<string>('gemini');
|
|
143
|
+
|
|
144
|
+
async function geminiIds(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<string[]> {
|
|
145
|
+
const conn = connectionFor(store.read(), req, 'gemini');
|
|
146
|
+
const key = `${deps.geminiBase ?? ''}:${conn.id}:${conn.gemini?.email ?? ''}:${conn.gemini?.project ?? ''}`;
|
|
147
|
+
return geminiLists.get(key, async () => (await geminiModelsOf(conn, deps, store)).map((m) => m.id));
|
|
148
|
+
}
|
|
149
|
+
|
|
143
150
|
export const GEMINI_ROUTES: Record<string, Route> = {
|
|
144
151
|
login: { method: 'POST', run: (_req, deps) => Promise.resolve(beginGeminiLogin(deps.geminiAuthBase)) },
|
|
145
152
|
code: { method: 'POST', run: connectGemini },
|
|
146
153
|
models: {
|
|
147
154
|
method: 'GET',
|
|
148
|
-
run: async (req, deps, store) => ({ models:
|
|
155
|
+
run: async (req, deps, store) => ({ models: await geminiIds(req, deps, store).catch(asApiError) }),
|
|
149
156
|
},
|
|
150
157
|
};
|
|
151
158
|
|
|
152
159
|
export function codexDeviceRoute(id: string, method: string | undefined): Route | number {
|
|
153
160
|
return method === 'GET' ? { method: 'GET', run: (req, deps, store) => pollDevice(id, req, deps, store) } : 405;
|
|
154
161
|
}
|
|
155
|
-
|
|
156
|
-
async function refreshOne(conn: Connection, deps: ModelApiDeps, now: number): Promise<void> {
|
|
157
|
-
if (conn.apiKey === '') return;
|
|
158
|
-
const seen = usageOf(conn.id);
|
|
159
|
-
if (seen !== undefined && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
|
|
160
|
-
try {
|
|
161
|
-
const credits = await openrouterCredits(conn.apiKey, deps.openrouterBase, deps.fetchImpl);
|
|
162
|
-
noteUsage(conn.id, openrouterUsage(credits.total, credits.spent, new Date(now)));
|
|
163
|
-
} catch (err) {
|
|
164
|
-
log.warn({ err: errMsg(err) }, 'model-api: could not read the OpenRouter credits');
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const modelsInUse = (conn: Connection): Set<string> => {
|
|
169
|
-
const served = lastServed();
|
|
170
|
-
return new Set([conn.model, served?.connection === conn.id ? served.model : ''].filter((m) => m !== ''));
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
174
|
-
if (conn.gemini === null) return;
|
|
175
|
-
const inUse = modelsInUse(conn);
|
|
176
|
-
const seen = usageOf(conn.id);
|
|
177
|
-
const covers = seen !== undefined && [...inUse].every((id) => seen.windows.some((w) => w.label === id));
|
|
178
|
-
if (seen !== undefined && covers && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
|
|
179
|
-
try {
|
|
180
|
-
const rows = (await geminiModelsOf(conn, deps, store)).filter((m) => inUse.has(m.id));
|
|
181
|
-
const usage = geminiUsage(rows, new Date(now));
|
|
182
|
-
if (usage !== null) noteUsage(conn.id, usage);
|
|
183
|
-
} catch (err) {
|
|
184
|
-
log.warn({ err: errMsg(err) }, 'model-api: could not read the Gemini quota');
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export async function refreshUsage(deps: ModelApiDeps, store: Store, now = Date.now()): Promise<void> {
|
|
189
|
-
for (const conn of store.read().connections) {
|
|
190
|
-
if (conn.provider === 'openrouter') await refreshOne(conn, deps, now);
|
|
191
|
-
if (conn.provider === 'gemini') await refreshQuota(conn, deps, store, now);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
2
|
import { GatewayError } from './forward.js';
|
|
3
|
+
import { listCache } from './model-lists.js';
|
|
3
4
|
import { stringOf } from '@metro-labs/http/api-http';
|
|
5
|
+
import type { KeySpend } from './usage.js';
|
|
4
6
|
|
|
5
7
|
export const OPENROUTER_BASE = 'https://openrouter.ai/api';
|
|
6
8
|
const MODELS_MAX = 2000;
|
|
9
|
+
const KEY_TIMEOUT_MS = 10_000;
|
|
7
10
|
|
|
8
11
|
export interface OpenRouterModel {
|
|
9
12
|
id: string;
|
|
@@ -13,6 +16,8 @@ export interface OpenRouterModel {
|
|
|
13
16
|
created: number | null;
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
const modelLists = listCache<OpenRouterModel>('openrouter');
|
|
20
|
+
const zdrLists = listCache<string>('openrouter-zdr');
|
|
16
21
|
|
|
17
22
|
function price(raw: unknown): number | null {
|
|
18
23
|
const value = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
|
|
@@ -23,8 +28,16 @@ function released(raw: unknown): number | null {
|
|
|
23
28
|
return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : null;
|
|
24
29
|
}
|
|
25
30
|
|
|
31
|
+
const strings = (value: unknown): string[] | null => (Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : null);
|
|
32
|
+
|
|
33
|
+
function chatCapable(entry: Record<string, unknown>): boolean {
|
|
34
|
+
const output = strings(isRecord(entry.architecture) ? entry.architecture.output_modalities : undefined);
|
|
35
|
+
const params = strings(entry.supported_parameters);
|
|
36
|
+
return (output === null || output.includes('text')) && (params === null || params.includes('tools'));
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
function modelOf(entry: unknown): OpenRouterModel | null {
|
|
27
|
-
if (!isRecord(entry)) return null;
|
|
40
|
+
if (!isRecord(entry) || !chatCapable(entry)) return null;
|
|
28
41
|
const id = stringOf(entry.id);
|
|
29
42
|
if (id === '') return null;
|
|
30
43
|
const pricing = isRecord(entry.pricing) ? entry.pricing : {};
|
|
@@ -42,7 +55,7 @@ function newestFirst(a: OpenRouterModel, b: OpenRouterModel): number {
|
|
|
42
55
|
return when !== 0 ? when : a.id.localeCompare(b.id);
|
|
43
56
|
}
|
|
44
57
|
|
|
45
|
-
|
|
58
|
+
async function listModels(base: string, fetchImpl: typeof fetch): Promise<OpenRouterModel[]> {
|
|
46
59
|
const res = await fetchImpl(`${base}/v1/models`, { headers: { accept: 'application/json' }, redirect: 'manual' });
|
|
47
60
|
if (!res.ok) throw new GatewayError(res.status, 'api_error', `OpenRouter would not list its models (${String(res.status)})`);
|
|
48
61
|
const body: unknown = await res.json();
|
|
@@ -55,30 +68,29 @@ export async function openrouterModels(base = OPENROUTER_BASE, fetchImpl: typeof
|
|
|
55
68
|
.slice(0, MODELS_MAX);
|
|
56
69
|
}
|
|
57
70
|
|
|
58
|
-
export
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
export const openrouterModels = (base = OPENROUTER_BASE, fetchImpl: typeof fetch = fetch): Promise<OpenRouterModel[]> =>
|
|
72
|
+
modelLists.get(base, () => listModels(base, fetchImpl));
|
|
73
|
+
|
|
74
|
+
const nullableAmount = (raw: unknown): number | null => (typeof raw === 'number' && Number.isFinite(raw) ? raw : null);
|
|
62
75
|
|
|
63
|
-
export async function
|
|
64
|
-
|
|
65
|
-
base = OPENROUTER_BASE,
|
|
66
|
-
fetchImpl: typeof fetch = fetch,
|
|
67
|
-
): Promise<OpenRouterCredits> {
|
|
68
|
-
const res = await fetchImpl(`${base}/v1/credits`, {
|
|
76
|
+
export async function openrouterKey(apiKey: string, base = OPENROUTER_BASE, fetchImpl: typeof fetch = fetch): Promise<KeySpend> {
|
|
77
|
+
const res = await fetchImpl(`${base}/v1/key`, {
|
|
69
78
|
headers: { accept: 'application/json', authorization: `Bearer ${apiKey}` },
|
|
70
79
|
redirect: 'manual',
|
|
80
|
+
signal: AbortSignal.timeout(KEY_TIMEOUT_MS),
|
|
71
81
|
});
|
|
72
|
-
if (!res.ok)
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
await res.body?.cancel();
|
|
84
|
+
throw new GatewayError(res.status, 'api_error', `OpenRouter would not describe the stored key (${String(res.status)})`);
|
|
85
|
+
}
|
|
73
86
|
const body: unknown = await res.json();
|
|
74
87
|
const data = isRecord(body) && isRecord(body.data) ? body.data : {};
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return { total, spent };
|
|
88
|
+
const spent = nullableAmount(data.usage);
|
|
89
|
+
if (spent === null) throw new GatewayError(502, 'api_error', 'OpenRouter answered with no spend for the key');
|
|
90
|
+
return { limit: nullableAmount(data.limit), remaining: nullableAmount(data.limit_remaining), spent };
|
|
79
91
|
}
|
|
80
92
|
|
|
81
|
-
|
|
93
|
+
async function listZdr(base: string, fetchImpl: typeof fetch): Promise<string[]> {
|
|
82
94
|
const res = await fetchImpl(`${base}/v1/endpoints/zdr`, { headers: { accept: 'application/json' }, redirect: 'manual' });
|
|
83
95
|
if (!res.ok) throw new GatewayError(res.status, 'api_error', `OpenRouter would not list its zero data retention endpoints (${String(res.status)})`);
|
|
84
96
|
const body: unknown = await res.json();
|
|
@@ -88,3 +100,5 @@ export async function openrouterZdrModels(base = OPENROUTER_BASE, fetchImpl: typ
|
|
|
88
100
|
for (const entry of data) if (isRecord(entry) && stringOf(entry.model_id) !== '') ids.add(stringOf(entry.model_id));
|
|
89
101
|
return [...ids].sort();
|
|
90
102
|
}
|
|
103
|
+
|
|
104
|
+
export const openrouterZdrModels = (base = OPENROUTER_BASE, fetchImpl: typeof fetch = fetch): Promise<string[]> => zdrLists.get(base, () => listZdr(base, fetchImpl));
|
|
@@ -1,49 +1,102 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders } from 'node:http';
|
|
1
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
3
|
import { GatewayError } from './forward.js';
|
|
3
4
|
import type { Connection } from './model-config.js';
|
|
5
|
+
import { fingerprint, listCache } from './model-lists.js';
|
|
4
6
|
import { stringOf } from '@metro-labs/http/api-http';
|
|
5
7
|
|
|
6
|
-
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
8
|
+
export const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
7
9
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
8
10
|
const LIST_MAX = 1000;
|
|
11
|
+
const LIST_TIMEOUT_MS = 10_000;
|
|
12
|
+
const OAUTH_BETA = 'oauth-2025-04-20';
|
|
13
|
+
const BEARER = 'Bearer ';
|
|
9
14
|
|
|
10
15
|
export interface ProviderModel {
|
|
11
16
|
id: string;
|
|
12
17
|
name: string;
|
|
13
18
|
}
|
|
14
19
|
|
|
20
|
+
interface Dated extends ProviderModel {
|
|
21
|
+
created: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
const KNOWN_CLAUDE: ProviderModel[] = [
|
|
16
|
-
{ id: 'claude-
|
|
25
|
+
{ id: 'claude-sonnet-5-5', name: 'Claude Sonnet 5.5' },
|
|
17
26
|
{ id: 'claude-opus-5-5', name: 'Claude Opus 5.5' },
|
|
27
|
+
{ id: 'claude-fable-5-1', name: 'Claude Fable 5.1' },
|
|
18
28
|
{ id: 'claude-opus-5', name: 'Claude Opus 5' },
|
|
19
29
|
{ id: 'claude-sonnet-5', name: 'Claude Sonnet 5' },
|
|
20
30
|
{ id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5' },
|
|
21
31
|
];
|
|
22
32
|
|
|
33
|
+
const claudeLists = listCache<ProviderModel>('anthropic');
|
|
34
|
+
const bedrockLists = listCache<ProviderModel>('bedrock');
|
|
23
35
|
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
const newestFirst = (list: Dated[]): ProviderModel[] =>
|
|
37
|
+
list.sort((a, b) => b.created.localeCompare(a.created) || a.id.localeCompare(b.id)).map(({ id, name }) => ({ id, name }));
|
|
38
|
+
|
|
39
|
+
function claudeEntry(entry: unknown): Dated[] {
|
|
40
|
+
if (!isRecord(entry) || stringOf(entry.id) === '') return [];
|
|
41
|
+
const type = stringOf(entry.type);
|
|
42
|
+
if (type !== '' && type !== 'model') return [];
|
|
43
|
+
const id = stringOf(entry.id);
|
|
44
|
+
return [{ id, name: stringOf(entry.display_name) || id, created: stringOf(entry.created_at) }];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function listClaude(auth: Record<string, string>, what: string, base: string, fetchImpl: typeof fetch): Promise<ProviderModel[]> {
|
|
26
48
|
const res = await fetchImpl(`${base}/v1/models?limit=${String(LIST_MAX)}`, {
|
|
27
|
-
headers: {
|
|
49
|
+
headers: { ...auth, 'anthropic-version': ANTHROPIC_VERSION, accept: 'application/json' },
|
|
28
50
|
redirect: 'manual',
|
|
51
|
+
signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
|
|
29
52
|
});
|
|
30
|
-
if (!res.ok) throw new GatewayError(res.status, 'api_error', `Anthropic would not list its models with
|
|
53
|
+
if (!res.ok) throw new GatewayError(res.status, 'api_error', `Anthropic would not list its models with ${what} (${String(res.status)})`);
|
|
31
54
|
const body: unknown = await res.json();
|
|
32
55
|
const data = isRecord(body) && Array.isArray(body.data) ? body.data : null;
|
|
33
56
|
if (data === null) throw new GatewayError(502, 'api_error', 'Anthropic answered with no model list');
|
|
34
|
-
return data
|
|
35
|
-
|
|
36
|
-
|
|
57
|
+
return newestFirst(data.flatMap(claudeEntry));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const loginKey = (base: string): string => `login:${base}`;
|
|
61
|
+
|
|
62
|
+
const first = (value: string | string[] | undefined): string => (Array.isArray(value) ? value[0] : value)?.trim() ?? '';
|
|
63
|
+
|
|
64
|
+
function loginAuth(headers: IncomingHttpHeaders): Record<string, string> | null {
|
|
65
|
+
const bearer = first(headers.authorization);
|
|
66
|
+
if (bearer.startsWith(BEARER) && bearer.length > BEARER.length) return { authorization: bearer, 'anthropic-beta': OAUTH_BETA };
|
|
67
|
+
const key = first(headers['x-api-key']);
|
|
68
|
+
return key === '' ? null : { 'x-api-key': key };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function refreshLoginModels(headers: IncomingHttpHeaders, base = ANTHROPIC_API, fetchImpl: typeof fetch = fetch): Promise<void> {
|
|
72
|
+
const auth = loginAuth(headers);
|
|
73
|
+
if (auth === null) return Promise.resolve();
|
|
74
|
+
return claudeLists.refresh(loginKey(base), () => listClaude(auth, 'the Claude Code login', base, fetchImpl));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function anthropicModels(settings: Connection, base = ANTHROPIC_API, fetchImpl: typeof fetch = fetch): Promise<ProviderModel[]> {
|
|
78
|
+
if (settings.apiKey === '') {
|
|
79
|
+
const live = claudeLists.peek(loginKey(base)) ?? [];
|
|
80
|
+
return live.length > 0 ? live : KNOWN_CLAUDE;
|
|
81
|
+
}
|
|
82
|
+
return claudeLists.get(`key:${base}:${fingerprint(settings.apiKey)}`, () => listClaude({ 'x-api-key': settings.apiKey }, 'the stored key', base, fetchImpl));
|
|
37
83
|
}
|
|
38
84
|
|
|
39
85
|
export const bedrockControlBase = (region: string): string => `https://bedrock.${region}.amazonaws.com`;
|
|
40
86
|
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
const
|
|
87
|
+
function bedrockEntry(entry: unknown): Dated[] {
|
|
88
|
+
if (!isRecord(entry)) return [];
|
|
89
|
+
const id = stringOf(entry.inferenceProfileId);
|
|
90
|
+
const status = stringOf(entry.status);
|
|
91
|
+
if (id === '' || !id.includes('anthropic') || (status !== '' && status !== 'ACTIVE')) return [];
|
|
92
|
+
return [{ id, name: stringOf(entry.inferenceProfileName) || id, created: stringOf(entry.createdAt) }];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function listBedrock(settings: Connection, base: string, fetchImpl: typeof fetch): Promise<ProviderModel[]> {
|
|
96
|
+
const res = await fetchImpl(`${base}/inference-profiles?maxResults=${String(LIST_MAX)}`, {
|
|
45
97
|
headers: { authorization: `Bearer ${settings.apiKey}`, accept: 'application/json' },
|
|
46
98
|
redirect: 'manual',
|
|
99
|
+
signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
|
|
47
100
|
});
|
|
48
101
|
if (!res.ok)
|
|
49
102
|
throw new GatewayError(
|
|
@@ -54,13 +107,12 @@ export async function bedrockModels(settings: Connection, base?: string, fetchIm
|
|
|
54
107
|
const body: unknown = await res.json();
|
|
55
108
|
const list = isRecord(body) && Array.isArray(body.inferenceProfileSummaries) ? body.inferenceProfileSummaries : null;
|
|
56
109
|
if (list === null) throw new GatewayError(502, 'api_error', 'Bedrock answered with no inference profile list');
|
|
57
|
-
return list
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
.sort((a, b) => a.id.localeCompare(b.id));
|
|
110
|
+
return newestFirst(list.flatMap(bedrockEntry));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function bedrockModels(settings: Connection, base?: string, fetchImpl: typeof fetch = fetch): Promise<ProviderModel[]> {
|
|
114
|
+
if (settings.region === '') throw new GatewayError(400, 'invalid_request_error', 'Bedrock needs a region before its models can be listed');
|
|
115
|
+
if (settings.apiKey === '') throw new GatewayError(400, 'invalid_request_error', 'Bedrock needs an API key before its models can be listed');
|
|
116
|
+
const from = base ?? bedrockControlBase(settings.region);
|
|
117
|
+
return bedrockLists.get(`${from}:${fingerprint(settings.apiKey)}`, () => listBedrock(settings, from, fetchImpl));
|
|
66
118
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
|
+
import { readClaudeUsage } from '../claude/usage-probe.js';
|
|
3
|
+
import { codexUsageNow, sharedCodexState } from './codex.js';
|
|
4
|
+
import { openrouterKey } from './openrouter.js';
|
|
5
|
+
import { lastServed } from './served.js';
|
|
6
|
+
import { claudeLoginUsage, geminiUsage, keepProbeAnswer, mayProbe, noteUsage, openrouterUsage, probeAnswer, usageOf, type Reported } from './usage.js';
|
|
7
|
+
import { PASSTHROUGH_ID, routedConnection, type Connection, type ModelConfig } from './model-config.js';
|
|
8
|
+
import { codexDepsFor, geminiModelsOf } from './model-signin.js';
|
|
9
|
+
import { CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
|
|
10
|
+
|
|
11
|
+
const WAIT_MS = 4_000;
|
|
12
|
+
const LOGIN_PROBE = 'claude-login';
|
|
13
|
+
|
|
14
|
+
const fresh = (id: string, now: number): boolean => {
|
|
15
|
+
const seen = usageOf(id);
|
|
16
|
+
return seen !== undefined && now - Date.parse(seen.at) < CREDITS_TTL_MS;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const due = (id: string, now: number): boolean => !fresh(id, now) && mayProbe(id, now, CREDITS_TTL_MS);
|
|
20
|
+
|
|
21
|
+
async function probe(what: string, run: () => Promise<void>): Promise<void> {
|
|
22
|
+
try {
|
|
23
|
+
await run();
|
|
24
|
+
} catch (err) {
|
|
25
|
+
log.warn({ err: errMsg(err) }, `model-api: could not read the ${what}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function refreshKey(conn: Connection, deps: ModelApiDeps, now: number): Promise<void> {
|
|
30
|
+
if (conn.apiKey === '' || !due(conn.id, now)) return;
|
|
31
|
+
await probe('OpenRouter key spend', async () => {
|
|
32
|
+
noteUsage(conn.id, openrouterUsage(await openrouterKey(conn.apiKey, deps.openrouterBase, deps.fetchImpl), new Date(now)));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function refreshCodex(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
37
|
+
if (conn.codex === null || !due(conn.id, now)) return;
|
|
38
|
+
await probe('Codex usage', async () => {
|
|
39
|
+
const usage = await codexUsageNow(conn, codexDepsFor(deps, store, conn.id), sharedCodexState);
|
|
40
|
+
if (usage !== null) noteUsage(conn.id, usage);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const modelsInUse = (conn: Connection): Set<string> => {
|
|
45
|
+
const served = lastServed();
|
|
46
|
+
return new Set([conn.model, served?.connection === conn.id ? served.model : ''].filter((m) => m !== ''));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
async function refreshQuota(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
50
|
+
if (conn.gemini === null) return;
|
|
51
|
+
const inUse = modelsInUse(conn);
|
|
52
|
+
const seen = usageOf(conn.id);
|
|
53
|
+
const covers = seen !== undefined && [...inUse].every((id) => seen.windows.some((w) => w.label === id));
|
|
54
|
+
if (seen !== undefined && covers && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
|
|
55
|
+
await probe('Gemini quota', async () => {
|
|
56
|
+
const rows = (await geminiModelsOf(conn, deps, store)).filter((m) => inUse.has(m.id));
|
|
57
|
+
const usage = geminiUsage(rows, new Date(now));
|
|
58
|
+
if (usage !== null) noteUsage(conn.id, usage);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const loginIds = (cfg: ModelConfig): string[] => [
|
|
63
|
+
...(routedConnection(cfg) === null ? [PASSTHROUGH_ID] : []),
|
|
64
|
+
...cfg.connections.filter((c) => c.provider === 'anthropic' && c.apiKey === '').map((c) => c.id),
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const olderThan = (seen: Reported | undefined, usage: Reported): boolean => seen === undefined || Date.parse(seen.at) < Date.parse(usage.at);
|
|
68
|
+
|
|
69
|
+
async function refreshLogin(cfg: ModelConfig, deps: ModelApiDeps, now: number): Promise<void> {
|
|
70
|
+
const stale = loginIds(cfg).filter((id) => !fresh(id, now));
|
|
71
|
+
if (stale.length === 0) return;
|
|
72
|
+
if (mayProbe(LOGIN_PROBE, now, CREDITS_TTL_MS))
|
|
73
|
+
await probe('usage of the Claude Code login', async () => {
|
|
74
|
+
const usage = claudeLoginUsage(await (deps.claudeUsage ?? readClaudeUsage)(), new Date(now));
|
|
75
|
+
if (usage !== null) keepProbeAnswer(LOGIN_PROBE, usage);
|
|
76
|
+
});
|
|
77
|
+
const usage = probeAnswer(LOGIN_PROBE);
|
|
78
|
+
if (usage === undefined || now - Date.parse(usage.at) >= CREDITS_TTL_MS) return;
|
|
79
|
+
for (const id of stale) if (olderThan(usageOf(id), usage)) noteUsage(id, usage);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function refreshOne(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
83
|
+
if (conn.provider === 'openrouter') return refreshKey(conn, deps, now);
|
|
84
|
+
if (conn.provider === 'codex') return refreshCodex(conn, deps, store, now);
|
|
85
|
+
if (conn.provider === 'gemini') return refreshQuota(conn, deps, store, now);
|
|
86
|
+
return Promise.resolve();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function within(ms: number, work: Promise<unknown>): Promise<void> {
|
|
90
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
91
|
+
const waited = new Promise<void>((resolve) => {
|
|
92
|
+
timer = setTimeout(resolve, ms);
|
|
93
|
+
});
|
|
94
|
+
await Promise.race([work, waited]);
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function refreshUsage(deps: ModelApiDeps, store: Store, now = Date.now()): Promise<void> {
|
|
99
|
+
const cfg = store.read();
|
|
100
|
+
const work = Promise.all([...cfg.connections.map((conn) => refreshOne(conn, deps, store, now)), refreshLogin(cfg, deps, now)]);
|
|
101
|
+
await within(deps.usageWaitMs ?? WAIT_MS, work);
|
|
102
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
import { stringOf } from '@metro-labs/http/api-http';
|
|
3
|
+
|
|
1
4
|
type Key = string;
|
|
2
5
|
|
|
3
6
|
interface UsageWindow {
|
|
@@ -22,10 +25,12 @@ export interface ProviderUsage {
|
|
|
22
25
|
tally: Tally | null;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
type Reported = Omit<ProviderUsage, 'tally'>;
|
|
28
|
+
export type Reported = Omit<ProviderUsage, 'tally'>;
|
|
26
29
|
|
|
27
30
|
const latest = new Map<Key, Reported>();
|
|
28
31
|
const tallies = new Map<Key, Tally>();
|
|
32
|
+
const probed = new Map<Key, number>();
|
|
33
|
+
const answers = new Map<Key, Reported>();
|
|
29
34
|
|
|
30
35
|
export const noteUsage = (key: Key, usage: Reported): void => {
|
|
31
36
|
latest.set(key, usage);
|
|
@@ -46,11 +51,31 @@ export const usageOf = (key: Key): Reported | undefined => latest.get(key);
|
|
|
46
51
|
export const forgetOne = (key: Key): void => {
|
|
47
52
|
latest.delete(key);
|
|
48
53
|
tallies.delete(key);
|
|
54
|
+
probed.delete(key);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const forgetReported = (key: Key): void => {
|
|
58
|
+
latest.delete(key);
|
|
59
|
+
probed.delete(key);
|
|
49
60
|
};
|
|
50
61
|
|
|
51
62
|
export const forgetUsage = (): void => {
|
|
52
63
|
latest.clear();
|
|
53
64
|
tallies.clear();
|
|
65
|
+
probed.clear();
|
|
66
|
+
answers.clear();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function mayProbe(key: Key, now: number, every: number): boolean {
|
|
70
|
+
if (now - (probed.get(key) ?? Number.NEGATIVE_INFINITY) < every) return false;
|
|
71
|
+
probed.set(key, now);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const probeAnswer = (key: Key): Reported | undefined => answers.get(key);
|
|
76
|
+
|
|
77
|
+
export const keepProbeAnswer = (key: Key, usage: Reported): void => {
|
|
78
|
+
answers.set(key, usage);
|
|
54
79
|
};
|
|
55
80
|
|
|
56
81
|
export interface Counted {
|
|
@@ -198,13 +223,16 @@ function codexWindow(headers: Headers, kind: 'primary' | 'secondary', now: Date)
|
|
|
198
223
|
return { label: windowLabel(Number.isFinite(minutes) ? minutes : null), used, resetAt, detail: null };
|
|
199
224
|
}
|
|
200
225
|
|
|
201
|
-
function
|
|
202
|
-
if (
|
|
203
|
-
const balance = Number(
|
|
226
|
+
function creditsLeft(hasCredits: boolean, raw: string | null): UsageWindow | null {
|
|
227
|
+
if (!hasCredits) return null;
|
|
228
|
+
const balance = Number(raw ?? 'none');
|
|
204
229
|
if (!Number.isFinite(balance)) return null;
|
|
205
230
|
return { label: 'Credits', used: null, resetAt: null, detail: `${balance.toLocaleString('en-US')} left` };
|
|
206
231
|
}
|
|
207
232
|
|
|
233
|
+
const codexCredits = (headers: Headers): UsageWindow | null =>
|
|
234
|
+
creditsLeft(headers.get('x-codex-credits-has-credits') === 'true', headers.get('x-codex-credits-balance'));
|
|
235
|
+
|
|
208
236
|
export function codexUsage(headers: Headers, now = new Date()): Reported | null {
|
|
209
237
|
const windows = [codexWindow(headers, 'primary', now), codexWindow(headers, 'secondary', now), codexCredits(headers)].filter(
|
|
210
238
|
(w): w is UsageWindow => w !== null,
|
|
@@ -214,6 +242,77 @@ export function codexUsage(headers: Headers, now = new Date()): Reported | null
|
|
|
214
242
|
return { windows, note: reached === null || reached === '' ? null : reached.replaceAll('_', ' '), at: now.toISOString() };
|
|
215
243
|
}
|
|
216
244
|
|
|
245
|
+
const present = (w: UsageWindow | null): w is UsageWindow => w !== null;
|
|
246
|
+
|
|
247
|
+
const textOf = (raw: unknown): string | null => (typeof raw === 'number' || typeof raw === 'string' ? String(raw) : null);
|
|
248
|
+
|
|
249
|
+
const numberOf = (raw: unknown): number | null => (typeof raw === 'number' && Number.isFinite(raw) ? raw : null);
|
|
250
|
+
|
|
251
|
+
const SECONDS_PER_MINUTE = 60;
|
|
252
|
+
|
|
253
|
+
function codexBodyWindow(raw: unknown, now: Date): UsageWindow | null {
|
|
254
|
+
if (!isRecord(raw)) return null;
|
|
255
|
+
const percent = numberOf(raw.used_percent);
|
|
256
|
+
if (percent === null) return null;
|
|
257
|
+
const seconds = numberOf(raw.limit_window_seconds);
|
|
258
|
+
const minutes = seconds === null || seconds <= 0 ? null : Math.ceil(seconds / SECONDS_PER_MINUTE);
|
|
259
|
+
const resetAt = whenFrom(textOf(raw.reset_at), now) ?? whenFrom(textOf(raw.reset_after_seconds), now, true);
|
|
260
|
+
return { label: windowLabel(minutes), used: clamp(percent / 100), resetAt, detail: null };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function codexWall(limit: Record<string, unknown>, reached: unknown): string | null {
|
|
264
|
+
if (limit.limit_reached !== true && limit.allowed !== false) return null;
|
|
265
|
+
const kind = isRecord(reached) ? stringOf(reached.type) : '';
|
|
266
|
+
return kind === '' ? 'limit reached' : kind.replaceAll('_', ' ');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function codexUsageBody(body: unknown, now = new Date()): Reported | null {
|
|
270
|
+
if (!isRecord(body)) return null;
|
|
271
|
+
const limit = isRecord(body.rate_limit) ? body.rate_limit : {};
|
|
272
|
+
const credits = isRecord(body.credits) ? body.credits : {};
|
|
273
|
+
const windows = [
|
|
274
|
+
codexBodyWindow(limit.primary_window, now),
|
|
275
|
+
codexBodyWindow(limit.secondary_window, now),
|
|
276
|
+
creditsLeft(credits.has_credits === true, textOf(credits.balance)),
|
|
277
|
+
].filter(present);
|
|
278
|
+
return windows.length === 0 ? null : { windows, note: codexWall(limit, body.rate_limit_reached_type), at: now.toISOString() };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const CLAUDE_WINDOWS: [string, string][] = [
|
|
282
|
+
['five_hour', '5-hour window'],
|
|
283
|
+
['seven_day', 'Weekly'],
|
|
284
|
+
['seven_day_sonnet', 'Weekly, Sonnet'],
|
|
285
|
+
['seven_day_opus', 'Weekly, Opus'],
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
function percentWindow(label: string, raw: unknown, now: Date): UsageWindow | null {
|
|
289
|
+
if (!isRecord(raw)) return null;
|
|
290
|
+
const percent = numberOf(raw.utilization) ?? numberOf(raw.percent);
|
|
291
|
+
if (percent === null) return null;
|
|
292
|
+
return { label, used: clamp(percent / 100), resetAt: whenFrom(textOf(raw.resets_at), now), detail: null };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function scopedName(entry: Record<string, unknown>): string {
|
|
296
|
+
if (stringOf(entry.display_name) !== '') return stringOf(entry.display_name);
|
|
297
|
+
if (entry.kind !== 'weekly_scoped' || !isRecord(entry.scope) || !isRecord(entry.scope.model)) return '';
|
|
298
|
+
return stringOf(entry.scope.model.display_name);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function scopedWindows(limits: Record<string, unknown>, now: Date): (UsageWindow | null)[] {
|
|
302
|
+
const listed = [limits.model_scoped, limits.limits].flatMap((list): unknown[] => (Array.isArray(list) ? (list as unknown[]) : []));
|
|
303
|
+
return listed.map((entry) => {
|
|
304
|
+
const name = isRecord(entry) ? scopedName(entry) : '';
|
|
305
|
+
return name === '' ? null : percentWindow(`Weekly, ${name}`, entry, now);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function claudeLoginUsage(limits: unknown, now = new Date()): Reported | null {
|
|
310
|
+
if (!isRecord(limits)) return null;
|
|
311
|
+
const all = [...CLAUDE_WINDOWS.map(([key, label]) => percentWindow(label, limits[key], now)), ...scopedWindows(limits, now)].filter(present);
|
|
312
|
+
const windows = all.filter((w, index) => all.findIndex((other) => other.label === w.label) === index);
|
|
313
|
+
return windows.length === 0 ? null : { windows, note: null, at: now.toISOString() };
|
|
314
|
+
}
|
|
315
|
+
|
|
217
316
|
export function noteUsageHeaders(kind: 'anthropic' | 'codex', key: Key, headers: Headers, now = new Date()): void {
|
|
218
317
|
const usage = kind === 'anthropic' ? anthropicUsage(headers, now) : codexUsage(headers, now);
|
|
219
318
|
if (usage !== null) noteUsage(key, usage);
|
|
@@ -234,11 +333,16 @@ export function geminiUsage(rows: QuotaRow[], now = new Date()): Reported | null
|
|
|
234
333
|
return windows.length === 0 ? null : { windows, note: null, at: now.toISOString() };
|
|
235
334
|
}
|
|
236
335
|
|
|
237
|
-
export
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
336
|
+
export interface KeySpend {
|
|
337
|
+
limit: number | null;
|
|
338
|
+
remaining: number | null;
|
|
339
|
+
spent: number;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function keyWindow(key: KeySpend): UsageWindow {
|
|
343
|
+
if (key.limit === null || key.limit <= 0) return { label: 'Credits', used: null, resetAt: null, detail: `${dollars(key.spent)} used, no limit on this key` };
|
|
344
|
+
const spent = key.remaining === null ? key.spent : Math.max(0, key.limit - key.remaining);
|
|
345
|
+
return { label: 'Credits', used: clamp(spent / key.limit), resetAt: null, detail: `${dollars(spent)} of ${dollars(key.limit)} used` };
|
|
244
346
|
}
|
|
347
|
+
|
|
348
|
+
export const openrouterUsage = (key: KeySpend, now = new Date()): Reported => ({ windows: [keyWindow(key)], note: null, at: now.toISOString() });
|
package/runtime/runtime.json
CHANGED