@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.253",
3
+ "version": "0.1.0-beta.255",
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.253",
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 { readFileSync } from 'node:fs';
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 { PendingLogins } from './pkce.js';
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
- export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
9
- export const CODEX_ISSUER = 'https://auth.openai.com';
10
- export const CODEX_REDIRECT = 'http://localhost:1455/auth/callback';
11
- const SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke';
12
- const AUTH_CLAIM = 'https://api.openai.com/auth';
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 ACCESS_TOKEN_TTL_MS = 55 * 60_000;
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
- idToken: string;
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
- const pending = new PendingLogins(24);
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 authorizeUrl(state: string, challenge: string, issuer = CODEX_ISSUER): string {
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
- code_challenge: challenge,
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
- originator: 'codex_cli_rs',
73
+ nonce,
74
+ code_challenge_method: 'S256',
75
+ code_challenge: challenge,
42
76
  });
43
- return `${issuer}/oauth/authorize?${query.toString()}`;
77
+ return `${issuer}/api/accounts/authorize?${query.toString()}`;
44
78
  }
45
79
 
46
- export function beginLogin(issuer = CODEX_ISSUER, now = Date.now()): { url: string; state: string } {
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
- query = new URL(text).searchParams;
83
+ return new URL(text).searchParams;
56
84
  } catch {
57
- query = new URLSearchParams(text.replace(/^\?/, ''));
85
+ return new URLSearchParams(text.replace(/^\?/, ''));
58
86
  }
59
- const code = query.get('code')?.trim() ?? '';
60
- const state = query.get('state')?.trim() ?? '';
61
- if (code === '' || state === '')
62
- throw new CodexAuthError('paste the whole address the browser landed on; it carries code= and state=');
63
- return { code, state };
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
- export function claimsOf(idToken: string): { accountId: string | null; email: string | null; plan: string | null } {
78
- const payload = decodeJwtPayload(idToken);
79
- const auth = isRecord(payload[AUTH_CLAIM]) ? payload[AUTH_CLAIM] : {};
80
- const profile = isRecord(payload[PROFILE_CLAIM]) ? payload[PROFILE_CLAIM] : {};
81
- return {
82
- accountId: nonEmpty(auth.chatgpt_account_id),
83
- email: nonEmpty(payload.email) ?? nonEmpty(profile.email),
84
- plan: nonEmpty(auth.chatgpt_plan_type),
85
- };
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
- interface TokenResponse {
89
- id_token?: unknown;
90
- access_token?: unknown;
91
- refresh_token?: unknown;
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
- if (!res.ok) {
124
- let detail = raw.slice(0, 300);
125
- try {
126
- const parsed: unknown = JSON.parse(raw);
127
- if (isRecord(parsed)) detail = nonEmpty(parsed.error_description) ?? nonEmpty(parsed.error) ?? detail;
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
- return JSON.parse(raw) as unknown;
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
- export async function finishLogin(
137
- callback: string,
138
- issuer = CODEX_ISSUER,
139
- fetchImpl: typeof fetch = fetch,
140
- now = Date.now(),
141
- ): Promise<CodexTokens> {
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
- export async function exchangeCode(
149
- issuer: string,
150
- code: string,
151
- verifier: string,
152
- redirectUri: string,
153
- fetchImpl: typeof fetch,
154
- now = Date.now(),
155
- ): Promise<CodexTokens> {
156
- const body = new URLSearchParams({
157
- grant_type: 'authorization_code',
158
- code,
159
- redirect_uri: redirectUri,
160
- client_id: CODEX_CLIENT_ID,
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
- { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString() },
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
- return tokensFrom(res, null, new Date(now));
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 res = await tokenCall(
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(res, previous);
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
- return errorFrame(errorKind(stringOf(error.code)), stringOf(error.message) || fallback);
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 { arch, platform, release } from 'node:os';
4
- import { codexVersion, learnCodexVersion } from './codex-version.js';
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 { codexUsageBody, noteUsageHeaders, type Reported } from './usage.js';
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 OS_NAMES: Record<string, string> = { darwin: 'Mac OS', linux: 'Linux', win32: 'Windows' };
40
-
41
- export const userAgent = (): string => `codex_cli_rs/${codexVersion()} (${OS_NAMES[platform()] ?? platform()} ${release()}; ${arch()}) metro`;
42
-
43
- function headersFor(tokens: CodexTokens, sessionId: string): Record<string, string> {
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), 'Codex is not connected: sign in on the Model page.');
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 ?? CODEX_BASE}/responses`, {
57
+ return (call.deps.fetchImpl ?? fetch)(`${call.deps.base ?? OPENAI_API}/responses`, {
71
58
  method: 'POST',
72
- headers: headersFor(tokens, call.sessionId),
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), upstreamMessage(text, `Codex answered ${String(upstream.status)}`));
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 version = await learnCodexVersion();
134
- const base = deps.base ?? CODEX_BASE;
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, version: string, base: string, fetchImpl: typeof fetch): Promise<string[]> {
139
- const res = await fetchImpl(`${base}/models?client_version=${version}`, {
140
- headers: jsonHeaders(tokens),
141
- redirect: 'manual',
142
- });
143
- if (!res.ok) throw new GatewayError(res.status, errorKind(res.status, INVALID), `Codex would not list models (${String(res.status)})`);
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 current = effortOf(body);
43
- if (current === '') return null;
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 wanted === current ? null : wanted;
51
+ return asked.every((effort) => effort === wanted) ? null : wanted;
48
52
  }
49
53
 
50
- export const withEffort = (body: Body, effort: string): Body => ({ ...body, output_config: { ...record(body.output_config), effort } });
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, codexDeviceRoute, GEMINI_ROUTES } from './model-signin.js';
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}/`, CODEX, ...TABLES.map(([prefix]) => prefix)];
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
- if (table !== undefined) return named(table[1], path.slice(table[0].length), method);
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 { claimsOf, type CodexTokens } from './codex-auth.js';
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
- const accountId = text(raw.accountId);
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
- idToken: text(raw.idToken),
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, 'Codex is not connected: sign in with ChatGPT on the Model page.'],
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 === null) return { account: c.gemini?.email ?? null, plan: c.gemini?.tier ?? null };
239
- const claims = claimsOf(c.codex.idToken);
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, finishLogin, readCodexCliAuth, type CodexTokens } from './codex-auth.js';
7
- import { beginDeviceLogin, pollDeviceLogin } from './codex-device.js';
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
- function keepCodex(store: Store, req: IncomingMessage, tokens: CodexTokens | null, note: string): unknown {
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
- device: {
54
+ login: {
68
55
  method: 'POST',
69
- run: async (_req, deps) => {
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
- return keepCodex(store, req, tokens, 'model-api: Codex connected');
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
- }
@@ -15,7 +15,6 @@ export interface ModelApiDeps {
15
15
  write?: (cfg: ModelConfig) => void;
16
16
  issuer?: string;
17
17
  fetchImpl?: typeof fetch;
18
- codexHome?: string;
19
18
  codexBase?: string;
20
19
  geminiAuthBase?: string;
21
20
  geminiTokenBase?: string;
@@ -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 { codexDepsFor, geminiModelsOf } from './model-signin.js';
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 {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.253"
2
+ "version": "0.1.0-beta.255"
3
3
  }
@@ -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
- }