@stage-labs/metro 0.1.0-beta.253 → 0.1.0-beta.255
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/gateway/codex-auth.ts +128 -156
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-stream.ts +8 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +33 -65
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/effort.ts +14 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +4 -13
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +10 -11
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +9 -41
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +1 -11
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +0 -30
- package/runtime/node_modules/@metro-labs/http/src/return-to.ts +4 -0
- package/runtime/runtime.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +0 -91
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-version.ts +0 -44
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.255",
|
|
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",
|
|
@@ -1,66 +1,106 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { homedir } from 'node:os';
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
-
import {
|
|
4
|
+
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
5
|
+
import { ticketStore } from '@metro-labs/core/tickets';
|
|
6
|
+
import { agentsDir } from '../agents/files.js';
|
|
7
|
+
import { newPkce } from './pkce.js';
|
|
6
8
|
import { nonEmpty } from './text.js';
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
export const
|
|
10
|
-
export const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
10
|
+
const CODEX_ISSUER = 'https://auth.openai.com';
|
|
11
|
+
export const CODEX_REDIRECT = 'http://127.0.0.1:1455/auth/callback';
|
|
12
|
+
export const OPENAI_API = 'https://api.openai.com/v1';
|
|
13
|
+
export const NEW_CLIENT = 'dynamic_agent_client';
|
|
14
|
+
export const APP_NAME = 'Metro';
|
|
15
|
+
export const PLAN_SCOPE = 'chatgpt.tokens.use.direct';
|
|
16
|
+
export const CODEX_SIGN_IN = "Codex needs a ChatGPT sign-in: Metro now uses OpenAI's official Sign in with ChatGPT, so sign in again on the Model page.";
|
|
17
|
+
const SCOPE = `openid profile email offline_access resource.invoke ${PLAN_SCOPE}`;
|
|
13
18
|
const PROFILE_CLAIM = 'https://api.openai.com/profile';
|
|
14
|
-
const
|
|
19
|
+
const HOST_FILE = 'chatgpt-host.json';
|
|
20
|
+
const HOST_RE = /^urn:uuid:[0-9a-f-]{36}$/;
|
|
21
|
+
const PENDING_TTL_MS = 10 * 60_000;
|
|
22
|
+
const DEFAULT_TTL_MS = 60 * 60_000;
|
|
23
|
+
const RENEW_BEFORE_MS = 2 * 60_000;
|
|
24
|
+
const AGAIN = 'press Continue with ChatGPT again';
|
|
15
25
|
|
|
16
26
|
export interface CodexTokens {
|
|
27
|
+
clientId: string;
|
|
28
|
+
subject: string;
|
|
29
|
+
email: string | null;
|
|
17
30
|
accessToken: string;
|
|
18
31
|
refreshToken: string;
|
|
19
|
-
|
|
20
|
-
accountId: string;
|
|
21
|
-
email: string | null;
|
|
22
|
-
plan: string | null;
|
|
32
|
+
expiresAt: number;
|
|
23
33
|
savedAt: string;
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
export class CodexAuthError extends Error {}
|
|
27
37
|
|
|
28
|
-
|
|
38
|
+
interface Pending {
|
|
39
|
+
verifier: string;
|
|
40
|
+
nonce: string;
|
|
41
|
+
clientId: string | null;
|
|
42
|
+
subject: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const pending = ticketStore<Pending>(PENDING_TTL_MS, 24);
|
|
46
|
+
|
|
47
|
+
export function chatgptHostId(dir = agentsDir()): string {
|
|
48
|
+
const path = join(dir, HOST_FILE);
|
|
49
|
+
const saved = readJson<unknown>(path, null);
|
|
50
|
+
if (isRecord(saved) && typeof saved.hostId === 'string' && HOST_RE.test(saved.hostId)) return saved.hostId;
|
|
51
|
+
const made = `urn:uuid:${randomUUID()}`;
|
|
52
|
+
writeSecure(path, JSON.stringify({ hostId: made }));
|
|
53
|
+
return made;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function returning(previous: CodexTokens | null): Record<string, string> {
|
|
57
|
+
if (previous === null) return { client_id: NEW_CLIENT, agent_name_hint: APP_NAME };
|
|
58
|
+
return { client_id: previous.clientId, ...(previous.email === null ? {} : { login_hint: previous.email }) };
|
|
59
|
+
}
|
|
29
60
|
|
|
30
|
-
export function
|
|
61
|
+
export function beginLogin(previous: CodexTokens | null, hostId: string, issuer = CODEX_ISSUER, now = Date.now()): string {
|
|
62
|
+
const { verifier, challenge } = newPkce();
|
|
63
|
+
const nonce = randomBytes(32).toString('base64url');
|
|
64
|
+
const { ticket: state } = pending.mint({ verifier, nonce, clientId: previous?.clientId ?? null, subject: previous?.subject ?? null }, now);
|
|
31
65
|
const query = new URLSearchParams({
|
|
66
|
+
...returning(previous),
|
|
67
|
+
ext_agent_host_id: hostId,
|
|
32
68
|
response_type: 'code',
|
|
33
|
-
client_id: CODEX_CLIENT_ID,
|
|
34
69
|
redirect_uri: CODEX_REDIRECT,
|
|
35
70
|
scope: SCOPE,
|
|
36
|
-
|
|
37
|
-
code_challenge_method: 'S256',
|
|
38
|
-
id_token_add_organizations: 'true',
|
|
39
|
-
codex_cli_simplified_flow: 'true',
|
|
71
|
+
resource: OPENAI_API,
|
|
40
72
|
state,
|
|
41
|
-
|
|
73
|
+
nonce,
|
|
74
|
+
code_challenge_method: 'S256',
|
|
75
|
+
code_challenge: challenge,
|
|
42
76
|
});
|
|
43
|
-
return `${issuer}/
|
|
77
|
+
return `${issuer}/api/accounts/authorize?${query.toString()}`;
|
|
44
78
|
}
|
|
45
79
|
|
|
46
|
-
|
|
47
|
-
const { state, challenge } = pending.begin(now);
|
|
48
|
-
return { url: authorizeUrl(state, challenge, issuer), state };
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function parseCallback(raw: string): { code: string; state: string } {
|
|
80
|
+
function queryOf(raw: string): URLSearchParams {
|
|
52
81
|
const text = raw.trim();
|
|
53
|
-
let query: URLSearchParams;
|
|
54
82
|
try {
|
|
55
|
-
|
|
83
|
+
return new URL(text).searchParams;
|
|
56
84
|
} catch {
|
|
57
|
-
|
|
85
|
+
return new URLSearchParams(text.replace(/^\?/, ''));
|
|
58
86
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const param = (query: URLSearchParams, name: string): string => query.get(name)?.trim() ?? '';
|
|
90
|
+
|
|
91
|
+
function assertAllowed(query: URLSearchParams): void {
|
|
92
|
+
const error = param(query, 'error');
|
|
93
|
+
if (error === 'access_denied') throw new CodexAuthError('you did not allow Metro to use your ChatGPT plan, so nothing was saved');
|
|
94
|
+
if (error !== '') throw new CodexAuthError(`ChatGPT stopped the sign-in (${query.get('error_description') ?? error}); ${AGAIN}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function parseCallback(raw: string): { code: string; state: string; clientId: string | null } {
|
|
98
|
+
const query = queryOf(raw);
|
|
99
|
+
assertAllowed(query);
|
|
100
|
+
const code = param(query, 'code');
|
|
101
|
+
const state = param(query, 'state');
|
|
102
|
+
if (code === '' || state === '') throw new CodexAuthError('paste the whole address the browser landed on; it carries code= and state=');
|
|
103
|
+
return { code, state, clientId: nonEmpty(param(query, 'client_id')) };
|
|
64
104
|
}
|
|
65
105
|
|
|
66
106
|
function decodeJwtPayload(jwt: string): Record<string, unknown> {
|
|
@@ -73,142 +113,74 @@ function decodeJwtPayload(jwt: string): Record<string, unknown> {
|
|
|
73
113
|
}
|
|
74
114
|
}
|
|
75
115
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
plan: nonEmpty(auth.chatgpt_plan_type),
|
|
85
|
-
};
|
|
116
|
+
function identityOf(idToken: unknown, expected: { issuer: string; clientId: string; nonce: string }): { subject: string; email: string | null } {
|
|
117
|
+
const claims = decodeJwtPayload(typeof idToken === 'string' ? idToken : '');
|
|
118
|
+
const audience: unknown[] = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
119
|
+
const subject = nonEmpty(claims.sub);
|
|
120
|
+
if (subject === null || claims.iss !== expected.issuer || !audience.includes(expected.clientId) || claims.nonce !== expected.nonce)
|
|
121
|
+
throw new CodexAuthError(`OpenAI sent back an identity that does not match this sign-in; ${AGAIN}`);
|
|
122
|
+
const profile = isRecord(claims[PROFILE_CLAIM]) ? claims[PROFILE_CLAIM] : {};
|
|
123
|
+
return { subject, email: nonEmpty(claims.email) ?? nonEmpty(profile.email) };
|
|
86
124
|
}
|
|
87
125
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const EMPTY_TOKENS: CodexTokens = { accessToken: '', refreshToken: '', idToken: '', accountId: '', email: null, plan: null, savedAt: '' };
|
|
95
|
-
|
|
96
|
-
function assertUsable(next: CodexTokens): CodexTokens {
|
|
97
|
-
if (next.accessToken === '' || next.refreshToken === '') throw new CodexAuthError('the token endpoint returned no usable tokens');
|
|
98
|
-
if (next.accountId === '') throw new CodexAuthError('the id token carries no ChatGPT account id; sign in with a ChatGPT account that has Codex');
|
|
99
|
-
return next;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const pick = (fresh: string | null, kept: string | null): string | null => fresh ?? kept;
|
|
103
|
-
|
|
104
|
-
export function tokensFrom(body: unknown, previous: CodexTokens | null, now = new Date()): CodexTokens {
|
|
105
|
-
const res = (isRecord(body) ? body : {}) as TokenResponse;
|
|
106
|
-
const kept = previous ?? EMPTY_TOKENS;
|
|
107
|
-
const idToken = pick(nonEmpty(res.id_token), kept.idToken) ?? '';
|
|
108
|
-
const claims = claimsOf(idToken);
|
|
109
|
-
return assertUsable({
|
|
110
|
-
accessToken: nonEmpty(res.access_token) ?? '',
|
|
111
|
-
refreshToken: pick(nonEmpty(res.refresh_token), kept.refreshToken) ?? '',
|
|
112
|
-
idToken,
|
|
113
|
-
accountId: pick(claims.accountId, kept.accountId) ?? '',
|
|
114
|
-
email: pick(claims.email, kept.email),
|
|
115
|
-
plan: pick(claims.plan, kept.plan),
|
|
116
|
-
savedAt: now.toISOString(),
|
|
126
|
+
async function tokenCall(issuer: string, form: Record<string, string>, fetchImpl: typeof fetch): Promise<Record<string, unknown>> {
|
|
127
|
+
const res = await fetchImpl(`${issuer}/api/accounts/oauth/token`, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
130
|
+
body: new URLSearchParams(form).toString(),
|
|
117
131
|
});
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function tokenCall(issuer: string, init: RequestInit, fetchImpl: typeof fetch): Promise<unknown> {
|
|
121
|
-
const res = await fetchImpl(`${issuer}/oauth/token`, init);
|
|
122
132
|
const raw = await res.text();
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
} catch {
|
|
129
|
-
detail = raw.slice(0, 300);
|
|
130
|
-
}
|
|
131
|
-
throw new CodexAuthError(`OpenAI refused the token request (${String(res.status)}): ${detail}`);
|
|
133
|
+
let parsed: unknown = null;
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(raw);
|
|
136
|
+
} catch {
|
|
137
|
+
parsed = null;
|
|
132
138
|
}
|
|
133
|
-
|
|
139
|
+
const body = isRecord(parsed) ? parsed : {};
|
|
140
|
+
if (!res.ok) throw new CodexAuthError(`OpenAI refused the token request (${String(res.status)}): ${nonEmpty(body.error_description) ?? nonEmpty(body.error) ?? raw.slice(0, 300)}`);
|
|
141
|
+
return body;
|
|
134
142
|
}
|
|
135
143
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const { code, state } = parseCallback(callback);
|
|
143
|
-
const verifier = pending.take(state, now);
|
|
144
|
-
if (verifier === null) throw new CodexAuthError('this sign-in link has expired or was started elsewhere; press Connect again');
|
|
145
|
-
return exchangeCode(issuer, code, verifier, CODEX_REDIRECT, fetchImpl, now);
|
|
144
|
+
function tokensFrom(body: Record<string, unknown>, who: Pick<CodexTokens, 'clientId' | 'subject' | 'email'>, keptRefresh: string, now: number): CodexTokens {
|
|
145
|
+
const accessToken = nonEmpty(body.access_token) ?? '';
|
|
146
|
+
const refreshToken = nonEmpty(body.refresh_token) ?? keptRefresh;
|
|
147
|
+
if (accessToken === '' || refreshToken === '') throw new CodexAuthError(`OpenAI returned no usable tokens; ${AGAIN}`);
|
|
148
|
+
const ttl = typeof body.expires_in === 'number' && body.expires_in > 0 ? body.expires_in * 1000 : DEFAULT_TTL_MS;
|
|
149
|
+
return { clientId: who.clientId, subject: who.subject, email: who.email, accessToken, refreshToken, expiresAt: now + ttl, savedAt: new Date(now).toISOString() };
|
|
146
150
|
}
|
|
147
151
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
): Promise<CodexTokens> {
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
code_verifier: verifier,
|
|
162
|
-
});
|
|
163
|
-
const res = await tokenCall(
|
|
152
|
+
function clientOf(started: Pending, returned: string | null): string {
|
|
153
|
+
const clientId = started.clientId ?? returned;
|
|
154
|
+
if (clientId === null || clientId === NEW_CLIENT) throw new CodexAuthError(`ChatGPT did not finish registering Metro; ${AGAIN}`);
|
|
155
|
+
if (returned !== null && returned !== clientId) throw new CodexAuthError(`ChatGPT answered for another Metro registration; ${AGAIN}`);
|
|
156
|
+
return clientId;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function finishLogin(callback: string, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<CodexTokens> {
|
|
160
|
+
const { code, state, clientId: returned } = parseCallback(callback);
|
|
161
|
+
const started = pending.take(state, now);
|
|
162
|
+
if (started === undefined) throw new CodexAuthError(`this sign-in link has expired or was started elsewhere; ${AGAIN}`);
|
|
163
|
+
const clientId = clientOf(started, returned);
|
|
164
|
+
const body = await tokenCall(
|
|
164
165
|
issuer,
|
|
165
|
-
{
|
|
166
|
+
{ grant_type: 'authorization_code', client_id: clientId, code, code_verifier: started.verifier, redirect_uri: CODEX_REDIRECT, resource: OPENAI_API },
|
|
166
167
|
fetchImpl,
|
|
167
168
|
);
|
|
168
|
-
|
|
169
|
+
const who = identityOf(body.id_token, { issuer, clientId, nonce: started.nonce });
|
|
170
|
+
if (started.subject !== null && who.subject !== started.subject)
|
|
171
|
+
throw new CodexAuthError('this is another ChatGPT account than the one this connection signed in with; add a new connection for it');
|
|
172
|
+
const scopes = (nonEmpty(body.scope) ?? '').split(/\s+/);
|
|
173
|
+
if (!scopes.includes(PLAN_SCOPE)) throw new CodexAuthError('ChatGPT did not allow Metro to use your plan: sign in again and allow it (it needs ChatGPT Plus or Pro)');
|
|
174
|
+
return tokensFrom(body, { clientId, ...who }, '', now);
|
|
169
175
|
}
|
|
170
176
|
|
|
171
|
-
export async function refreshTokens(previous: CodexTokens, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch): Promise<CodexTokens> {
|
|
172
|
-
const
|
|
177
|
+
export async function refreshTokens(previous: CodexTokens, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<CodexTokens> {
|
|
178
|
+
const body = await tokenCall(
|
|
173
179
|
issuer,
|
|
174
|
-
{
|
|
175
|
-
method: 'POST',
|
|
176
|
-
headers: { 'content-type': 'application/json' },
|
|
177
|
-
body: JSON.stringify({ client_id: CODEX_CLIENT_ID, grant_type: 'refresh_token', refresh_token: previous.refreshToken }),
|
|
178
|
-
},
|
|
180
|
+
{ grant_type: 'refresh_token', client_id: previous.clientId, refresh_token: previous.refreshToken, resource: OPENAI_API },
|
|
179
181
|
fetchImpl,
|
|
180
182
|
);
|
|
181
|
-
return tokensFrom(
|
|
183
|
+
return tokensFrom(body, previous, previous.refreshToken, now);
|
|
182
184
|
}
|
|
183
185
|
|
|
184
|
-
export const tokensStale = (tokens: CodexTokens, now = Date.now()): boolean =>
|
|
185
|
-
now - Date.parse(tokens.savedAt) > ACCESS_TOKEN_TTL_MS;
|
|
186
|
-
|
|
187
|
-
function readAuthFile(home: string): Record<string, unknown> {
|
|
188
|
-
let raw: unknown;
|
|
189
|
-
try {
|
|
190
|
-
raw = JSON.parse(readFileSync(join(home, '.codex', 'auth.json'), 'utf8'));
|
|
191
|
-
} catch {
|
|
192
|
-
throw new CodexAuthError('no Codex CLI login on this machine (~/.codex/auth.json); run codex login there first, or connect here');
|
|
193
|
-
}
|
|
194
|
-
return isRecord(raw) ? raw : {};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export function readCodexCliAuth(home = homedir()): CodexTokens {
|
|
198
|
-
const raw = readAuthFile(home);
|
|
199
|
-
if (!isRecord(raw.tokens)) throw new CodexAuthError('~/.codex/auth.json holds no ChatGPT tokens (an API-key login cannot be reused here)');
|
|
200
|
-
const stored = raw.tokens;
|
|
201
|
-
const idToken = nonEmpty(stored.id_token) ?? '';
|
|
202
|
-
const claims = claimsOf(idToken);
|
|
203
|
-
const found: CodexTokens = {
|
|
204
|
-
accessToken: nonEmpty(stored.access_token) ?? '',
|
|
205
|
-
refreshToken: nonEmpty(stored.refresh_token) ?? '',
|
|
206
|
-
idToken,
|
|
207
|
-
accountId: nonEmpty(stored.account_id) ?? claims.accountId ?? '',
|
|
208
|
-
email: claims.email,
|
|
209
|
-
plan: claims.plan,
|
|
210
|
-
savedAt: nonEmpty(raw.last_refresh) ?? new Date(0).toISOString(),
|
|
211
|
-
};
|
|
212
|
-
if ([found.accessToken, found.refreshToken, found.accountId].includes('')) throw new CodexAuthError('~/.codex/auth.json is missing a token or the account id');
|
|
213
|
-
return found;
|
|
214
|
-
}
|
|
186
|
+
export const tokensStale = (tokens: CodexTokens, now = Date.now()): boolean => now >= tokens.expiresAt - RENEW_BEFORE_MS;
|
|
@@ -15,8 +15,13 @@ interface Block {
|
|
|
15
15
|
const responseOf = (data: Item): Item => (isRecord(data.response) ? data.response : {});
|
|
16
16
|
const itemOf = (data: Item): Item => (isRecord(data.item) ? data.item : {});
|
|
17
17
|
|
|
18
|
+
const PLAN_LIMIT = 'subscription_sharing_usage_limit_exceeded';
|
|
19
|
+
const USAGE_URL = 'https://chatgpt.com/settings/usage';
|
|
20
|
+
|
|
21
|
+
export const withUsageLink = (code: string, message: string): string => (code === PLAN_LIMIT ? `${message} Manage usage: ${USAGE_URL}` : message);
|
|
22
|
+
|
|
18
23
|
function errorKind(code: string): string {
|
|
19
|
-
if (code === 'rate_limit_exceeded' || code === 'usage_limit_reached') return 'rate_limit_error';
|
|
24
|
+
if (code === 'rate_limit_exceeded' || code === 'usage_limit_reached' || code === PLAN_LIMIT) return 'rate_limit_error';
|
|
20
25
|
if (code === 'context_length_exceeded' || code === 'invalid_prompt') return 'invalid_request_error';
|
|
21
26
|
if (code === 'insufficient_quota') return 'permission_error';
|
|
22
27
|
return 'api_error';
|
|
@@ -157,7 +162,8 @@ export class CodexEventTranslator {
|
|
|
157
162
|
private failed(response: Item, fallback: string): string {
|
|
158
163
|
this.done = true;
|
|
159
164
|
const error = isRecord(response.error) ? response.error : {};
|
|
160
|
-
|
|
165
|
+
const code = stringOf(error.code);
|
|
166
|
+
return errorFrame(errorKind(code), withUsageLink(code, stringOf(error.message) || fallback));
|
|
161
167
|
}
|
|
162
168
|
|
|
163
169
|
push(event: string, data: Item): string {
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { refreshTokens, tokensStale, type CodexTokens } from './codex-auth.js';
|
|
6
|
-
import { CodexEventTranslator } from './codex-stream.js';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { stringOf } from '@metro-labs/http/api-http';
|
|
5
|
+
import { OPENAI_API, CODEX_SIGN_IN, refreshTokens, tokensStale, type CodexTokens } from './codex-auth.js';
|
|
6
|
+
import { CodexEventTranslator, withUsageLink } from './codex-stream.js';
|
|
7
7
|
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
11
|
import { listCache } from './model-lists.js';
|
|
12
12
|
import { answerWhole, currentOf, errorKind, reach, refreshed, relayTranslated, sessionHeader, type TokenSource, type TokenState } from './subscription.js';
|
|
13
|
-
import {
|
|
13
|
+
import { noteUsageHeaders } from './usage.js';
|
|
14
14
|
|
|
15
|
-
const CODEX_BASE = 'https://chatgpt.com/backend-api/codex';
|
|
16
15
|
const INVALID = [400, 404, 422];
|
|
17
|
-
const USAGE_TIMEOUT_MS = 10_000;
|
|
18
16
|
|
|
19
17
|
export interface CodexDeps {
|
|
20
18
|
base?: string;
|
|
@@ -36,25 +34,14 @@ const sourceOf = (deps: CodexDeps): TokenSource<CodexTokens> => ({
|
|
|
36
34
|
save: deps.save,
|
|
37
35
|
});
|
|
38
36
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
authorization: `Bearer ${tokens.accessToken}`,
|
|
46
|
-
'chatgpt-account-id': tokens.accountId,
|
|
47
|
-
'content-type': 'application/json',
|
|
48
|
-
accept: 'text/event-stream',
|
|
49
|
-
originator: 'codex_cli_rs',
|
|
50
|
-
'user-agent': userAgent(),
|
|
51
|
-
'openai-beta': 'responses=experimental',
|
|
52
|
-
'session-id': sessionId,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
37
|
+
const headersFor = (tokens: CodexTokens, accept: string): Record<string, string> => ({
|
|
38
|
+
authorization: `Bearer ${tokens.accessToken}`,
|
|
39
|
+
'content-type': 'application/json',
|
|
40
|
+
accept,
|
|
41
|
+
});
|
|
55
42
|
|
|
56
43
|
export const currentTokens = (conn: Connection, deps: CodexDeps, state: CodexState): Promise<CodexTokens> =>
|
|
57
|
-
currentOf(state, conn.id, conn.codex, sourceOf(deps),
|
|
44
|
+
currentOf(state, conn.id, conn.codex, sourceOf(deps), CODEX_SIGN_IN);
|
|
58
45
|
|
|
59
46
|
interface Call {
|
|
60
47
|
body: Record<string, unknown>;
|
|
@@ -67,15 +54,26 @@ interface Call {
|
|
|
67
54
|
|
|
68
55
|
function send(call: Call, tokens: CodexTokens): Promise<Response> {
|
|
69
56
|
const request = toResponsesRequest(call.body, call.model, { promptCacheKey: call.sessionId, names: call.names });
|
|
70
|
-
return (call.deps.fetchImpl ?? fetch)(`${call.deps.base ??
|
|
57
|
+
return (call.deps.fetchImpl ?? fetch)(`${call.deps.base ?? OPENAI_API}/responses`, {
|
|
71
58
|
method: 'POST',
|
|
72
|
-
headers: headersFor(tokens,
|
|
59
|
+
headers: headersFor(tokens, 'text/event-stream'),
|
|
73
60
|
body: JSON.stringify(request),
|
|
74
61
|
signal: call.watch.signal,
|
|
75
62
|
redirect: 'manual',
|
|
76
63
|
});
|
|
77
64
|
}
|
|
78
65
|
|
|
66
|
+
function refusalOf(text: string, status: number): string {
|
|
67
|
+
let code = '';
|
|
68
|
+
try {
|
|
69
|
+
const parsed: unknown = JSON.parse(text);
|
|
70
|
+
code = isRecord(parsed) && isRecord(parsed.error) ? stringOf(parsed.error.code) : '';
|
|
71
|
+
} catch {
|
|
72
|
+
code = '';
|
|
73
|
+
}
|
|
74
|
+
return withUsageLink(code, upstreamMessage(text, `OpenAI answered ${String(status)}`));
|
|
75
|
+
}
|
|
76
|
+
|
|
79
77
|
export async function codexMessages(
|
|
80
78
|
req: IncomingMessage,
|
|
81
79
|
res: ServerResponse,
|
|
@@ -95,7 +93,7 @@ export async function codexMessages(
|
|
|
95
93
|
noteUsageHeaders('codex', conn.id, upstream.headers);
|
|
96
94
|
if (!upstream.ok) {
|
|
97
95
|
const text = await upstream.text();
|
|
98
|
-
sendError(res, providerStatus(upstream.status), errorKind(upstream.status, INVALID),
|
|
96
|
+
sendError(res, providerStatus(upstream.status), errorKind(upstream.status, INVALID), refusalOf(text, upstream.status));
|
|
99
97
|
return;
|
|
100
98
|
}
|
|
101
99
|
const translator = new CodexEventTranslator(model, (name) => call.names.restore(name));
|
|
@@ -111,45 +109,15 @@ export async function codexMessages(
|
|
|
111
109
|
answerWhole(res, frames + translator.close(), conn.id);
|
|
112
110
|
}
|
|
113
111
|
|
|
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
|
-
|
|
132
112
|
export async function codexModels(tokens: CodexTokens, deps: Omit<CodexDeps, 'save'>): Promise<string[]> {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
return codexLists.get(`${base}:${tokens.accountId}:${version}`, () => listCodex(tokens, version, base, deps.fetchImpl ?? fetch));
|
|
113
|
+
const base = deps.base ?? OPENAI_API;
|
|
114
|
+
return codexLists.get(`${base}:${tokens.clientId}`, () => listCodex(tokens, base, deps.fetchImpl ?? fetch));
|
|
136
115
|
}
|
|
137
116
|
|
|
138
|
-
async function listCodex(tokens: CodexTokens,
|
|
139
|
-
const res = await fetchImpl(`${base}/models
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const slugs = new Set<string>();
|
|
145
|
-
const walk = (value: unknown): void => {
|
|
146
|
-
if (Array.isArray(value)) value.forEach(walk);
|
|
147
|
-
else if (typeof value === 'object' && value !== null) {
|
|
148
|
-
const record = value as Record<string, unknown>;
|
|
149
|
-
if (typeof record.slug === 'string' && record.slug !== '') slugs.add(record.slug);
|
|
150
|
-
Object.values(record).forEach(walk);
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
walk(await res.json());
|
|
154
|
-
return [...slugs];
|
|
117
|
+
async function listCodex(tokens: CodexTokens, base: string, fetchImpl: typeof fetch): Promise<string[]> {
|
|
118
|
+
const res = await fetchImpl(`${base}/models`, { headers: headersFor(tokens, 'application/json'), redirect: 'manual' });
|
|
119
|
+
if (!res.ok) throw new GatewayError(res.status, errorKind(res.status, INVALID), `OpenAI would not list the models of this ChatGPT plan (${String(res.status)})`);
|
|
120
|
+
const body: unknown = await res.json();
|
|
121
|
+
const models: unknown[] = isRecord(body) && Array.isArray(body.models) ? body.models : [];
|
|
122
|
+
return models.filter(isRecord).flatMap((m) => (m.visibility === 'list' && stringOf(m.slug) !== '' ? [stringOf(m.slug)] : []));
|
|
155
123
|
}
|
|
@@ -38,16 +38,26 @@ export function requestKind(req: IncomingMessage, body: Body): RequestKind {
|
|
|
38
38
|
return chore(body) ? 'other' : 'main';
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
const messagesOf = (body: Body): unknown[] => (Array.isArray(body.messages) ? body.messages : []);
|
|
42
|
+
|
|
43
|
+
const askedEfforts = (body: Body): string[] => [body, ...messagesOf(body).map(record)].map(effortOf).filter((effort) => effort !== '');
|
|
44
|
+
|
|
41
45
|
export function plannedEffort(req: IncomingMessage, body: Body): string | null {
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
46
|
+
const asked = askedEfforts(body);
|
|
47
|
+
if (asked.length === 0) return null;
|
|
44
48
|
const kind = requestKind(req, body);
|
|
45
49
|
if (kind === 'other') return null;
|
|
46
50
|
const wanted = kind === 'subagent' ? SUBAGENT_EFFORT : MAIN_EFFORT;
|
|
47
|
-
return
|
|
51
|
+
return asked.every((effort) => effort === wanted) ? null : wanted;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
const withOwnEffort = (item: Body, effort: string): Body => (effortOf(item) === '' ? item : { ...item, output_config: { ...record(item.output_config), effort } });
|
|
55
|
+
|
|
56
|
+
export function withEffort(body: Body, effort: string): Body {
|
|
57
|
+
const top = withOwnEffort(body, effort);
|
|
58
|
+
if (!Array.isArray(body.messages)) return top;
|
|
59
|
+
return { ...top, messages: body.messages.map((message: unknown) => (isRecord(message) ? withOwnEffort(message, effort) : message)) };
|
|
60
|
+
}
|
|
51
61
|
|
|
52
62
|
export function withoutEffort(body: Body): Body {
|
|
53
63
|
const config = Object.fromEntries(Object.entries(record(body.output_config)).filter(([key]) => key !== 'effort'));
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from './model-config.js';
|
|
23
23
|
import { asApiError, BODY_MAX, claudeLoginDeps, connectionFor, settingsBody, type ModelApiDeps, type Route, type Store } from './model-store.js';
|
|
24
24
|
import { dropUnusedClaudeLogins } from './claude-logins.js';
|
|
25
|
-
import { CODEX_ROUTES,
|
|
25
|
+
import { CODEX_ROUTES, GEMINI_ROUTES } from './model-signin.js';
|
|
26
26
|
import { refreshUsage } from './usage-refresh.js';
|
|
27
27
|
|
|
28
28
|
const PATH = '/api/model';
|
|
@@ -36,8 +36,6 @@ const BEDROCK = '/api/model/bedrock/';
|
|
|
36
36
|
const BUNDLE = '/api/model/bundle';
|
|
37
37
|
const RESTORE = '/api/model/restore';
|
|
38
38
|
const RESTORE_MAX = 64 * 1024;
|
|
39
|
-
const DEVICE_PREFIX = 'device/';
|
|
40
|
-
const DEVICE_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
41
39
|
|
|
42
40
|
export type { ModelApiDeps } from './model-store.js';
|
|
43
41
|
|
|
@@ -164,21 +162,15 @@ function connectionsRoute(path: string, method: string | undefined): Route | num
|
|
|
164
162
|
return 405;
|
|
165
163
|
}
|
|
166
164
|
|
|
167
|
-
function codexRoute(rest: string, method: string | undefined): Route | number {
|
|
168
|
-
if (rest in CODEX_ROUTES) return named(CODEX_ROUTES, rest, method);
|
|
169
|
-
const id = rest.startsWith(DEVICE_PREFIX) ? rest.slice(DEVICE_PREFIX.length) : '';
|
|
170
|
-
if (!DEVICE_ID_RE.test(id)) return 404;
|
|
171
|
-
return codexDeviceRoute(id, method);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
165
|
const TABLES: [string, Record<string, Route>][] = [
|
|
175
166
|
[OPENROUTER, OPENROUTER_ROUTES],
|
|
176
167
|
[ANTHROPIC, ANTHROPIC_ROUTES],
|
|
177
168
|
[BEDROCK, BEDROCK_ROUTES],
|
|
178
169
|
[GEMINI, GEMINI_ROUTES],
|
|
170
|
+
[CODEX, CODEX_ROUTES],
|
|
179
171
|
];
|
|
180
172
|
const EXACT = new Set([PATH, FALLBACKS, BUNDLE, RESTORE, CONNECTIONS]);
|
|
181
|
-
const PREFIXES = [`${CONNECTIONS}/`,
|
|
173
|
+
const PREFIXES = [`${CONNECTIONS}/`, ...TABLES.map(([prefix]) => prefix)];
|
|
182
174
|
|
|
183
175
|
const mine = (path: string): boolean => EXACT.has(path) || PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
184
176
|
|
|
@@ -187,8 +179,7 @@ function routeFor(path: string, method: string | undefined): Route | number {
|
|
|
187
179
|
if (path === BUNDLE || path === RESTORE) return bundleRoute(path, method);
|
|
188
180
|
if (path === CONNECTIONS || path.startsWith(`${CONNECTIONS}/`)) return connectionsRoute(path, method);
|
|
189
181
|
const table = TABLES.find(([prefix]) => path.startsWith(prefix));
|
|
190
|
-
|
|
191
|
-
return codexRoute(path.slice(CODEX.length), method);
|
|
182
|
+
return table === undefined ? 404 : named(table[1], path.slice(table[0].length), method);
|
|
192
183
|
}
|
|
193
184
|
|
|
194
185
|
export function handleModelRequest(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps): boolean {
|
|
@@ -3,7 +3,7 @@ import { ID_RE, 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 {
|
|
6
|
+
import { CODEX_SIGN_IN, NEW_CLIENT, 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;
|
|
@@ -91,17 +91,17 @@ function claudeFromDisk(raw: unknown): ClaudeLogin | null {
|
|
|
91
91
|
|
|
92
92
|
function codexFromDisk(raw: unknown): CodexTokens | null {
|
|
93
93
|
if (!isRecord(raw)) return null;
|
|
94
|
+
const clientId = text(raw.clientId);
|
|
94
95
|
const accessToken = text(raw.accessToken);
|
|
95
96
|
const refreshToken = text(raw.refreshToken);
|
|
96
|
-
|
|
97
|
-
if (accessToken === '' || refreshToken === '' || accountId === '') return null;
|
|
97
|
+
if (clientId === '' || clientId === NEW_CLIENT || accessToken === '' || refreshToken === '') return null;
|
|
98
98
|
return {
|
|
99
|
+
clientId,
|
|
100
|
+
subject: text(raw.subject),
|
|
101
|
+
email: maybe(raw.email),
|
|
99
102
|
accessToken,
|
|
100
103
|
refreshToken,
|
|
101
|
-
|
|
102
|
-
accountId,
|
|
103
|
-
email: maybe(raw.email),
|
|
104
|
-
plan: maybe(raw.plan),
|
|
104
|
+
expiresAt: typeof raw.expiresAt === 'number' && Number.isFinite(raw.expiresAt) ? raw.expiresAt : 0,
|
|
105
105
|
savedAt: text(raw.savedAt) === '' ? new Date(0).toISOString() : text(raw.savedAt),
|
|
106
106
|
};
|
|
107
107
|
}
|
|
@@ -186,7 +186,7 @@ const CHECKS: Record<Provider, [(c: Connection) => boolean, string][]> = {
|
|
|
186
186
|
[(c) => c.model === '', 'OpenRouter needs a model id: choose one on the Model page.'],
|
|
187
187
|
],
|
|
188
188
|
codex: [
|
|
189
|
-
[(c) => c.codex === null,
|
|
189
|
+
[(c) => c.codex === null, CODEX_SIGN_IN],
|
|
190
190
|
[(c) => c.model === '', 'Codex needs a model id: choose one on the Model page.'],
|
|
191
191
|
],
|
|
192
192
|
gemini: [
|
|
@@ -235,9 +235,8 @@ export const PASSTHROUGH_ID = 'passthrough';
|
|
|
235
235
|
|
|
236
236
|
function signedInAs(c: Connection): { account: string | null; plan: string | null } {
|
|
237
237
|
if (c.claude !== null) return { account: c.claude.email, plan: c.claude.plan };
|
|
238
|
-
if (c.codex
|
|
239
|
-
|
|
240
|
-
return { account: c.codex.email ?? claims.email, plan: c.codex.plan ?? claims.plan };
|
|
238
|
+
if (c.codex !== null) return { account: c.codex.email, plan: null };
|
|
239
|
+
return { account: c.gemini?.email ?? null, plan: c.gemini?.tier ?? null };
|
|
241
240
|
}
|
|
242
241
|
|
|
243
242
|
function publicConnection(c: Connection): Record<string, unknown> {
|
|
@@ -3,9 +3,8 @@ 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
5
|
import { log } from '@metro-labs/core/log';
|
|
6
|
-
import { beginLogin,
|
|
7
|
-
import {
|
|
8
|
-
import { codexModels, currentTokens, sharedCodexState } from './codex.js';
|
|
6
|
+
import { beginLogin, chatgptHostId, finishLogin, type CodexTokens } from './codex-auth.js';
|
|
7
|
+
import { codexModels, currentTokens, sharedCodexState, type CodexDeps } from './codex.js';
|
|
9
8
|
import { beginLogin as beginGeminiLogin, exchangeCode as exchangeGeminiCode, userEmail, type GeminiTokens } from './gemini-auth.js';
|
|
10
9
|
import { onboard, parseGeminiProject } from './gemini-setup.js';
|
|
11
10
|
import { currentGeminiTokens, listGeminiModels, sharedGeminiState, type GeminiDeps } from './gemini.js';
|
|
@@ -40,21 +39,7 @@ const saved = (store: Store, cfg: ModelConfig, note: string, fields: { connectio
|
|
|
40
39
|
return settingsBody(cfg);
|
|
41
40
|
};
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
const { cfg, id } = connectionToFill(store, req, 'codex');
|
|
45
|
-
const twin = cfg.connections.find((c) => c.id !== id && tokens !== null && c.codex?.refreshToken === tokens.refreshToken);
|
|
46
|
-
if (twin !== undefined) throw new ApiError(`this ChatGPT login is already connected as ${twin.label}; sign in with ChatGPT to add another one`, 409);
|
|
47
|
-
const next = setCodexAuth(cfg, id, tokens);
|
|
48
|
-
return saved(store, next, note, { connection: id, plan: tokens?.plan ?? null });
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function pollDevice(id: string, req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
|
|
52
|
-
const result = await pollDeviceLogin(id, deps.fetchImpl).catch(asApiError);
|
|
53
|
-
if (result.status !== 'done') return result;
|
|
54
|
-
return { status: 'done', settings: keepCodex(store, req, result.tokens, 'model-api: Codex connected by device code') };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export const codexDepsFor = (deps: ModelApiDeps, store: Store, id: string): { issuer?: string; fetchImpl?: typeof fetch; base?: string; save: (connId: string, t: CodexTokens) => void } => ({
|
|
42
|
+
const codexDepsFor = (deps: ModelApiDeps, store: Store, id: string): CodexDeps => ({
|
|
58
43
|
issuer: deps.issuer,
|
|
59
44
|
fetchImpl: deps.fetchImpl,
|
|
60
45
|
base: deps.codexBase,
|
|
@@ -63,34 +48,21 @@ export const codexDepsFor = (deps: ModelApiDeps, store: Store, id: string): { is
|
|
|
63
48
|
},
|
|
64
49
|
});
|
|
65
50
|
|
|
51
|
+
const renewing = (store: Store, req: IncomingMessage): CodexTokens | null => (askedConnection(req) === '' ? null : connectionFor(store.read(), req, 'codex').codex);
|
|
52
|
+
|
|
66
53
|
export const CODEX_ROUTES: Record<string, Route> = {
|
|
67
|
-
|
|
54
|
+
login: {
|
|
68
55
|
method: 'POST',
|
|
69
|
-
run:
|
|
70
|
-
const login = await beginDeviceLogin(deps.issuer, deps.fetchImpl).catch(asApiError);
|
|
71
|
-
return { id: login.id, user_code: login.userCode, verify_url: login.verifyUrl, interval: login.interval };
|
|
72
|
-
},
|
|
56
|
+
run: (req, deps, store) => Promise.resolve({ url: beginLogin(renewing(store, req), chatgptHostId(deps.setup?.agents), deps.issuer) }),
|
|
73
57
|
},
|
|
74
|
-
login: { method: 'POST', run: (_req, deps) => Promise.resolve({ url: beginLogin(deps.issuer).url }) },
|
|
75
58
|
callback: {
|
|
76
59
|
method: 'POST',
|
|
77
60
|
run: async (req, deps, store) => {
|
|
78
61
|
const body = await readJsonBody(req, BODY_MAX);
|
|
79
62
|
const raw = isRecord(body) && typeof body.url === 'string' ? body.url : '';
|
|
80
63
|
const tokens = await finishLogin(raw, deps.issuer, deps.fetchImpl).catch(asApiError);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
},
|
|
84
|
-
import: {
|
|
85
|
-
method: 'POST',
|
|
86
|
-
run: (_req, deps, store) => {
|
|
87
|
-
let tokens;
|
|
88
|
-
try {
|
|
89
|
-
tokens = readCodexCliAuth(deps.codexHome);
|
|
90
|
-
} catch (err) {
|
|
91
|
-
asApiError(err);
|
|
92
|
-
}
|
|
93
|
-
return Promise.resolve(keepCodex(store, _req, tokens, 'model-api: Codex CLI login imported'));
|
|
64
|
+
const { cfg, id } = connectionToFill(store, req, 'codex');
|
|
65
|
+
return saved(store, setCodexAuth(cfg, id, tokens), 'model-api: Codex connected with Sign in with ChatGPT', { connection: id });
|
|
94
66
|
},
|
|
95
67
|
},
|
|
96
68
|
models: {
|
|
@@ -157,7 +129,3 @@ export const GEMINI_ROUTES: Record<string, Route> = {
|
|
|
157
129
|
run: async (req, deps, store) => ({ models: await geminiIds(req, deps, store).catch(asApiError) }),
|
|
158
130
|
},
|
|
159
131
|
};
|
|
160
|
-
|
|
161
|
-
export function codexDeviceRoute(id: string, method: string | undefined): Route | number {
|
|
162
|
-
return method === 'GET' ? { method: 'GET', run: (req, deps, store) => pollDevice(id, req, deps, store) } : 405;
|
|
163
|
-
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
2
|
import { readClaudeUsage } from '../claude/usage-probe.js';
|
|
3
|
-
import { codexUsageNow, sharedCodexState } from './codex.js';
|
|
4
3
|
import { openrouterKey } from './openrouter.js';
|
|
5
4
|
import { lastServed } from './served.js';
|
|
6
5
|
import { claudeLoginUsage, geminiUsage, keepProbeAnswer, mayProbe, mergeUsage, noteUsage, openrouterUsage, probeAnswer, usageOf } from './usage.js';
|
|
7
6
|
import { PASSTHROUGH_ID, readModelConfig, routedConnection, writeModelConfig, type Connection, type ModelConfig } from './model-config.js';
|
|
8
|
-
import {
|
|
7
|
+
import { geminiModelsOf } from './model-signin.js';
|
|
9
8
|
import { claudeLoginDeps, CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
|
|
10
9
|
import { probeClaudeLogin } from './claude-logins.js';
|
|
11
10
|
|
|
@@ -34,14 +33,6 @@ async function refreshKey(conn: Connection, deps: ModelApiDeps, now: number): Pr
|
|
|
34
33
|
});
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
async function refreshCodex(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
38
|
-
if (conn.codex === null || !due(conn.id, now)) return;
|
|
39
|
-
await probe('Codex usage', async () => {
|
|
40
|
-
const usage = await codexUsageNow(conn, codexDepsFor(deps, store, conn.id), sharedCodexState);
|
|
41
|
-
if (usage !== null) noteUsage(conn.id, usage);
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
36
|
const modelsInUse = (conn: Connection, cfg: ModelConfig): Set<string> => {
|
|
46
37
|
const served = lastServed();
|
|
47
38
|
const fallbacks = (cfg.fallbacks ?? []).filter((f) => f.connection === conn.id).map((f) => f.model);
|
|
@@ -92,7 +83,6 @@ async function refreshLogin(cfg: ModelConfig, deps: ModelApiDeps, now: number):
|
|
|
92
83
|
|
|
93
84
|
function refreshOne(conn: Connection, cfg: ModelConfig, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
94
85
|
if (conn.provider === 'openrouter') return refreshKey(conn, deps, now);
|
|
95
|
-
if (conn.provider === 'codex') return refreshCodex(conn, deps, store, now);
|
|
96
86
|
if (conn.provider === 'gemini') return refreshQuota(conn, deps, store, now);
|
|
97
87
|
if (conn.provider === 'anthropic') return refreshOwnLogin(conn, cfg, deps, now);
|
|
98
88
|
return Promise.resolve();
|
|
@@ -271,36 +271,6 @@ const textOf = (raw: unknown): string | null => (typeof raw === 'number' || type
|
|
|
271
271
|
|
|
272
272
|
const numberOf = (raw: unknown): number | null => (typeof raw === 'number' && Number.isFinite(raw) ? raw : null);
|
|
273
273
|
|
|
274
|
-
const SECONDS_PER_MINUTE = 60;
|
|
275
|
-
|
|
276
|
-
function codexBodyWindow(raw: unknown, now: Date): UsageWindow | null {
|
|
277
|
-
if (!isRecord(raw)) return null;
|
|
278
|
-
const percent = numberOf(raw.used_percent);
|
|
279
|
-
if (percent === null) return null;
|
|
280
|
-
const seconds = numberOf(raw.limit_window_seconds);
|
|
281
|
-
const minutes = seconds === null || seconds <= 0 ? null : Math.ceil(seconds / SECONDS_PER_MINUTE);
|
|
282
|
-
const resetAt = whenFrom(textOf(raw.reset_at), now) ?? whenFrom(textOf(raw.reset_after_seconds), now, true);
|
|
283
|
-
return { label: windowLabel(minutes), used: clamp(percent / 100), resetAt, detail: null };
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function codexWall(limit: Record<string, unknown>, reached: unknown): string | null {
|
|
287
|
-
if (limit.limit_reached !== true && limit.allowed !== false) return null;
|
|
288
|
-
const kind = isRecord(reached) ? stringOf(reached.type) : '';
|
|
289
|
-
return kind === '' ? 'limit reached' : kind.replaceAll('_', ' ');
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
export function codexUsageBody(body: unknown, now = new Date()): Reported | null {
|
|
293
|
-
if (!isRecord(body)) return null;
|
|
294
|
-
const limit = isRecord(body.rate_limit) ? body.rate_limit : {};
|
|
295
|
-
const credits = isRecord(body.credits) ? body.credits : {};
|
|
296
|
-
const windows = [
|
|
297
|
-
codexBodyWindow(limit.primary_window, now),
|
|
298
|
-
codexBodyWindow(limit.secondary_window, now),
|
|
299
|
-
creditsLeft(credits.has_credits === true, textOf(credits.balance)),
|
|
300
|
-
].filter(present);
|
|
301
|
-
return windows.length === 0 ? null : { windows, note: codexWall(limit, body.rate_limit_reached_type), at: now.toISOString() };
|
|
302
|
-
}
|
|
303
|
-
|
|
304
274
|
const CLAUDE_WINDOWS: [string, string][] = [
|
|
305
275
|
['five_hour', '5-hour window'],
|
|
306
276
|
['seven_day', 'Weekly'],
|
|
@@ -6,6 +6,10 @@ export function allowedWebHost(host: string): boolean {
|
|
|
6
6
|
return PREVIEW.test(host);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export const APP_RETURN = 'metro://auth';
|
|
10
|
+
|
|
11
|
+
export const isAppReturn = (returnTo: string): boolean => returnTo === APP_RETURN;
|
|
12
|
+
|
|
9
13
|
export function validateReturnTo(returnTo: string): boolean {
|
|
10
14
|
let url: URL;
|
|
11
15
|
try {
|
package/runtime/runtime.json
CHANGED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
-
import { errMsg } from '@metro-labs/core/log';
|
|
4
|
-
import { CODEX_CLIENT_ID, CODEX_ISSUER, CodexAuthError, exchangeCode, type CodexTokens } from './codex-auth.js';
|
|
5
|
-
import { stringOf } from '@metro-labs/http/api-http';
|
|
6
|
-
|
|
7
|
-
const VERIFY_PATH = '/codex/device';
|
|
8
|
-
const DEVICE_TTL_MS = 15 * 60_000;
|
|
9
|
-
const DEFAULT_INTERVAL_S = 5;
|
|
10
|
-
const ID_BYTES = 18;
|
|
11
|
-
|
|
12
|
-
export interface DeviceLogin {
|
|
13
|
-
id: string;
|
|
14
|
-
userCode: string;
|
|
15
|
-
verifyUrl: string;
|
|
16
|
-
interval: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export type DeviceStatus = { status: 'pending' } | { status: 'done'; tokens: CodexTokens } | { status: 'failed'; error: string };
|
|
20
|
-
|
|
21
|
-
interface Entry {
|
|
22
|
-
deviceAuthId: string;
|
|
23
|
-
userCode: string;
|
|
24
|
-
interval: number;
|
|
25
|
-
issuer: string;
|
|
26
|
-
startedAt: number;
|
|
27
|
-
polledAt: number | null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const logins = new Map<string, Entry>();
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
function intervalOf(value: unknown): number {
|
|
34
|
-
const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
|
|
35
|
-
return Number.isFinite(n) && n >= 1 ? Math.round(n) : DEFAULT_INTERVAL_S;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function sweep(now: number): void {
|
|
39
|
-
for (const [id, entry] of logins) if (now - entry.startedAt > DEVICE_TTL_MS) logins.delete(id);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const post = (url: string, body: unknown, fetchImpl: typeof fetch): Promise<Response> =>
|
|
43
|
-
fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
44
|
-
|
|
45
|
-
export async function beginDeviceLogin(issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<DeviceLogin> {
|
|
46
|
-
sweep(now);
|
|
47
|
-
const res = await post(`${issuer}/api/accounts/deviceauth/usercode`, { client_id: CODEX_CLIENT_ID }, fetchImpl);
|
|
48
|
-
if (res.status === 404) throw new CodexAuthError('OpenAI is not offering the device-code sign-in right now; use the browser sign-in instead');
|
|
49
|
-
if (!res.ok) throw new CodexAuthError(`OpenAI refused to start the device-code sign-in (${String(res.status)})`);
|
|
50
|
-
const body: unknown = await res.json();
|
|
51
|
-
const parsed = isRecord(body) ? body : {};
|
|
52
|
-
const deviceAuthId = stringOf(parsed.device_auth_id);
|
|
53
|
-
const userCode = stringOf(parsed.user_code) || stringOf(parsed.usercode);
|
|
54
|
-
if (deviceAuthId === '' || userCode === '') throw new CodexAuthError('OpenAI answered without a device code');
|
|
55
|
-
const id = randomBytes(ID_BYTES).toString('base64url');
|
|
56
|
-
const interval = intervalOf(parsed.interval);
|
|
57
|
-
logins.set(id, { deviceAuthId, userCode, interval, issuer, startedAt: now, polledAt: null });
|
|
58
|
-
return { id, userCode, verifyUrl: `${issuer}${VERIFY_PATH}`, interval };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function claim(entry: Entry, fetchImpl: typeof fetch, now: number): Promise<DeviceStatus> {
|
|
62
|
-
const res = await post(`${entry.issuer}/api/accounts/deviceauth/token`, { device_auth_id: entry.deviceAuthId, user_code: entry.userCode }, fetchImpl);
|
|
63
|
-
if (res.status === 403 || res.status === 404) {
|
|
64
|
-
await res.body?.cancel();
|
|
65
|
-
return { status: 'pending' };
|
|
66
|
-
}
|
|
67
|
-
if (!res.ok) return { status: 'failed', error: `OpenAI ended the device-code sign-in (${String(res.status)})` };
|
|
68
|
-
const body: unknown = await res.json();
|
|
69
|
-
const parsed = isRecord(body) ? body : {};
|
|
70
|
-
const code = stringOf(parsed.authorization_code);
|
|
71
|
-
const verifier = stringOf(parsed.code_verifier);
|
|
72
|
-
if (code === '' || verifier === '') return { status: 'failed', error: 'OpenAI approved the sign-in but sent no code back' };
|
|
73
|
-
const tokens = await exchangeCode(entry.issuer, code, verifier, `${entry.issuer}/deviceauth/callback`, fetchImpl, now);
|
|
74
|
-
return { status: 'done', tokens };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export async function pollDeviceLogin(id: string, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<DeviceStatus> {
|
|
78
|
-
sweep(now);
|
|
79
|
-
const entry = logins.get(id);
|
|
80
|
-
if (entry === undefined) throw new CodexAuthError('this sign-in has expired or already finished; press Connect again');
|
|
81
|
-
if (entry.polledAt !== null && now - entry.polledAt < entry.interval * 1000) return { status: 'pending' };
|
|
82
|
-
entry.polledAt = now;
|
|
83
|
-
let result: DeviceStatus;
|
|
84
|
-
try {
|
|
85
|
-
result = await claim(entry, fetchImpl, now);
|
|
86
|
-
} catch (err) {
|
|
87
|
-
result = { status: 'failed', error: errMsg(err) };
|
|
88
|
-
}
|
|
89
|
-
if (result.status !== 'pending') logins.delete(id);
|
|
90
|
-
return result;
|
|
91
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
|
-
|
|
3
|
-
const BUILT_IN = '0.157.1';
|
|
4
|
-
const REGISTRY = 'https://registry.npmjs.org/@openai/codex/latest';
|
|
5
|
-
const OVERRIDE_RE = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?$/;
|
|
6
|
-
const STABLE_RE = /^\d+\.\d+\.\d+$/;
|
|
7
|
-
const FRESH_MS = 6 * 3_600_000;
|
|
8
|
-
const FETCH_MS = 5_000;
|
|
9
|
-
|
|
10
|
-
let learned: { version: string; at: number } | null = null;
|
|
11
|
-
|
|
12
|
-
const parts = (version: string): number[] => version.split('.').map(Number);
|
|
13
|
-
|
|
14
|
-
function newer(a: string, b: string): boolean {
|
|
15
|
-
const [x, y] = [parts(a), parts(b)];
|
|
16
|
-
for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
|
|
17
|
-
return false;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function codexVersion(): string {
|
|
21
|
-
const wanted = process.env.METRO_CODEX_VERSION?.trim() ?? '';
|
|
22
|
-
if (wanted !== '' && OVERRIDE_RE.test(wanted)) return wanted;
|
|
23
|
-
if (wanted !== '') log.warn({ value: wanted }, 'gateway: METRO_CODEX_VERSION is not a version like 0.157.1; using the known one');
|
|
24
|
-
return learned !== null && newer(learned.version, BUILT_IN) ? learned.version : BUILT_IN;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async function latestOnNpm(): Promise<string> {
|
|
28
|
-
const res = await fetch(process.env.METRO_CODEX_REGISTRY ?? REGISTRY, { signal: AbortSignal.timeout(FETCH_MS) });
|
|
29
|
-
const body: unknown = await res.json();
|
|
30
|
-
const version = typeof body === 'object' && body !== null ? (body as { version?: unknown }).version : undefined;
|
|
31
|
-
if (!res.ok || typeof version !== 'string' || !STABLE_RE.test(version)) throw new Error(`npm answered ${String(res.status)} without a stable version`);
|
|
32
|
-
return version;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function learnCodexVersion(now = Date.now()): Promise<string> {
|
|
36
|
-
if (learned !== null && now - learned.at < FRESH_MS) return codexVersion();
|
|
37
|
-
try {
|
|
38
|
-
learned = { version: await latestOnNpm(), at: now };
|
|
39
|
-
} catch (err) {
|
|
40
|
-
log.warn({ err: errMsg(err) }, 'gateway: the latest Codex version could not be read from npm; using the known one');
|
|
41
|
-
learned = { version: learned?.version ?? BUILT_IN, at: now };
|
|
42
|
-
}
|
|
43
|
-
return codexVersion();
|
|
44
|
-
}
|