@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.255",
3
+ "version": "0.1.0-beta.256",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.255",
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 = "Codex needs a ChatGPT sign-in: Metro now uses OpenAI's official Sign in with ChatGPT, so sign in again on the Model page.";
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 interface CodexTokens {
27
- clientId: string;
28
- subject: string;
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: CodexTokens | null): Record<string, string> {
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: CodexTokens | null, hostId: string, issuer = CODEX_ISSUER, now = Date.now()): string {
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
- const profile = isRecord(claims[PROFILE_CLAIM]) ? claims[PROFILE_CLAIM] : {};
123
- return { subject, email: nonEmpty(claims.email) ?? nonEmpty(profile.email) };
141
+ return { subject, email: emailOf(claims) };
124
142
  }
125
143
 
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(),
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 tokensFrom(body: Record<string, unknown>, who: Pick<CodexTokens, 'clientId' | 'subject' | 'email'>, keptRefresh: string, now: number): CodexTokens {
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(`OpenAI returned no usable tokens; ${AGAIN}`);
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 { clientId: who.clientId, subject: who.subject, email: who.email, accessToken, refreshToken, expiresAt: now + ttl, savedAt: new Date(now).toISOString() };
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<CodexTokens> {
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: CodexTokens, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<CodexTokens> {
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 headersFor = (tokens: CodexTokens, accept: string): Record<string, string> => ({
38
- authorization: `Bearer ${tokens.accessToken}`,
39
- 'content-type': 'application/json',
40
- accept,
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.base ?? OPENAI_API}/responses`, {
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.base ?? OPENAI_API;
114
- return codexLists.get(`${base}:${tokens.clientId}`, () => listCodex(tokens, base, deps.fetchImpl ?? fetch));
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, base: string, fetchImpl: typeof fetch): Promise<string[]> {
118
- const res = await fetchImpl(`${base}/models`, { headers: headersFor(tokens, 'application/json'), redirect: 'manual' });
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
- return table === undefined ? 404 : named(table[1], path.slice(table[0].length), method);
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 codexFromDisk(raw: unknown): CodexTokens | null {
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 (clientId === '' || clientId === NEW_CLIENT || accessToken === '' || refreshToken === '') return null;
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
- const renewing = (store: Store, req: IncomingMessage): CodexTokens | null => (askedConnection(req) === '' ? null : connectionFor(store.read(), req, 'codex').codex);
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
- 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 });
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
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.255"
2
+ "version": "0.1.0-beta.256"
3
3
  }