@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 +120 -16
- package/dist/accounts.d.ts +23 -0
- package/dist/accounts.js +253 -0
- package/dist/analytics.d.ts +99 -0
- package/dist/analytics.js +198 -0
- package/dist/cli.js +209 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +11 -0
- package/dist/openai-backend.d.ts +19 -0
- package/dist/openai-backend.js +170 -0
- package/dist/pool.d.ts +68 -0
- package/dist/pool.js +212 -0
- package/dist/proxy.js +174 -10
- package/package.json +1 -1
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
|
-
**
|
|
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
|
-
**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
##
|
|
255
|
+
## Dario and askalf
|
|
156
256
|
|
|
157
|
-
Dario is fully useful on its own
|
|
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
|
-
|
|
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
|
|
261
|
+
| | dario | askalf |
|
|
162
262
|
|---|---|---|
|
|
163
|
-
| **Accounts** | 1 (
|
|
164
|
-
| **Rate limits** |
|
|
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
|
-
| **
|
|
269
|
+
| **Runs where** | Your machine | Hosted |
|
|
270
|
+
| **Price** | Free | Paid |
|
|
171
271
|
|
|
172
|
-
|
|
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;
|
package/dist/accounts.js
ADDED
|
@@ -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 {};
|