@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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-auth.ts +161 -156
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-device.ts +36 -8
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex-stream.ts +8 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/codex.ts +39 -51
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +19 -14
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-signin.ts +27 -33
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-store.ts +0 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage-refresh.ts +1 -11
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +0 -30
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.256",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -1,69 +1,123 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { homedir } from 'node:os';
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
5
|
-
import {
|
|
4
|
+
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
5
|
+
import { ticketStore } from '@metro-labs/core/tickets';
|
|
6
|
+
import { agentsDir } from '../agents/files.js';
|
|
7
|
+
import { newPkce } from './pkce.js';
|
|
6
8
|
import { nonEmpty } from './text.js';
|
|
7
9
|
|
|
8
|
-
export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
9
10
|
export const CODEX_ISSUER = 'https://auth.openai.com';
|
|
10
|
-
export const CODEX_REDIRECT = 'http://
|
|
11
|
-
const
|
|
12
|
-
const
|
|
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
|
|
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
|
|
26
|
+
export type CodexMethod = 'chatgpt' | 'code';
|
|
27
|
+
|
|
28
|
+
export interface CodexCredential {
|
|
29
|
+
email: string | null;
|
|
17
30
|
accessToken: string;
|
|
18
31
|
refreshToken: string;
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
87
|
+
nonce,
|
|
88
|
+
code_challenge_method: 'S256',
|
|
89
|
+
code_challenge: challenge,
|
|
42
90
|
});
|
|
43
|
-
return `${issuer}/
|
|
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
|
-
|
|
94
|
+
function queryOf(raw: string): URLSearchParams {
|
|
52
95
|
const text = raw.trim();
|
|
53
|
-
let query: URLSearchParams;
|
|
54
96
|
try {
|
|
55
|
-
|
|
97
|
+
return new URL(text).searchParams;
|
|
56
98
|
} catch {
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
refresh_token?: unknown;
|
|
144
|
+
interface Encoded {
|
|
145
|
+
type: string;
|
|
146
|
+
body: string;
|
|
92
147
|
}
|
|
93
148
|
|
|
94
|
-
const
|
|
149
|
+
export const asForm = (fields: Record<string, string>): Encoded => ({ type: 'application/x-www-form-urlencoded', body: new URLSearchParams(fields).toString() });
|
|
95
150
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
} catch {
|
|
129
|
-
detail = raw.slice(0, 300);
|
|
130
|
-
}
|
|
131
|
-
throw new CodexAuthError(`OpenAI refused the token request (${String(res.status)}): ${detail}`);
|
|
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
|
-
|
|
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:
|
|
172
|
-
const
|
|
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(
|
|
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:
|
|
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
|
|
49
|
-
if (!res.ok) throw new CodexAuthError(`OpenAI refused to start the
|
|
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
|
|
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
|
|
74
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
16
|
+
import { noteUsageHeaders } from './usage.js';
|
|
14
17
|
|
|
15
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
|
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),
|
|
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
|
-
|
|
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,
|
|
139
|
-
const res = await fetchImpl(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const slugs = new Set<string>();
|
|
145
|
-
const walk = (value: unknown): void => {
|
|
146
|
-
if (Array.isArray(value)) value.forEach(walk);
|
|
147
|
-
else if (typeof value === 'object' && value !== null) {
|
|
148
|
-
const record = value as Record<string, unknown>;
|
|
149
|
-
if (typeof record.slug === 'string' && record.slug !== '') slugs.add(record.slug);
|
|
150
|
-
Object.values(record).forEach(walk);
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
walk(await res.json());
|
|
154
|
-
return [...slugs];
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
239
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
70
|
+
login: {
|
|
68
71
|
method: 'POST',
|
|
69
|
-
run:
|
|
70
|
-
const login = await beginDeviceLogin(deps.issuer, deps.fetchImpl).catch(asApiError);
|
|
71
|
-
return { id: login.id, user_code: login.userCode, verify_url: login.verifyUrl, interval: login.interval };
|
|
72
|
-
},
|
|
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
|
-
|
|
83
|
+
device: {
|
|
85
84
|
method: 'POST',
|
|
86
|
-
run: (_req, deps
|
|
87
|
-
|
|
88
|
-
|
|
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: {
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
2
|
import { readClaudeUsage } from '../claude/usage-probe.js';
|
|
3
|
-
import { codexUsageNow, sharedCodexState } from './codex.js';
|
|
4
3
|
import { openrouterKey } from './openrouter.js';
|
|
5
4
|
import { lastServed } from './served.js';
|
|
6
5
|
import { claudeLoginUsage, geminiUsage, keepProbeAnswer, mayProbe, mergeUsage, noteUsage, openrouterUsage, probeAnswer, usageOf } from './usage.js';
|
|
7
6
|
import { PASSTHROUGH_ID, readModelConfig, routedConnection, writeModelConfig, type Connection, type ModelConfig } from './model-config.js';
|
|
8
|
-
import {
|
|
7
|
+
import { geminiModelsOf } from './model-signin.js';
|
|
9
8
|
import { claudeLoginDeps, CREDITS_TTL_MS, type ModelApiDeps, type Store } from './model-store.js';
|
|
10
9
|
import { probeClaudeLogin } from './claude-logins.js';
|
|
11
10
|
|
|
@@ -34,14 +33,6 @@ async function refreshKey(conn: Connection, deps: ModelApiDeps, now: number): Pr
|
|
|
34
33
|
});
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
async function refreshCodex(conn: Connection, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
38
|
-
if (conn.codex === null || !due(conn.id, now)) return;
|
|
39
|
-
await probe('Codex usage', async () => {
|
|
40
|
-
const usage = await codexUsageNow(conn, codexDepsFor(deps, store, conn.id), sharedCodexState);
|
|
41
|
-
if (usage !== null) noteUsage(conn.id, usage);
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
36
|
const modelsInUse = (conn: Connection, cfg: ModelConfig): Set<string> => {
|
|
46
37
|
const served = lastServed();
|
|
47
38
|
const fallbacks = (cfg.fallbacks ?? []).filter((f) => f.connection === conn.id).map((f) => f.model);
|
|
@@ -92,7 +83,6 @@ async function refreshLogin(cfg: ModelConfig, deps: ModelApiDeps, now: number):
|
|
|
92
83
|
|
|
93
84
|
function refreshOne(conn: Connection, cfg: ModelConfig, deps: ModelApiDeps, store: Store, now: number): Promise<void> {
|
|
94
85
|
if (conn.provider === 'openrouter') return refreshKey(conn, deps, now);
|
|
95
|
-
if (conn.provider === 'codex') return refreshCodex(conn, deps, store, now);
|
|
96
86
|
if (conn.provider === 'gemini') return refreshQuota(conn, deps, store, now);
|
|
97
87
|
if (conn.provider === 'anthropic') return refreshOwnLogin(conn, cfg, deps, now);
|
|
98
88
|
return Promise.resolve();
|
|
@@ -271,36 +271,6 @@ const textOf = (raw: unknown): string | null => (typeof raw === 'number' || type
|
|
|
271
271
|
|
|
272
272
|
const numberOf = (raw: unknown): number | null => (typeof raw === 'number' && Number.isFinite(raw) ? raw : null);
|
|
273
273
|
|
|
274
|
-
const SECONDS_PER_MINUTE = 60;
|
|
275
|
-
|
|
276
|
-
function codexBodyWindow(raw: unknown, now: Date): UsageWindow | null {
|
|
277
|
-
if (!isRecord(raw)) return null;
|
|
278
|
-
const percent = numberOf(raw.used_percent);
|
|
279
|
-
if (percent === null) return null;
|
|
280
|
-
const seconds = numberOf(raw.limit_window_seconds);
|
|
281
|
-
const minutes = seconds === null || seconds <= 0 ? null : Math.ceil(seconds / SECONDS_PER_MINUTE);
|
|
282
|
-
const resetAt = whenFrom(textOf(raw.reset_at), now) ?? whenFrom(textOf(raw.reset_after_seconds), now, true);
|
|
283
|
-
return { label: windowLabel(minutes), used: clamp(percent / 100), resetAt, detail: null };
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function codexWall(limit: Record<string, unknown>, reached: unknown): string | null {
|
|
287
|
-
if (limit.limit_reached !== true && limit.allowed !== false) return null;
|
|
288
|
-
const kind = isRecord(reached) ? stringOf(reached.type) : '';
|
|
289
|
-
return kind === '' ? 'limit reached' : kind.replaceAll('_', ' ');
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
export function codexUsageBody(body: unknown, now = new Date()): Reported | null {
|
|
293
|
-
if (!isRecord(body)) return null;
|
|
294
|
-
const limit = isRecord(body.rate_limit) ? body.rate_limit : {};
|
|
295
|
-
const credits = isRecord(body.credits) ? body.credits : {};
|
|
296
|
-
const windows = [
|
|
297
|
-
codexBodyWindow(limit.primary_window, now),
|
|
298
|
-
codexBodyWindow(limit.secondary_window, now),
|
|
299
|
-
creditsLeft(credits.has_credits === true, textOf(credits.balance)),
|
|
300
|
-
].filter(present);
|
|
301
|
-
return windows.length === 0 ? null : { windows, note: codexWall(limit, body.rate_limit_reached_type), at: now.toISOString() };
|
|
302
|
-
}
|
|
303
|
-
|
|
304
274
|
const CLAUDE_WINDOWS: [string, string][] = [
|
|
305
275
|
['five_hour', '5-hour window'],
|
|
306
276
|
['seven_day', 'Weekly'],
|
package/runtime/runtime.json
CHANGED