@stage-labs/metro 0.1.0-beta.254 → 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.254",
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.254",
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",
@@ -1,69 +1,123 @@
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
10
  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';
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 is not signed in: sign in with ChatGPT 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
- export interface CodexTokens {
26
+ export type CodexMethod = 'chatgpt' | 'code';
27
+
28
+ export interface CodexCredential {
29
+ email: string | null;
17
30
  accessToken: string;
18
31
  refreshToken: string;
19
- idToken: string;
32
+ expiresAt: number;
33
+ savedAt: string;
34
+ }
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';
20
44
  accountId: string;
21
- email: string | null;
22
45
  plan: string | null;
23
- savedAt: string;
24
46
  }
25
47
 
48
+ export type CodexTokens = ChatgptTokens | CodeTokens;
49
+
26
50
  export class CodexAuthError extends Error {}
27
51
 
28
- const pending = new PendingLogins(24);
52
+ interface Pending {
53
+ verifier: string;
54
+ nonce: string;
55
+ clientId: string | null;
56
+ subject: string | null;
57
+ }
58
+
59
+ const pending = ticketStore<Pending>(PENDING_TTL_MS, 24);
60
+
61
+ export function chatgptHostId(dir = agentsDir()): string {
62
+ const path = join(dir, HOST_FILE);
63
+ const saved = readJson<unknown>(path, null);
64
+ if (isRecord(saved) && typeof saved.hostId === 'string' && HOST_RE.test(saved.hostId)) return saved.hostId;
65
+ const made = `urn:uuid:${randomUUID()}`;
66
+ writeSecure(path, JSON.stringify({ hostId: made }));
67
+ return made;
68
+ }
69
+
70
+ function returning(previous: ChatgptTokens | null): Record<string, string> {
71
+ if (previous === null) return { client_id: NEW_CLIENT, agent_name_hint: APP_NAME };
72
+ return { client_id: previous.clientId, ...(previous.email === null ? {} : { login_hint: previous.email }) };
73
+ }
29
74
 
30
- export function authorizeUrl(state: string, challenge: string, issuer = CODEX_ISSUER): string {
75
+ export function beginLogin(previous: ChatgptTokens | null, hostId: string, issuer = CODEX_ISSUER, now = Date.now()): string {
76
+ const { verifier, challenge } = newPkce();
77
+ const nonce = randomBytes(32).toString('base64url');
78
+ const { ticket: state } = pending.mint({ verifier, nonce, clientId: previous?.clientId ?? null, subject: previous?.subject ?? null }, now);
31
79
  const query = new URLSearchParams({
80
+ ...returning(previous),
81
+ ext_agent_host_id: hostId,
32
82
  response_type: 'code',
33
- client_id: CODEX_CLIENT_ID,
34
83
  redirect_uri: CODEX_REDIRECT,
35
84
  scope: SCOPE,
36
- code_challenge: challenge,
37
- code_challenge_method: 'S256',
38
- id_token_add_organizations: 'true',
39
- codex_cli_simplified_flow: 'true',
85
+ resource: OPENAI_API,
40
86
  state,
41
- originator: 'codex_cli_rs',
87
+ nonce,
88
+ code_challenge_method: 'S256',
89
+ code_challenge: challenge,
42
90
  });
43
- return `${issuer}/oauth/authorize?${query.toString()}`;
44
- }
45
-
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 };
91
+ return `${issuer}/api/accounts/authorize?${query.toString()}`;
49
92
  }
50
93
 
51
- export function parseCallback(raw: string): { code: string; state: string } {
94
+ function queryOf(raw: string): URLSearchParams {
52
95
  const text = raw.trim();
53
- let query: URLSearchParams;
54
96
  try {
55
- query = new URL(text).searchParams;
97
+ return new URL(text).searchParams;
56
98
  } catch {
57
- query = new URLSearchParams(text.replace(/^\?/, ''));
99
+ return new URLSearchParams(text.replace(/^\?/, ''));
58
100
  }
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 };
64
101
  }
65
102
 
66
- function decodeJwtPayload(jwt: string): Record<string, unknown> {
103
+ const param = (query: URLSearchParams, name: string): string => query.get(name)?.trim() ?? '';
104
+
105
+ function assertAllowed(query: URLSearchParams): void {
106
+ const error = param(query, 'error');
107
+ if (error === 'access_denied') throw new CodexAuthError('you did not allow Metro to use your ChatGPT plan, so nothing was saved');
108
+ if (error !== '') throw new CodexAuthError(`ChatGPT stopped the sign-in (${query.get('error_description') ?? error}); ${AGAIN}`);
109
+ }
110
+
111
+ export function parseCallback(raw: string): { code: string; state: string; clientId: string | null } {
112
+ const query = queryOf(raw);
113
+ assertAllowed(query);
114
+ const code = param(query, 'code');
115
+ const state = param(query, 'state');
116
+ if (code === '' || state === '') throw new CodexAuthError('paste the whole address the browser landed on; it carries code= and state=');
117
+ return { code, state, clientId: nonEmpty(param(query, 'client_id')) };
118
+ }
119
+
120
+ export function decodeJwtPayload(jwt: string): Record<string, unknown> {
67
121
  const part = jwt.split('.')[1] ?? '';
68
122
  try {
69
123
  const parsed: unknown = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
@@ -73,142 +127,93 @@ function decodeJwtPayload(jwt: string): Record<string, unknown> {
73
127
  }
74
128
  }
75
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
+ }
76
134
 
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
- };
135
+ function identityOf(idToken: unknown, expected: { issuer: string; clientId: string; nonce: string }): { subject: string; email: string | null } {
136
+ const claims = decodeJwtPayload(typeof idToken === 'string' ? idToken : '');
137
+ const audience: unknown[] = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
138
+ const subject = nonEmpty(claims.sub);
139
+ if (subject === null || claims.iss !== expected.issuer || !audience.includes(expected.clientId) || claims.nonce !== expected.nonce)
140
+ throw new CodexAuthError(`OpenAI sent back an identity that does not match this sign-in; ${AGAIN}`);
141
+ return { subject, email: emailOf(claims) };
86
142
  }
87
143
 
88
- interface TokenResponse {
89
- id_token?: unknown;
90
- access_token?: unknown;
91
- refresh_token?: unknown;
144
+ interface Encoded {
145
+ type: string;
146
+ body: string;
92
147
  }
93
148
 
94
- const EMPTY_TOKENS: CodexTokens = { accessToken: '', refreshToken: '', idToken: '', accountId: '', email: null, plan: null, savedAt: '' };
149
+ export const asForm = (fields: Record<string, string>): Encoded => ({ type: 'application/x-www-form-urlencoded', body: new URLSearchParams(fields).toString() });
95
150
 
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;
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 });
155
+ const raw = await res.text();
156
+ let parsed: unknown = null;
157
+ try {
158
+ parsed = JSON.parse(raw);
159
+ } catch {
160
+ parsed = null;
161
+ }
162
+ const body = isRecord(parsed) ? parsed : {};
163
+ 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)}`);
164
+ return body;
100
165
  }
101
166
 
102
- const pick = (fresh: string | null, kept: string | null): string | null => fresh ?? kept;
167
+ export function grantOf(body: Record<string, unknown>, keptRefresh: string, now: number): Omit<CodexCredential, 'email'> {
168
+ const accessToken = nonEmpty(body.access_token) ?? '';
169
+ const refreshToken = nonEmpty(body.refresh_token) ?? keptRefresh;
170
+ if (accessToken === '' || refreshToken === '') throw new CodexAuthError('OpenAI returned no usable tokens; sign in again');
171
+ const ttl = typeof body.expires_in === 'number' && body.expires_in > 0 ? body.expires_in * 1000 : DEFAULT_TTL_MS;
172
+ return { accessToken, refreshToken, expiresAt: now + ttl, savedAt: new Date(now).toISOString() };
173
+ }
103
174
 
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(),
117
- });
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
+
185
+ function clientOf(started: Pending, returned: string | null): string {
186
+ const clientId = started.clientId ?? returned;
187
+ if (clientId === null || clientId === NEW_CLIENT) throw new CodexAuthError(`ChatGPT did not finish registering Metro; ${AGAIN}`);
188
+ if (returned !== null && returned !== clientId) throw new CodexAuthError(`ChatGPT answered for another Metro registration; ${AGAIN}`);
189
+ return clientId;
118
190
  }
119
191
 
120
- async function tokenCall(issuer: string, init: RequestInit, fetchImpl: typeof fetch): Promise<unknown> {
121
- const res = await fetchImpl(`${issuer}/oauth/token`, init);
122
- 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}`);
132
- }
133
- return JSON.parse(raw) as unknown;
134
- }
135
-
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);
146
- }
147
-
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(
164
- issuer,
165
- { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString() },
192
+ export async function finishLogin(callback: string, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<ChatgptTokens> {
193
+ const { code, state, clientId: returned } = parseCallback(callback);
194
+ const started = pending.take(state, now);
195
+ if (started === undefined) throw new CodexAuthError(`this sign-in link has expired or was started elsewhere; ${AGAIN}`);
196
+ const clientId = clientOf(started, returned);
197
+ const body = await tokenCall(
198
+ officialToken(issuer),
199
+ asForm({ grant_type: 'authorization_code', client_id: clientId, code, code_verifier: started.verifier, redirect_uri: CODEX_REDIRECT, resource: OPENAI_API }),
166
200
  fetchImpl,
167
201
  );
168
- return tokensFrom(res, null, new Date(now));
202
+ const who = identityOf(body.id_token, { issuer, clientId, nonce: started.nonce });
203
+ if (started.subject !== null && who.subject !== started.subject)
204
+ throw new CodexAuthError('this is another ChatGPT account than the one this connection signed in with; add a new connection for it');
205
+ const scopes = (nonEmpty(body.scope) ?? '').split(/\s+/);
206
+ 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)');
207
+ return tokensFrom(body, { clientId, ...who }, '', now);
169
208
  }
170
209
 
171
- export async function refreshTokens(previous: CodexTokens, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch): Promise<CodexTokens> {
172
- const res = await tokenCall(
173
- 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
- },
210
+ export async function refreshTokens(previous: ChatgptTokens, issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<ChatgptTokens> {
211
+ const body = await tokenCall(
212
+ officialToken(issuer),
213
+ asForm({ grant_type: 'refresh_token', client_id: previous.clientId, refresh_token: previous.refreshToken, resource: OPENAI_API }),
179
214
  fetchImpl,
180
215
  );
181
- return tokensFrom(res, previous);
216
+ return tokensFrom(body, previous, previous.refreshToken, now);
182
217
  }
183
218
 
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
- }
219
+ export const tokensStale = (tokens: CodexTokens, now = Date.now()): boolean => now >= tokens.expiresAt - RENEW_BEFORE_MS;
@@ -1,13 +1,17 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { errMsg } from '@metro-labs/core/log';
4
- import { CODEX_CLIENT_ID, CODEX_ISSUER, CodexAuthError, exchangeCode, type CodexTokens } from './codex-auth.js';
5
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';
6
7
 
8
+ const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
9
+ const AUTH_CLAIM = 'https://api.openai.com/auth';
7
10
  const VERIFY_PATH = '/codex/device';
8
11
  const DEVICE_TTL_MS = 15 * 60_000;
9
12
  const DEFAULT_INTERVAL_S = 5;
10
13
  const ID_BYTES = 18;
14
+ const AGAIN = 'press Sign in with a code again';
11
15
 
12
16
  export interface DeviceLogin {
13
17
  id: string;
@@ -16,7 +20,7 @@ export interface DeviceLogin {
16
20
  interval: number;
17
21
  }
18
22
 
19
- export type DeviceStatus = { status: 'pending' } | { status: 'done'; tokens: CodexTokens } | { status: 'failed'; error: string };
23
+ export type DeviceStatus = { status: 'pending' } | { status: 'done'; tokens: CodeTokens } | { status: 'failed'; error: string };
20
24
 
21
25
  interface Entry {
22
26
  deviceAuthId: string;
@@ -29,6 +33,26 @@ interface Entry {
29
33
 
30
34
  const logins = new Map<string, Entry>();
31
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
+ }
32
56
 
33
57
  function intervalOf(value: unknown): number {
34
58
  const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
@@ -45,8 +69,8 @@ const post = (url: string, body: unknown, fetchImpl: typeof fetch): Promise<Resp
45
69
  export async function beginDeviceLogin(issuer = CODEX_ISSUER, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<DeviceLogin> {
46
70
  sweep(now);
47
71
  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)})`);
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)})`);
50
74
  const body: unknown = await res.json();
51
75
  const parsed = isRecord(body) ? body : {};
52
76
  const deviceAuthId = stringOf(parsed.device_auth_id);
@@ -64,20 +88,24 @@ async function claim(entry: Entry, fetchImpl: typeof fetch, now: number): Promis
64
88
  await res.body?.cancel();
65
89
  return { status: 'pending' };
66
90
  }
67
- if (!res.ok) return { status: 'failed', error: `OpenAI ended the device-code sign-in (${String(res.status)})` };
91
+ if (!res.ok) return { status: 'failed', error: `OpenAI ended the code sign-in (${String(res.status)})` };
68
92
  const body: unknown = await res.json();
69
93
  const parsed = isRecord(body) ? body : {};
70
94
  const code = stringOf(parsed.authorization_code);
71
95
  const verifier = stringOf(parsed.code_verifier);
72
96
  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 };
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) };
75
103
  }
76
104
 
77
105
  export async function pollDeviceLogin(id: string, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<DeviceStatus> {
78
106
  sweep(now);
79
107
  const entry = logins.get(id);
80
- if (entry === undefined) throw new CodexAuthError('this sign-in has expired or already finished; press Connect again');
108
+ if (entry === undefined) throw new CodexAuthError(`this sign-in has expired or already finished; ${AGAIN}`);
81
109
  if (entry.polledAt !== null && now - entry.polledAt < entry.interval * 1000) return { status: 'pending' };
82
110
  entry.polledAt = now;
83
111
  let result: DeviceStatus;
@@ -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,22 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http';
3
3
  import { arch, platform, release } from 'node:os';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+ import { stringOf } from '@metro-labs/http/api-http';
6
+ import { OPENAI_API, CODEX_SIGN_IN, refreshTokens, tokensStale, type CodexTokens } from './codex-auth.js';
7
+ import { refreshCodeTokens } from './codex-device.js';
4
8
  import { codexVersion, learnCodexVersion } from './codex-version.js';
5
- import { refreshTokens, tokensStale, type CodexTokens } from './codex-auth.js';
6
- import { CodexEventTranslator } from './codex-stream.js';
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';
9
12
  import { parseEvent, SseParser, type SseEvent } from './frames.js';
10
13
  import type { Connection } from './model-config.js';
11
14
  import { listCache } from './model-lists.js';
12
15
  import { answerWhole, currentOf, errorKind, reach, refreshed, relayTranslated, sessionHeader, type TokenSource, type TokenState } from './subscription.js';
13
- import { codexUsageBody, noteUsageHeaders, type Reported } from './usage.js';
16
+ import { noteUsageHeaders } from './usage.js';
14
17
 
15
- const CODEX_BASE = 'https://chatgpt.com/backend-api/codex';
18
+ const CODEX_BACKEND = 'https://chatgpt.com/backend-api/codex';
16
19
  const INVALID = [400, 404, 422];
17
- const USAGE_TIMEOUT_MS = 10_000;
18
20
 
19
21
  export interface CodexDeps {
20
22
  base?: string;
@@ -32,7 +34,7 @@ const codexLists = listCache<string>('codex');
32
34
  const sourceOf = (deps: CodexDeps): TokenSource<CodexTokens> => ({
33
35
  label: 'Codex',
34
36
  stale: (tokens) => tokensStale(tokens),
35
- 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)),
36
38
  save: deps.save,
37
39
  });
38
40
 
@@ -40,12 +42,14 @@ const OS_NAMES: Record<string, string> = { darwin: 'Mac OS', linux: 'Linux', win
40
42
 
41
43
  export const userAgent = (): string => `codex_cli_rs/${codexVersion()} (${OS_NAMES[platform()] ?? platform()} ${release()}; ${arch()}) metro`;
42
44
 
43
- function headersFor(tokens: CodexTokens, sessionId: string): Record<string, string> {
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;
44
50
  return {
45
- authorization: `Bearer ${tokens.accessToken}`,
51
+ ...plain,
46
52
  'chatgpt-account-id': tokens.accountId,
47
- 'content-type': 'application/json',
48
- accept: 'text/event-stream',
49
53
  originator: 'codex_cli_rs',
50
54
  'user-agent': userAgent(),
51
55
  'openai-beta': 'responses=experimental',
@@ -54,7 +58,7 @@ function headersFor(tokens: CodexTokens, sessionId: string): Record<string, stri
54
58
  }
55
59
 
56
60
  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.');
61
+ currentOf(state, conn.id, conn.codex, sourceOf(deps), CODEX_SIGN_IN);
58
62
 
59
63
  interface Call {
60
64
  body: Record<string, unknown>;
@@ -67,15 +71,26 @@ interface Call {
67
71
 
68
72
  function send(call: Call, tokens: CodexTokens): Promise<Response> {
69
73
  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`, {
74
+ return (call.deps.fetchImpl ?? fetch)(`${baseFor(tokens, call.deps)}/responses`, {
71
75
  method: 'POST',
72
- headers: headersFor(tokens, call.sessionId),
76
+ headers: headersFor(tokens, 'text/event-stream', call.sessionId),
73
77
  body: JSON.stringify(request),
74
78
  signal: call.watch.signal,
75
79
  redirect: 'manual',
76
80
  });
77
81
  }
78
82
 
83
+ function refusalOf(text: string, status: number): string {
84
+ let code = '';
85
+ try {
86
+ const parsed: unknown = JSON.parse(text);
87
+ code = isRecord(parsed) && isRecord(parsed.error) ? stringOf(parsed.error.code) : '';
88
+ } catch {
89
+ code = '';
90
+ }
91
+ return withUsageLink(code, upstreamMessage(text, `OpenAI answered ${String(status)}`));
92
+ }
93
+
79
94
  export async function codexMessages(
80
95
  req: IncomingMessage,
81
96
  res: ServerResponse,
@@ -95,7 +110,7 @@ export async function codexMessages(
95
110
  noteUsageHeaders('codex', conn.id, upstream.headers);
96
111
  if (!upstream.ok) {
97
112
  const text = await upstream.text();
98
- sendError(res, providerStatus(upstream.status), errorKind(upstream.status, INVALID), upstreamMessage(text, `Codex answered ${String(upstream.status)}`));
113
+ sendError(res, providerStatus(upstream.status), errorKind(upstream.status, INVALID), refusalOf(text, upstream.status));
99
114
  return;
100
115
  }
101
116
  const translator = new CodexEventTranslator(model, (name) => call.names.restore(name));
@@ -111,45 +126,18 @@ export async function codexMessages(
111
126
  answerWhole(res, frames + translator.close(), conn.id);
112
127
  }
113
128
 
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
129
  export async function codexModels(tokens: CodexTokens, deps: Omit<CodexDeps, 'save'>): Promise<string[]> {
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
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));
134
+ return codexLists.get(`${base}:${tokens.accountId}:${version}`, () => listCodex(tokens, `${base}/models?client_version=${version}`, fetchImpl));
136
135
  }
137
136
 
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];
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' });
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)})`);
140
+ const body: unknown = await res.json();
141
+ const models: unknown[] = isRecord(body) && Array.isArray(body.models) ? body.models : [];
142
+ return models.filter(isRecord).flatMap((m) => ((m.visibility ?? 'list') === 'list' && stringOf(m.slug) !== '' ? [stringOf(m.slug)] : []));
155
143
  }
@@ -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 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,23 +89,29 @@ 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;
92
+ function credentialFromDisk(raw: Record<string, unknown>): CodexCredential | null {
94
93
  const accessToken = text(raw.accessToken);
95
94
  const refreshToken = text(raw.refreshToken);
96
- const accountId = text(raw.accountId);
97
- if (accessToken === '' || refreshToken === '' || accountId === '') return null;
95
+ if (accessToken === '' || refreshToken === '') return null;
98
96
  return {
97
+ email: maybe(raw.email),
99
98
  accessToken,
100
99
  refreshToken,
101
- idToken: text(raw.idToken),
102
- accountId,
103
- email: maybe(raw.email),
104
- plan: maybe(raw.plan),
100
+ expiresAt: typeof raw.expiresAt === 'number' && Number.isFinite(raw.expiresAt) ? raw.expiresAt : 0,
105
101
  savedAt: text(raw.savedAt) === '' ? new Date(0).toISOString() : text(raw.savedAt),
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);
@@ -186,7 +192,7 @@ const CHECKS: Record<Provider, [(c: Connection) => boolean, string][]> = {
186
192
  [(c) => c.model === '', 'OpenRouter needs a model id: choose one on the Model page.'],
187
193
  ],
188
194
  codex: [
189
- [(c) => c.codex === null, 'Codex is not connected: sign in with ChatGPT on the Model page.'],
195
+ [(c) => c.codex === null, CODEX_SIGN_IN],
190
196
  [(c) => c.model === '', 'Codex needs a model id: choose one on the Model page.'],
191
197
  ],
192
198
  gemini: [
@@ -233,11 +239,10 @@ 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.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 };
244
+ if (c.codex !== null) return { account: c.codex.email, plan: c.codex.method === 'code' ? c.codex.plan : null, method: c.codex.method };
245
+ return { account: c.gemini?.email ?? null, plan: c.gemini?.tier ?? null };
241
246
  }
242
247
 
243
248
  function publicConnection(c: Connection): Record<string, unknown> {
@@ -3,9 +3,9 @@ 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';
6
+ import { beginLogin, chatgptHostId, finishLogin, type ChatgptTokens, type CodexTokens } from './codex-auth.js';
7
7
  import { beginDeviceLogin, pollDeviceLogin } from './codex-device.js';
8
- import { codexModels, currentTokens, sharedCodexState } from './codex.js';
8
+ import { codexModels, currentTokens, sharedCodexState, type CodexDeps } from './codex.js';
9
9
  import { beginLogin as beginGeminiLogin, exchangeCode as exchangeGeminiCode, userEmail, type GeminiTokens } from './gemini-auth.js';
10
10
  import { onboard, parseGeminiProject } from './gemini-setup.js';
11
11
  import { currentGeminiTokens, listGeminiModels, sharedGeminiState, type GeminiDeps } from './gemini.js';
@@ -40,21 +40,7 @@ const saved = (store: Store, cfg: ModelConfig, note: string, fields: { connectio
40
40
  return settingsBody(cfg);
41
41
  };
42
42
 
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 } => ({
43
+ const codexDepsFor = (deps: ModelApiDeps, store: Store, id: string): CodexDeps => ({
58
44
  issuer: deps.issuer,
59
45
  fetchImpl: deps.fetchImpl,
60
46
  base: deps.codexBase,
@@ -63,34 +49,42 @@ export const codexDepsFor = (deps: ModelApiDeps, store: Store, id: string): { is
63
49
  },
64
50
  });
65
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
+ }
68
+
66
69
  export const CODEX_ROUTES: Record<string, Route> = {
67
- device: {
70
+ login: {
68
71
  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
- },
72
+ run: (req, deps, store) => Promise.resolve({ url: beginLogin(renewing(store, req), chatgptHostId(deps.setup?.agents), deps.issuer) }),
73
73
  },
74
- login: { method: 'POST', run: (_req, deps) => Promise.resolve({ url: beginLogin(deps.issuer).url }) },
75
74
  callback: {
76
75
  method: 'POST',
77
76
  run: async (req, deps, store) => {
78
77
  const body = await readJsonBody(req, BODY_MAX);
79
78
  const raw = isRecord(body) && typeof body.url === 'string' ? body.url : '';
80
79
  const tokens = await finishLogin(raw, deps.issuer, deps.fetchImpl).catch(asApiError);
81
- return keepCodex(store, req, tokens, 'model-api: Codex connected');
80
+ return keepCodex(store, req, tokens, 'model-api: Codex connected with Sign in with ChatGPT');
82
81
  },
83
82
  },
84
- import: {
83
+ device: {
85
84
  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'));
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 };
94
88
  },
95
89
  },
96
90
  models: {
@@ -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'],
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.254"
2
+ "version": "0.1.0-beta.256"
3
3
  }