@askalf/dario 6.0.21 → 6.0.22
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 +4 -2
- package/dist/cli.js +12 -2
- package/dist/codex-accounts.d.ts +34 -0
- package/dist/codex-accounts.js +98 -3
- package/dist/codex-backend.d.ts +20 -0
- package/dist/codex-backend.js +192 -13
- package/dist/codex-oauth.d.ts +15 -0
- package/dist/codex-oauth.js +21 -1
- package/dist/proxy.js +51 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
<p><strong>One local endpoint. Every AI tool you own. The subscriptions you already pay for.</strong></p>
|
|
20
20
|
|
|
21
|
-
<sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~
|
|
21
|
+
<sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~30k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
|
|
22
22
|
|
|
23
23
|
<sub>Part of <a href="#own-your-stack"><strong>Own Your Stack</strong></a> — 12 open tools for owning your AI infra: <a href="https://github.com/askalf/truecopy">truecopy</a> · <a href="https://github.com/askalf/strongroom">strongroom</a> · <a href="https://github.com/askalf/fieldpass">fieldpass</a> · <a href="https://github.com/askalf/plumbline">plumbline</a> · <a href="#own-your-stack">full family ↓</a></sub>
|
|
24
24
|
|
|
@@ -165,6 +165,8 @@ curl localhost:3456/v1/messages -H 'content-type: application/json' \
|
|
|
165
165
|
|
|
166
166
|
Streaming, tool calls, and tool-result round trips work on both shapes: dario translates chat/completions **or** Messages into the Responses API the subscription backend speaks, and translates the stream back into `chat.completion.chunk` or Anthropic message events to match what the client asked in. There is no `/v1/responses` inbound yet.
|
|
167
167
|
|
|
168
|
+
**Chat/completions fidelity:** text and `image_url` user-content parts (HTTPS URLs and data URIs, including the `detail` fidelity setting) carry through to Responses input items. The Codex subscription backend does not accept every chat field, so `response_format`, `stop`, `n`, `logprobs`, `stream_options` and other unmapped chat-only fields are intentionally lossy — and so are the sampling parameters `temperature`, `top_p`, `max_tokens` and `max_completion_tokens`, which translate cleanly but are then rejected by the backend and stripped before the request goes out. With `--verbose`, dario reports each field that does not reach Codex once per process.
|
|
169
|
+
|
|
168
170
|
Codex accounts live in `~/.dario/codex-accounts/`, entirely separate from the Claude pool. Nothing about `dario login`, `dario accounts`, or Claude routing changes.
|
|
169
171
|
|
|
170
172
|
---
|
|
@@ -307,7 +309,7 @@ The split isn't live, but it was announced once on short notice and could return
|
|
|
307
309
|
|
|
308
310
|
| Signal | Status |
|
|
309
311
|
|---|---|
|
|
310
|
-
| Source | **~
|
|
312
|
+
| Source | **~30k** lines of TypeScript across **65** files — auditable in a weekend (v5 removed shim; the pool is the one code path) |
|
|
311
313
|
| Dependencies | **0 runtime.** Verify: `npm ls --production` |
|
|
312
314
|
| Provenance | Every release [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions + Sigstore |
|
|
313
315
|
| Scanning | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) on every push and weekly |
|
package/dist/cli.js
CHANGED
|
@@ -1121,7 +1121,7 @@ async function codex() {
|
|
|
1121
1121
|
console.error(`[dario] Codex account "${alias}" already exists. Remove it first with \`dario codex remove ${alias}\`.`);
|
|
1122
1122
|
process.exit(1);
|
|
1123
1123
|
}
|
|
1124
|
-
const { authorizeUrl, codeVerifier } = await startAddCodexAccount(alias);
|
|
1124
|
+
const { authorizeUrl, codeVerifier, state } = await startAddCodexAccount(alias);
|
|
1125
1125
|
console.log('');
|
|
1126
1126
|
console.log(' Open this URL and log in with your ChatGPT Plus/Pro account.');
|
|
1127
1127
|
console.log(' The browser will land on a localhost page that does not load —');
|
|
@@ -1130,11 +1130,21 @@ async function codex() {
|
|
|
1130
1130
|
console.log(` ${authorizeUrl}`);
|
|
1131
1131
|
console.log('');
|
|
1132
1132
|
const pasted = await readLineFromStdin(' Redirect URL (or code): ');
|
|
1133
|
-
const { code } = parseCodexManualPaste(pasted);
|
|
1133
|
+
const { code, state: pastedState } = parseCodexManualPaste(pasted);
|
|
1134
1134
|
if (!code) {
|
|
1135
1135
|
console.error('[dario] No code found in what you pasted.');
|
|
1136
1136
|
process.exit(1);
|
|
1137
1137
|
}
|
|
1138
|
+
// The state parameter is what ties this paste to the URL printed above.
|
|
1139
|
+
// It was parsed and then discarded, which made the CSRF guard the OAuth
|
|
1140
|
+
// flow carries decorative: a redirect from some other authorize request
|
|
1141
|
+
// would have been accepted and stored. A paste with no state at all (a
|
|
1142
|
+
// bare code — accepted shape #3) can't be checked and is left alone.
|
|
1143
|
+
if (pastedState !== null && pastedState !== state) {
|
|
1144
|
+
console.error('[dario] The pasted redirect does not match this login attempt (state mismatch).');
|
|
1145
|
+
console.error(' Start over with `dario codex add ' + alias + '` and paste the URL from THAT browser tab.');
|
|
1146
|
+
process.exit(1);
|
|
1147
|
+
}
|
|
1138
1148
|
try {
|
|
1139
1149
|
await completeAddCodexAccount(alias, code, codeVerifier);
|
|
1140
1150
|
console.log('');
|
package/dist/codex-accounts.d.ts
CHANGED
|
@@ -22,9 +22,43 @@ export declare function startAddCodexAccount(alias: string): Promise<{
|
|
|
22
22
|
export declare function completeAddCodexAccount(alias: string, code: string, codeVerifier: string): Promise<CodexAccountCredentials>;
|
|
23
23
|
export declare function codexAccountNeedsRefresh(creds: CodexAccountCredentials): boolean;
|
|
24
24
|
export declare function refreshCodexAccount(creds: CodexAccountCredentials): Promise<CodexAccountCredentials>;
|
|
25
|
+
/**
|
|
26
|
+
* A refresh this process is not going to attempt right now: either the last one
|
|
27
|
+
* failed inside the cool-down below, or the one just attempted failed. Carries
|
|
28
|
+
* the alias so the caller can name the CODEX account in the client's error —
|
|
29
|
+
* the whole point of the fix, since the request otherwise fell through to the
|
|
30
|
+
* Claude path and got told to run `dario login`.
|
|
31
|
+
*/
|
|
32
|
+
export declare class CodexCredentialsUnavailableError extends Error {
|
|
33
|
+
readonly alias: string;
|
|
34
|
+
readonly status: number;
|
|
35
|
+
readonly failedAt: number;
|
|
36
|
+
constructor(alias: string, failure: CodexRefreshFailure);
|
|
37
|
+
}
|
|
38
|
+
/** Last refresh rejection remembered for an alias. No token material here. */
|
|
39
|
+
export interface CodexRefreshFailure {
|
|
40
|
+
/** When the failure was observed (ms epoch). */
|
|
41
|
+
at: number;
|
|
42
|
+
/** Upstream HTTP status, or 0 when no response arrived (transport/timeout). */
|
|
43
|
+
status: number;
|
|
44
|
+
/** First ~120 chars of the upstream body / error text — never a token. */
|
|
45
|
+
message: string;
|
|
46
|
+
}
|
|
47
|
+
/** Last remembered refresh failure for an alias, or null. Read-only view for
|
|
48
|
+
* the admin surface (`GET /codex`) — never triggers an upstream call. */
|
|
49
|
+
export declare function getCodexRefreshFailure(alias: string): CodexRefreshFailure | null;
|
|
50
|
+
/** Test seam — forget every remembered failure. */
|
|
51
|
+
export declare function _resetCodexRefreshFailuresForTest(): void;
|
|
25
52
|
/**
|
|
26
53
|
* Return credentials guaranteed fresh enough to send upstream, refreshing (once,
|
|
27
54
|
* per alias, per process) when within the expiry buffer.
|
|
55
|
+
*
|
|
56
|
+
* Throws {@link CodexCredentialsUnavailableError} when the refresh failed —
|
|
57
|
+
* including when it failed RECENTLY and this call therefore made no upstream
|
|
58
|
+
* attempt at all. Callers must treat the throw as "the codex route is not
|
|
59
|
+
* available for this request" and say so naming the codex account; swallowing
|
|
60
|
+
* it is what turned a dead token into a per-request token-endpoint storm with
|
|
61
|
+
* a misleading "run `dario login`" answer to the client.
|
|
28
62
|
*/
|
|
29
63
|
export declare function getFreshCodexAccount(creds: CodexAccountCredentials): Promise<CodexAccountCredentials>;
|
|
30
64
|
/**
|
package/dist/codex-accounts.js
CHANGED
|
@@ -13,7 +13,7 @@ import { readFile, mkdir, readdir, unlink } from 'node:fs/promises';
|
|
|
13
13
|
import { join, basename } from 'node:path';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import { randomBytes } from 'node:crypto';
|
|
16
|
-
import { generateCodexPKCE, buildCodexAuthorizeUrl, exchangeCodexAuthorizationCode, refreshCodexAccessToken, } from './codex-oauth.js';
|
|
16
|
+
import { generateCodexPKCE, buildCodexAuthorizeUrl, exchangeCodexAuthorizationCode, refreshCodexAccessToken, CodexRefreshError, } from './codex-oauth.js';
|
|
17
17
|
import { durableWriteFile } from './durable-write.js';
|
|
18
18
|
const DARIO_DIR = join(homedir(), '.dario');
|
|
19
19
|
const CODEX_ACCOUNTS_DIR = join(DARIO_DIR, 'codex-accounts');
|
|
@@ -106,6 +106,7 @@ export async function removeCodexAccount(alias) {
|
|
|
106
106
|
return false;
|
|
107
107
|
try {
|
|
108
108
|
await unlink(path);
|
|
109
|
+
refreshFailures.delete(alias);
|
|
109
110
|
return true;
|
|
110
111
|
}
|
|
111
112
|
catch {
|
|
@@ -140,6 +141,8 @@ export async function completeAddCodexAccount(alias, code, codeVerifier) {
|
|
|
140
141
|
idToken: tokens.idToken,
|
|
141
142
|
};
|
|
142
143
|
await saveCodexAccount(creds);
|
|
144
|
+
// Freshly minted credentials — whatever the old ones failed with is history.
|
|
145
|
+
refreshFailures.delete(alias);
|
|
143
146
|
return creds;
|
|
144
147
|
}
|
|
145
148
|
/**
|
|
@@ -163,6 +166,75 @@ export async function refreshCodexAccount(creds) {
|
|
|
163
166
|
await saveCodexAccount(updated);
|
|
164
167
|
return updated;
|
|
165
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* A refresh this process is not going to attempt right now: either the last one
|
|
171
|
+
* failed inside the cool-down below, or the one just attempted failed. Carries
|
|
172
|
+
* the alias so the caller can name the CODEX account in the client's error —
|
|
173
|
+
* the whole point of the fix, since the request otherwise fell through to the
|
|
174
|
+
* Claude path and got told to run `dario login`.
|
|
175
|
+
*/
|
|
176
|
+
export class CodexCredentialsUnavailableError extends Error {
|
|
177
|
+
alias;
|
|
178
|
+
status;
|
|
179
|
+
failedAt;
|
|
180
|
+
constructor(alias, failure) {
|
|
181
|
+
super(`codex account ${alias}: token refresh failed (${failure.status || 'no response'}): ${failure.message}`);
|
|
182
|
+
this.name = 'CodexCredentialsUnavailableError';
|
|
183
|
+
this.alias = alias;
|
|
184
|
+
this.status = failure.status;
|
|
185
|
+
this.failedAt = failure.at;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* A dead refresh_token used to cost one token-endpoint POST PER REQUEST,
|
|
190
|
+
* forever, silently: getFreshCodexAccount only collapsed CONCURRENT refreshes
|
|
191
|
+
* and remembered nothing about a failure, so every inbound request re-asked.
|
|
192
|
+
* Same shape as codex-backend.ts's MODEL_CACHE_ERROR_TTL_MS — remember the
|
|
193
|
+
* failure briefly, so a broken account costs one attempt a minute and recovers
|
|
194
|
+
* on its own within a minute of the operator fixing it.
|
|
195
|
+
*/
|
|
196
|
+
const REFRESH_FAILURE_TTL_MS = 60 * 1000;
|
|
197
|
+
const refreshFailures = new Map();
|
|
198
|
+
/** Last remembered refresh failure for an alias, or null. Read-only view for
|
|
199
|
+
* the admin surface (`GET /codex`) — never triggers an upstream call. */
|
|
200
|
+
export function getCodexRefreshFailure(alias) {
|
|
201
|
+
const hit = refreshFailures.get(alias);
|
|
202
|
+
return hit ? { at: hit.at, status: hit.status, message: hit.message } : null;
|
|
203
|
+
}
|
|
204
|
+
/** Test seam — forget every remembered failure. */
|
|
205
|
+
export function _resetCodexRefreshFailuresForTest() {
|
|
206
|
+
refreshFailures.clear();
|
|
207
|
+
}
|
|
208
|
+
function noteRefreshFailure(alias, err) {
|
|
209
|
+
const status = err instanceof CodexRefreshError ? err.status : 0;
|
|
210
|
+
const raw = err instanceof CodexRefreshError
|
|
211
|
+
? err.bodySnippet
|
|
212
|
+
: (err instanceof Error ? err.message : String(err));
|
|
213
|
+
const previous = refreshFailures.get(alias);
|
|
214
|
+
const entry = {
|
|
215
|
+
at: Date.now(),
|
|
216
|
+
status,
|
|
217
|
+
message: raw.slice(0, 120),
|
|
218
|
+
retryAt: Date.now() + REFRESH_FAILURE_TTL_MS,
|
|
219
|
+
logged: previous?.logged ?? false,
|
|
220
|
+
};
|
|
221
|
+
if (!entry.logged) {
|
|
222
|
+
// ONE line per outage. The body snippet is the upstream's own error text
|
|
223
|
+
// (`invalid_grant`, an HTML 502 page, a timeout message) — the tokens
|
|
224
|
+
// themselves are never logged.
|
|
225
|
+
console.error(`[dario] codex account ${alias}: token refresh failed (${status || 'no response'}): ${entry.message} — ` +
|
|
226
|
+
`not retrying for ${Math.round(REFRESH_FAILURE_TTL_MS / 1000)}s. Re-add with \`dario codex add ${alias}\` if this persists.`);
|
|
227
|
+
entry.logged = true;
|
|
228
|
+
}
|
|
229
|
+
refreshFailures.set(alias, entry);
|
|
230
|
+
return entry;
|
|
231
|
+
}
|
|
232
|
+
function noteRefreshRecovered(alias) {
|
|
233
|
+
const previous = refreshFailures.get(alias);
|
|
234
|
+
refreshFailures.delete(alias);
|
|
235
|
+
if (previous?.logged)
|
|
236
|
+
console.log(`[dario] codex account ${alias}: token refresh recovered`);
|
|
237
|
+
}
|
|
166
238
|
/**
|
|
167
239
|
* In-process refresh serialization.
|
|
168
240
|
*
|
|
@@ -183,14 +255,37 @@ const inflightRefresh = new Map();
|
|
|
183
255
|
/**
|
|
184
256
|
* Return credentials guaranteed fresh enough to send upstream, refreshing (once,
|
|
185
257
|
* per alias, per process) when within the expiry buffer.
|
|
258
|
+
*
|
|
259
|
+
* Throws {@link CodexCredentialsUnavailableError} when the refresh failed —
|
|
260
|
+
* including when it failed RECENTLY and this call therefore made no upstream
|
|
261
|
+
* attempt at all. Callers must treat the throw as "the codex route is not
|
|
262
|
+
* available for this request" and say so naming the codex account; swallowing
|
|
263
|
+
* it is what turned a dead token into a per-request token-endpoint storm with
|
|
264
|
+
* a misleading "run `dario login`" answer to the client.
|
|
186
265
|
*/
|
|
187
266
|
export async function getFreshCodexAccount(creds) {
|
|
188
|
-
if (!codexAccountNeedsRefresh(creds))
|
|
267
|
+
if (!codexAccountNeedsRefresh(creds)) {
|
|
268
|
+
// Credentials on disk are fresh again — an operator re-added the account,
|
|
269
|
+
// or another process refreshed it. Nothing to cool down anymore.
|
|
270
|
+
noteRefreshRecovered(creds.alias);
|
|
189
271
|
return creds;
|
|
272
|
+
}
|
|
190
273
|
const existing = inflightRefresh.get(creds.alias);
|
|
191
274
|
if (existing)
|
|
192
275
|
return existing;
|
|
193
|
-
const
|
|
276
|
+
const remembered = refreshFailures.get(creds.alias);
|
|
277
|
+
if (remembered && Date.now() < remembered.retryAt) {
|
|
278
|
+
throw new CodexCredentialsUnavailableError(creds.alias, remembered);
|
|
279
|
+
}
|
|
280
|
+
const p = refreshCodexAccount(creds)
|
|
281
|
+
.then((fresh) => {
|
|
282
|
+
noteRefreshRecovered(creds.alias);
|
|
283
|
+
return fresh;
|
|
284
|
+
})
|
|
285
|
+
.catch((err) => {
|
|
286
|
+
throw new CodexCredentialsUnavailableError(creds.alias, noteRefreshFailure(creds.alias, err));
|
|
287
|
+
})
|
|
288
|
+
.finally(() => {
|
|
194
289
|
inflightRefresh.delete(creds.alias);
|
|
195
290
|
});
|
|
196
291
|
inflightRefresh.set(creds.alias, p);
|
package/dist/codex-backend.d.ts
CHANGED
|
@@ -113,6 +113,24 @@ export declare function pickClaudeTarget(models: readonly string[], slugs: reado
|
|
|
113
113
|
* header, which is what the CLI does for accounts without a workspace.
|
|
114
114
|
*/
|
|
115
115
|
export declare function extractChatGPTAccountId(idToken: string | undefined): string | null;
|
|
116
|
+
/** True when a chat field survives BOTH translation and the Codex scrub. */
|
|
117
|
+
export declare function chatFieldReachesCodex(field: string): boolean;
|
|
118
|
+
/** Report each chat field that never reaches Codex, once per process. */
|
|
119
|
+
export declare function logUnsupportedChatFields(body: Record<string, unknown>, verbose: boolean): void;
|
|
120
|
+
/**
|
|
121
|
+
* Translate a chat/completions `tool_choice` into the Responses shape.
|
|
122
|
+
*
|
|
123
|
+
* The two APIs agree on the string forms ("auto"/"none"/"required") but not on
|
|
124
|
+
* the forced-tool form: chat/completions nests the name under `function`,
|
|
125
|
+
* Responses takes it FLAT. Forwarding the nested form verbatim makes the Codex
|
|
126
|
+
* backend answer 400 "Missing required parameter: 'tool_choice.name'", which is
|
|
127
|
+
* what every client that forces a tool (Cursor, Continue, Aider) sends. Mirrors
|
|
128
|
+
* translateToolChoice() on the Anthropic path.
|
|
129
|
+
*
|
|
130
|
+
* Anything else passes through untouched: an unknown object is the backend's to
|
|
131
|
+
* reject, not ours to guess at. Pure; exported for tests.
|
|
132
|
+
*/
|
|
133
|
+
export declare function toResponsesToolChoice(choice: unknown): unknown;
|
|
116
134
|
/**
|
|
117
135
|
* Translate an OpenAI chat/completions request body into a Responses request.
|
|
118
136
|
*
|
|
@@ -141,6 +159,8 @@ export declare function isTerminalResponsesEvent(type: string): boolean;
|
|
|
141
159
|
export declare function isFailedResponse(resp: unknown): boolean;
|
|
142
160
|
/** The upstream message on a failed Responses payload, for the error body. */
|
|
143
161
|
export declare function failedResponseMessage(resp: unknown): string;
|
|
162
|
+
/** The upstream error code on a failed Responses payload, or null when absent. */
|
|
163
|
+
export declare function failedResponseCode(resp: unknown): string | null;
|
|
144
164
|
/**
|
|
145
165
|
* Stateful per-request translator: Responses SSE in, chat/completions out.
|
|
146
166
|
*
|
package/dist/codex-backend.js
CHANGED
|
@@ -180,6 +180,46 @@ export function extractChatGPTAccountId(idToken) {
|
|
|
180
180
|
return null;
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Which Responses field each chat/completions field is translated into.
|
|
185
|
+
*
|
|
186
|
+
* Translation is only half the transport: `toCodexSupportedBody` scrubs the
|
|
187
|
+
* translated body again, so a chat field reaches Codex only when its
|
|
188
|
+
* TRANSLATED name also survives that allowlist. `temperature` and `top_p`
|
|
189
|
+
* are translated by name and `max_tokens`/`max_completion_tokens` become
|
|
190
|
+
* `max_output_tokens` — all four are then scrubbed away, so the diagnostic
|
|
191
|
+
* has to report them as dropped. A caller passing `temperature: 0` needs to
|
|
192
|
+
* hear that precisely because the translation step looks like it worked.
|
|
193
|
+
*/
|
|
194
|
+
const CHAT_COMPLETIONS_FIELD_TRANSLATIONS = {
|
|
195
|
+
model: 'model',
|
|
196
|
+
messages: 'input',
|
|
197
|
+
tools: 'tools',
|
|
198
|
+
tool_choice: 'tool_choice',
|
|
199
|
+
stream: 'stream',
|
|
200
|
+
reasoning_effort: 'reasoning',
|
|
201
|
+
temperature: 'temperature',
|
|
202
|
+
top_p: 'top_p',
|
|
203
|
+
max_tokens: 'max_output_tokens',
|
|
204
|
+
max_completion_tokens: 'max_output_tokens',
|
|
205
|
+
};
|
|
206
|
+
const loggedUnsupportedChatFields = new Set();
|
|
207
|
+
/** True when a chat field survives BOTH translation and the Codex scrub. */
|
|
208
|
+
export function chatFieldReachesCodex(field) {
|
|
209
|
+
const target = CHAT_COMPLETIONS_FIELD_TRANSLATIONS[field];
|
|
210
|
+
return target !== undefined && CODEX_SUPPORTED_FIELDS.includes(target);
|
|
211
|
+
}
|
|
212
|
+
/** Report each chat field that never reaches Codex, once per process. */
|
|
213
|
+
export function logUnsupportedChatFields(body, verbose) {
|
|
214
|
+
if (!verbose)
|
|
215
|
+
return;
|
|
216
|
+
for (const field of Object.keys(body)) {
|
|
217
|
+
if (chatFieldReachesCodex(field) || loggedUnsupportedChatFields.has(field))
|
|
218
|
+
continue;
|
|
219
|
+
loggedUnsupportedChatFields.add(field);
|
|
220
|
+
console.log(`[dario] codex: dropping unsupported chat field ${field}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
183
223
|
function messageContentToText(content) {
|
|
184
224
|
if (typeof content === 'string')
|
|
185
225
|
return content;
|
|
@@ -193,6 +233,60 @@ function messageContentToText(content) {
|
|
|
193
233
|
}
|
|
194
234
|
return content == null ? '' : JSON.stringify(content);
|
|
195
235
|
}
|
|
236
|
+
/** Preserve OpenAI chat text and image_url parts in a Responses user item. */
|
|
237
|
+
function chatContentToResponsesParts(content) {
|
|
238
|
+
if (!Array.isArray(content))
|
|
239
|
+
return [{ type: 'input_text', text: messageContentToText(content) }];
|
|
240
|
+
const parts = [];
|
|
241
|
+
for (const part of content) {
|
|
242
|
+
const p = part;
|
|
243
|
+
if (p?.type === 'text' && typeof p.text === 'string') {
|
|
244
|
+
parts.push({ type: 'input_text', text: p.text });
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (p?.type === 'image_url') {
|
|
248
|
+
const obj = typeof p.image_url === 'object' && p.image_url !== null
|
|
249
|
+
? p.image_url
|
|
250
|
+
: undefined;
|
|
251
|
+
const imageUrl = typeof p.image_url === 'string' ? p.image_url : obj?.url;
|
|
252
|
+
if (typeof imageUrl === 'string') {
|
|
253
|
+
// `detail` is a fidelity/cost control the Responses input_image part
|
|
254
|
+
// takes too; dropping it silently downgrades a low-detail request to
|
|
255
|
+
// the backend's automatic behaviour and changes image-token cost.
|
|
256
|
+
const detail = obj?.detail;
|
|
257
|
+
parts.push({
|
|
258
|
+
type: 'input_image',
|
|
259
|
+
image_url: imageUrl,
|
|
260
|
+
...(detail === 'auto' || detail === 'low' || detail === 'high' ? { detail } : {}),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return parts;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Translate a chat/completions `tool_choice` into the Responses shape.
|
|
269
|
+
*
|
|
270
|
+
* The two APIs agree on the string forms ("auto"/"none"/"required") but not on
|
|
271
|
+
* the forced-tool form: chat/completions nests the name under `function`,
|
|
272
|
+
* Responses takes it FLAT. Forwarding the nested form verbatim makes the Codex
|
|
273
|
+
* backend answer 400 "Missing required parameter: 'tool_choice.name'", which is
|
|
274
|
+
* what every client that forces a tool (Cursor, Continue, Aider) sends. Mirrors
|
|
275
|
+
* translateToolChoice() on the Anthropic path.
|
|
276
|
+
*
|
|
277
|
+
* Anything else passes through untouched: an unknown object is the backend's to
|
|
278
|
+
* reject, not ours to guess at. Pure; exported for tests.
|
|
279
|
+
*/
|
|
280
|
+
export function toResponsesToolChoice(choice) {
|
|
281
|
+
if (choice == null || typeof choice !== 'object')
|
|
282
|
+
return choice;
|
|
283
|
+
const c = choice;
|
|
284
|
+
const name = c.function?.name;
|
|
285
|
+
if (typeof name === 'string' && name.length > 0) {
|
|
286
|
+
return { type: 'function', name };
|
|
287
|
+
}
|
|
288
|
+
return choice;
|
|
289
|
+
}
|
|
196
290
|
/**
|
|
197
291
|
* Translate an OpenAI chat/completions request body into a Responses request.
|
|
198
292
|
*
|
|
@@ -239,7 +333,7 @@ export function chatCompletionsToResponses(body) {
|
|
|
239
333
|
}
|
|
240
334
|
continue;
|
|
241
335
|
}
|
|
242
|
-
input.push({ role: 'user', content:
|
|
336
|
+
input.push({ role: 'user', content: chatContentToResponsesParts(m.content) });
|
|
243
337
|
}
|
|
244
338
|
const out = {
|
|
245
339
|
model: String(body.model ?? ''),
|
|
@@ -259,7 +353,7 @@ export function chatCompletionsToResponses(body) {
|
|
|
259
353
|
}));
|
|
260
354
|
}
|
|
261
355
|
if (body.tool_choice != null)
|
|
262
|
-
out.tool_choice = body.tool_choice;
|
|
356
|
+
out.tool_choice = toResponsesToolChoice(body.tool_choice);
|
|
263
357
|
if (body.temperature != null)
|
|
264
358
|
out.temperature = body.temperature;
|
|
265
359
|
if (body.top_p != null)
|
|
@@ -297,6 +391,11 @@ export function failedResponseMessage(resp) {
|
|
|
297
391
|
const code = e && typeof e.code === 'string' ? e.code : '';
|
|
298
392
|
return msg || code || 'the Codex backend ended the response as failed';
|
|
299
393
|
}
|
|
394
|
+
/** The upstream error code on a failed Responses payload, or null when absent. */
|
|
395
|
+
export function failedResponseCode(resp) {
|
|
396
|
+
const e = resp?.error;
|
|
397
|
+
return e && typeof e.code === 'string' && e.code ? e.code : null;
|
|
398
|
+
}
|
|
300
399
|
/**
|
|
301
400
|
* Stateful per-request translator: Responses SSE in, chat/completions out.
|
|
302
401
|
*
|
|
@@ -410,6 +509,21 @@ export function createResponsesTranslator(model) {
|
|
|
410
509
|
total_tokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0),
|
|
411
510
|
};
|
|
412
511
|
}
|
|
512
|
+
if (failed) {
|
|
513
|
+
// A failed turn must NOT close like a finished one: a
|
|
514
|
+
// `finish_reason:"stop"` frame is, to every OpenAI SDK, a successful
|
|
515
|
+
// empty completion, so the failure disappears entirely. Emit the
|
|
516
|
+
// error frame instead — openai-node's ChatCompletionStream and
|
|
517
|
+
// openai-python both raise on a chunk carrying `error` — then
|
|
518
|
+
// `[DONE]` so the parser terminates rather than waiting.
|
|
519
|
+
return opened(`data: ${JSON.stringify({
|
|
520
|
+
error: {
|
|
521
|
+
message: failedResponseMessage(e.response),
|
|
522
|
+
type: 'server_error',
|
|
523
|
+
code: failedResponseCode(e.response),
|
|
524
|
+
},
|
|
525
|
+
})}\n\ndata: [DONE]\n\n`);
|
|
526
|
+
}
|
|
413
527
|
return opened(`${frame({}, toolCalls.size > 0 ? 'tool_calls' : 'stop')}data: [DONE]\n\n`);
|
|
414
528
|
}
|
|
415
529
|
return null;
|
|
@@ -551,6 +665,8 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
551
665
|
report(400, null, false, '');
|
|
552
666
|
return true;
|
|
553
667
|
}
|
|
668
|
+
if (!isAnthropic)
|
|
669
|
+
logUnsupportedChatFields(parsed, verbose);
|
|
554
670
|
const clientWantsStream = parsed.stream === true;
|
|
555
671
|
const model = String(parsed.model ?? '');
|
|
556
672
|
// stream is forced: the backend is always streamed and collapsed here.
|
|
@@ -560,6 +676,31 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
560
676
|
const scrubbed = toCodexSupportedBody(upstreamBody);
|
|
561
677
|
const target = `${CODEX_BACKEND_BASE_URL.replace(/\/$/, '')}/responses`;
|
|
562
678
|
const abort = new AbortController();
|
|
679
|
+
// Once the client is gone there is nobody left to write to, but the upstream
|
|
680
|
+
// Responses stream keeps generating — and the ChatGPT subscription keeps
|
|
681
|
+
// paying for output nobody will read. Aborting the fetch is what stops the
|
|
682
|
+
// meter; `clientGone` stops us writing to a socket we no longer have.
|
|
683
|
+
// `finished` tells our own teardown apart from a client that walked away,
|
|
684
|
+
// the same distinction proxy.ts makes with res.writableEnded.
|
|
685
|
+
let finished = false;
|
|
686
|
+
let clientGone = false;
|
|
687
|
+
const onClientClose = () => {
|
|
688
|
+
if (finished || clientGone)
|
|
689
|
+
return;
|
|
690
|
+
clientGone = true;
|
|
691
|
+
if (verbose)
|
|
692
|
+
console.log(`[dario] codex client disconnected (${creds.alias}) — aborting upstream`);
|
|
693
|
+
if (!abort.signal.aborted)
|
|
694
|
+
abort.abort();
|
|
695
|
+
};
|
|
696
|
+
res.on('close', onClientClose);
|
|
697
|
+
// Every streamed write goes through here: after a disconnect the socket is
|
|
698
|
+
// gone and writing to it is wasted at best, an EPIPE at worst.
|
|
699
|
+
const write = (chunk) => { if (!clientGone)
|
|
700
|
+
res.write(chunk); };
|
|
701
|
+
// Usage seen so far, so a stream the client abandoned still reports what the
|
|
702
|
+
// subscription already spent. Populated once the translators exist.
|
|
703
|
+
let usageSoFar = () => null;
|
|
563
704
|
const timeout = setTimeout(() => abort.abort(), upstreamTimeoutMs);
|
|
564
705
|
try {
|
|
565
706
|
if (verbose)
|
|
@@ -574,6 +715,15 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
574
715
|
const detail = await upstream.text().catch(() => '');
|
|
575
716
|
if (verbose)
|
|
576
717
|
console.error(`[dario] codex backend ${upstream.status}: ${detail.slice(0, 300)}`);
|
|
718
|
+
// The client can hang up while this buffered error body is being read:
|
|
719
|
+
// an already-arrived response resolves regardless of the abort, so the
|
|
720
|
+
// signal never rejects here. With nobody left to serve, a decline would
|
|
721
|
+
// hand the chain a request whose only remaining effect is billing the
|
|
722
|
+
// next provider for output no one reads.
|
|
723
|
+
if (clientGone) {
|
|
724
|
+
report(499, usageSoFar(), clientWantsStream, model);
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
577
727
|
// "Not right now" — a rate limit or an upstream fault — is the caller's
|
|
578
728
|
// cue to fail over, not something to hand the client. A 4xx that is our
|
|
579
729
|
// own fault (a bad body, an unsupported parameter) is NOT: failing over
|
|
@@ -608,6 +758,11 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
608
758
|
const antAssembler = isAnthropic ? createAnthropicMessageAssembler() : null;
|
|
609
759
|
let terminalResponse = null;
|
|
610
760
|
let anthropicFailed = false;
|
|
761
|
+
// Reported on the abandoned-client exit as well as the normal one, so a
|
|
762
|
+
// stream the client walked away from still shows what it already spent.
|
|
763
|
+
usageSoFar = isAnthropic
|
|
764
|
+
? () => { const tr = terminalResponse; return tr?.usage ? { input: Number(tr.usage.input_tokens ?? 0), output: Number(tr.usage.output_tokens ?? 0) } : null; }
|
|
765
|
+
: () => { const u = translator.usage(); return u ? { input: u.prompt_tokens, output: u.completion_tokens } : null; };
|
|
611
766
|
const emitAnthropic = (events) => {
|
|
612
767
|
for (const ev of events) {
|
|
613
768
|
const t = ev.type ?? '';
|
|
@@ -623,7 +778,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
623
778
|
antAssembler.push(produced);
|
|
624
779
|
for (const out of produced) {
|
|
625
780
|
if (clientWantsStream)
|
|
626
|
-
|
|
781
|
+
write(formatResponsesAnthropicSSE(out));
|
|
627
782
|
}
|
|
628
783
|
}
|
|
629
784
|
};
|
|
@@ -645,6 +800,10 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
645
800
|
const { done, value } = await reader.read();
|
|
646
801
|
if (done)
|
|
647
802
|
break;
|
|
803
|
+
// The abort above normally makes this read reject; a stream that
|
|
804
|
+
// ignores its signal must not keep us looping for a client that left.
|
|
805
|
+
if (clientGone)
|
|
806
|
+
break;
|
|
648
807
|
const decoded = decoder.decode(value, { stream: true });
|
|
649
808
|
if (isAnthropic) {
|
|
650
809
|
// The parser owns its own partial-frame buffering.
|
|
@@ -658,7 +817,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
658
817
|
for (const line of lines) {
|
|
659
818
|
const out = translator.chunk(line);
|
|
660
819
|
if (out && clientWantsStream)
|
|
661
|
-
|
|
820
|
+
write(out);
|
|
662
821
|
}
|
|
663
822
|
}
|
|
664
823
|
}
|
|
@@ -671,9 +830,17 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
671
830
|
else if (buffered.length > 0) {
|
|
672
831
|
const out = translator.chunk(buffered);
|
|
673
832
|
if (out && clientWantsStream)
|
|
674
|
-
|
|
833
|
+
write(out);
|
|
675
834
|
}
|
|
676
835
|
}
|
|
836
|
+
// The client left mid-stream: upstream is aborted, the socket is gone and
|
|
837
|
+
// there is nothing left to collapse or send. 499 is nginx's "client closed
|
|
838
|
+
// request" — the request happened and cost tokens, so it is reported, but
|
|
839
|
+
// it is neither an upstream failure nor a decline.
|
|
840
|
+
if (clientGone) {
|
|
841
|
+
report(499, usageSoFar(), clientWantsStream, model);
|
|
842
|
+
return true;
|
|
843
|
+
}
|
|
677
844
|
// A stream that ended in failure must not collapse into a 200 with empty
|
|
678
845
|
// content: to a non-streaming client that is indistinguishable from a model
|
|
679
846
|
// that chose to say nothing. Streaming clients already saw the terminal
|
|
@@ -686,6 +853,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
686
853
|
if (verbose)
|
|
687
854
|
console.error(`[dario] codex backend (${creds.alias}) response failed: ${detail}`);
|
|
688
855
|
res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
856
|
+
finished = true;
|
|
689
857
|
res.end(errBody(`Codex backend response failed: ${detail}`, { account: creds.alias }));
|
|
690
858
|
report(502, null, clientWantsStream, model);
|
|
691
859
|
return true;
|
|
@@ -693,7 +861,8 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
693
861
|
if (isAnthropic) {
|
|
694
862
|
if (clientWantsStream) {
|
|
695
863
|
for (const out of antTranslator.end())
|
|
696
|
-
|
|
864
|
+
write(formatResponsesAnthropicSSE(out));
|
|
865
|
+
finished = true;
|
|
697
866
|
res.end();
|
|
698
867
|
}
|
|
699
868
|
else {
|
|
@@ -703,10 +872,12 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
703
872
|
...securityHeaders,
|
|
704
873
|
});
|
|
705
874
|
antAssembler.push(antTranslator.end());
|
|
875
|
+
finished = true;
|
|
706
876
|
res.end(JSON.stringify(antAssembler.message(model)));
|
|
707
877
|
}
|
|
708
878
|
}
|
|
709
879
|
else if (clientWantsStream) {
|
|
880
|
+
finished = true;
|
|
710
881
|
res.end();
|
|
711
882
|
}
|
|
712
883
|
else {
|
|
@@ -715,18 +886,13 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
715
886
|
'Access-Control-Allow-Origin': corsOrigin,
|
|
716
887
|
...securityHeaders,
|
|
717
888
|
});
|
|
889
|
+
finished = true;
|
|
718
890
|
res.end(JSON.stringify(translator.complete()));
|
|
719
891
|
}
|
|
720
892
|
// Token usage rides the terminal Responses event on either shape. A stream
|
|
721
893
|
// that failed upstream still ended as 200 on the wire (the client saw the
|
|
722
894
|
// failure event); analytics must count it as the 502 it was.
|
|
723
|
-
|
|
724
|
-
const tr = terminalResponse;
|
|
725
|
-
const usage = isAnthropic
|
|
726
|
-
? (tr?.usage ? { input: Number(tr.usage.input_tokens ?? 0), output: Number(tr.usage.output_tokens ?? 0) } : null)
|
|
727
|
-
: (() => { const u = translator.usage(); return u ? { input: u.prompt_tokens, output: u.completion_tokens } : null; })();
|
|
728
|
-
report(upstreamFailed ? 502 : 200, usage, clientWantsStream, model);
|
|
729
|
-
}
|
|
895
|
+
report(upstreamFailed ? 502 : 200, usageSoFar(), clientWantsStream, model);
|
|
730
896
|
return true;
|
|
731
897
|
}
|
|
732
898
|
catch (err) {
|
|
@@ -735,6 +901,16 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
735
901
|
const detail = err instanceof Error ? err.message : String(err);
|
|
736
902
|
if (verbose)
|
|
737
903
|
console.error(`[dario] codex backend (${creds.alias}) error: ${detail}`);
|
|
904
|
+
// A client that hung up aborted this fetch itself. That is not the
|
|
905
|
+
// subscription declining — failing over would re-bill a request nobody is
|
|
906
|
+
// reading — and there is no socket left to write a 502 to. This has to be
|
|
907
|
+
// checked BEFORE the decline branch: an abort that fires before the first
|
|
908
|
+
// byte still leaves headersSent false, which would otherwise look exactly
|
|
909
|
+
// like an unreachable backend and trigger a pointless failover.
|
|
910
|
+
if (clientGone) {
|
|
911
|
+
report(499, usageSoFar(), clientWantsStream, model);
|
|
912
|
+
return true;
|
|
913
|
+
}
|
|
738
914
|
// A transport failure before any byte was written is the same "not right
|
|
739
915
|
// now" as a 429: DNS, a refused connection, a reset socket or our own
|
|
740
916
|
// upstream timeout all mean this subscription did not answer, and none of
|
|
@@ -756,9 +932,11 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
756
932
|
}
|
|
757
933
|
if (!res.headersSent) {
|
|
758
934
|
res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
935
|
+
finished = true;
|
|
759
936
|
res.end(errBody('Upstream Codex backend error', { account: creds.alias }));
|
|
760
937
|
}
|
|
761
938
|
else {
|
|
939
|
+
finished = true;
|
|
762
940
|
try {
|
|
763
941
|
res.end();
|
|
764
942
|
}
|
|
@@ -769,5 +947,6 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
769
947
|
}
|
|
770
948
|
finally {
|
|
771
949
|
clearTimeout(timeout);
|
|
950
|
+
res.removeListener('close', onClientClose);
|
|
772
951
|
}
|
|
773
952
|
}
|
package/dist/codex-oauth.d.ts
CHANGED
|
@@ -9,6 +9,21 @@ export interface CodexTokens {
|
|
|
9
9
|
expiresAt: number;
|
|
10
10
|
idToken?: string;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* A refresh the token endpoint REJECTED, carrying the two facts the caller
|
|
14
|
+
* needs to act on it: the HTTP status (a 400/401 `invalid_grant` is a dead
|
|
15
|
+
* refresh token and re-asking will never help; a 5xx or a transport failure
|
|
16
|
+
* may clear on its own) and a short snippet of the body for the operator.
|
|
17
|
+
*
|
|
18
|
+
* The message keeps its old shape so anything matching on the text still
|
|
19
|
+
* matches; `status`/`bodySnippet` exist so codex-accounts.ts can record the
|
|
20
|
+
* failure without re-parsing prose. `status` is 0 when no response arrived.
|
|
21
|
+
*/
|
|
22
|
+
export declare class CodexRefreshError extends Error {
|
|
23
|
+
readonly status: number;
|
|
24
|
+
readonly bodySnippet: string;
|
|
25
|
+
constructor(status: number, bodySnippet: string);
|
|
26
|
+
}
|
|
12
27
|
export declare function generateCodexPKCE(): {
|
|
13
28
|
codeVerifier: string;
|
|
14
29
|
codeChallenge: string;
|
package/dist/codex-oauth.js
CHANGED
|
@@ -33,6 +33,26 @@ export const CODEX_AUTHORIZE_URL = process.env.DARIO_CODEX_AUTHORIZE_URL || 'htt
|
|
|
33
33
|
export const CODEX_TOKEN_URL = process.env.DARIO_CODEX_TOKEN_URL || 'https://auth.openai.com/oauth/token';
|
|
34
34
|
export const CODEX_REDIRECT_URI = process.env.DARIO_CODEX_REDIRECT_URI || 'http://localhost:1455/auth/callback';
|
|
35
35
|
export const CODEX_SCOPE = 'openid profile email offline_access';
|
|
36
|
+
/**
|
|
37
|
+
* A refresh the token endpoint REJECTED, carrying the two facts the caller
|
|
38
|
+
* needs to act on it: the HTTP status (a 400/401 `invalid_grant` is a dead
|
|
39
|
+
* refresh token and re-asking will never help; a 5xx or a transport failure
|
|
40
|
+
* may clear on its own) and a short snippet of the body for the operator.
|
|
41
|
+
*
|
|
42
|
+
* The message keeps its old shape so anything matching on the text still
|
|
43
|
+
* matches; `status`/`bodySnippet` exist so codex-accounts.ts can record the
|
|
44
|
+
* failure without re-parsing prose. `status` is 0 when no response arrived.
|
|
45
|
+
*/
|
|
46
|
+
export class CodexRefreshError extends Error {
|
|
47
|
+
status;
|
|
48
|
+
bodySnippet;
|
|
49
|
+
constructor(status, bodySnippet) {
|
|
50
|
+
super(`Codex token refresh failed (${status}): ${bodySnippet}`);
|
|
51
|
+
this.name = 'CodexRefreshError';
|
|
52
|
+
this.status = status;
|
|
53
|
+
this.bodySnippet = bodySnippet;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
36
56
|
function base64url(buf) {
|
|
37
57
|
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
38
58
|
}
|
|
@@ -108,7 +128,7 @@ export async function refreshCodexAccessToken(refreshToken) {
|
|
|
108
128
|
});
|
|
109
129
|
if (!res.ok) {
|
|
110
130
|
const body = await res.text().catch(() => '');
|
|
111
|
-
throw new
|
|
131
|
+
throw new CodexRefreshError(res.status, body.slice(0, 200));
|
|
112
132
|
}
|
|
113
133
|
return parseTokenResponse(await res.json());
|
|
114
134
|
}
|
package/dist/proxy.js
CHANGED
|
@@ -22,7 +22,7 @@ import { createTokenBucket } from './rate-limit.js';
|
|
|
22
22
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
23
23
|
import { forwardToCodex, getCodexModelSlugs, peekCodexModelSlugs, pickCodexFallback, pickClaudeTarget, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
|
|
24
24
|
import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
|
|
25
|
-
import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount } from './codex-accounts.js';
|
|
25
|
+
import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount, getCodexRefreshFailure, CodexCredentialsUnavailableError } from './codex-accounts.js';
|
|
26
26
|
import { route as routeProvider } from './provider-adapter.js';
|
|
27
27
|
import { selectPoolFallbackModels } from './pool-fallback-tier.js';
|
|
28
28
|
import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
|
|
@@ -2190,6 +2190,11 @@ export async function startProxy(opts = {}) {
|
|
|
2190
2190
|
needsRefresh: codexAccountNeedsRefresh(a),
|
|
2191
2191
|
models: peekCodexModelSlugs(a.alias) ?? [],
|
|
2192
2192
|
requestCount: codexRequestCounts.get(a.alias) ?? 0,
|
|
2193
|
+
// Why an account that LOOKS present is serving nothing: the last
|
|
2194
|
+
// token-endpoint rejection, remembered in-process for a minute
|
|
2195
|
+
// (DEV-179a412f). Read from memory only — a status read never spends
|
|
2196
|
+
// or exposes a credential, so there is no token in {at,status,message}.
|
|
2197
|
+
lastRefreshError: getCodexRefreshFailure(a.alias),
|
|
2193
2198
|
}));
|
|
2194
2199
|
res.writeHead(200, JSON_HEADERS);
|
|
2195
2200
|
res.end(JSON.stringify({
|
|
@@ -2528,6 +2533,26 @@ export async function startProxy(opts = {}) {
|
|
|
2528
2533
|
reason: ALL_PROVIDERS_RATE_LIMITED,
|
|
2529
2534
|
}));
|
|
2530
2535
|
};
|
|
2536
|
+
/**
|
|
2537
|
+
* The codex route was named — by a listed slug or a `codex:` prefix —
|
|
2538
|
+
* but that account's credentials could not be refreshed (DEV-179a412f).
|
|
2539
|
+
*
|
|
2540
|
+
* Before this the throw was swallowed by the JSON-peek `catch` around the
|
|
2541
|
+
* routing block, so the route silently disappeared and the request fell to
|
|
2542
|
+
* the Claude path, where an empty pool answered "No account configured …
|
|
2543
|
+
* run `dario login`" — pointing the operator at the wrong subscription
|
|
2544
|
+
* entirely. Answer about the CODEX account instead, in the client's own
|
|
2545
|
+
* wire shape.
|
|
2546
|
+
*/
|
|
2547
|
+
const writeCodexCredentialsUnavailable = (err, shape) => {
|
|
2548
|
+
const message = `Codex account "${err.alias}" cannot refresh its token (${err.status || 'no response'} from the OpenAI token endpoint). `
|
|
2549
|
+
+ `This is the ChatGPT subscription, not the Claude one — re-add it with \`dario codex add ${err.alias}\`.`;
|
|
2550
|
+
console.log(`[dario] #${requestCount} codex account ${err.alias} credentials unavailable — 503`);
|
|
2551
|
+
res.writeHead(503, { ...JSON_HEADERS, 'x-dario-upstream-rejection': 'credential_rejected' });
|
|
2552
|
+
res.end(JSON.stringify(shape === 'anthropic'
|
|
2553
|
+
? { type: 'error', error: { type: 'authentication_error', message }, account: err.alias }
|
|
2554
|
+
: { error: message, account: err.alias }));
|
|
2555
|
+
};
|
|
2531
2556
|
const selectPoolAccount = () => {
|
|
2532
2557
|
if (upstreamApiKey) {
|
|
2533
2558
|
// Per-token API-key mode: no OAuth, no pool selection. `poolAccount`
|
|
@@ -2781,11 +2806,28 @@ export async function startProxy(opts = {}) {
|
|
|
2781
2806
|
// an account added while the proxy runs routes on the very next
|
|
2782
2807
|
// request; the absent answer is cached ~30s, so an idle proxy with
|
|
2783
2808
|
// no codex account is not stat-ing the filesystem per request.
|
|
2809
|
+
// Credentials that exist on disk but could not be refreshed. Kept
|
|
2810
|
+
// separately from `codexCreds` so the decision below can still see
|
|
2811
|
+
// that this request was BOUND for codex and answer about that
|
|
2812
|
+
// account, rather than letting the throw escape into the JSON-peek
|
|
2813
|
+
// catch below and disappear (DEV-179a412f).
|
|
2814
|
+
let codexUnavailable = null;
|
|
2784
2815
|
if (await hasAnyCodexAccount()) {
|
|
2785
2816
|
const stored = await selectCodexAccount();
|
|
2786
2817
|
if (stored) {
|
|
2787
|
-
|
|
2788
|
-
|
|
2818
|
+
try {
|
|
2819
|
+
codexCreds = await getFreshCodexAccount(stored);
|
|
2820
|
+
codexModels = await getCodexModelSlugs(codexCreds);
|
|
2821
|
+
}
|
|
2822
|
+
catch (err) {
|
|
2823
|
+
if (!(err instanceof CodexCredentialsUnavailableError))
|
|
2824
|
+
throw err;
|
|
2825
|
+
codexUnavailable = err;
|
|
2826
|
+
// Cached slugs only — never an upstream call with a credential
|
|
2827
|
+
// already known to be bad. Enough to answer the routing
|
|
2828
|
+
// question "was this request the subscription's to serve".
|
|
2829
|
+
codexModels = peekCodexModelSlugs(stored.alias) ?? [];
|
|
2830
|
+
}
|
|
2789
2831
|
}
|
|
2790
2832
|
}
|
|
2791
2833
|
const decision = routeProvider({
|
|
@@ -2793,11 +2835,16 @@ export async function startProxy(opts = {}) {
|
|
|
2793
2835
|
model: rawModel,
|
|
2794
2836
|
forcedProvider,
|
|
2795
2837
|
hasOpenAIBackend: openaiBackend !== null,
|
|
2796
|
-
hasCodexAccount: codexCreds !== null,
|
|
2838
|
+
hasCodexAccount: codexCreds !== null || codexUnavailable !== null,
|
|
2797
2839
|
codexModels,
|
|
2798
2840
|
poolFallbackModel: requestPoolFallbackModel,
|
|
2799
2841
|
poolSize: pool.size,
|
|
2800
2842
|
});
|
|
2843
|
+
if (rawModel && codexUnavailable && decision.provider === 'codex') {
|
|
2844
|
+
requestCount++;
|
|
2845
|
+
writeCodexCredentialsUnavailable(codexUnavailable, isOpenAI ? 'openai' : 'anthropic');
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2801
2848
|
if (rawModel && codexCreds && decision.provider === 'codex') {
|
|
2802
2849
|
if (verbose) {
|
|
2803
2850
|
console.log(`[dario] #${requestCount} ${req.method} ${urlPath} (model: ${rawModel}) → codex account ${codexCreds.alias}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.22",
|
|
4
4
|
"description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|