@askalf/dario 5.5.83 → 5.5.85

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.
@@ -0,0 +1,455 @@
1
+ export const CODEX_BACKEND_BASE_URL = process.env.DARIO_CODEX_BASE_URL || 'https://chatgpt.com/backend-api/codex';
2
+ /** Originator string the codex CLI identifies itself with. */
3
+ const CODEX_ORIGINATOR = 'codex_cli_rs';
4
+ /**
5
+ * Client version sent on the model-discovery call. The backend REQUIRES the
6
+ * `client_version` query parameter and rejects the request without it; the
7
+ * value tracks a released codex CLI. Bump it when the backend starts gating on
8
+ * a newer one — it is a constant precisely so that stays a one-line change.
9
+ */
10
+ export const CODEX_CLIENT_VERSION = process.env.DARIO_CODEX_CLIENT_VERSION || '0.152.0';
11
+ /**
12
+ * Which models a subscription may use is decided by the backend, not by us, and
13
+ * the set moves (it is per-account, and changes as new slugs ship). Hardcoded
14
+ * name patterns do not work: `gpt-5-codex`, `codex-*`, `gpt-5`, `gpt-5.1`, `o3`
15
+ * and `gpt-4.1` all 400 with "The '<model>' model is not supported when using
16
+ * Codex with a <plan> account". So the routable set is DISCOVERED — GET
17
+ * `${base}/models?client_version=…` with the account's own headers, keeping the
18
+ * entries the backend marks visible.
19
+ *
20
+ * Cached per process because it gates routing on every request; a stale entry
21
+ * costs at most one upstream 400, and the TTL is short.
22
+ */
23
+ const MODEL_CACHE_TTL_MS = 10 * 60 * 1000;
24
+ /** Failures cache too, briefly, so an outage isn't one fetch per request. */
25
+ const MODEL_CACHE_ERROR_TTL_MS = 60 * 1000;
26
+ const modelCache = new Map();
27
+ /** Test seam — drop the discovery cache. */
28
+ export function clearCodexModelCache() {
29
+ modelCache.clear();
30
+ }
31
+ /**
32
+ * Slugs the backend lists for this account. `visibility` separates models meant
33
+ * for a picker ("list") from internal ones ("hide" — e.g. `gpt-reserve`,
34
+ * `codex-auto-review`); only listed ones are routable and advertisable.
35
+ * Throws on a non-2xx or unparseable response; getCodexModelSlugs absorbs it.
36
+ */
37
+ export async function fetchCodexModels(creds, fetchImpl = fetch) {
38
+ const base = CODEX_BACKEND_BASE_URL.replace(/\/$/, '');
39
+ const target = `${base}/models?client_version=${encodeURIComponent(CODEX_CLIENT_VERSION)}`;
40
+ const headers = buildCodexHeaders(creds);
41
+ // Discovery is JSON, not SSE — the shared header builder asks for a stream.
42
+ headers['Accept'] = 'application/json';
43
+ const res = await fetchImpl(target, { method: 'GET', headers });
44
+ if (!res.ok)
45
+ throw new Error(`codex /models ${res.status}`);
46
+ const parsed = (await res.json());
47
+ const entries = parsed.models ?? parsed.data ?? [];
48
+ const slugs = [];
49
+ for (const m of entries) {
50
+ const slug = m?.slug ?? m?.id;
51
+ if (typeof slug !== 'string' || slug.length === 0)
52
+ continue;
53
+ // Absent visibility counts as listed: a backend that stops sending the
54
+ // field should degrade to "offer everything", not to "offer nothing".
55
+ if (m.visibility != null && m.visibility !== 'list')
56
+ continue;
57
+ slugs.push(slug);
58
+ }
59
+ return slugs;
60
+ }
61
+ /**
62
+ * Cached {@link fetchCodexModels}, keyed by account alias. Never throws — an
63
+ * unreachable backend yields the last known set, or an empty one, which means
64
+ * "route nothing here by name". An explicit `codex:`/`chatgpt:` prefix still
65
+ * routes, so discovery being down never makes the engine unusable.
66
+ */
67
+ export async function getCodexModelSlugs(creds, fetchImpl = fetch) {
68
+ const hit = modelCache.get(creds.alias);
69
+ if (hit && Date.now() - hit.fetchedAt < hit.ttlMs)
70
+ return hit.slugs;
71
+ try {
72
+ const slugs = await fetchCodexModels(creds, fetchImpl);
73
+ modelCache.set(creds.alias, { slugs, fetchedAt: Date.now(), ttlMs: MODEL_CACHE_TTL_MS });
74
+ return slugs;
75
+ }
76
+ catch {
77
+ const slugs = hit?.slugs ?? [];
78
+ modelCache.set(creds.alias, { slugs, fetchedAt: Date.now(), ttlMs: MODEL_CACHE_ERROR_TTL_MS });
79
+ return slugs;
80
+ }
81
+ }
82
+ /**
83
+ * Whether a request naming `model` should be served from the subscription: the
84
+ * name matches a discovered slug. Pure, with the slugs injected, so routing is
85
+ * testable without network — and checked BEFORE openai-backend's
86
+ * `isOpenAIModel` (the codex adapter has the higher priority), so a discovered
87
+ * `gpt-5.5` reaches the subscription while a plain `gpt-4o` still reaches a
88
+ * configured API-key backend.
89
+ */
90
+ export function isCodexModel(model, slugs) {
91
+ if (!model)
92
+ return false;
93
+ const m = model.toLowerCase();
94
+ return slugs.some(s => s.toLowerCase() === m);
95
+ }
96
+ /**
97
+ * Pull `chatgpt_account_id` out of the id_token's `https://api.openai.com/auth`
98
+ * claim. Payload only — this is reading our own token for a routing header, not
99
+ * validating a token, so there's nothing to verify a signature against here.
100
+ * Null when the token is absent/malformed/claimless; the caller then omits the
101
+ * header, which is what the CLI does for accounts without a workspace.
102
+ */
103
+ export function extractChatGPTAccountId(idToken) {
104
+ if (!idToken)
105
+ return null;
106
+ const parts = idToken.split('.');
107
+ if (parts.length < 2)
108
+ return null;
109
+ try {
110
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
111
+ const auth = payload['https://api.openai.com/auth'];
112
+ const id = auth?.chatgpt_account_id;
113
+ return typeof id === 'string' && id.length > 0 ? id : null;
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ function messageContentToText(content) {
120
+ if (typeof content === 'string')
121
+ return content;
122
+ if (Array.isArray(content)) {
123
+ return content
124
+ .map(part => {
125
+ const p = part;
126
+ return typeof p?.text === 'string' ? p.text : '';
127
+ })
128
+ .join('');
129
+ }
130
+ return content == null ? '' : JSON.stringify(content);
131
+ }
132
+ /**
133
+ * Translate an OpenAI chat/completions request body into a Responses request.
134
+ *
135
+ * - system messages collapse into `instructions` (Responses has no system role)
136
+ * - assistant `tool_calls` become `function_call` items, `role: "tool"` replies
137
+ * become `function_call_output` items, keyed by the same call_id
138
+ * - tools lose the `{type:'function', function:{…}}` nesting; Responses takes
139
+ * name/description/parameters flat on the tool
140
+ * - `store: false` because dario is a proxy: nothing here is a ChatGPT thread
141
+ * the user would expect to find in their history
142
+ */
143
+ export function chatCompletionsToResponses(body) {
144
+ const messages = body.messages ?? [];
145
+ const instructions = messages
146
+ .filter(m => m.role === 'system' || m.role === 'developer')
147
+ .map(m => messageContentToText(m.content))
148
+ .filter(t => t.length > 0)
149
+ .join('\n\n');
150
+ const input = [];
151
+ for (const m of messages) {
152
+ if (m.role === 'system' || m.role === 'developer')
153
+ continue;
154
+ if (m.role === 'tool') {
155
+ input.push({
156
+ type: 'function_call_output',
157
+ call_id: m.tool_call_id ?? '',
158
+ output: messageContentToText(m.content),
159
+ });
160
+ continue;
161
+ }
162
+ if (m.role === 'assistant') {
163
+ const text = messageContentToText(m.content);
164
+ if (text.length > 0) {
165
+ input.push({ role: 'assistant', content: [{ type: 'output_text', text }] });
166
+ }
167
+ const calls = m.tool_calls;
168
+ for (const c of calls ?? []) {
169
+ input.push({
170
+ type: 'function_call',
171
+ call_id: c.id ?? '',
172
+ name: c.function?.name ?? '',
173
+ arguments: c.function?.arguments ?? '{}',
174
+ });
175
+ }
176
+ continue;
177
+ }
178
+ input.push({ role: 'user', content: [{ type: 'input_text', text: messageContentToText(m.content) }] });
179
+ }
180
+ const out = {
181
+ model: String(body.model ?? ''),
182
+ input,
183
+ store: false,
184
+ stream: true,
185
+ };
186
+ if (instructions.length > 0)
187
+ out.instructions = instructions;
188
+ const tools = body.tools;
189
+ if (Array.isArray(tools) && tools.length > 0) {
190
+ out.tools = tools.map(t => ({
191
+ type: 'function',
192
+ name: t.function?.name ?? '',
193
+ description: t.function?.description,
194
+ parameters: t.function?.parameters ?? { type: 'object', properties: {} },
195
+ }));
196
+ }
197
+ if (body.tool_choice != null)
198
+ out.tool_choice = body.tool_choice;
199
+ if (body.temperature != null)
200
+ out.temperature = body.temperature;
201
+ if (body.top_p != null)
202
+ out.top_p = body.top_p;
203
+ if (body.max_tokens != null || body.max_completion_tokens != null) {
204
+ out.max_output_tokens = body.max_completion_tokens ?? body.max_tokens;
205
+ }
206
+ if (body.reasoning_effort != null)
207
+ out.reasoning = { effort: body.reasoning_effort };
208
+ return out;
209
+ }
210
+ /**
211
+ * Stateful per-request translator: Responses SSE in, chat/completions out.
212
+ *
213
+ * `chunk()` returns the chat.completion.chunk SSE line to write back (null when
214
+ * the upstream event has no chat-shape equivalent — reasoning summaries,
215
+ * progress events). It also accumulates, so `complete()` can hand back a single
216
+ * non-streaming `chat.completion` body. dario always asks the Codex backend for
217
+ * a stream and collapses it here when the client didn't want one; that keeps one
218
+ * upstream code path instead of two.
219
+ *
220
+ * One translator per request — never module-global — for the same reason
221
+ * createOpenAIStreamTranslator is per-call (#642-audit: interleaved streams
222
+ * corrupting shared tool-call indices).
223
+ */
224
+ export function createResponsesTranslator(model) {
225
+ const created = Math.floor(Date.now() / 1000);
226
+ let id = 'chatcmpl-dario';
227
+ let text = '';
228
+ let usage = null;
229
+ const toolCalls = new Map();
230
+ let nextToolIndex = 0;
231
+ const frame = (delta, finish) => `data: ${JSON.stringify({
232
+ id,
233
+ object: 'chat.completion.chunk',
234
+ created,
235
+ model,
236
+ choices: [{ index: 0, delta, finish_reason: finish }],
237
+ })}\n\n`;
238
+ return {
239
+ /** Feed one raw SSE line. Returns the line to forward, or null. */
240
+ chunk(line) {
241
+ if (!line.startsWith('data: '))
242
+ return null;
243
+ const raw = line.slice(6).trim();
244
+ if (!raw || raw === '[DONE]')
245
+ return null;
246
+ let e;
247
+ try {
248
+ e = JSON.parse(raw);
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ const type = String(e.type ?? '');
254
+ if (type === 'response.created') {
255
+ const r = e.response;
256
+ if (r?.id)
257
+ id = `chatcmpl-${r.id.replace(/^resp_/, '')}`;
258
+ return null;
259
+ }
260
+ if (type === 'response.output_text.delta') {
261
+ const d = typeof e.delta === 'string' ? e.delta : '';
262
+ if (!d)
263
+ return null;
264
+ text += d;
265
+ return frame({ content: d }, null);
266
+ }
267
+ if (type === 'response.output_item.added') {
268
+ const item = e.item;
269
+ if (item?.type !== 'function_call')
270
+ return null;
271
+ const key = item.id ?? item.call_id ?? `item_${nextToolIndex}`;
272
+ const acc = {
273
+ index: nextToolIndex++,
274
+ id: item.call_id ?? key,
275
+ name: item.name ?? '',
276
+ args: '',
277
+ };
278
+ toolCalls.set(key, acc);
279
+ return frame({ tool_calls: [{ index: acc.index, id: acc.id, type: 'function', function: { name: acc.name, arguments: '' } }] }, null);
280
+ }
281
+ if (type === 'response.function_call_arguments.delta') {
282
+ const key = String(e.item_id ?? '');
283
+ const acc = toolCalls.get(key) ?? [...toolCalls.values()].at(-1);
284
+ const d = typeof e.delta === 'string' ? e.delta : '';
285
+ if (!acc || !d)
286
+ return null;
287
+ acc.args += d;
288
+ return frame({ tool_calls: [{ index: acc.index, function: { arguments: d } }] }, null);
289
+ }
290
+ if (type === 'response.completed' || type === 'response.incomplete') {
291
+ const r = e.response;
292
+ const u = r?.usage;
293
+ if (u) {
294
+ usage = {
295
+ prompt_tokens: u.input_tokens ?? 0,
296
+ completion_tokens: u.output_tokens ?? 0,
297
+ total_tokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0),
298
+ };
299
+ }
300
+ return `${frame({}, toolCalls.size > 0 ? 'tool_calls' : 'stop')}data: [DONE]\n\n`;
301
+ }
302
+ return null;
303
+ },
304
+ /** Everything seen so far, as one non-streaming chat.completion body. */
305
+ complete() {
306
+ const calls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
307
+ const message = { role: 'assistant', content: text || null };
308
+ if (calls.length > 0) {
309
+ message.tool_calls = calls.map(c => ({
310
+ id: c.id,
311
+ type: 'function',
312
+ function: { name: c.name, arguments: c.args },
313
+ }));
314
+ }
315
+ return {
316
+ id,
317
+ object: 'chat.completion',
318
+ created,
319
+ model,
320
+ choices: [{ index: 0, message, finish_reason: calls.length > 0 ? 'tool_calls' : 'stop' }],
321
+ usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
322
+ };
323
+ },
324
+ };
325
+ }
326
+ export function buildCodexHeaders(creds) {
327
+ const headers = {
328
+ 'Content-Type': 'application/json',
329
+ 'Accept': 'text/event-stream',
330
+ 'Authorization': `Bearer ${creds.accessToken}`,
331
+ 'originator': CODEX_ORIGINATOR,
332
+ 'OpenAI-Beta': 'responses=experimental',
333
+ };
334
+ const accountId = extractChatGPTAccountId(creds.idToken);
335
+ if (accountId)
336
+ headers['ChatGPT-Account-ID'] = accountId;
337
+ return headers;
338
+ }
339
+ /**
340
+ * Serve a /v1/chat/completions request from a stored Codex account.
341
+ *
342
+ * `fetchImpl` is injectable so the translation and header construction are
343
+ * testable without network (test/codex-backend.mjs), matching the pattern
344
+ * test/codex-oauth.mjs already uses.
345
+ */
346
+ export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, fetchImpl = fetch) {
347
+ void req;
348
+ let parsed;
349
+ try {
350
+ parsed = JSON.parse(body.toString());
351
+ }
352
+ catch {
353
+ res.writeHead(400, { 'Content-Type': 'application/json', ...securityHeaders });
354
+ res.end(JSON.stringify({ error: 'Codex backend requires a JSON chat/completions body' }));
355
+ return;
356
+ }
357
+ const clientWantsStream = parsed.stream === true;
358
+ const model = String(parsed.model ?? '');
359
+ const upstreamBody = chatCompletionsToResponses(parsed);
360
+ const target = `${CODEX_BACKEND_BASE_URL.replace(/\/$/, '')}/responses`;
361
+ const abort = new AbortController();
362
+ const timeout = setTimeout(() => abort.abort(), upstreamTimeoutMs);
363
+ try {
364
+ if (verbose)
365
+ console.log(`[dario] → codex backend: ${target} (model: ${model})`);
366
+ const upstream = await fetchImpl(target, {
367
+ method: 'POST',
368
+ headers: buildCodexHeaders(creds),
369
+ body: JSON.stringify(upstreamBody),
370
+ signal: abort.signal,
371
+ });
372
+ if (!upstream.ok) {
373
+ const detail = await upstream.text().catch(() => '');
374
+ if (verbose)
375
+ console.error(`[dario] codex backend ${upstream.status}: ${detail.slice(0, 300)}`);
376
+ res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
377
+ res.end(JSON.stringify({
378
+ error: 'Upstream Codex backend error',
379
+ status: upstream.status,
380
+ account: creds.alias,
381
+ }));
382
+ return;
383
+ }
384
+ const translator = createResponsesTranslator(model);
385
+ if (clientWantsStream) {
386
+ res.writeHead(200, {
387
+ 'Content-Type': 'text/event-stream',
388
+ 'Cache-Control': 'no-cache',
389
+ 'Connection': 'keep-alive',
390
+ 'Access-Control-Allow-Origin': corsOrigin,
391
+ ...securityHeaders,
392
+ });
393
+ }
394
+ let buffered = '';
395
+ if (upstream.body) {
396
+ const reader = upstream.body.getReader();
397
+ const decoder = new TextDecoder();
398
+ try {
399
+ while (true) {
400
+ const { done, value } = await reader.read();
401
+ if (done)
402
+ break;
403
+ buffered += decoder.decode(value, { stream: true });
404
+ // SSE frames are newline-delimited; keep the trailing partial line.
405
+ const lines = buffered.split('\n');
406
+ buffered = lines.pop() ?? '';
407
+ for (const line of lines) {
408
+ const out = translator.chunk(line);
409
+ if (out && clientWantsStream)
410
+ res.write(out);
411
+ }
412
+ }
413
+ }
414
+ finally {
415
+ reader.releaseLock();
416
+ }
417
+ if (buffered.length > 0) {
418
+ const out = translator.chunk(buffered);
419
+ if (out && clientWantsStream)
420
+ res.write(out);
421
+ }
422
+ }
423
+ if (clientWantsStream) {
424
+ res.end();
425
+ }
426
+ else {
427
+ res.writeHead(200, {
428
+ 'Content-Type': 'application/json',
429
+ 'Access-Control-Allow-Origin': corsOrigin,
430
+ ...securityHeaders,
431
+ });
432
+ res.end(JSON.stringify(translator.complete()));
433
+ }
434
+ }
435
+ catch (err) {
436
+ // Detail stays server-side (CodeQL js/stack-trace-exposure), same as
437
+ // forwardToOpenAI.
438
+ const detail = err instanceof Error ? err.message : String(err);
439
+ if (verbose)
440
+ console.error(`[dario] codex backend (${creds.alias}) error: ${detail}`);
441
+ if (!res.headersSent) {
442
+ res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
443
+ res.end(JSON.stringify({ error: 'Upstream Codex backend error', account: creds.alias }));
444
+ }
445
+ else {
446
+ try {
447
+ res.end();
448
+ }
449
+ catch { /* already closed */ }
450
+ }
451
+ }
452
+ finally {
453
+ clearTimeout(timeout);
454
+ }
455
+ }
@@ -0,0 +1,31 @@
1
+ export declare const CODEX_CLIENT_ID: string;
2
+ export declare const CODEX_AUTHORIZE_URL: string;
3
+ export declare const CODEX_TOKEN_URL: string;
4
+ export declare const CODEX_REDIRECT_URI: string;
5
+ export declare const CODEX_SCOPE = "openid profile email offline_access";
6
+ export interface CodexTokens {
7
+ accessToken: string;
8
+ refreshToken: string;
9
+ expiresAt: number;
10
+ idToken?: string;
11
+ }
12
+ export declare function generateCodexPKCE(): {
13
+ codeVerifier: string;
14
+ codeChallenge: string;
15
+ };
16
+ /**
17
+ * Build the authorize URL for a manual-paste login flow (same shape as
18
+ * accounts.ts's startAddAccount — dario has no localhost callback server
19
+ * running by default, so the manual copy-paste flow is the primary path,
20
+ * not a fallback).
21
+ */
22
+ export declare function buildCodexAuthorizeUrl(codeChallenge: string, state: string): string;
23
+ export declare function exchangeCodexAuthorizationCode(code: string, codeVerifier: string): Promise<CodexTokens>;
24
+ /**
25
+ * Refresh a Codex access token. Deliberately NOT wrapped in any
26
+ * single-flight/lock machinery yet — that's exactly the thing
27
+ * test/manual/codex-refresh-race.mjs exists to determine the need for.
28
+ * Adding pool/lock complexity before that answer is known would be
29
+ * guessing at a solution to an unconfirmed problem.
30
+ */
31
+ export declare function refreshCodexAccessToken(refreshToken: string): Promise<CodexTokens>;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Codex OAuth primitives — the "altman" engine (dario#1009).
3
+ *
4
+ * A deliberately separate, standalone module from oauth.ts/accounts.ts,
5
+ * not a generalization of them. Two providers isn't enough to know what a
6
+ * good shared abstraction looks like yet; forcing one now would mean
7
+ * guessing at the shape from a single example. See the dario#993/#1009
8
+ * scoping discussion for the reasoning.
9
+ *
10
+ * Unlike Claude's OAuth config (auto-detected from the installed CC binary
11
+ * — see cc-oauth-detect.ts — because Anthropic rotates client_id/URLs
12
+ * between CC releases), Codex's values below are stable, publicly known
13
+ * constants used by OpenAI's own `codex` CLI. Hardcoded here the same way
14
+ * every other OSS OAuth client for this flow does (e.g.
15
+ * numman-ali/opencode-openai-codex-auth), with an env override escape
16
+ * hatch in case that changes.
17
+ *
18
+ * THE OPEN QUESTION THIS MODULE EXISTS TO ANSWER (see
19
+ * test/manual/codex-refresh-race.mjs): does OpenAI invalidate the previous
20
+ * refresh_token on every refresh, the way Anthropic does? That single fact
21
+ * determines whether Codex needs pool/lock machinery at all, or something
22
+ * much simpler. Not yet known — no public source documents it, because
23
+ * every existing OAuth client for this flow (this codebase's own
24
+ * inspiration included) is single-instance, single-user, and has never
25
+ * had a reason to race two refreshes against the same token.
26
+ */
27
+ import { randomBytes, createHash } from 'node:crypto';
28
+ // OAuth constants — from OpenAI's own `codex` CLI, reused unmodified by
29
+ // every third-party client for this flow (Cline, opencode's codex-auth
30
+ // plugin). Not dario-specific; not secret.
31
+ export const CODEX_CLIENT_ID = process.env.DARIO_CODEX_CLIENT_ID || 'app_EMoamEEZ73f0CkXaXp7hrann';
32
+ export const CODEX_AUTHORIZE_URL = process.env.DARIO_CODEX_AUTHORIZE_URL || 'https://auth.openai.com/oauth/authorize';
33
+ export const CODEX_TOKEN_URL = process.env.DARIO_CODEX_TOKEN_URL || 'https://auth.openai.com/oauth/token';
34
+ export const CODEX_REDIRECT_URI = process.env.DARIO_CODEX_REDIRECT_URI || 'http://localhost:1455/auth/callback';
35
+ export const CODEX_SCOPE = 'openid profile email offline_access';
36
+ function base64url(buf) {
37
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
38
+ }
39
+ export function generateCodexPKCE() {
40
+ const codeVerifier = base64url(randomBytes(32));
41
+ const codeChallenge = base64url(createHash('sha256').update(codeVerifier).digest());
42
+ return { codeVerifier, codeChallenge };
43
+ }
44
+ /**
45
+ * Build the authorize URL for a manual-paste login flow (same shape as
46
+ * accounts.ts's startAddAccount — dario has no localhost callback server
47
+ * running by default, so the manual copy-paste flow is the primary path,
48
+ * not a fallback).
49
+ */
50
+ export function buildCodexAuthorizeUrl(codeChallenge, state) {
51
+ const url = new URL(CODEX_AUTHORIZE_URL);
52
+ url.searchParams.set('response_type', 'code');
53
+ url.searchParams.set('client_id', CODEX_CLIENT_ID);
54
+ url.searchParams.set('redirect_uri', CODEX_REDIRECT_URI);
55
+ url.searchParams.set('scope', CODEX_SCOPE);
56
+ url.searchParams.set('code_challenge', codeChallenge);
57
+ url.searchParams.set('code_challenge_method', 'S256');
58
+ url.searchParams.set('state', state);
59
+ return url.toString();
60
+ }
61
+ function parseTokenResponse(json) {
62
+ if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== 'number') {
63
+ throw new Error(`Codex token response missing required fields: ${JSON.stringify(Object.keys(json ?? {}))}`);
64
+ }
65
+ return {
66
+ accessToken: json.access_token,
67
+ refreshToken: json.refresh_token,
68
+ expiresAt: Date.now() + json.expires_in * 1000,
69
+ idToken: json.id_token,
70
+ };
71
+ }
72
+ export async function exchangeCodexAuthorizationCode(code, codeVerifier) {
73
+ const res = await fetch(CODEX_TOKEN_URL, {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
76
+ body: new URLSearchParams({
77
+ grant_type: 'authorization_code',
78
+ client_id: CODEX_CLIENT_ID,
79
+ code,
80
+ code_verifier: codeVerifier,
81
+ redirect_uri: CODEX_REDIRECT_URI,
82
+ }),
83
+ signal: AbortSignal.timeout(30_000),
84
+ });
85
+ if (!res.ok) {
86
+ const body = await res.text().catch(() => '');
87
+ throw new Error(`Codex code->token exchange failed (${res.status}): ${body.slice(0, 200)}`);
88
+ }
89
+ return parseTokenResponse(await res.json());
90
+ }
91
+ /**
92
+ * Refresh a Codex access token. Deliberately NOT wrapped in any
93
+ * single-flight/lock machinery yet — that's exactly the thing
94
+ * test/manual/codex-refresh-race.mjs exists to determine the need for.
95
+ * Adding pool/lock complexity before that answer is known would be
96
+ * guessing at a solution to an unconfirmed problem.
97
+ */
98
+ export async function refreshCodexAccessToken(refreshToken) {
99
+ const res = await fetch(CODEX_TOKEN_URL, {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
102
+ body: new URLSearchParams({
103
+ grant_type: 'refresh_token',
104
+ refresh_token: refreshToken,
105
+ client_id: CODEX_CLIENT_ID,
106
+ }),
107
+ signal: AbortSignal.timeout(30_000),
108
+ });
109
+ if (!res.ok) {
110
+ const body = await res.text().catch(() => '');
111
+ throw new Error(`Codex token refresh failed (${res.status}): ${body.slice(0, 200)}`);
112
+ }
113
+ return parseTokenResponse(await res.json());
114
+ }
@@ -74,8 +74,15 @@ export declare function resolveFamilyBase(family: string, bases: readonly string
74
74
  * absent/ineligible — callers fall back to their static map.
75
75
  */
76
76
  export declare function resolveAliasAgainst(model: string, bases: readonly string[]): string | null;
77
- /** OpenAI-shape /v1/models payload for a list of advertised ids. */
78
- export declare function buildOpenAIModelsList(ids: readonly string[]): {
77
+ /**
78
+ * OpenAI-shape /v1/models payload for a list of advertised ids.
79
+ *
80
+ * `ownedBy` names the ids served by a non-Anthropic backend — the
81
+ * Codex/ChatGPT-subscription slugs (dario#1137). Everything else stays
82
+ * `anthropic`: the advertised catalog is Claude's, and a client picking a
83
+ * model shouldn't have to guess which vendor answers it.
84
+ */
85
+ export declare function buildOpenAIModelsList(ids: readonly string[], ownedBy?: Readonly<Record<string, string>>): {
79
86
  object: string;
80
87
  data: Array<{
81
88
  id: string;
@@ -202,11 +202,18 @@ export function resolveAliasAgainst(model, bases) {
202
202
  }
203
203
  return null;
204
204
  }
205
- /** OpenAI-shape /v1/models payload for a list of advertised ids. */
206
- export function buildOpenAIModelsList(ids) {
205
+ /**
206
+ * OpenAI-shape /v1/models payload for a list of advertised ids.
207
+ *
208
+ * `ownedBy` names the ids served by a non-Anthropic backend — the
209
+ * Codex/ChatGPT-subscription slugs (dario#1137). Everything else stays
210
+ * `anthropic`: the advertised catalog is Claude's, and a client picking a
211
+ * model shouldn't have to guess which vendor answers it.
212
+ */
213
+ export function buildOpenAIModelsList(ids, ownedBy = {}) {
207
214
  return {
208
215
  object: 'list',
209
- data: ids.map((id) => ({ id, object: 'model', created: 1700000000, owned_by: 'anthropic' })),
216
+ data: ids.map((id) => ({ id, object: 'model', created: 1700000000, owned_by: ownedBy[id] ?? 'anthropic' })),
210
217
  };
211
218
  }
212
219
  export const DEFAULT_CATALOG_TTL_MS = 3_600_000; // 1h — model launches are rare