@askalf/dario 3.4.6 → 3.6.0

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 CHANGED
@@ -29,9 +29,13 @@ dario is a local process that turns your Claude Max or Pro subscription into an
29
29
 
30
30
  Install it once, log in once (using your existing Claude Code credentials if you have them), and from that point on every tool that speaks the Anthropic API or the OpenAI chat completions API — Cursor, Continue, Aider, LiteLLM, your own scripts, whatever — can reach Claude through `http://localhost:3456`.
31
31
 
32
- **Standalone is the default.** You don't need an account anywhere, you don't need to wait for anything, and nothing phones home. Install and run.
32
+ **Single-account mode is the default.** You don't need an account anywhere, you don't need to wait for anything, and nothing phones home. Install and run.
33
33
 
34
- **Dario is also the local bridge for [askalf](https://askalf.org).** When your workload outgrows a single subscription — multi-account pooling, session shaping to stay under Anthropic's behavioral classifiers, 24/7 fleets, scheduled workflows — dario is the component that keeps running on your machine and connects to the askalf platform. Same install, same commands, extra capabilities unlocked when you link it. You don't have to use askalf to use dario today, and you won't have to change how you use dario when you join askalf later.
34
+ **Pool mode** (new in v3.5.0) lifts multi-account routing into dario itself. Add two or more Claude subscriptions with `dario accounts add`, and dario starts selecting per request by the account with the most headroom, marking exhausted accounts rejected until they reset. No hosted platform required you run the pool on your machine, against your own subscriptions. See [Multi-Account Pool Mode](#multi-account-pool-mode) below.
35
+
36
+ **Multi-provider routing** (new in v3.6.0) lets dario speak to more than just Claude. Configure an OpenAI-compat backend (OpenAI, OpenRouter, Groq, local LiteLLM, Ollama's openai-compat mode) with `dario backend add openai --key=... [--base-url=...]`, and GPT-family model names at `/v1/chat/completions` route to that backend while Claude model names keep flowing through the Claude subscription path. Point your tool at `http://localhost:3456` once and use any model from any provider. See [Multi-Provider Routing](#multi-provider-routing) below.
37
+
38
+ Separately, [askalf](https://askalf.org) is the hosted platform that does the things a local proxy on your machine can't — browser and desktop control, scheduling, persistent memory, 24/7 hosted fleets. Different problem, different tool. Dario does not depend on askalf, and askalf is not required to use any dario feature.
35
39
 
36
40
  ---
37
41
 
@@ -75,9 +79,11 @@ No separate API key. No Extra Usage charges. No rebuilding your workflow around
75
79
 
76
80
  **Use dario if** you already pay for Claude Max or Pro and you want Claude inside the tools you already use, without paying API rates for every request or routing your work through a second hosted stack.
77
81
 
78
- **Use the Anthropic API directly if** you need platform-native primitives, vendor-managed production usage, high-scale control, or SLAs your subscription tier doesn't cover. Dario isn't trying to replace the API it's trying to unlock the subscription you already bought.
82
+ **Use dario pool mode if** you're running multi-agent workloads and hitting per-subscription rate limits add 2–N accounts with `dario accounts add` and dario handles headroom-aware routing across them, all on your machine, against your own subscriptions. No hosted stack to sign up for. See [Multi-Account Pool Mode](#multi-account-pool-mode).
79
83
 
80
- **Use dario + askalf if** you're running multi-agent workloads, hitting session-level rate limits that a single account can't clear, or need a 24/7 fleet. Dario keeps running on your machine as the local bridge; askalf adds multi-account pooling, session shaping, and the platform pieces a single-subscription proxy can't deliver. See [below](#from-standalone-to-askalf).
84
+ **Use dario multi-provider routing if** you want one local endpoint that speaks to Claude subscriptions *and* OpenAI / OpenRouter / Groq / a local LiteLLM / anything else OpenAI-compat. `dario backend add openai --key=...`, point your tool at `http://localhost:3456`, and every model from every provider flows through the same URL. See [Multi-Provider Routing](#multi-provider-routing).
85
+
86
+ **Use the Anthropic API directly if** you need platform-native primitives, vendor-managed production usage, high-scale control, or SLAs your subscription tier doesn't cover. Dario isn't trying to replace the API — it's trying to unlock the subscription you already bought.
81
87
 
82
88
  **Don't use dario if** you want a subprocess bridge that shells out to `claude --print` under the hood. Those tools (openclaw-claude-bridge and similar) work well for single-team single-machine workloads that can accept a one-subscription rate ceiling and a one-machine deployment. Dario is the API-path alternative, which trades that simplicity for pooling-friendly behavior on the wire. Different tradeoffs, different tool.
83
89
 
@@ -148,33 +154,125 @@ Use it when the upstream tool already builds a Claude-Code-shaped request on its
148
154
 
149
155
  ### Detection scope
150
156
 
151
- Dario is a **per-request layer**. Every request it sends upstream is designed to be indistinguishable from a Claude Code request, and the per-request scrubbing hardened in v3.4.5 makes that meaningfully harder to fingerprint than it was when v3.0 first shipped. What dario cannot do at the per-request level is defend against Anthropic's session-level and account-level classifiers — those operate on cumulative per-OAuth behavioral aggregates (token throughput, conversation depth, streaming duration, inter-arrival timing) and no amount of per-request hardening reaches them. That's a different problem at a different layer, and it's [askalf](https://askalf.org)'s job rather than dario's. See the [FAQ entry](#faq) for the full explanation.
157
+ Dario is a **per-request layer**. Every request it sends upstream is designed to be indistinguishable from a Claude Code request, and the per-request scrubbing hardened in v3.4.5 makes that meaningfully harder to fingerprint than it was when v3.0 first shipped. What dario cannot do at the per-request level is defend against Anthropic's session-level behavioral classifiers — those operate on cumulative per-OAuth aggregates (token throughput, conversation depth, streaming duration, inter-arrival timing) and no amount of per-request hardening reaches them. The practical answer to that problem is *distributing* load across multiple subscriptions so no single account accumulates enough signal to trip the classifier which is what pool mode (below) does.
158
+
159
+ ---
160
+
161
+ ## Multi-Account Pool Mode
162
+
163
+ *New in v3.5.0.* Dario can manage multiple Claude subscriptions and route each request to the account with the most headroom. Single-account dario is unchanged and remains the default — pool mode activates **only** when `~/.dario/accounts/` contains 2+ accounts.
164
+
165
+ ```bash
166
+ # Add accounts to the pool. Each runs its own OAuth flow.
167
+ dario accounts add work
168
+ dario accounts add personal
169
+ dario accounts add side-project
170
+
171
+ # List them
172
+ dario accounts list
173
+
174
+ # Start the proxy — pool mode activates automatically
175
+ dario proxy
176
+ ```
177
+
178
+ ### How it routes
179
+
180
+ Each incoming request picks the account with the highest **headroom**:
181
+
182
+ ```
183
+ headroom = 1 - max(util_5h, util_7d)
184
+ ```
185
+
186
+ The response's `anthropic-ratelimit-unified-*` headers are parsed back into the pool so the next request sees fresh utilization. An account that returns a 429 is marked `rejected` and routed around until its window resets. When every account is exhausted, incoming requests queue for up to 60 seconds waiting for headroom to reappear, with backoff-aware draining.
187
+
188
+ Accounts can use different plans — mix Max and Pro accounts freely. The pool doesn't care about tier, only headroom.
189
+
190
+ ### Why pool over per-request tricks alone
191
+
192
+ Per-request template replay is necessary but not sufficient for multi-agent workloads. Anthropic's classifier operates on cumulative per-OAuth-session aggregates (see the [FAQ entry](#faq) on multi-agent reclassification), and no amount of per-request hardening reaches that layer. The practical answer is *distribution* — spread load so no single account accumulates enough signal to trip anything. Pool mode is the piece that does that, and the headroom-aware selection means you don't have to think about which account is which; dario picks.
193
+
194
+ ### Inspection endpoints
195
+
196
+ ```bash
197
+ # Live pool snapshot — per-account utilization, claim, status
198
+ curl http://localhost:3456/accounts
199
+
200
+ # Pool analytics — per-account / per-model stats, burn-rate, exhaustion predictions
201
+ curl http://localhost:3456/analytics
202
+ ```
203
+
204
+ ### Known scope for v3.5.0
205
+
206
+ Pool mode v3.5.0 ships **headroom-aware selection across requests**. It does not yet retry a single in-flight request against a different account when that request 429s — that ships in v3.5.1 along with analytics recording wiring. Across-request routing is already effective: a 429 on one request immediately marks that account rejected, and the next request goes somewhere else.
207
+
208
+ ---
209
+
210
+ ## Multi-Provider Routing
211
+
212
+ *New in v3.6.0.* Dario is no longer Claude-only. Configure an OpenAI-compat backend once, and GPT-family model names at `/v1/chat/completions` route to that backend while Claude model names keep flowing through the Claude subscription path. Works with **any** OpenAI-compat provider — OpenAI, OpenRouter, Groq, a local LiteLLM instance, Ollama's openai-compat mode, whatever — via a configurable `--base-url`.
213
+
214
+ ```bash
215
+ # OpenAI itself (default base URL)
216
+ dario backend add openai --key=sk-proj-...
217
+
218
+ # Groq
219
+ dario backend add groq --key=gsk_... --base-url=https://api.groq.com/openai/v1
220
+
221
+ # OpenRouter
222
+ dario backend add openrouter --key=sk-or-... --base-url=https://openrouter.ai/api/v1
223
+
224
+ # Local LiteLLM / Ollama / any openai-compat server
225
+ dario backend add local --key=anything --base-url=http://127.0.0.1:4000/v1
226
+
227
+ # Inspect
228
+ dario backend list
229
+ ```
230
+
231
+ ### How it routes
232
+
233
+ After the proxy starts, every request at `/v1/chat/completions` is checked:
234
+
235
+ | Request model | Route |
236
+ |---|---|
237
+ | `gpt-*`, `o1-*`, `o3-*`, `o4-*`, `chatgpt-*`, `text-davinci-*`, `text-embedding-*` | OpenAI-compat backend (if configured) |
238
+ | `claude-*` (or the shortcut `opus`/`sonnet`/`haiku`) | Claude subscription path |
239
+ | Anything else on `/v1/chat/completions` | Claude subscription path with existing OpenAI-compat translation |
240
+
241
+ Point any tool that speaks the OpenAI Chat Completions API at `http://localhost:3456/v1` once, and both GPT and Claude models work through the same base URL. Cursor, Continue, Aider, any OpenAI SDK — they don't need to know anything changed.
242
+
243
+ ### Why this matters
244
+
245
+ Dario's earlier layers (template replay, framework scrubbing, pool routing) keep the Claude subscription path defensible against Anthropic's classifier. Multi-provider routing changes the *game board*: when dario also speaks OpenAI and OpenAI-compat, a squeeze on the Claude side stops being an existential issue — traffic for affected workloads shifts to another backend, and dario is still useful. The moment Anthropic ships their own "bring your subscription to the API" feature, dario's Claude backend simplifies and keeps working. Either way, dario is still the local router between every model and every tool on your machine.
246
+
247
+ ### Not yet in this release
248
+
249
+ - **Cross-format translation** (Anthropic → OpenAI). Requests at `/v1/messages` with GPT-family model names fall through to the existing Claude-side handling (which maps them to Claude equivalents). Full Anthropic → OpenAI request translation, including tool_use format conversion, lands in a follow-up.
250
+ - **Per-model routing rules.** v3.6.0 supports one active openai-compat backend. Per-model selection (`llama-*` → Groq, `mixtral-*` → OpenRouter) ships next.
251
+ - **Fallback rules.** "If Claude 429s, use Gemini" is a follow-up goal. v3.6.0 ships the routing plumbing; fallback logic layers on top.
152
252
 
153
253
  ---
154
254
 
155
- ## From standalone to askalf
255
+ ## Dario and askalf
156
256
 
157
- Dario is fully useful on its own. You don't need an account, you don't need to wait for anything, and the standalone mode is and will remain the default.
257
+ Dario is fully useful on its own single-account mode is the default, pool mode (above) scales to as many Claude subscriptions as you want to add, and neither mode requires an account anywhere. Everything dario does is open-source and self-hosted.
158
258
 
159
- When your workload grows past what a single Claude subscription can hold, dario is also the local-edge component of the [askalf](https://askalf.org) platform. Same binary, same commands, same local proxy linking to askalf adds what a per-request layer alone can't deliver:
259
+ [askalf](https://askalf.org) is the hosted platform built on top of the same OAuth and billing infrastructure, targeting the things a local proxy can't deliver by design:
160
260
 
161
- | | dario standalone | dario + askalf |
261
+ | | dario | askalf |
162
262
  |---|---|---|
163
- | **Accounts** | 1 (yours) | Pool of 2–20+ |
164
- | **Rate limits** | Your subscription's 5h / 7d windows | Distributed across the pool, near-zero 429s |
165
- | **Session shaping** | Per-request scrub | Per-session cumulative tracking, rotation, classifier avoidance |
263
+ | **Accounts** | 1 (single) or N (pool mode) | Managed pool, no setup |
264
+ | **Rate limits** | Distributed across your own pool | Distributed across the hosted fleet |
166
265
  | **Browser / desktop control** | No | Yes — full computer use |
167
266
  | **Scheduling** | No | Cron, webhooks, triggers |
168
267
  | **Persistent memory** | No | Per-agent context and state |
169
268
  | **Hosted dashboard** | No | Yes |
170
- | **Local proxy component** | dario | dario |
269
+ | **Runs where** | Your machine | Hosted |
270
+ | **Price** | Free | Paid |
171
271
 
172
- When the askalf bridge endpoint is live, a single command (`dario link`) will pair this local instance with your askalf account and the extra capabilities unlock in place. Until then, standalone is the only mode that runs nothing to wait for to use dario as it is today.
272
+ Pool mode in dario covers the "I want multi-account routing on my own machine with my own subscriptions" case. askalf covers the "I want someone else to run this, with a dashboard, and 24/7 fleet capabilities my own machine can't give me" case. Dario is and will remain open-source and free.
173
273
 
174
274
  **[Join the askalf waitlist →](https://askalf.org)**
175
275
 
176
- Dario will always be open-source and free. askalf is the hosted tier for teams who need the layer above.
177
-
178
276
  ---
179
277
 
180
278
  ## Commands
@@ -186,6 +284,12 @@ Dario will always be open-source and free. askalf is the hosted tier for teams w
186
284
  | `dario status` | Show OAuth token health and expiry |
187
285
  | `dario refresh` | Force an immediate token refresh |
188
286
  | `dario logout` | Delete stored credentials |
287
+ | `dario accounts list` | List accounts in the multi-account pool |
288
+ | `dario accounts add <alias>` | Add a new account to the pool (runs OAuth flow) |
289
+ | `dario accounts remove <alias>` | Remove an account from the pool |
290
+ | `dario backend list` | List configured OpenAI-compat backends |
291
+ | `dario backend add <name> --key=<k> [--base-url=<u>]` | Add an OpenAI-compat backend |
292
+ | `dario backend remove <name>` | Remove an OpenAI-compat backend |
189
293
  | `dario help` | Full command reference |
190
294
 
191
295
  ### Proxy options
@@ -0,0 +1,23 @@
1
+ export interface AccountCredentials {
2
+ alias: string;
3
+ accessToken: string;
4
+ refreshToken: string;
5
+ expiresAt: number;
6
+ scopes: string[];
7
+ deviceId: string;
8
+ accountUuid: string;
9
+ }
10
+ export declare function listAccountAliases(): Promise<string[]>;
11
+ export declare function loadAccount(alias: string): Promise<AccountCredentials | null>;
12
+ export declare function loadAllAccounts(): Promise<AccountCredentials[]>;
13
+ export declare function saveAccount(creds: AccountCredentials): Promise<void>;
14
+ export declare function removeAccount(alias: string): Promise<boolean>;
15
+ /** Refresh an account's OAuth token using dario's auto-detected CC OAuth config. */
16
+ export declare function refreshAccountToken(creds: AccountCredentials): Promise<AccountCredentials>;
17
+ /**
18
+ * Interactive OAuth flow that adds a new account to the pool. Uses dario's
19
+ * auto-detected CC OAuth config (same scanner the single-account path uses).
20
+ * Saves to `~/.dario/accounts/<alias>.json` on success.
21
+ */
22
+ export declare function addAccountViaOAuth(alias: string): Promise<AccountCredentials>;
23
+ export declare function getAccountsDir(): string;
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Multi-account credential storage.
3
+ *
4
+ * Accounts live at `~/.dario/accounts/<alias>.json`. Single-account dario
5
+ * still uses `~/.dario/credentials.json` and does not touch this module.
6
+ * When `~/.dario/accounts/` contains 2+ files the proxy activates pool mode
7
+ * (see pool.ts). Each account has its own independent OAuth lifecycle and
8
+ * can refresh without affecting the others.
9
+ *
10
+ * OAuth config (client_id, scopes, authorize URL, token URL) comes from
11
+ * dario's cc-oauth-detect scanner — the same source the single-account
12
+ * path already uses. No hardcoded client IDs here.
13
+ */
14
+ import { readFile, writeFile, mkdir, readdir, unlink, rename } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ import { randomUUID, randomBytes, createHash } from 'node:crypto';
18
+ import { createServer } from 'node:http';
19
+ import { detectCCOAuthConfig } from './cc-oauth-detect.js';
20
+ const DARIO_DIR = join(homedir(), '.dario');
21
+ const ACCOUNTS_DIR = join(DARIO_DIR, 'accounts');
22
+ async function ensureDir() {
23
+ await mkdir(ACCOUNTS_DIR, { recursive: true, mode: 0o700 });
24
+ }
25
+ export async function listAccountAliases() {
26
+ try {
27
+ await ensureDir();
28
+ const entries = await readdir(ACCOUNTS_DIR);
29
+ return entries.filter(f => f.endsWith('.json')).map(f => f.replace('.json', ''));
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ }
35
+ export async function loadAccount(alias) {
36
+ const path = join(ACCOUNTS_DIR, `${alias}.json`);
37
+ try {
38
+ const raw = await readFile(path, 'utf-8');
39
+ return JSON.parse(raw);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ export async function loadAllAccounts() {
46
+ const aliases = await listAccountAliases();
47
+ const loaded = await Promise.all(aliases.map(a => loadAccount(a)));
48
+ return loaded.filter((a) => a !== null);
49
+ }
50
+ export async function saveAccount(creds) {
51
+ await ensureDir();
52
+ const path = join(ACCOUNTS_DIR, `${creds.alias}.json`);
53
+ const tmp = `${path}.tmp.${randomBytes(4).toString('hex')}`;
54
+ await writeFile(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 });
55
+ try {
56
+ await rename(tmp, path);
57
+ }
58
+ catch {
59
+ // Windows can fail renames on busy files — fall back to direct write
60
+ await writeFile(path, JSON.stringify(creds, null, 2), { mode: 0o600 });
61
+ try {
62
+ await unlink(tmp);
63
+ }
64
+ catch { /* ignore */ }
65
+ }
66
+ }
67
+ export async function removeAccount(alias) {
68
+ const path = join(ACCOUNTS_DIR, `${alias}.json`);
69
+ try {
70
+ await unlink(path);
71
+ return true;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ /** Detect deviceId + accountUuid from an installed Claude Code. */
78
+ async function detectClaudeIdentity() {
79
+ const paths = [
80
+ join(homedir(), '.claude', '.claude.json'),
81
+ join(homedir(), '.claude.json'),
82
+ ];
83
+ for (const p of paths) {
84
+ try {
85
+ const raw = await readFile(p, 'utf-8');
86
+ const data = JSON.parse(raw);
87
+ const deviceId = data.userID || data.installId || data.deviceId || '';
88
+ const accountUuid = data.oauthAccount?.accountUuid || data.accountUuid || '';
89
+ if (deviceId || accountUuid) {
90
+ return { deviceId, accountUuid };
91
+ }
92
+ }
93
+ catch { /* try next */ }
94
+ }
95
+ return null;
96
+ }
97
+ /** Refresh an account's OAuth token using dario's auto-detected CC OAuth config. */
98
+ export async function refreshAccountToken(creds) {
99
+ const cfg = await detectCCOAuthConfig();
100
+ const res = await fetch(cfg.tokenUrl, {
101
+ method: 'POST',
102
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
103
+ body: new URLSearchParams({
104
+ grant_type: 'refresh_token',
105
+ refresh_token: creds.refreshToken,
106
+ client_id: cfg.clientId,
107
+ }).toString(),
108
+ signal: AbortSignal.timeout(15_000),
109
+ });
110
+ if (!res.ok) {
111
+ const errBody = await res.text().catch(() => '');
112
+ throw new Error(`Refresh failed for ${creds.alias} (${res.status}): ${errBody.slice(0, 200)}`);
113
+ }
114
+ const data = await res.json();
115
+ const updated = {
116
+ ...creds,
117
+ accessToken: data.access_token,
118
+ refreshToken: data.refresh_token,
119
+ expiresAt: Date.now() + data.expires_in * 1000,
120
+ };
121
+ await saveAccount(updated);
122
+ return updated;
123
+ }
124
+ // ── PKCE OAuth flow for adding a new account ────────────────────────────
125
+ function base64url(buf) {
126
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
127
+ }
128
+ function generatePKCE() {
129
+ const codeVerifier = base64url(randomBytes(32));
130
+ const codeChallenge = base64url(createHash('sha256').update(codeVerifier).digest());
131
+ return { codeVerifier, codeChallenge };
132
+ }
133
+ function openBrowser(url) {
134
+ const { exec } = require('node:child_process');
135
+ const cmd = process.platform === 'win32' ? `start "" "${url}"`
136
+ : process.platform === 'darwin' ? `open "${url}"`
137
+ : `xdg-open "${url}"`;
138
+ exec(cmd, () => { });
139
+ }
140
+ /**
141
+ * Interactive OAuth flow that adds a new account to the pool. Uses dario's
142
+ * auto-detected CC OAuth config (same scanner the single-account path uses).
143
+ * Saves to `~/.dario/accounts/<alias>.json` on success.
144
+ */
145
+ export async function addAccountViaOAuth(alias) {
146
+ const cfg = await detectCCOAuthConfig();
147
+ const { codeVerifier, codeChallenge } = generatePKCE();
148
+ const state = base64url(randomBytes(16));
149
+ return new Promise((resolve, reject) => {
150
+ let port = 0;
151
+ const server = createServer(async (req, res) => {
152
+ try {
153
+ const url = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
154
+ if (url.pathname !== '/callback') {
155
+ res.writeHead(404);
156
+ res.end();
157
+ return;
158
+ }
159
+ const code = url.searchParams.get('code');
160
+ const returnedState = url.searchParams.get('state');
161
+ if (!code) {
162
+ res.writeHead(400);
163
+ res.end('No authorization code received');
164
+ server.close();
165
+ reject(new Error('No authorization code received'));
166
+ return;
167
+ }
168
+ if (returnedState !== state) {
169
+ res.writeHead(400);
170
+ res.end('Invalid state parameter');
171
+ server.close();
172
+ reject(new Error('OAuth state mismatch — possible CSRF'));
173
+ return;
174
+ }
175
+ res.writeHead(302, {
176
+ Location: 'https://platform.claude.com/oauth/code/success?app=claude-code',
177
+ });
178
+ res.end();
179
+ server.close();
180
+ // Exchange code for tokens
181
+ const tokenRes = await fetch(cfg.tokenUrl, {
182
+ method: 'POST',
183
+ headers: { 'Content-Type': 'application/json' },
184
+ body: JSON.stringify({
185
+ grant_type: 'authorization_code',
186
+ client_id: cfg.clientId,
187
+ code,
188
+ redirect_uri: `http://localhost:${port}/callback`,
189
+ code_verifier: codeVerifier,
190
+ state,
191
+ }),
192
+ signal: AbortSignal.timeout(30_000),
193
+ });
194
+ if (!tokenRes.ok) {
195
+ const body = await tokenRes.text().catch(() => '');
196
+ throw new Error(`Token exchange failed (${tokenRes.status}): ${body.slice(0, 200)}`);
197
+ }
198
+ const tokens = await tokenRes.json();
199
+ // Prefer CC identity if installed; otherwise generate fresh IDs.
200
+ const identity = (await detectClaudeIdentity()) ?? {
201
+ deviceId: randomUUID(),
202
+ accountUuid: randomUUID(),
203
+ };
204
+ const creds = {
205
+ alias,
206
+ accessToken: tokens.access_token,
207
+ refreshToken: tokens.refresh_token,
208
+ expiresAt: Date.now() + tokens.expires_in * 1000,
209
+ scopes: tokens.scope?.split(' ') ?? cfg.scopes.split(' '),
210
+ deviceId: identity.deviceId,
211
+ accountUuid: identity.accountUuid,
212
+ };
213
+ await saveAccount(creds);
214
+ resolve(creds);
215
+ }
216
+ catch (err) {
217
+ server.close();
218
+ reject(err instanceof Error ? err : new Error(String(err)));
219
+ }
220
+ });
221
+ server.listen(0, 'localhost', () => {
222
+ const addr = server.address();
223
+ port = typeof addr === 'object' && addr ? addr.port : 0;
224
+ const params = new URLSearchParams({
225
+ code: 'true',
226
+ client_id: cfg.clientId,
227
+ response_type: 'code',
228
+ redirect_uri: `http://localhost:${port}/callback`,
229
+ scope: cfg.scopes,
230
+ code_challenge: codeChallenge,
231
+ code_challenge_method: 'S256',
232
+ state,
233
+ });
234
+ const authUrl = `${cfg.authorizeUrl}?${params.toString()}`;
235
+ console.log(` Opening browser to add account "${alias}"...`);
236
+ console.log(` If the browser didn't open, visit:`);
237
+ console.log(` ${authUrl}`);
238
+ console.log();
239
+ openBrowser(authUrl);
240
+ });
241
+ server.on('error', (err) => {
242
+ reject(new Error(`Failed to start OAuth callback server: ${err.message}`));
243
+ });
244
+ const timeout = setTimeout(() => {
245
+ server.close();
246
+ reject(new Error('OAuth flow timed out after 5 minutes. Try `dario accounts add` again.'));
247
+ }, 300_000);
248
+ timeout.unref();
249
+ });
250
+ }
251
+ export function getAccountsDir() {
252
+ return ACCOUNTS_DIR;
253
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Token analytics — per-request billing tracking, utilization trends,
3
+ * window exhaustion predictions, cost estimation.
4
+ *
5
+ * In-memory rolling window; exposed via the /analytics endpoint when
6
+ * pool mode is active.
7
+ */
8
+ export interface RequestRecord {
9
+ timestamp: number;
10
+ account: string;
11
+ model: string;
12
+ inputTokens: number;
13
+ outputTokens: number;
14
+ cacheReadTokens: number;
15
+ cacheCreateTokens: number;
16
+ thinkingTokens: number;
17
+ claim: string;
18
+ util5h: number;
19
+ util7d: number;
20
+ overageUtil: number;
21
+ latencyMs: number;
22
+ status: number;
23
+ isStream: boolean;
24
+ isOpenAI: boolean;
25
+ }
26
+ export declare class Analytics {
27
+ private records;
28
+ private maxRecords;
29
+ constructor(maxRecords?: number);
30
+ record(r: RequestRecord): void;
31
+ /** Parse usage from a non-streaming Anthropic response body. */
32
+ static parseUsage(body: Record<string, unknown>): {
33
+ inputTokens: number;
34
+ outputTokens: number;
35
+ cacheReadTokens: number;
36
+ cacheCreateTokens: number;
37
+ thinkingTokens: number;
38
+ model: string;
39
+ };
40
+ summary(windowMinutes?: number): AnalyticsSummary;
41
+ private computeStats;
42
+ private perAccountStats;
43
+ private perModelStats;
44
+ private utilizationTrend;
45
+ private predict;
46
+ }
47
+ interface PerAccountStat {
48
+ requests: number;
49
+ inputTokens: number;
50
+ outputTokens: number;
51
+ estimatedCost: number;
52
+ currentUtil5h: number;
53
+ currentUtil7d: number;
54
+ lastClaim: string;
55
+ }
56
+ interface PerModelStat {
57
+ requests: number;
58
+ avgInputTokens: number;
59
+ avgOutputTokens: number;
60
+ avgThinkingTokens: number;
61
+ estimatedCost: number;
62
+ }
63
+ export interface AnalyticsSummary {
64
+ window: {
65
+ minutes: number;
66
+ requests: number;
67
+ totalInputTokens: number;
68
+ totalOutputTokens: number;
69
+ totalThinkingTokens: number;
70
+ estimatedCost: number;
71
+ avgLatencyMs: number;
72
+ errorRate: number;
73
+ claimBreakdown: Record<string, number>;
74
+ };
75
+ allTime: {
76
+ requests: number;
77
+ totalInputTokens: number;
78
+ totalOutputTokens: number;
79
+ totalThinkingTokens: number;
80
+ estimatedCost: number;
81
+ avgLatencyMs: number;
82
+ errorRate: number;
83
+ claimBreakdown: Record<string, number>;
84
+ };
85
+ perAccount: Record<string, PerAccountStat>;
86
+ perModel: Record<string, PerModelStat>;
87
+ utilization: Array<{
88
+ timestamp: number;
89
+ avgUtil5h: number;
90
+ avgUtil7d: number;
91
+ requests: number;
92
+ }>;
93
+ predictions: {
94
+ estimatedExhaustionMinutes: number | null;
95
+ tokenBurnRate: number;
96
+ costBurnRate: number;
97
+ };
98
+ }
99
+ export {};