@askalf/dario 5.5.83 → 5.5.84
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/README.md +31 -2
- package/dist/cli.js +108 -1
- package/dist/codex-accounts.d.ts +57 -0
- package/dist/codex-accounts.js +227 -0
- package/dist/codex-backend.d.ts +107 -0
- package/dist/codex-backend.js +455 -0
- package/dist/codex-oauth.d.ts +31 -0
- package/dist/codex-oauth.js +114 -0
- package/dist/model-catalog.d.ts +9 -2
- package/dist/model-catalog.js +10 -3
- package/dist/provider-adapter.d.ts +24 -3
- package/dist/provider-adapter.js +34 -4
- package/dist/proxy.d.ts +10 -4
- package/dist/proxy.js +123 -26
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/model-catalog.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
78
|
-
|
|
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;
|
package/dist/model-catalog.js
CHANGED
|
@@ -202,11 +202,18 @@ export function resolveAliasAgainst(model, bases) {
|
|
|
202
202
|
}
|
|
203
203
|
return null;
|
|
204
204
|
}
|
|
205
|
-
/**
|
|
206
|
-
|
|
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
|