@askalf/dario 5.5.83 → 5.5.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -2
- package/dist/cli.js +110 -1
- package/dist/codex-accounts.d.ts +60 -0
- package/dist/codex-accounts.js +260 -0
- package/dist/codex-backend.d.ts +107 -0
- package/dist/codex-backend.js +455 -0
- package/dist/codex-oauth.d.ts +31 -0
- package/dist/codex-oauth.js +114 -0
- package/dist/model-catalog.d.ts +9 -2
- package/dist/model-catalog.js +10 -3
- package/dist/provider-adapter.d.ts +24 -3
- package/dist/provider-adapter.js +34 -4
- package/dist/proxy.d.ts +10 -4
- package/dist/proxy.js +126 -26
- 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 subscription 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 · ~26k 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
|
|
|
@@ -121,6 +121,7 @@ You point every tool at one URL. dario reads each request, decides which backend
|
|
|
121
121
|
| Anthropic Messages | `claude-*` / `opus` / `sonnet` / `haiku` | Claude backend | OAuth swap + CC template → `api.anthropic.com` |
|
|
122
122
|
| Anthropic Messages | `gpt-*`, `llama-*`, … | OpenAI-compat backend | Anthropic→OpenAI translation, forwarded |
|
|
123
123
|
| OpenAI Chat | `gpt-*` / `o1-*` / `o3-*` | OpenAI-compat backend | Auth swap, body forwarded byte-for-byte |
|
|
124
|
+
| OpenAI Chat | a slug your ChatGPT account lists | Codex backend | chat/completions→Responses translation, subscription auth |
|
|
124
125
|
| OpenAI Chat | `claude-*` | Claude backend | OpenAI→Anthropic translation, then Claude path |
|
|
125
126
|
| Either | `<provider>:<model>` | Forced by prefix | Explicit override |
|
|
126
127
|
|
|
@@ -130,6 +131,34 @@ The tool doesn't know. The backend doesn't know. dario is the seam.
|
|
|
130
131
|
|
|
131
132
|
---
|
|
132
133
|
|
|
134
|
+
## ChatGPT subscription accounts (Codex engine)
|
|
135
|
+
|
|
136
|
+
Your ChatGPT Plus/Pro plan, served on dario's OpenAI-compatible endpoint — so any client or harness that speaks `/v1/chat/completions` can use it: Codex CLI, OpenClaw-style clients, the OpenAI SDKs, your own scripts.
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
dario codex add work # prints an authorize URL; paste the redirect URL back
|
|
140
|
+
dario codex list
|
|
141
|
+
dario codex remove work
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The browser lands on a `localhost` page that doesn't load — that's expected, nothing is listening there. Copy the whole address bar and paste it at the prompt; dario reads the code out of it. A bare code (or `code#state`) works too.
|
|
145
|
+
|
|
146
|
+
Once an account is stored, an OpenAI-shape request naming a model that account may use is served from the subscription:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
curl localhost:3456/v1/models | jq -r '.data[].id'
|
|
150
|
+
curl localhost:3456/v1/chat/completions -H 'content-type: application/json' \
|
|
151
|
+
-d '{"model":"gpt-5.5","messages":[{"role":"user","content":"hi"}]}'
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Model names are discovered, not hardcoded.** The set a ChatGPT subscription may use is per-account and moves; dario asks the backend which models this account lists, caches the answer, and advertises them on `GET /v1/models` so a client's model picker finds them. Anything not on that list — `gpt-4o` and friends — is untouched and still routes to a configured API-key backend as before. `codex:<model>` / `chatgpt:<model>` forces the route explicitly.
|
|
155
|
+
|
|
156
|
+
Streaming, tool calls, and tool-result round trips work: dario translates chat/completions to the Responses API the subscription backend speaks, and translates the stream back to `chat.completion.chunk`. Inbound is chat/completions only — there is no `/v1/responses` inbound yet.
|
|
157
|
+
|
|
158
|
+
Codex accounts live in `~/.dario/codex-accounts/`, entirely separate from the Claude pool. Nothing about `dario login`, `dario accounts`, or Claude routing changes.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
133
162
|
## Multi-account pool
|
|
134
163
|
|
|
135
164
|
**In v5 every dario is a pool** — a plain `dario login` is a pool of one, no separate mode to switch on. One Claude subscription has a ceiling; hold more than one seat — a personal Max and a work Max, a couple of Pros, team seats — and the same `localhost:3456` routes every request to whichever seat has the most headroom, live, per request. A single `dario accounts add` even bootstraps a servable proxy with no `dario login` step:
|
|
@@ -274,7 +303,7 @@ Ongoing discussion, including other users' experiences: [#724](https://github.co
|
|
|
274
303
|
|
|
275
304
|
## Commands
|
|
276
305
|
|
|
277
|
-
`dario` (TUI) · `login` · `proxy` · `doctor` · `accounts {list,add,remove}` · `backend {list,add,remove}` · `mcp` · `subagent {install,status,remove}` · `usage` · `config` · `upgrade` · `status` · `refresh` · `resume` · `logout` · `help`
|
|
306
|
+
`dario` (TUI) · `login` · `proxy` · `doctor` · `accounts {list,add,remove}` · `backend {list,add,remove}` · `codex {list,add,remove}` · `mcp` · `subagent {install,status,remove}` · `usage` · `config` · `upgrade` · `status` · `refresh` · `resume` · `logout` · `help`
|
|
278
307
|
|
|
279
308
|
Per-flag reference: [`docs/commands.md`](./docs/commands.md) · env vars grouped by task, for Docker / k8s / systemd: [`docs/configuration.md`](./docs/configuration.md) · SDK examples + per-tool setup: [`docs/usage.md`](./docs/usage.md)
|
|
280
309
|
|
package/dist/cli.js
CHANGED
|
@@ -21,10 +21,11 @@ import { realpathSync, readFileSync } from 'node:fs';
|
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { homedir } from 'node:os';
|
|
23
23
|
import { pathToFileURL } from 'node:url';
|
|
24
|
-
import { startAutoOAuthFlow, startManualOAuthFlow, detectHeadlessEnvironment, getStatus, refreshTokens, loadCredentials } from './oauth.js';
|
|
24
|
+
import { startAutoOAuthFlow, startManualOAuthFlow, detectHeadlessEnvironment, getStatus, refreshTokens, loadCredentials, readLineFromStdin } from './oauth.js';
|
|
25
25
|
import { startProxy, sanitizeError, parseModelAliasSpecs } from './proxy.js';
|
|
26
26
|
import { VALID_EFFORT_VALUES } from './cc-template.js';
|
|
27
27
|
import { listAccountAliases, loadAllAccounts, addAccountViaOAuth, addAccountViaManualOAuth, addAccountFromKeychain, KeychainImportError, removeAccount, ensureLoginCredentialsInPool, resyncLoginFromCredentialsIfStale, MIGRATED_LOGIN_ALIAS } from './accounts.js';
|
|
28
|
+
import { listCodexAccountAliases, loadAllCodexAccounts, startAddCodexAccount, completeAddCodexAccount, removeCodexAccount, parseCodexManualPaste } from './codex-accounts.js';
|
|
28
29
|
import { listBackends, saveBackend, removeBackend } from './openai-backend.js';
|
|
29
30
|
import { parseOutboundProxy, installOutboundProxyWrapper } from './outbound-proxy.js';
|
|
30
31
|
// `args` / `command` at module scope — command handlers below close over
|
|
@@ -1067,6 +1068,108 @@ async function accounts() {
|
|
|
1067
1068
|
console.error('Usage: dario accounts [list|add <alias>|remove <alias>]');
|
|
1068
1069
|
process.exit(1);
|
|
1069
1070
|
}
|
|
1071
|
+
/**
|
|
1072
|
+
* The "altman" engine (dario#1009) — a Codex/ChatGPT-subscription account
|
|
1073
|
+
* pool, structurally parallel to `accounts()` above but for a fully
|
|
1074
|
+
* separate provider. Manual-paste only (no localhost callback server):
|
|
1075
|
+
* simpler, and matches the flow OpenAI's own `codex` CLI already trains
|
|
1076
|
+
* users on. Accounts added here serve /v1/chat/completions requests naming a
|
|
1077
|
+
* model the ChatGPT backend lists for them — see codex-backend.ts.
|
|
1078
|
+
*/
|
|
1079
|
+
async function codex() {
|
|
1080
|
+
const sub = args[1];
|
|
1081
|
+
if (!sub || sub === 'list') {
|
|
1082
|
+
const aliases = await listCodexAccountAliases();
|
|
1083
|
+
console.log('');
|
|
1084
|
+
console.log(' dario — Codex accounts (altman engine)');
|
|
1085
|
+
console.log(' ───────────────────────────────────────');
|
|
1086
|
+
console.log('');
|
|
1087
|
+
if (aliases.length === 0) {
|
|
1088
|
+
console.log(' No Codex accounts yet.');
|
|
1089
|
+
console.log(' dario codex add <alias>');
|
|
1090
|
+
console.log('');
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
const loaded = await loadAllCodexAccounts();
|
|
1094
|
+
const now = Date.now();
|
|
1095
|
+
console.log(` ${aliases.length} account${aliases.length === 1 ? '' : 's'}`);
|
|
1096
|
+
console.log('');
|
|
1097
|
+
for (const a of loaded) {
|
|
1098
|
+
const msLeft = Math.max(0, a.expiresAt - now);
|
|
1099
|
+
const mins = Math.floor(msLeft / 60000);
|
|
1100
|
+
const expiry = msLeft > 0 ? `${mins}m` : 'expired';
|
|
1101
|
+
console.log(` ${a.alias.padEnd(20)} token expires in ${expiry}`);
|
|
1102
|
+
}
|
|
1103
|
+
console.log('');
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (sub === 'add') {
|
|
1107
|
+
const alias = args[2];
|
|
1108
|
+
if (!alias) {
|
|
1109
|
+
console.error('');
|
|
1110
|
+
console.error(' Usage: dario codex add <alias>');
|
|
1111
|
+
console.error('');
|
|
1112
|
+
process.exit(1);
|
|
1113
|
+
}
|
|
1114
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(alias)) {
|
|
1115
|
+
console.error('[dario] Invalid alias. Use letters, numbers, dot, underscore, dash only.');
|
|
1116
|
+
process.exit(1);
|
|
1117
|
+
}
|
|
1118
|
+
const existing = await listCodexAccountAliases();
|
|
1119
|
+
if (existing.includes(alias)) {
|
|
1120
|
+
console.error(`[dario] Codex account "${alias}" already exists. Remove it first with \`dario codex remove ${alias}\`.`);
|
|
1121
|
+
process.exit(1);
|
|
1122
|
+
}
|
|
1123
|
+
const { authorizeUrl, codeVerifier } = await startAddCodexAccount(alias);
|
|
1124
|
+
console.log('');
|
|
1125
|
+
console.log(' Open this URL and log in with your ChatGPT Plus/Pro account.');
|
|
1126
|
+
console.log(' The browser will land on a localhost page that does not load —');
|
|
1127
|
+
console.log(' that is expected. Copy the whole address bar and paste it here:');
|
|
1128
|
+
console.log('');
|
|
1129
|
+
console.log(` ${authorizeUrl}`);
|
|
1130
|
+
console.log('');
|
|
1131
|
+
const pasted = await readLineFromStdin(' Redirect URL (or code): ');
|
|
1132
|
+
const { code } = parseCodexManualPaste(pasted);
|
|
1133
|
+
if (!code) {
|
|
1134
|
+
console.error('[dario] No code found in what you pasted.');
|
|
1135
|
+
process.exit(1);
|
|
1136
|
+
}
|
|
1137
|
+
try {
|
|
1138
|
+
await completeAddCodexAccount(alias, code, codeVerifier);
|
|
1139
|
+
console.log('');
|
|
1140
|
+
console.log(` Added Codex account "${alias}".`);
|
|
1141
|
+
console.log(' A proxy that is already running picks it up within ~30s —');
|
|
1142
|
+
console.log(' no restart needed (dario#1138).');
|
|
1143
|
+
console.log('');
|
|
1144
|
+
}
|
|
1145
|
+
catch (err) {
|
|
1146
|
+
console.error(`[dario] Failed to add Codex account: ${err.message}`);
|
|
1147
|
+
process.exit(1);
|
|
1148
|
+
}
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
if (sub === 'remove' || sub === 'rm') {
|
|
1152
|
+
const alias = args[2];
|
|
1153
|
+
if (!alias) {
|
|
1154
|
+
console.error('');
|
|
1155
|
+
console.error(' Usage: dario codex remove <alias>');
|
|
1156
|
+
console.error('');
|
|
1157
|
+
process.exit(1);
|
|
1158
|
+
}
|
|
1159
|
+
const ok = await removeCodexAccount(alias);
|
|
1160
|
+
if (ok) {
|
|
1161
|
+
console.log(`[dario] Codex account "${alias}" removed.`);
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
console.error(`[dario] No Codex account "${alias}" found.`);
|
|
1165
|
+
process.exit(1);
|
|
1166
|
+
}
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
console.error(`[dario] Unknown codex subcommand: ${sub}`);
|
|
1170
|
+
console.error('Usage: dario codex [list|add <alias>|remove <alias>]');
|
|
1171
|
+
process.exit(1);
|
|
1172
|
+
}
|
|
1070
1173
|
async function backend() {
|
|
1071
1174
|
const sub = args[1];
|
|
1072
1175
|
if (!sub || sub === 'list') {
|
|
@@ -1202,6 +1305,11 @@ async function help() {
|
|
|
1202
1305
|
entry by its platform identifier (Linux account
|
|
1203
1306
|
attribute, Windows TargetName).
|
|
1204
1307
|
dario accounts remove N Remove an account from the pool
|
|
1308
|
+
dario codex list List ChatGPT-subscription accounts, served on
|
|
1309
|
+
/v1/chat/completions.
|
|
1310
|
+
dario codex add NAME Add a ChatGPT-subscription account (prints an
|
|
1311
|
+
authorize URL; paste the redirect URL back).
|
|
1312
|
+
dario codex remove NAME Remove a ChatGPT-subscription account.
|
|
1205
1313
|
dario backend list List configured OpenAI-compat backends
|
|
1206
1314
|
dario backend add NAME --key=sk-... [--base-url=...]
|
|
1207
1315
|
Add an OpenAI-compat backend (OpenAI, OpenRouter, Groq, etc.)
|
|
@@ -2129,6 +2237,7 @@ const commands = {
|
|
|
2129
2237
|
resume,
|
|
2130
2238
|
logout,
|
|
2131
2239
|
accounts,
|
|
2240
|
+
codex,
|
|
2132
2241
|
backend,
|
|
2133
2242
|
shim,
|
|
2134
2243
|
subagent,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface CodexAccountCredentials {
|
|
2
|
+
alias: string;
|
|
3
|
+
accessToken: string;
|
|
4
|
+
refreshToken: string;
|
|
5
|
+
expiresAt: number;
|
|
6
|
+
idToken?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function listCodexAccountAliases(): Promise<string[]>;
|
|
9
|
+
export declare function hasAnyCodexAccount(nowMs?: number): Promise<boolean>;
|
|
10
|
+
/** Drop the negative cache so a test doesn't have to sleep out its TTL. */
|
|
11
|
+
export declare function _resetCodexPresenceCacheForTest(): void;
|
|
12
|
+
export declare function loadCodexAccount(alias: string): Promise<CodexAccountCredentials | null>;
|
|
13
|
+
export declare function loadAllCodexAccounts(): Promise<CodexAccountCredentials[]>;
|
|
14
|
+
export declare function saveCodexAccount(creds: CodexAccountCredentials): Promise<void>;
|
|
15
|
+
export declare function removeCodexAccount(alias: string): Promise<boolean>;
|
|
16
|
+
export declare function getCodexAccountsDir(): string;
|
|
17
|
+
export declare function startAddCodexAccount(alias: string): Promise<{
|
|
18
|
+
authorizeUrl: string;
|
|
19
|
+
codeVerifier: string;
|
|
20
|
+
state: string;
|
|
21
|
+
}>;
|
|
22
|
+
export declare function completeAddCodexAccount(alias: string, code: string, codeVerifier: string): Promise<CodexAccountCredentials>;
|
|
23
|
+
export declare function codexAccountNeedsRefresh(creds: CodexAccountCredentials): boolean;
|
|
24
|
+
export declare function refreshCodexAccount(creds: CodexAccountCredentials): Promise<CodexAccountCredentials>;
|
|
25
|
+
/**
|
|
26
|
+
* Return credentials guaranteed fresh enough to send upstream, refreshing (once,
|
|
27
|
+
* per alias, per process) when within the expiry buffer.
|
|
28
|
+
*/
|
|
29
|
+
export declare function getFreshCodexAccount(creds: CodexAccountCredentials): Promise<CodexAccountCredentials>;
|
|
30
|
+
/**
|
|
31
|
+
* Pick the account to serve a request. Single account is the expected case (one
|
|
32
|
+
* ChatGPT subscription); with several, `DARIO_CODEX_ACCOUNT` names one and
|
|
33
|
+
* otherwise the first alphabetically wins. No rotation/least-recently-used
|
|
34
|
+
* balancing — a subscription is per-seat, so spreading load across seats is the
|
|
35
|
+
* user's decision to make explicitly, not something to do implicitly.
|
|
36
|
+
*/
|
|
37
|
+
export declare function selectCodexAccount(preferredAlias?: string): Promise<CodexAccountCredentials | null>;
|
|
38
|
+
/**
|
|
39
|
+
* Parse whatever the user pastes back after authorizing.
|
|
40
|
+
*
|
|
41
|
+
* Three accepted shapes, because all three are what people actually have on
|
|
42
|
+
* their clipboard:
|
|
43
|
+
*
|
|
44
|
+
* 1. the FULL redirect URL out of the browser address bar —
|
|
45
|
+
* `http://localhost:1455/auth/callback?code=…&state=…`. This is the
|
|
46
|
+
* common one: the manual-paste flow starts no listener on 1455, so the
|
|
47
|
+
* browser lands on a connection error and the address bar is the only
|
|
48
|
+
* place the code is visible. The first live login failed exactly here.
|
|
49
|
+
* 2. `code#state`, the fragment-joined form the Claude flow's success page
|
|
50
|
+
* renders (parseManualPaste in oauth.ts).
|
|
51
|
+
* 3. a bare code.
|
|
52
|
+
*
|
|
53
|
+
* Codex-local rather than shared with oauth.ts: the Claude flow has no
|
|
54
|
+
* redirect-URL shape to parse, and its parser is on the login path for every
|
|
55
|
+
* user of the Claude engine.
|
|
56
|
+
*/
|
|
57
|
+
export declare function parseCodexManualPaste(input: string): {
|
|
58
|
+
code: string;
|
|
59
|
+
state: string | null;
|
|
60
|
+
};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex account storage — the "altman" engine (dario#1009).
|
|
3
|
+
*
|
|
4
|
+
* Deliberately isolated from accounts.ts: separate directory
|
|
5
|
+
* (~/.dario/codex-accounts/), separate types, no shared code path with the
|
|
6
|
+
* Claude pool — in particular none of the pool/lock/lease machinery, which
|
|
7
|
+
* a live race test showed this provider does not need (see the
|
|
8
|
+
* getFreshCodexAccount comment below). Request routing lives in
|
|
9
|
+
* provider-adapter.ts + codex-backend.ts; this module owns credentials on
|
|
10
|
+
* disk and the selection/refresh in front of them.
|
|
11
|
+
*/
|
|
12
|
+
import { readFile, mkdir, readdir, unlink } from 'node:fs/promises';
|
|
13
|
+
import { join, basename } from 'node:path';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { randomBytes } from 'node:crypto';
|
|
16
|
+
import { generateCodexPKCE, buildCodexAuthorizeUrl, exchangeCodexAuthorizationCode, refreshCodexAccessToken, } from './codex-oauth.js';
|
|
17
|
+
import { durableWriteFile } from './durable-write.js';
|
|
18
|
+
const DARIO_DIR = join(homedir(), '.dario');
|
|
19
|
+
const CODEX_ACCOUNTS_DIR = join(DARIO_DIR, 'codex-accounts');
|
|
20
|
+
/** Same alias charset/traversal guard as accounts.ts's safeAliasPath. */
|
|
21
|
+
function safeAliasPath(alias) {
|
|
22
|
+
if (typeof alias !== 'string' || alias.length === 0)
|
|
23
|
+
return null;
|
|
24
|
+
const leaf = basename(alias);
|
|
25
|
+
if (leaf !== alias)
|
|
26
|
+
return null;
|
|
27
|
+
if (leaf === '.' || leaf === '..')
|
|
28
|
+
return null;
|
|
29
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_\-.]{0,63}$/.test(leaf))
|
|
30
|
+
return null;
|
|
31
|
+
return join(CODEX_ACCOUNTS_DIR, `${leaf}.json`);
|
|
32
|
+
}
|
|
33
|
+
async function ensureDir() {
|
|
34
|
+
await mkdir(CODEX_ACCOUNTS_DIR, { recursive: true, mode: 0o700 });
|
|
35
|
+
}
|
|
36
|
+
export async function listCodexAccountAliases() {
|
|
37
|
+
try {
|
|
38
|
+
await ensureDir();
|
|
39
|
+
const entries = await readdir(CODEX_ACCOUNTS_DIR);
|
|
40
|
+
return entries.filter(f => f.endsWith('.json')).map(f => f.replace('.json', ''));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* "Is the codex route available at all" — the routing question, asked per
|
|
48
|
+
* request rather than once at startup (dario#1138).
|
|
49
|
+
*
|
|
50
|
+
* The proxy used to resolve this a single time while booting, so an account
|
|
51
|
+
* stored by `dario codex add` against an ALREADY-RUNNING proxy stayed
|
|
52
|
+
* invisible until a restart: /v1/models advertised no gpt slugs and a listed
|
|
53
|
+
* slug fell through to the Claude path as an unknown model. `dario login`
|
|
54
|
+
* restarts the proxy by convention, `dario codex add` does not.
|
|
55
|
+
*
|
|
56
|
+
* Cheap enough to ask often — a readdir of a directory holding at most a
|
|
57
|
+
* handful of files — but an idle proxy still shouldn't hit the filesystem on
|
|
58
|
+
* every request, so the NEGATIVE answer is cached briefly. The positive one
|
|
59
|
+
* isn't cached: the caller goes on to read the credentials anyway, and a
|
|
60
|
+
* `codex remove` has to take effect immediately.
|
|
61
|
+
*/
|
|
62
|
+
const CODEX_PRESENCE_NEGATIVE_TTL_MS = 30_000;
|
|
63
|
+
let codexAbsentUntil = 0;
|
|
64
|
+
export async function hasAnyCodexAccount(nowMs = Date.now()) {
|
|
65
|
+
if (nowMs < codexAbsentUntil)
|
|
66
|
+
return false;
|
|
67
|
+
const present = (await listCodexAccountAliases()).length > 0;
|
|
68
|
+
codexAbsentUntil = present ? 0 : nowMs + CODEX_PRESENCE_NEGATIVE_TTL_MS;
|
|
69
|
+
return present;
|
|
70
|
+
}
|
|
71
|
+
/** Drop the negative cache so a test doesn't have to sleep out its TTL. */
|
|
72
|
+
export function _resetCodexPresenceCacheForTest() {
|
|
73
|
+
codexAbsentUntil = 0;
|
|
74
|
+
}
|
|
75
|
+
export async function loadCodexAccount(alias) {
|
|
76
|
+
const path = safeAliasPath(alias);
|
|
77
|
+
if (!path)
|
|
78
|
+
return null;
|
|
79
|
+
try {
|
|
80
|
+
const raw = await readFile(path, 'utf-8');
|
|
81
|
+
return JSON.parse(raw);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export async function loadAllCodexAccounts() {
|
|
88
|
+
const aliases = await listCodexAccountAliases();
|
|
89
|
+
const loaded = await Promise.all(aliases.map(a => loadCodexAccount(a)));
|
|
90
|
+
return loaded.filter((a) => a !== null);
|
|
91
|
+
}
|
|
92
|
+
export async function saveCodexAccount(creds) {
|
|
93
|
+
const path = safeAliasPath(creds.alias);
|
|
94
|
+
if (!path)
|
|
95
|
+
throw new Error(`invalid codex account alias: ${creds.alias}`);
|
|
96
|
+
await ensureDir();
|
|
97
|
+
await durableWriteFile(path, JSON.stringify(creds, null, 2), 0o600);
|
|
98
|
+
// An account stored in THIS process (`dario codex add` in a running admin
|
|
99
|
+
// path, or a test) must not be hidden by a negative cache entry taken
|
|
100
|
+
// moments earlier.
|
|
101
|
+
codexAbsentUntil = 0;
|
|
102
|
+
}
|
|
103
|
+
export async function removeCodexAccount(alias) {
|
|
104
|
+
const path = safeAliasPath(alias);
|
|
105
|
+
if (!path)
|
|
106
|
+
return false;
|
|
107
|
+
try {
|
|
108
|
+
await unlink(path);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function getCodexAccountsDir() {
|
|
116
|
+
return CODEX_ACCOUNTS_DIR;
|
|
117
|
+
}
|
|
118
|
+
function base64url(buf) {
|
|
119
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
120
|
+
}
|
|
121
|
+
export async function startAddCodexAccount(alias) {
|
|
122
|
+
if (!safeAliasPath(alias)) {
|
|
123
|
+
throw new Error(`invalid account alias "${alias}" (allowed: letters, digits, _-. — up to 64 chars, no path separators)`);
|
|
124
|
+
}
|
|
125
|
+
const { codeVerifier, codeChallenge } = generateCodexPKCE();
|
|
126
|
+
const state = base64url(randomBytes(32));
|
|
127
|
+
const authorizeUrl = buildCodexAuthorizeUrl(codeChallenge, state);
|
|
128
|
+
return { authorizeUrl, codeVerifier, state };
|
|
129
|
+
}
|
|
130
|
+
export async function completeAddCodexAccount(alias, code, codeVerifier) {
|
|
131
|
+
if (!safeAliasPath(alias)) {
|
|
132
|
+
throw new Error(`invalid account alias "${alias}"`);
|
|
133
|
+
}
|
|
134
|
+
const tokens = await exchangeCodexAuthorizationCode(code, codeVerifier);
|
|
135
|
+
const creds = {
|
|
136
|
+
alias,
|
|
137
|
+
accessToken: tokens.accessToken,
|
|
138
|
+
refreshToken: tokens.refreshToken,
|
|
139
|
+
expiresAt: tokens.expiresAt,
|
|
140
|
+
idToken: tokens.idToken,
|
|
141
|
+
};
|
|
142
|
+
await saveCodexAccount(creds);
|
|
143
|
+
return creds;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Refresh 30 min before expiry — same buffer as oauth.ts's Claude path.
|
|
147
|
+
* No single-flight or distributed-lock wrapping here (unlike accounts.ts's
|
|
148
|
+
* refreshAccountToken) — see codex-oauth.ts's header for why.
|
|
149
|
+
*/
|
|
150
|
+
const REFRESH_BUFFER_MS = 30 * 60 * 1000;
|
|
151
|
+
export function codexAccountNeedsRefresh(creds) {
|
|
152
|
+
return Date.now() >= creds.expiresAt - REFRESH_BUFFER_MS;
|
|
153
|
+
}
|
|
154
|
+
export async function refreshCodexAccount(creds) {
|
|
155
|
+
const tokens = await refreshCodexAccessToken(creds.refreshToken);
|
|
156
|
+
const updated = {
|
|
157
|
+
...creds,
|
|
158
|
+
accessToken: tokens.accessToken,
|
|
159
|
+
refreshToken: tokens.refreshToken,
|
|
160
|
+
expiresAt: tokens.expiresAt,
|
|
161
|
+
idToken: tokens.idToken,
|
|
162
|
+
};
|
|
163
|
+
await saveCodexAccount(updated);
|
|
164
|
+
return updated;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* In-process refresh serialization.
|
|
168
|
+
*
|
|
169
|
+
* dario#1010's open question is answered: test/manual/codex-refresh-race.mjs
|
|
170
|
+
* was run twice against a live ChatGPT Plus account and came back TOLERANT both
|
|
171
|
+
* times — two concurrent refreshes of the same refresh_token BOTH succeed, and
|
|
172
|
+
* each returns a different new refresh_token. OpenAI does not invalidate the
|
|
173
|
+
* previous one. So Codex needs none of the Claude engine's pool/lock/lease
|
|
174
|
+
* machinery (#993/#1008): no Durable Object, no Redis, nothing cross-process.
|
|
175
|
+
*
|
|
176
|
+
* What's left is purely an efficiency concern — N concurrent requests arriving
|
|
177
|
+
* on an expiring token shouldn't fire N identical refresh calls. One in-memory
|
|
178
|
+
* promise per alias collapses them. If a second dario process refreshes the same
|
|
179
|
+
* account at the same time, both simply succeed; last writer wins on disk and
|
|
180
|
+
* the other process's token stays valid until its own expiry.
|
|
181
|
+
*/
|
|
182
|
+
const inflightRefresh = new Map();
|
|
183
|
+
/**
|
|
184
|
+
* Return credentials guaranteed fresh enough to send upstream, refreshing (once,
|
|
185
|
+
* per alias, per process) when within the expiry buffer.
|
|
186
|
+
*/
|
|
187
|
+
export async function getFreshCodexAccount(creds) {
|
|
188
|
+
if (!codexAccountNeedsRefresh(creds))
|
|
189
|
+
return creds;
|
|
190
|
+
const existing = inflightRefresh.get(creds.alias);
|
|
191
|
+
if (existing)
|
|
192
|
+
return existing;
|
|
193
|
+
const p = refreshCodexAccount(creds).finally(() => {
|
|
194
|
+
inflightRefresh.delete(creds.alias);
|
|
195
|
+
});
|
|
196
|
+
inflightRefresh.set(creds.alias, p);
|
|
197
|
+
return p;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Pick the account to serve a request. Single account is the expected case (one
|
|
201
|
+
* ChatGPT subscription); with several, `DARIO_CODEX_ACCOUNT` names one and
|
|
202
|
+
* otherwise the first alphabetically wins. No rotation/least-recently-used
|
|
203
|
+
* balancing — a subscription is per-seat, so spreading load across seats is the
|
|
204
|
+
* user's decision to make explicitly, not something to do implicitly.
|
|
205
|
+
*/
|
|
206
|
+
export async function selectCodexAccount(preferredAlias) {
|
|
207
|
+
const alias = preferredAlias || process.env.DARIO_CODEX_ACCOUNT;
|
|
208
|
+
if (alias) {
|
|
209
|
+
const one = await loadCodexAccount(alias);
|
|
210
|
+
if (one)
|
|
211
|
+
return one;
|
|
212
|
+
}
|
|
213
|
+
const all = await loadAllCodexAccounts();
|
|
214
|
+
if (all.length === 0)
|
|
215
|
+
return null;
|
|
216
|
+
return [...all].sort((a, b) => a.alias.localeCompare(b.alias))[0];
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Parse whatever the user pastes back after authorizing.
|
|
220
|
+
*
|
|
221
|
+
* Three accepted shapes, because all three are what people actually have on
|
|
222
|
+
* their clipboard:
|
|
223
|
+
*
|
|
224
|
+
* 1. the FULL redirect URL out of the browser address bar —
|
|
225
|
+
* `http://localhost:1455/auth/callback?code=…&state=…`. This is the
|
|
226
|
+
* common one: the manual-paste flow starts no listener on 1455, so the
|
|
227
|
+
* browser lands on a connection error and the address bar is the only
|
|
228
|
+
* place the code is visible. The first live login failed exactly here.
|
|
229
|
+
* 2. `code#state`, the fragment-joined form the Claude flow's success page
|
|
230
|
+
* renders (parseManualPaste in oauth.ts).
|
|
231
|
+
* 3. a bare code.
|
|
232
|
+
*
|
|
233
|
+
* Codex-local rather than shared with oauth.ts: the Claude flow has no
|
|
234
|
+
* redirect-URL shape to parse, and its parser is on the login path for every
|
|
235
|
+
* user of the Claude engine.
|
|
236
|
+
*/
|
|
237
|
+
export function parseCodexManualPaste(input) {
|
|
238
|
+
const trimmed = input.trim();
|
|
239
|
+
if (!trimmed)
|
|
240
|
+
return { code: '', state: null };
|
|
241
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
242
|
+
try {
|
|
243
|
+
const url = new URL(trimmed);
|
|
244
|
+
return {
|
|
245
|
+
code: url.searchParams.get('code') ?? '',
|
|
246
|
+
state: url.searchParams.get('state'),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return { code: '', state: null };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const hashIdx = trimmed.indexOf('#');
|
|
254
|
+
if (hashIdx === -1)
|
|
255
|
+
return { code: trimmed, state: null };
|
|
256
|
+
return {
|
|
257
|
+
code: trimmed.slice(0, hashIdx).trim(),
|
|
258
|
+
state: trimmed.slice(hashIdx + 1).trim(),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex backend — request path for the "altman" engine (dario#1009/#1010).
|
|
3
|
+
*
|
|
4
|
+
* The ChatGPT subscription is NOT an api.openai.com API key: it can't be used
|
|
5
|
+
* with `Authorization: Bearer sk-…` against the public API. OpenAI's own `codex`
|
|
6
|
+
* CLI sends the OAuth access_token as a bearer to the ChatGPT Codex backend's
|
|
7
|
+
* Responses endpoint, with the workspace id from the id_token as a header.
|
|
8
|
+
* Mirrored from the CLI source rather than guessed:
|
|
9
|
+
*
|
|
10
|
+
* base URL codex-rs/model-provider-info/src/lib.rs
|
|
11
|
+
* `CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"`
|
|
12
|
+
* (used as the default base_url whenever auth_mode is Chatgpt)
|
|
13
|
+
* wire api same file, `WireApi::Responses` — "the Responses API exposed by
|
|
14
|
+
* OpenAI at /v1/responses", i.e. `${base}/responses`
|
|
15
|
+
* headers codex-rs/model-provider/src/bearer_auth_provider.rs
|
|
16
|
+
* `Authorization: Bearer <access_token>` + `ChatGPT-Account-ID: <id>`
|
|
17
|
+
* account id codex-rs/login/src/token_data.rs — id_token claim
|
|
18
|
+
* `https://api.openai.com/auth`.chatgpt_account_id
|
|
19
|
+
*
|
|
20
|
+
* dario's inbound is OpenAI chat/completions — what any OpenAI-compatible
|
|
21
|
+
* client speaks — so this module owns the chat/completions ⇄ Responses
|
|
22
|
+
* translation in both directions, including SSE.
|
|
23
|
+
*/
|
|
24
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
25
|
+
import type { CodexAccountCredentials } from './codex-accounts.js';
|
|
26
|
+
export declare const CODEX_BACKEND_BASE_URL: string;
|
|
27
|
+
/**
|
|
28
|
+
* Client version sent on the model-discovery call. The backend REQUIRES the
|
|
29
|
+
* `client_version` query parameter and rejects the request without it; the
|
|
30
|
+
* value tracks a released codex CLI. Bump it when the backend starts gating on
|
|
31
|
+
* a newer one — it is a constant precisely so that stays a one-line change.
|
|
32
|
+
*/
|
|
33
|
+
export declare const CODEX_CLIENT_VERSION: string;
|
|
34
|
+
/** Test seam — drop the discovery cache. */
|
|
35
|
+
export declare function clearCodexModelCache(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Slugs the backend lists for this account. `visibility` separates models meant
|
|
38
|
+
* for a picker ("list") from internal ones ("hide" — e.g. `gpt-reserve`,
|
|
39
|
+
* `codex-auto-review`); only listed ones are routable and advertisable.
|
|
40
|
+
* Throws on a non-2xx or unparseable response; getCodexModelSlugs absorbs it.
|
|
41
|
+
*/
|
|
42
|
+
export declare function fetchCodexModels(creds: CodexAccountCredentials, fetchImpl?: typeof fetch): Promise<readonly string[]>;
|
|
43
|
+
/**
|
|
44
|
+
* Cached {@link fetchCodexModels}, keyed by account alias. Never throws — an
|
|
45
|
+
* unreachable backend yields the last known set, or an empty one, which means
|
|
46
|
+
* "route nothing here by name". An explicit `codex:`/`chatgpt:` prefix still
|
|
47
|
+
* routes, so discovery being down never makes the engine unusable.
|
|
48
|
+
*/
|
|
49
|
+
export declare function getCodexModelSlugs(creds: CodexAccountCredentials, fetchImpl?: typeof fetch): Promise<readonly string[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Whether a request naming `model` should be served from the subscription: the
|
|
52
|
+
* name matches a discovered slug. Pure, with the slugs injected, so routing is
|
|
53
|
+
* testable without network — and checked BEFORE openai-backend's
|
|
54
|
+
* `isOpenAIModel` (the codex adapter has the higher priority), so a discovered
|
|
55
|
+
* `gpt-5.5` reaches the subscription while a plain `gpt-4o` still reaches a
|
|
56
|
+
* configured API-key backend.
|
|
57
|
+
*/
|
|
58
|
+
export declare function isCodexModel(model: string, slugs: readonly string[]): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Pull `chatgpt_account_id` out of the id_token's `https://api.openai.com/auth`
|
|
61
|
+
* claim. Payload only — this is reading our own token for a routing header, not
|
|
62
|
+
* validating a token, so there's nothing to verify a signature against here.
|
|
63
|
+
* Null when the token is absent/malformed/claimless; the caller then omits the
|
|
64
|
+
* header, which is what the CLI does for accounts without a workspace.
|
|
65
|
+
*/
|
|
66
|
+
export declare function extractChatGPTAccountId(idToken: string | undefined): string | null;
|
|
67
|
+
/**
|
|
68
|
+
* Translate an OpenAI chat/completions request body into a Responses request.
|
|
69
|
+
*
|
|
70
|
+
* - system messages collapse into `instructions` (Responses has no system role)
|
|
71
|
+
* - assistant `tool_calls` become `function_call` items, `role: "tool"` replies
|
|
72
|
+
* become `function_call_output` items, keyed by the same call_id
|
|
73
|
+
* - tools lose the `{type:'function', function:{…}}` nesting; Responses takes
|
|
74
|
+
* name/description/parameters flat on the tool
|
|
75
|
+
* - `store: false` because dario is a proxy: nothing here is a ChatGPT thread
|
|
76
|
+
* the user would expect to find in their history
|
|
77
|
+
*/
|
|
78
|
+
export declare function chatCompletionsToResponses(body: Record<string, unknown>): Record<string, unknown>;
|
|
79
|
+
/**
|
|
80
|
+
* Stateful per-request translator: Responses SSE in, chat/completions out.
|
|
81
|
+
*
|
|
82
|
+
* `chunk()` returns the chat.completion.chunk SSE line to write back (null when
|
|
83
|
+
* the upstream event has no chat-shape equivalent — reasoning summaries,
|
|
84
|
+
* progress events). It also accumulates, so `complete()` can hand back a single
|
|
85
|
+
* non-streaming `chat.completion` body. dario always asks the Codex backend for
|
|
86
|
+
* a stream and collapses it here when the client didn't want one; that keeps one
|
|
87
|
+
* upstream code path instead of two.
|
|
88
|
+
*
|
|
89
|
+
* One translator per request — never module-global — for the same reason
|
|
90
|
+
* createOpenAIStreamTranslator is per-call (#642-audit: interleaved streams
|
|
91
|
+
* corrupting shared tool-call indices).
|
|
92
|
+
*/
|
|
93
|
+
export declare function createResponsesTranslator(model: string): {
|
|
94
|
+
/** Feed one raw SSE line. Returns the line to forward, or null. */
|
|
95
|
+
chunk(line: string): string | null;
|
|
96
|
+
/** Everything seen so far, as one non-streaming chat.completion body. */
|
|
97
|
+
complete(): Record<string, unknown>;
|
|
98
|
+
};
|
|
99
|
+
export declare function buildCodexHeaders(creds: CodexAccountCredentials): Record<string, string>;
|
|
100
|
+
/**
|
|
101
|
+
* Serve a /v1/chat/completions request from a stored Codex account.
|
|
102
|
+
*
|
|
103
|
+
* `fetchImpl` is injectable so the translation and header construction are
|
|
104
|
+
* testable without network (test/codex-backend.mjs), matching the pattern
|
|
105
|
+
* test/codex-oauth.mjs already uses.
|
|
106
|
+
*/
|
|
107
|
+
export declare function forwardToCodex(req: IncomingMessage, res: ServerResponse, body: Buffer, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, fetchImpl?: typeof fetch): Promise<void>;
|