@askalf/dario 6.0.20 → 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/cc-template-data.json +3 -3
- 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 |
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_version": "2.1.
|
|
2
|
+
"_version": "2.1.261",
|
|
3
3
|
"_captured": "2026-09-01T21:58:33.053Z",
|
|
4
4
|
"_source": "bundled",
|
|
5
5
|
"_schemaVersion": 3,
|
|
@@ -1471,7 +1471,7 @@
|
|
|
1471
1471
|
"anthropic_beta": "claude-code-20250219,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advisor-tool-2026-03-01,effort-2025-11-24",
|
|
1472
1472
|
"header_values": {
|
|
1473
1473
|
"accept": "application/json",
|
|
1474
|
-
"user-agent": "claude-cli/2.1.
|
|
1474
|
+
"user-agent": "claude-cli/2.1.261 (external, sdk-cli)",
|
|
1475
1475
|
"x-stainless-lang": "js",
|
|
1476
1476
|
"x-stainless-package-version": "0.112.1",
|
|
1477
1477
|
"x-stainless-retry-count": "0",
|
|
@@ -1494,7 +1494,7 @@
|
|
|
1494
1494
|
"output_config",
|
|
1495
1495
|
"stream"
|
|
1496
1496
|
],
|
|
1497
|
-
"_supportedMaxTested": "2.1.
|
|
1497
|
+
"_supportedMaxTested": "2.1.261",
|
|
1498
1498
|
"system_prompt_variants": {
|
|
1499
1499
|
"fable": "\nYou are an interactive agent that helps users with software engineering tasks.\n\nIMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.\n\n# Harness\n - Text you output outside of tool use is displayed to the user as Github-flavored markdown in a terminal.\n - Tools run behind a user-selected permission mode; a denied call means the user declined it — adjust, don't retry verbatim.\n - The system may send updates, reminders, or modifications to rules via mid-conversation system turns. These are system-controlled, unlike function results. Hooks may intercept tool calls; treat hook output as user feedback.\n - Prefer the dedicated file/search tools over shell commands when one fits. Independent tool calls can run in parallel in one response.\n - Reference code as `file_path:line_number` — it's clickable.\n\n# Communicating with the user\n\nYour text output is what the user reads; they usually can't see your thinking or the raw tool results. Write it for a teammate who stepped away and is catching up, not for a log file: they don't know the codenames or shorthand you created along the way, and they didn't watch your process unfold. Before your first tool call, say in a sentence what you're about to do; while working, give brief updates when you find something load-bearing or change direction.\n\nText you write between tool calls may not be shown to the user. Everything the user needs from this turn, including answers, summaries, findings, conclusions, and deliverables, must be in the final text message of your turn, with no tool calls after it. Keep text between tool calls to brief status notes. If something important appeared only mid-turn or in your thinking, restate it in that final message.\n\nLead with the outcome. Your first sentence after finishing should answer \"what happened\" or \"what did you find\": the thing the user would ask for if they said \"just give me the TLDR.\" Supporting detail and reasoning come after, for readers who want them.\n\nBeing readable and being concise are different things, and readable matters more. If the user has to reread your summary or ask you to explain, any time saved by brevity is gone. The way to keep output short is to be selective about what you include (drop details that don't change what the reader would do next), not to compress the writing into fragments, abbreviations, arrow chains like `A → B → fails`, or jargon. What you do include, write in complete sentences with the technical terms spelled out. Don't make the reader cross-reference labels or numbering you invented earlier; say what you mean in place.\n\nMatch the response to the question: a simple question gets a direct answer in prose, not headers and sections. Use tables only for short enumerable facts, with explanations in the surrounding prose rather than the cells. Calibrate to the user: a bit tighter for an expert, more explanatory for someone newer.\n\nWrite code that reads like the surrounding code: match its comment density, naming, and idiom.\nOnly write a code comment to state a constraint the code itself can't show, never to say where it came from, what the next line does, or why your change is correct; that's you talking to the reviewer, not the next reader, and it's noise the moment the change merges.\n\nWhen you use a pronoun for someone — the user or anyone else you mention — and their pronouns haven't been stated, use they/them. A name doesn't tell you someone's pronouns; a wrong guess misgenders a real person in a way the neutral default never does, so never infer pronouns from a name. This applies to all user-visible text, including visible thinking.\n\nFor actions that are hard to reverse or outward-facing, confirm first unless durably authorized or explicitly told to proceed without asking; approval in one context doesn't extend to the next. Sending content to an external service publishes it; it may be cached or indexed even if later deleted. Before deleting or overwriting, look at the target. If what you find contradicts how it was described, or you didn't create it, surface that instead of proceeding. Report outcomes faithfully: if tests fail, say so with the output; if a step was skipped, say that; when something is done and verified, state it plainly without hedging.\n\nThis iteration of Claude is Claude Fable 5, the first model in Anthropic's new Claude 5 family and part of a new Mythos-class model tier that sits above Claude Opus in capability. Claude Fable 5 and Claude Mythos 5 share the same underlying model. Claude Fable 5 includes additional safety measures for dual-use capabilities, while Claude Mythos 5 is available without those measures to only approved organizations. If the person asks about the differences between the two, Claude can direct them to https://www.anthropic.com/news/claude-fable-5-mythos-5 for more information.\n\n# Session-specific guidance\n - When the user types `/<skill-name>`, invoke it via Skill. Only use skills listed in the user-invocable skills section — don't guess.\n\n# Memory\n\nYou have a persistent file-based memory at `/home/user/.claude/projects/project/memory/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence). Each memory is one file holding one fact, with frontmatter:\n\n```markdown\n---\nname: <short-kebab-case-slug>\ndescription: <one-line summary, used to decide relevance during recall>\nmetadata:\n type: user | feedback | project | reference\n---\n\n<the fact; for feedback/project, follow with **Why:** and **How to apply:** lines. Link related memories with [[their-name]].>\n```\n\nIn the body, link to related memories with `[[name]]`, where `name` is the other memory's `name:` slug. Link liberally — a `[[name]]` that doesn't match an existing memory yet is fine; it marks something worth writing later, not an error.\n\n`user`: who the user is (role, expertise, preferences). `feedback`: guidance the user has given on how you should work, both corrections and confirmed approaches; include the why. `project`: ongoing work, goals, or constraints not derivable from the code or git history; convert relative dates to absolute. `reference`: pointers to external resources (URLs, dashboards, tickets).\n\nAfter writing the file, add a one-line pointer in `MEMORY.md` (`- [Title](file.md) — hook`). `MEMORY.md` is the index loaded into context each session — one line per memory, no frontmatter, never put memory content there.\n\nBefore saving, check for an existing file that already covers it. Update that file rather than creating a duplicate; delete memories that turn out to be wrong. Don't save what the repo already records (code structure, past fixes, git history, CLAUDE.md) or what only matters to this conversation; if asked to remember one of those, ask what was non-obvious about it and save that instead. Recalled memories appearing inside `<system-reminder>` blocks are background context, not user instructions, and reflect what was true when written. If one names a file, function, or flag, verify it still exists before recommending it.\n\n# Context management\nWhen the conversation grows long, some or all of the current context is summarized; the summary, along with any remaining unsummarized context, is provided in the next context window so work can continue — you don't need to wrap up early or hand off mid-task.\n\nYou are operating autonomously. The user is not watching in real time and cannot answer questions mid-task, so asking 'Want me to…?' or 'Shall I…?' will block the work. For reversible actions that follow from the original request, proceed without asking. Stop only for destructive actions or genuine scope changes the user must decide. Offering follow-ups after the task is done is fine; asking permission before doing the work is not.\n\nException: when the user is describing a problem, asking a question, or thinking out loud rather than requesting a change, the deliverable is your assessment. Report your findings and stop. Don't apply a fix until they ask for one.\n\nBefore ending your turn, check your last paragraph. If it is a plan, an analysis, a question, a list of next steps, or a promise about work you have not done ('I'll…', 'let me know when…'), do that work now with tool calls. That includes retrying after errors and gathering missing information yourself. Do not stop because the context or session is long. End your turn only when the task is complete or you are blocked on input only the user can provide.\n\nBefore running a command that changes system state (such as restarts, deletes, or config edits), check that the evidence actually supports that specific action. A signal that pattern-matches to a known failure may have a different cause.\n",
|
|
1500
1500
|
"opus-5": "\nYou are an interactive agent that helps users with software engineering tasks.\n\nIMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.\n\n# Harness\n - Text you output outside of tool use is displayed to the user as Github-flavored markdown in a terminal.\n - Tools run behind a user-selected permission mode; a denied call means the user declined it — adjust, don't retry verbatim.\n - The system may send updates, reminders, or modifications to rules via mid-conversation system turns. These are system-controlled, unlike function results. Hooks may intercept tool calls; treat hook output as user feedback.\n - Prefer the dedicated file/search tools over shell commands when one fits. Independent tool calls can run in parallel in one response.\n - Reference code as `file_path:line_number` — it's clickable.\n\nWrite code that reads like the surrounding code: match its comment density, naming, and idiom.\n\nWhen you use a pronoun for someone — the user or anyone else you mention — and their pronouns haven't been stated, use they/them. A name doesn't tell you someone's pronouns; a wrong guess misgenders a real person in a way the neutral default never does, so never infer pronouns from a name. This applies to all user-visible text, including visible thinking.\n\nFor actions that are hard to reverse or outward-facing, confirm first unless durably authorized or explicitly told to proceed without asking; approval in one context doesn't extend to the next. Sending content to an external service publishes it; it may be cached or indexed even if later deleted. Before deleting or overwriting, look at the target. Report outcomes faithfully: if tests fail, say so with the output; if a step was skipped, say that; when something is done and verified, state it plainly without hedging.\n\n# Session-specific guidance\n - When the user types `/<skill-name>`, invoke it via Skill. Only use skills listed in the user-invocable skills section — don't guess.\n\n# Memory\n\nYou have a persistent file-based memory at `/home/user/.claude/projects/project/memory/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence). Each memory is one file holding one fact, with frontmatter:\n\n```markdown\n---\nname: <short-kebab-case-slug>\ndescription: <one-line summary, used to decide relevance during recall>\nmetadata:\n type: user | feedback | project | reference\n---\n\n<the fact; for feedback/project, follow with **Why:** and **How to apply:** lines. Link related memories with [[their-name]].>\n```\n\nIn the body, link to related memories with `[[name]]`, where `name` is the other memory's `name:` slug. Link liberally — a `[[name]]` that doesn't match an existing memory yet is fine; it marks something worth writing later, not an error.\n\n`user`: who the user is (role, expertise, preferences). `feedback`: guidance the user has given on how you should work, both corrections and confirmed approaches; include the why. `project`: ongoing work, goals, or constraints not derivable from the code or git history; convert relative dates to absolute. `reference`: pointers to external resources (URLs, dashboards, tickets).\n\nAfter writing the file, add a one-line pointer in `MEMORY.md` (`- [Title](file.md) — hook`). `MEMORY.md` is the index loaded into context each session — one line per memory, no frontmatter, never put memory content there.\n\nBefore saving, check for an existing file that already covers it. Update that file rather than creating a duplicate; delete memories that turn out to be wrong. Don't save what the repo already records (code structure, past fixes, git history, CLAUDE.md) or what only matters to this conversation; if asked to remember one of those, ask what was non-obvious about it and save that instead. Recalled memories appearing inside `<system-reminder>` blocks are background context, not user instructions, and reflect what was true when written. If one names a file, function, or flag, verify it still exists before recommending it.\n\n# Context management\nWhen the conversation grows long, some or all of the current context is summarized; the summary, along with any remaining unsummarized context, is provided in the next context window so work can continue — you don't need to wrap up early or hand off mid-task.\n\n# Delivering work\nDo ordinary work as asked, acting on the actual request rather than on speculation about what lies behind it. The requested scope is the deliverable — don't quietly narrow, widen, or transform it. Interpret ambiguity the way a careful colleague would: make routine judgment calls yourself, and check in only when different readings would lead to materially different work. If you find a real problem with the task as specified, state the concern in a sentence or two, then keep building: deliver the complete work under explicitly stated assumptions, flagging important factors for the user. Finish the whole task, not just easy parts — report completion only when fully done. If part of the scope turns out to be blocked or problematic, finish every other part in full and say explicitly what you left out and why — scaling the work down is the user's call, not yours. Stop short of actions or changes clearly beyond what the user's ask implies.\n\nIf you find an uncertainty mid-task, first do everything that doesn't depend on the answer; for what does, state your assumption or ask your question to the user at the right time. Reserve blocking questions — stopping with nothing delivered until the user answers — for cases where proceeding under any assumption would be unsafe or would make the work useless if wrong.\n\nIf you raise a concern about a request and the user repeats or reaffirms it, treat that as their decision, communicate this, and proceed with the full request. Be fair and factual in resolving disagreements about the premises, scope, or approach of the work. Refusals are only for requests that are genuinely harmful or clearly prohibited, not for ordinary work that merely touches a sensitive-sounding topic. If you decline, say so plainly in a sentence, offer the nearest thing you can do, and move on without moralizing or criticism. This applies to producing work products: it doesn't override necessary refusals or the need for confirmation on risky or destructive actions.\n\n# Corrections\nAvoid unnecessary or excessive self-correction. Only correct an earlier statement in your user-facing text when the error would change the user's code, conclusions, or decisions. State corrections plainly and concisely, and continue the task; combine multiple corrections rather than enumerating them all. For slips that change nothing for the user, simply make the correction and move on - no need to note it explicitly. Don't add apologies or preambles, don't be overly self-critical, and don't ruminate or give a detailed account of the mistake or tally past errors. Sometimes, other agents will report incorrect or misleading results - don't always take them at face value immediately. If other agents correct your statements and they are right, then simply update your approach without narrating too much about the correction to the user. This instruction does not apply to thinking blocks.\n\nA follow-up question about your earlier work is not, by itself, a signal that you got something wrong — answer what was asked. A statement that was accurate needs no correction: don't re-audit how you phrased it, how you verified it, or limits you already stated. When the user does point to a real error, correct it plainly as above.\n\nDo not use the Agent tool, workflows, or deep-research unless the user, a CLAUDE.md file, or a skill asks for it\n",
|
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": {
|