@stage-labs/metro 0.1.0-beta.255 → 0.1.0-beta.256
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 +58 -25
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +119 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-version.ts +44 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +33 -13
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +13 -4
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +15 -9
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +30 -4
- 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.256",
|
|
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",
|
|
@@ -7,13 +7,13 @@ import { agentsDir } from '../agents/files.js';
|
|
|
7
7
|
import { newPkce } from './pkce.js';
|
|
8
8
|
import { nonEmpty } from './text.js';
|
|
9
9
|
|
|
10
|
-
const CODEX_ISSUER = 'https://auth.openai.com';
|
|
10
|
+
export const CODEX_ISSUER = 'https://auth.openai.com';
|
|
11
11
|
export const CODEX_REDIRECT = 'http://127.0.0.1:1455/auth/callback';
|
|
12
12
|
export const OPENAI_API = 'https://api.openai.com/v1';
|
|
13
13
|
export const NEW_CLIENT = 'dynamic_agent_client';
|
|
14
14
|
export const APP_NAME = 'Metro';
|
|
15
15
|
export const PLAN_SCOPE = 'chatgpt.tokens.use.direct';
|
|
16
|
-
export const CODEX_SIGN_IN =
|
|
16
|
+
export const CODEX_SIGN_IN = 'Codex is not signed in: sign in with ChatGPT on the Model page.';
|
|
17
17
|
const SCOPE = `openid profile email offline_access resource.invoke ${PLAN_SCOPE}`;
|
|
18
18
|
const PROFILE_CLAIM = 'https://api.openai.com/profile';
|
|
19
19
|
const HOST_FILE = 'chatgpt-host.json';
|
|
@@ -23,9 +23,9 @@ const DEFAULT_TTL_MS = 60 * 60_000;
|
|
|
23
23
|
const RENEW_BEFORE_MS = 2 * 60_000;
|
|
24
24
|
const AGAIN = 'press Continue with ChatGPT again';
|
|
25
25
|
|
|
26
|
-
export
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
export type CodexMethod = 'chatgpt' | 'code';
|
|
27
|
+
|
|
28
|
+
export interface CodexCredential {
|
|
29
29
|
email: string | null;
|
|
30
30
|
accessToken: string;
|
|
31
31
|
refreshToken: string;
|
|
@@ -33,6 +33,20 @@ export interface CodexTokens {
|
|
|
33
33
|
savedAt: string;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export interface ChatgptTokens extends CodexCredential {
|
|
37
|
+
method: 'chatgpt';
|
|
38
|
+
clientId: string;
|
|
39
|
+
subject: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CodeTokens extends CodexCredential {
|
|
43
|
+
method: 'code';
|
|
44
|
+
accountId: string;
|
|
45
|
+
plan: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type CodexTokens = ChatgptTokens | CodeTokens;
|
|
49
|
+
|
|
36
50
|
export class CodexAuthError extends Error {}
|
|
37
51
|
|
|
38
52
|
interface Pending {
|
|
@@ -53,12 +67,12 @@ export function chatgptHostId(dir = agentsDir()): string {
|
|
|
53
67
|
return made;
|
|
54
68
|
}
|
|
55
69
|
|
|
56
|
-
function returning(previous:
|
|
70
|
+
function returning(previous: ChatgptTokens | null): Record<string, string> {
|
|
57
71
|
if (previous === null) return { client_id: NEW_CLIENT, agent_name_hint: APP_NAME };
|
|
58
72
|
return { client_id: previous.clientId, ...(previous.email === null ? {} : { login_hint: previous.email }) };
|
|
59
73
|
}
|
|
60
74
|
|
|
61
|
-
export function beginLogin(previous:
|
|
75
|
+
export function beginLogin(previous: ChatgptTokens | null, hostId: string, issuer = CODEX_ISSUER, now = Date.now()): string {
|
|
62
76
|
const { verifier, challenge } = newPkce();
|
|
63
77
|
const nonce = randomBytes(32).toString('base64url');
|
|
64
78
|
const { ticket: state } = pending.mint({ verifier, nonce, clientId: previous?.clientId ?? null, subject: previous?.subject ?? null }, now);
|
|
@@ -103,7 +117,7 @@ export function parseCallback(raw: string): { code: string; state: string; clien
|
|
|
103
117
|
return { code, state, clientId: nonEmpty(param(query, 'client_id')) };
|
|
104
118
|
}
|
|
105
119
|
|
|
106
|
-
function decodeJwtPayload(jwt: string): Record<string, unknown> {
|
|
120
|
+
export function decodeJwtPayload(jwt: string): Record<string, unknown> {
|
|
107
121
|
const part = jwt.split('.')[1] ?? '';
|
|
108
122
|
try {
|
|
109
123
|
const parsed: unknown = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
|
@@ -113,22 +127,31 @@ function decodeJwtPayload(jwt: string): Record<string, unknown> {
|
|
|
113
127
|
}
|
|
114
128
|
}
|
|
115
129
|
|
|
130
|
+
export function emailOf(claims: Record<string, unknown>): string | null {
|
|
131
|
+
const profile = isRecord(claims[PROFILE_CLAIM]) ? claims[PROFILE_CLAIM] : {};
|
|
132
|
+
return nonEmpty(claims.email) ?? nonEmpty(profile.email);
|
|
133
|
+
}
|
|
134
|
+
|
|
116
135
|
function identityOf(idToken: unknown, expected: { issuer: string; clientId: string; nonce: string }): { subject: string; email: string | null } {
|
|
117
136
|
const claims = decodeJwtPayload(typeof idToken === 'string' ? idToken : '');
|
|
118
137
|
const audience: unknown[] = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
119
138
|
const subject = nonEmpty(claims.sub);
|
|
120
139
|
if (subject === null || claims.iss !== expected.issuer || !audience.includes(expected.clientId) || claims.nonce !== expected.nonce)
|
|
121
140
|
throw new CodexAuthError(`OpenAI sent back an identity that does not match this sign-in; ${AGAIN}`);
|
|
122
|
-
|
|
123
|
-
return { subject, email: nonEmpty(claims.email) ?? nonEmpty(profile.email) };
|
|
141
|
+
return { subject, email: emailOf(claims) };
|
|
124
142
|
}
|
|
125
143
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
144
|
+
interface Encoded {
|
|
145
|
+
type: string;
|
|
146
|
+
body: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const asForm = (fields: Record<string, string>): Encoded => ({ type: 'application/x-www-form-urlencoded', body: new URLSearchParams(fields).toString() });
|
|
150
|
+
|
|
151
|
+
export const asJson = (fields: Record<string, string>): Encoded => ({ type: 'application/json', body: JSON.stringify(fields) });
|
|
152
|
+
|
|
153
|
+
export async function tokenCall(url: string, sent: Encoded, fetchImpl: typeof fetch): Promise<Record<string, unknown>> {
|
|
154
|
+
const res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': sent.type, accept: 'application/json' }, body: sent.body });
|
|
132
155
|
const raw = await res.text();
|
|
133
156
|
let parsed: unknown = null;
|
|
134
157
|
try {
|
|
@@ -141,14 +164,24 @@ async function tokenCall(issuer: string, form: Record<string, string>, fetchImpl
|
|
|
141
164
|
return body;
|
|
142
165
|
}
|
|
143
166
|
|
|
144
|
-
function
|
|
167
|
+
export function grantOf(body: Record<string, unknown>, keptRefresh: string, now: number): Omit<CodexCredential, 'email'> {
|
|
145
168
|
const accessToken = nonEmpty(body.access_token) ?? '';
|
|
146
169
|
const refreshToken = nonEmpty(body.refresh_token) ?? keptRefresh;
|
|
147
|
-
if (accessToken === '' || refreshToken === '') throw new CodexAuthError(
|
|
170
|
+
if (accessToken === '' || refreshToken === '') throw new CodexAuthError('OpenAI returned no usable tokens; sign in again');
|
|
148
171
|
const ttl = typeof body.expires_in === 'number' && body.expires_in > 0 ? body.expires_in * 1000 : DEFAULT_TTL_MS;
|
|
149
|
-
return {
|
|
172
|
+
return { accessToken, refreshToken, expiresAt: now + ttl, savedAt: new Date(now).toISOString() };
|
|
150
173
|
}
|
|
151
174
|
|
|
175
|
+
const tokensFrom = (body: Record<string, unknown>, who: Pick<ChatgptTokens, 'clientId' | 'subject' | 'email'>, keptRefresh: string, now: number): ChatgptTokens => ({
|
|
176
|
+
method: 'chatgpt',
|
|
177
|
+
clientId: who.clientId,
|
|
178
|
+
subject: who.subject,
|
|
179
|
+
email: who.email,
|
|
180
|
+
...grantOf(body, keptRefresh, now),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const officialToken = (issuer: string): string => `${issuer}/api/accounts/oauth/token`;
|
|
184
|
+
|
|
152
185
|
function clientOf(started: Pending, returned: string | null): string {
|
|
153
186
|
const clientId = started.clientId ?? returned;
|
|
154
187
|
if (clientId === null || clientId === NEW_CLIENT) throw new CodexAuthError(`ChatGPT did not finish registering Metro; ${AGAIN}`);
|
|
@@ -156,14 +189,14 @@ function clientOf(started: Pending, returned: string | null): string {
|
|
|
156
189
|
return clientId;
|
|
157
190
|
}
|
|
158
191
|
|
|
159
|
-
export async function finishLogin(callback: string, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<
|
|
192
|
+
export async function finishLogin(callback: string, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<ChatgptTokens> {
|
|
160
193
|
const { code, state, clientId: returned } = parseCallback(callback);
|
|
161
194
|
const started = pending.take(state, now);
|
|
162
195
|
if (started === undefined) throw new CodexAuthError(`this sign-in link has expired or was started elsewhere; ${AGAIN}`);
|
|
163
196
|
const clientId = clientOf(started, returned);
|
|
164
197
|
const body = await tokenCall(
|
|
165
|
-
issuer,
|
|
166
|
-
{ grant_type: 'authorization_code', client_id: clientId, code, code_verifier: started.verifier, redirect_uri: CODEX_REDIRECT, resource: OPENAI_API },
|
|
198
|
+
officialToken(issuer),
|
|
199
|
+
asForm({ grant_type: 'authorization_code', client_id: clientId, code, code_verifier: started.verifier, redirect_uri: CODEX_REDIRECT, resource: OPENAI_API }),
|
|
167
200
|
fetchImpl,
|
|
168
201
|
);
|
|
169
202
|
const who = identityOf(body.id_token, { issuer, clientId, nonce: started.nonce });
|
|
@@ -174,10 +207,10 @@ export async function finishLogin(callback: string, issuer = CODEX_ISSUER, fetch
|
|
|
174
207
|
return tokensFrom(body, { clientId, ...who }, '', now);
|
|
175
208
|
}
|
|
176
209
|
|
|
177
|
-
export async function refreshTokens(previous:
|
|
210
|
+
export async function refreshTokens(previous: ChatgptTokens, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<ChatgptTokens> {
|
|
178
211
|
const body = await tokenCall(
|
|
179
|
-
issuer,
|
|
180
|
-
{ grant_type: 'refresh_token', client_id: previous.clientId, refresh_token: previous.refreshToken, resource: OPENAI_API },
|
|
212
|
+
officialToken(issuer),
|
|
213
|
+
asForm({ grant_type: 'refresh_token', client_id: previous.clientId, refresh_token: previous.refreshToken, resource: OPENAI_API }),
|
|
181
214
|
fetchImpl,
|
|
182
215
|
);
|
|
183
216
|
return tokensFrom(body, previous, previous.refreshToken, now);
|
|
@@ -0,0 +1,119 @@
|
|
|
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 { stringOf } from '@metro-labs/http/api-http';
|
|
5
|
+
import { asForm, asJson, CODEX_ISSUER, CodexAuthError, decodeJwtPayload, emailOf, grantOf, tokenCall, type CodeTokens } from './codex-auth.js';
|
|
6
|
+
import { nonEmpty } from './text.js';
|
|
7
|
+
|
|
8
|
+
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
9
|
+
const AUTH_CLAIM = 'https://api.openai.com/auth';
|
|
10
|
+
const VERIFY_PATH = '/codex/device';
|
|
11
|
+
const DEVICE_TTL_MS = 15 * 60_000;
|
|
12
|
+
const DEFAULT_INTERVAL_S = 5;
|
|
13
|
+
const ID_BYTES = 18;
|
|
14
|
+
const AGAIN = 'press Sign in with a code again';
|
|
15
|
+
|
|
16
|
+
export interface DeviceLogin {
|
|
17
|
+
id: string;
|
|
18
|
+
userCode: string;
|
|
19
|
+
verifyUrl: string;
|
|
20
|
+
interval: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type DeviceStatus = { status: 'pending' } | { status: 'done'; tokens: CodeTokens } | { status: 'failed'; error: string };
|
|
24
|
+
|
|
25
|
+
interface Entry {
|
|
26
|
+
deviceAuthId: string;
|
|
27
|
+
userCode: string;
|
|
28
|
+
interval: number;
|
|
29
|
+
issuer: string;
|
|
30
|
+
startedAt: number;
|
|
31
|
+
polledAt: number | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const logins = new Map<string, Entry>();
|
|
35
|
+
|
|
36
|
+
function claimsOf(idToken: string): { accountId: string | null; email: string | null; plan: string | null } {
|
|
37
|
+
const payload = decodeJwtPayload(idToken);
|
|
38
|
+
const auth = isRecord(payload[AUTH_CLAIM]) ? payload[AUTH_CLAIM] : {};
|
|
39
|
+
return { accountId: nonEmpty(auth.chatgpt_account_id), email: emailOf(payload), plan: nonEmpty(auth.chatgpt_plan_type) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function codeTokensFrom(body: Record<string, unknown>, previous: CodeTokens | null, now: number): CodeTokens {
|
|
43
|
+
const fresh = claimsOf(nonEmpty(body.id_token) ?? '');
|
|
44
|
+
const kept = previous ?? { accountId: null, email: null, plan: null, refreshToken: '' };
|
|
45
|
+
const accountId = fresh.accountId ?? kept.accountId ?? '';
|
|
46
|
+
if (accountId === '') throw new CodexAuthError('the id token carries no ChatGPT account id; sign in with a ChatGPT account that has Codex');
|
|
47
|
+
return { method: 'code', accountId, email: fresh.email ?? kept.email, plan: fresh.plan ?? kept.plan, ...grantOf(body, kept.refreshToken, now) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const cliToken = (issuer: string): string => `${issuer}/oauth/token`;
|
|
51
|
+
|
|
52
|
+
export async function refreshCodeTokens(previous: CodeTokens, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<CodeTokens> {
|
|
53
|
+
const body = await tokenCall(cliToken(issuer), asJson({ client_id: CODEX_CLIENT_ID, grant_type: 'refresh_token', refresh_token: previous.refreshToken }), fetchImpl);
|
|
54
|
+
return codeTokensFrom(body, previous, now);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function intervalOf(value: unknown): number {
|
|
58
|
+
const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
|
|
59
|
+
return Number.isFinite(n) && n >= 1 ? Math.round(n) : DEFAULT_INTERVAL_S;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sweep(now: number): void {
|
|
63
|
+
for (const [id, entry] of logins) if (now - entry.startedAt > DEVICE_TTL_MS) logins.delete(id);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const post = (url: string, body: unknown, fetchImpl: typeof fetch): Promise<Response> =>
|
|
67
|
+
fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
68
|
+
|
|
69
|
+
export async function beginDeviceLogin(issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<DeviceLogin> {
|
|
70
|
+
sweep(now);
|
|
71
|
+
const res = await post(`${issuer}/api/accounts/deviceauth/usercode`, { client_id: CODEX_CLIENT_ID }, fetchImpl);
|
|
72
|
+
if (res.status === 404) throw new CodexAuthError('OpenAI is not offering the code sign-in right now; use Continue with ChatGPT instead');
|
|
73
|
+
if (!res.ok) throw new CodexAuthError(`OpenAI refused to start the code sign-in (${String(res.status)})`);
|
|
74
|
+
const body: unknown = await res.json();
|
|
75
|
+
const parsed = isRecord(body) ? body : {};
|
|
76
|
+
const deviceAuthId = stringOf(parsed.device_auth_id);
|
|
77
|
+
const userCode = stringOf(parsed.user_code) || stringOf(parsed.usercode);
|
|
78
|
+
if (deviceAuthId === '' || userCode === '') throw new CodexAuthError('OpenAI answered without a device code');
|
|
79
|
+
const id = randomBytes(ID_BYTES).toString('base64url');
|
|
80
|
+
const interval = intervalOf(parsed.interval);
|
|
81
|
+
logins.set(id, { deviceAuthId, userCode, interval, issuer, startedAt: now, polledAt: null });
|
|
82
|
+
return { id, userCode, verifyUrl: `${issuer}${VERIFY_PATH}`, interval };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function claim(entry: Entry, fetchImpl: typeof fetch, now: number): Promise<DeviceStatus> {
|
|
86
|
+
const res = await post(`${entry.issuer}/api/accounts/deviceauth/token`, { device_auth_id: entry.deviceAuthId, user_code: entry.userCode }, fetchImpl);
|
|
87
|
+
if (res.status === 403 || res.status === 404) {
|
|
88
|
+
await res.body?.cancel();
|
|
89
|
+
return { status: 'pending' };
|
|
90
|
+
}
|
|
91
|
+
if (!res.ok) return { status: 'failed', error: `OpenAI ended the code sign-in (${String(res.status)})` };
|
|
92
|
+
const body: unknown = await res.json();
|
|
93
|
+
const parsed = isRecord(body) ? body : {};
|
|
94
|
+
const code = stringOf(parsed.authorization_code);
|
|
95
|
+
const verifier = stringOf(parsed.code_verifier);
|
|
96
|
+
if (code === '' || verifier === '') return { status: 'failed', error: 'OpenAI approved the sign-in but sent no code back' };
|
|
97
|
+
const granted = await tokenCall(
|
|
98
|
+
cliToken(entry.issuer),
|
|
99
|
+
asForm({ grant_type: 'authorization_code', code, redirect_uri: `${entry.issuer}/deviceauth/callback`, client_id: CODEX_CLIENT_ID, code_verifier: verifier }),
|
|
100
|
+
fetchImpl,
|
|
101
|
+
);
|
|
102
|
+
return { status: 'done', tokens: codeTokensFrom(granted, null, now) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function pollDeviceLogin(id: string, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<DeviceStatus> {
|
|
106
|
+
sweep(now);
|
|
107
|
+
const entry = logins.get(id);
|
|
108
|
+
if (entry === undefined) throw new CodexAuthError(`this sign-in has expired or already finished; ${AGAIN}`);
|
|
109
|
+
if (entry.polledAt !== null && now - entry.polledAt < entry.interval * 1000) return { status: 'pending' };
|
|
110
|
+
entry.polledAt = now;
|
|
111
|
+
let result: DeviceStatus;
|
|
112
|
+
try {
|
|
113
|
+
result = await claim(entry, fetchImpl, now);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
result = { status: 'failed', error: errMsg(err) };
|
|
116
|
+
}
|
|
117
|
+
if (result.status !== 'pending') logins.delete(id);
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { arch, platform, release } from 'node:os';
|
|
3
4
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
5
|
import { stringOf } from '@metro-labs/http/api-http';
|
|
5
6
|
import { OPENAI_API, CODEX_SIGN_IN, refreshTokens, tokensStale, type CodexTokens } from './codex-auth.js';
|
|
7
|
+
import { refreshCodeTokens } from './codex-device.js';
|
|
8
|
+
import { codexVersion, learnCodexVersion } from './codex-version.js';
|
|
6
9
|
import { CodexEventTranslator, withUsageLink } from './codex-stream.js';
|
|
7
10
|
import { ToolNames, toResponsesRequest } from './codex-translate.js';
|
|
8
11
|
import { GatewayError, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
|
|
@@ -12,6 +15,7 @@ import { listCache } from './model-lists.js';
|
|
|
12
15
|
import { answerWhole, currentOf, errorKind, reach, refreshed, relayTranslated, sessionHeader, type TokenSource, type TokenState } from './subscription.js';
|
|
13
16
|
import { noteUsageHeaders } from './usage.js';
|
|
14
17
|
|
|
18
|
+
const CODEX_BACKEND = 'https://chatgpt.com/backend-api/codex';
|
|
15
19
|
const INVALID = [400, 404, 422];
|
|
16
20
|
|
|
17
21
|
export interface CodexDeps {
|
|
@@ -30,15 +34,28 @@ const codexLists = listCache<string>('codex');
|
|
|
30
34
|
const sourceOf = (deps: CodexDeps): TokenSource<CodexTokens> => ({
|
|
31
35
|
label: 'Codex',
|
|
32
36
|
stale: (tokens) => tokensStale(tokens),
|
|
33
|
-
refresh: (tokens) => refreshTokens(tokens, deps.issuer, deps.fetchImpl),
|
|
37
|
+
refresh: (tokens) => (tokens.method === 'code' ? refreshCodeTokens(tokens, deps.issuer, deps.fetchImpl) : refreshTokens(tokens, deps.issuer, deps.fetchImpl)),
|
|
34
38
|
save: deps.save,
|
|
35
39
|
});
|
|
36
40
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
const OS_NAMES: Record<string, string> = { darwin: 'Mac OS', linux: 'Linux', win32: 'Windows' };
|
|
42
|
+
|
|
43
|
+
export const userAgent = (): string => `codex_cli_rs/${codexVersion()} (${OS_NAMES[platform()] ?? platform()} ${release()}; ${arch()}) metro`;
|
|
44
|
+
|
|
45
|
+
const baseFor = (tokens: CodexTokens, deps: Pick<CodexDeps, 'base'>): string => deps.base ?? (tokens.method === 'code' ? CODEX_BACKEND : OPENAI_API);
|
|
46
|
+
|
|
47
|
+
function headersFor(tokens: CodexTokens, accept: string, sessionId: string): Record<string, string> {
|
|
48
|
+
const plain = { authorization: `Bearer ${tokens.accessToken}`, 'content-type': 'application/json', accept };
|
|
49
|
+
if (tokens.method === 'chatgpt') return plain;
|
|
50
|
+
return {
|
|
51
|
+
...plain,
|
|
52
|
+
'chatgpt-account-id': tokens.accountId,
|
|
53
|
+
originator: 'codex_cli_rs',
|
|
54
|
+
'user-agent': userAgent(),
|
|
55
|
+
'openai-beta': 'responses=experimental',
|
|
56
|
+
'session-id': sessionId,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
42
59
|
|
|
43
60
|
export const currentTokens = (conn: Connection, deps: CodexDeps, state: CodexState): Promise<CodexTokens> =>
|
|
44
61
|
currentOf(state, conn.id, conn.codex, sourceOf(deps), CODEX_SIGN_IN);
|
|
@@ -54,9 +71,9 @@ interface Call {
|
|
|
54
71
|
|
|
55
72
|
function send(call: Call, tokens: CodexTokens): Promise<Response> {
|
|
56
73
|
const request = toResponsesRequest(call.body, call.model, { promptCacheKey: call.sessionId, names: call.names });
|
|
57
|
-
return (call.deps.fetchImpl ?? fetch)(`${call.deps
|
|
74
|
+
return (call.deps.fetchImpl ?? fetch)(`${baseFor(tokens, call.deps)}/responses`, {
|
|
58
75
|
method: 'POST',
|
|
59
|
-
headers: headersFor(tokens, 'text/event-stream'),
|
|
76
|
+
headers: headersFor(tokens, 'text/event-stream', call.sessionId),
|
|
60
77
|
body: JSON.stringify(request),
|
|
61
78
|
signal: call.watch.signal,
|
|
62
79
|
redirect: 'manual',
|
|
@@ -110,14 +127,17 @@ export async function codexMessages(
|
|
|
110
127
|
}
|
|
111
128
|
|
|
112
129
|
export async function codexModels(tokens: CodexTokens, deps: Omit<CodexDeps, 'save'>): Promise<string[]> {
|
|
113
|
-
const base = deps
|
|
114
|
-
|
|
130
|
+
const base = baseFor(tokens, deps);
|
|
131
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
132
|
+
if (tokens.method === 'chatgpt') return codexLists.get(`${base}:${tokens.clientId}`, () => listCodex(tokens, `${base}/models`, fetchImpl));
|
|
133
|
+
const version = await learnCodexVersion();
|
|
134
|
+
return codexLists.get(`${base}:${tokens.accountId}:${version}`, () => listCodex(tokens, `${base}/models?client_version=${version}`, fetchImpl));
|
|
115
135
|
}
|
|
116
136
|
|
|
117
|
-
async function listCodex(tokens: CodexTokens,
|
|
118
|
-
const res = await fetchImpl(
|
|
137
|
+
async function listCodex(tokens: CodexTokens, url: string, fetchImpl: typeof fetch): Promise<string[]> {
|
|
138
|
+
const res = await fetchImpl(url, { headers: headersFor(tokens, 'application/json', randomUUID()), redirect: 'manual' });
|
|
119
139
|
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
140
|
const body: unknown = await res.json();
|
|
121
141
|
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)] : []));
|
|
142
|
+
return models.filter(isRecord).flatMap((m) => ((m.visibility ?? 'list') === 'list' && stringOf(m.slug) !== '' ? [stringOf(m.slug)] : []));
|
|
123
143
|
}
|
|
@@ -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, GEMINI_ROUTES } from './model-signin.js';
|
|
25
|
+
import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES } from './model-signin.js';
|
|
26
26
|
import { refreshUsage } from './usage-refresh.js';
|
|
27
27
|
|
|
28
28
|
const PATH = '/api/model';
|
|
@@ -36,6 +36,8 @@ 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}$/;
|
|
39
41
|
|
|
40
42
|
export type { ModelApiDeps } from './model-store.js';
|
|
41
43
|
|
|
@@ -162,15 +164,21 @@ function connectionsRoute(path: string, method: string | undefined): Route | num
|
|
|
162
164
|
return 405;
|
|
163
165
|
}
|
|
164
166
|
|
|
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
|
+
|
|
165
174
|
const TABLES: [string, Record<string, Route>][] = [
|
|
166
175
|
[OPENROUTER, OPENROUTER_ROUTES],
|
|
167
176
|
[ANTHROPIC, ANTHROPIC_ROUTES],
|
|
168
177
|
[BEDROCK, BEDROCK_ROUTES],
|
|
169
178
|
[GEMINI, GEMINI_ROUTES],
|
|
170
|
-
[CODEX, CODEX_ROUTES],
|
|
171
179
|
];
|
|
172
180
|
const EXACT = new Set([PATH, FALLBACKS, BUNDLE, RESTORE, CONNECTIONS]);
|
|
173
|
-
const PREFIXES = [`${CONNECTIONS}/`, ...TABLES.map(([prefix]) => prefix)];
|
|
181
|
+
const PREFIXES = [`${CONNECTIONS}/`, CODEX, ...TABLES.map(([prefix]) => prefix)];
|
|
174
182
|
|
|
175
183
|
const mine = (path: string): boolean => EXACT.has(path) || PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
176
184
|
|
|
@@ -179,7 +187,8 @@ function routeFor(path: string, method: string | undefined): Route | number {
|
|
|
179
187
|
if (path === BUNDLE || path === RESTORE) return bundleRoute(path, method);
|
|
180
188
|
if (path === CONNECTIONS || path.startsWith(`${CONNECTIONS}/`)) return connectionsRoute(path, method);
|
|
181
189
|
const table = TABLES.find(([prefix]) => path.startsWith(prefix));
|
|
182
|
-
|
|
190
|
+
if (table !== undefined) return named(table[1], path.slice(table[0].length), method);
|
|
191
|
+
return codexRoute(path.slice(CODEX.length), method);
|
|
183
192
|
}
|
|
184
193
|
|
|
185
194
|
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 { CODEX_SIGN_IN, NEW_CLIENT, type CodexTokens } from './codex-auth.js';
|
|
6
|
+
import { CODEX_SIGN_IN, NEW_CLIENT, type CodexCredential, type CodexMethod, 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;
|
|
@@ -89,15 +89,11 @@ function claudeFromDisk(raw: unknown): ClaudeLogin | null {
|
|
|
89
89
|
return { id: text(raw.id), email: maybe(raw.email), plan: maybe(raw.plan), savedAt: text(raw.savedAt) };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
function
|
|
93
|
-
if (!isRecord(raw)) return null;
|
|
94
|
-
const clientId = text(raw.clientId);
|
|
92
|
+
function credentialFromDisk(raw: Record<string, unknown>): CodexCredential | null {
|
|
95
93
|
const accessToken = text(raw.accessToken);
|
|
96
94
|
const refreshToken = text(raw.refreshToken);
|
|
97
|
-
if (
|
|
95
|
+
if (accessToken === '' || refreshToken === '') return null;
|
|
98
96
|
return {
|
|
99
|
-
clientId,
|
|
100
|
-
subject: text(raw.subject),
|
|
101
97
|
email: maybe(raw.email),
|
|
102
98
|
accessToken,
|
|
103
99
|
refreshToken,
|
|
@@ -106,6 +102,16 @@ function codexFromDisk(raw: unknown): CodexTokens | null {
|
|
|
106
102
|
};
|
|
107
103
|
}
|
|
108
104
|
|
|
105
|
+
function codexFromDisk(raw: unknown): CodexTokens | null {
|
|
106
|
+
if (!isRecord(raw)) return null;
|
|
107
|
+
const held = credentialFromDisk(raw);
|
|
108
|
+
if (held === null) return null;
|
|
109
|
+
const accountId = text(raw.accountId);
|
|
110
|
+
if (accountId !== '') return { method: 'code', accountId, plan: maybe(raw.plan), ...held };
|
|
111
|
+
const clientId = text(raw.clientId);
|
|
112
|
+
return clientId === '' || clientId === NEW_CLIENT ? null : { method: 'chatgpt', clientId, subject: text(raw.subject), ...held };
|
|
113
|
+
}
|
|
114
|
+
|
|
109
115
|
function connectionFromDisk(raw: unknown): Connection | null {
|
|
110
116
|
if (!isRecord(raw) || !isProvider(raw.provider)) return null;
|
|
111
117
|
const id = text(raw.id);
|
|
@@ -233,9 +239,9 @@ export const routeLabel = (route: Route): string =>
|
|
|
233
239
|
|
|
234
240
|
export const PASSTHROUGH_ID = 'passthrough';
|
|
235
241
|
|
|
236
|
-
function signedInAs(c: Connection): { account: string | null; plan: string | null } {
|
|
242
|
+
function signedInAs(c: Connection): { account: string | null; plan: string | null; method?: CodexMethod } {
|
|
237
243
|
if (c.claude !== null) return { account: c.claude.email, plan: c.claude.plan };
|
|
238
|
-
if (c.codex !== null) return { account: c.codex.email, plan: null };
|
|
244
|
+
if (c.codex !== null) return { account: c.codex.email, plan: c.codex.method === 'code' ? c.codex.plan : null, method: c.codex.method };
|
|
239
245
|
return { account: c.gemini?.email ?? null, plan: c.gemini?.tier ?? null };
|
|
240
246
|
}
|
|
241
247
|
|
|
@@ -3,7 +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, chatgptHostId, finishLogin, type CodexTokens } from './codex-auth.js';
|
|
6
|
+
import { beginLogin, chatgptHostId, finishLogin, type ChatgptTokens, type CodexTokens } from './codex-auth.js';
|
|
7
|
+
import { beginDeviceLogin, pollDeviceLogin } from './codex-device.js';
|
|
7
8
|
import { codexModels, currentTokens, sharedCodexState, type CodexDeps } from './codex.js';
|
|
8
9
|
import { beginLogin as beginGeminiLogin, exchangeCode as exchangeGeminiCode, userEmail, type GeminiTokens } from './gemini-auth.js';
|
|
9
10
|
import { onboard, parseGeminiProject } from './gemini-setup.js';
|
|
@@ -48,7 +49,22 @@ const codexDepsFor = (deps: ModelApiDeps, store: Store, id: string): CodexDeps =
|
|
|
48
49
|
},
|
|
49
50
|
});
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
function renewing(store: Store, req: IncomingMessage): ChatgptTokens | null {
|
|
53
|
+
if (askedConnection(req) === '') return null;
|
|
54
|
+
const tokens = connectionFor(store.read(), req, 'codex').codex;
|
|
55
|
+
return tokens?.method === 'chatgpt' ? tokens : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function keepCodex(store: Store, req: IncomingMessage, tokens: CodexTokens, note: string): unknown {
|
|
59
|
+
const { cfg, id } = connectionToFill(store, req, 'codex');
|
|
60
|
+
return saved(store, setCodexAuth(cfg, id, tokens), note, { connection: id, method: tokens.method });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function pollDevice(id: string, req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
|
|
64
|
+
const result = await pollDeviceLogin(id, deps.fetchImpl).catch(asApiError);
|
|
65
|
+
if (result.status !== 'done') return result;
|
|
66
|
+
return { status: 'done', settings: keepCodex(store, req, result.tokens, 'model-api: Codex connected with a code') };
|
|
67
|
+
}
|
|
52
68
|
|
|
53
69
|
export const CODEX_ROUTES: Record<string, Route> = {
|
|
54
70
|
login: {
|
|
@@ -61,8 +77,14 @@ export const CODEX_ROUTES: Record<string, Route> = {
|
|
|
61
77
|
const body = await readJsonBody(req, BODY_MAX);
|
|
62
78
|
const raw = isRecord(body) && typeof body.url === 'string' ? body.url : '';
|
|
63
79
|
const tokens = await finishLogin(raw, deps.issuer, deps.fetchImpl).catch(asApiError);
|
|
64
|
-
|
|
65
|
-
|
|
80
|
+
return keepCodex(store, req, tokens, 'model-api: Codex connected with Sign in with ChatGPT');
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
device: {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
run: async (_req, deps) => {
|
|
86
|
+
const login = await beginDeviceLogin(deps.issuer, deps.fetchImpl).catch(asApiError);
|
|
87
|
+
return { id: login.id, user_code: login.userCode, verify_url: login.verifyUrl, interval: login.interval };
|
|
66
88
|
},
|
|
67
89
|
},
|
|
68
90
|
models: {
|
|
@@ -129,3 +151,7 @@ export const GEMINI_ROUTES: Record<string, Route> = {
|
|
|
129
151
|
run: async (req, deps, store) => ({ models: await geminiIds(req, deps, store).catch(asApiError) }),
|
|
130
152
|
},
|
|
131
153
|
};
|
|
154
|
+
|
|
155
|
+
export function codexDeviceRoute(id: string, method: string | undefined): Route | number {
|
|
156
|
+
return method === 'GET' ? { method: 'GET', run: (req, deps, store) => pollDevice(id, req, deps, store) } : 405;
|
|
157
|
+
}
|
package/runtime/runtime.json
CHANGED