@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
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
// Anthropic pricing (per 1M tokens, USD). Not authoritative — used for
|
|
9
|
+
// rough burn-rate display in the /analytics summary.
|
|
10
|
+
const PRICING = {
|
|
11
|
+
'claude-opus-4-6': { input: 15, output: 75, cacheRead: 1.5, cacheCreate: 18.75 },
|
|
12
|
+
'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheCreate: 3.75 },
|
|
13
|
+
'claude-haiku-4-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheCreate: 1 },
|
|
14
|
+
};
|
|
15
|
+
function estimateCost(record) {
|
|
16
|
+
const p = PRICING[record.model] ?? PRICING['claude-sonnet-4-6'];
|
|
17
|
+
return ((record.inputTokens * p.input) +
|
|
18
|
+
(record.outputTokens * p.output) +
|
|
19
|
+
(record.cacheReadTokens * p.cacheRead) +
|
|
20
|
+
(record.cacheCreateTokens * p.cacheCreate)) / 1_000_000;
|
|
21
|
+
}
|
|
22
|
+
export class Analytics {
|
|
23
|
+
records = [];
|
|
24
|
+
maxRecords;
|
|
25
|
+
constructor(maxRecords = 10_000) {
|
|
26
|
+
this.maxRecords = maxRecords;
|
|
27
|
+
}
|
|
28
|
+
record(r) {
|
|
29
|
+
this.records.push(r);
|
|
30
|
+
if (this.records.length > this.maxRecords) {
|
|
31
|
+
this.records = this.records.slice(-this.maxRecords);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Parse usage from a non-streaming Anthropic response body. */
|
|
35
|
+
static parseUsage(body) {
|
|
36
|
+
const u = body.usage;
|
|
37
|
+
const content = body.content;
|
|
38
|
+
const thinkingChars = content
|
|
39
|
+
?.filter(b => b.type === 'thinking')
|
|
40
|
+
.reduce((s, b) => s + (b.thinking?.length ?? 0), 0) ?? 0;
|
|
41
|
+
const thinkingTokens = Math.round(thinkingChars / 4);
|
|
42
|
+
return {
|
|
43
|
+
inputTokens: u?.input_tokens ?? 0,
|
|
44
|
+
outputTokens: u?.output_tokens ?? 0,
|
|
45
|
+
cacheReadTokens: u?.cache_read_input_tokens ?? 0,
|
|
46
|
+
cacheCreateTokens: u?.cache_creation_input_tokens ?? 0,
|
|
47
|
+
thinkingTokens,
|
|
48
|
+
model: body.model ?? 'unknown',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
summary(windowMinutes = 60) {
|
|
52
|
+
const cutoff = Date.now() - windowMinutes * 60_000;
|
|
53
|
+
const recent = this.records.filter(r => r.timestamp >= cutoff);
|
|
54
|
+
const allTime = this.records;
|
|
55
|
+
return {
|
|
56
|
+
window: {
|
|
57
|
+
minutes: windowMinutes,
|
|
58
|
+
requests: recent.length,
|
|
59
|
+
...this.computeStats(recent),
|
|
60
|
+
},
|
|
61
|
+
allTime: {
|
|
62
|
+
requests: allTime.length,
|
|
63
|
+
...this.computeStats(allTime),
|
|
64
|
+
},
|
|
65
|
+
perAccount: this.perAccountStats(recent),
|
|
66
|
+
perModel: this.perModelStats(recent),
|
|
67
|
+
utilization: this.utilizationTrend(recent),
|
|
68
|
+
predictions: this.predict(recent),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
computeStats(records) {
|
|
72
|
+
if (records.length === 0) {
|
|
73
|
+
return {
|
|
74
|
+
totalInputTokens: 0, totalOutputTokens: 0, totalThinkingTokens: 0,
|
|
75
|
+
estimatedCost: 0, avgLatencyMs: 0, errorRate: 0,
|
|
76
|
+
claimBreakdown: {},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const totalInput = records.reduce((s, r) => s + r.inputTokens, 0);
|
|
80
|
+
const totalOutput = records.reduce((s, r) => s + r.outputTokens, 0);
|
|
81
|
+
const totalThinking = records.reduce((s, r) => s + r.thinkingTokens, 0);
|
|
82
|
+
const cost = records.reduce((s, r) => s + estimateCost(r), 0);
|
|
83
|
+
const avgLatency = records.reduce((s, r) => s + r.latencyMs, 0) / records.length;
|
|
84
|
+
const errors = records.filter(r => r.status >= 400).length;
|
|
85
|
+
const claims = {};
|
|
86
|
+
for (const r of records) {
|
|
87
|
+
claims[r.claim] = (claims[r.claim] ?? 0) + 1;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
totalInputTokens: totalInput,
|
|
91
|
+
totalOutputTokens: totalOutput,
|
|
92
|
+
totalThinkingTokens: totalThinking,
|
|
93
|
+
estimatedCost: Math.round(cost * 10000) / 10000,
|
|
94
|
+
avgLatencyMs: Math.round(avgLatency),
|
|
95
|
+
errorRate: Math.round((errors / records.length) * 10000) / 10000,
|
|
96
|
+
claimBreakdown: claims,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
perAccountStats(records) {
|
|
100
|
+
const grouped = {};
|
|
101
|
+
for (const r of records) {
|
|
102
|
+
(grouped[r.account] ??= []).push(r);
|
|
103
|
+
}
|
|
104
|
+
const result = {};
|
|
105
|
+
for (const [account, recs] of Object.entries(grouped)) {
|
|
106
|
+
const last = recs[recs.length - 1];
|
|
107
|
+
result[account] = {
|
|
108
|
+
requests: recs.length,
|
|
109
|
+
inputTokens: recs.reduce((s, r) => s + r.inputTokens, 0),
|
|
110
|
+
outputTokens: recs.reduce((s, r) => s + r.outputTokens, 0),
|
|
111
|
+
estimatedCost: Math.round(recs.reduce((s, r) => s + estimateCost(r), 0) * 10000) / 10000,
|
|
112
|
+
currentUtil5h: last.util5h,
|
|
113
|
+
currentUtil7d: last.util7d,
|
|
114
|
+
lastClaim: last.claim,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
perModelStats(records) {
|
|
120
|
+
const grouped = {};
|
|
121
|
+
for (const r of records) {
|
|
122
|
+
(grouped[r.model] ??= []).push(r);
|
|
123
|
+
}
|
|
124
|
+
const result = {};
|
|
125
|
+
for (const [model, recs] of Object.entries(grouped)) {
|
|
126
|
+
result[model] = {
|
|
127
|
+
requests: recs.length,
|
|
128
|
+
avgInputTokens: Math.round(recs.reduce((s, r) => s + r.inputTokens, 0) / recs.length),
|
|
129
|
+
avgOutputTokens: Math.round(recs.reduce((s, r) => s + r.outputTokens, 0) / recs.length),
|
|
130
|
+
avgThinkingTokens: Math.round(recs.reduce((s, r) => s + r.thinkingTokens, 0) / recs.length),
|
|
131
|
+
estimatedCost: Math.round(recs.reduce((s, r) => s + estimateCost(r), 0) * 10000) / 10000,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
utilizationTrend(records) {
|
|
137
|
+
if (records.length === 0)
|
|
138
|
+
return [];
|
|
139
|
+
const bucketMs = 5 * 60_000;
|
|
140
|
+
const buckets = new Map();
|
|
141
|
+
for (const r of records) {
|
|
142
|
+
const key = Math.floor(r.timestamp / bucketMs) * bucketMs;
|
|
143
|
+
const existing = buckets.get(key);
|
|
144
|
+
if (existing) {
|
|
145
|
+
existing.push(r);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
buckets.set(key, [r]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return [...buckets.entries()]
|
|
152
|
+
.sort(([a], [b]) => a - b)
|
|
153
|
+
.map(([ts, recs]) => ({
|
|
154
|
+
timestamp: ts,
|
|
155
|
+
avgUtil5h: Math.round(recs.reduce((s, r) => s + r.util5h, 0) / recs.length * 100) / 100,
|
|
156
|
+
avgUtil7d: Math.round(recs.reduce((s, r) => s + r.util7d, 0) / recs.length * 100) / 100,
|
|
157
|
+
requests: recs.length,
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
predict(records) {
|
|
161
|
+
if (records.length < 3) {
|
|
162
|
+
return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, costBurnRate: 0 };
|
|
163
|
+
}
|
|
164
|
+
const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp);
|
|
165
|
+
const first = sorted[0];
|
|
166
|
+
const last = sorted[sorted.length - 1];
|
|
167
|
+
const durationMin = (last.timestamp - first.timestamp) / 60_000;
|
|
168
|
+
if (durationMin < 1) {
|
|
169
|
+
return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, costBurnRate: 0 };
|
|
170
|
+
}
|
|
171
|
+
const totalTokens = sorted.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
|
|
172
|
+
const totalCost = sorted.reduce((s, r) => s + estimateCost(r), 0);
|
|
173
|
+
const tokenBurnRate = totalTokens / durationMin;
|
|
174
|
+
const costBurnRate = (totalCost / durationMin) * 60;
|
|
175
|
+
const currentUtil = last.util5h;
|
|
176
|
+
if (currentUtil >= 0.95) {
|
|
177
|
+
return {
|
|
178
|
+
estimatedExhaustionMinutes: 0,
|
|
179
|
+
tokenBurnRate: Math.round(tokenBurnRate),
|
|
180
|
+
costBurnRate: Math.round(costBurnRate * 100) / 100,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const utilGrowthRate = (last.util5h - first.util5h) / durationMin;
|
|
184
|
+
if (utilGrowthRate <= 0) {
|
|
185
|
+
return {
|
|
186
|
+
estimatedExhaustionMinutes: null,
|
|
187
|
+
tokenBurnRate: Math.round(tokenBurnRate),
|
|
188
|
+
costBurnRate: Math.round(costBurnRate * 100) / 100,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const minutesToExhaustion = (1.0 - currentUtil) / utilGrowthRate;
|
|
192
|
+
return {
|
|
193
|
+
estimatedExhaustionMinutes: Math.round(minutesToExhaustion),
|
|
194
|
+
tokenBurnRate: Math.round(tokenBurnRate),
|
|
195
|
+
costBurnRate: Math.round(costBurnRate * 100) / 100,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -37,6 +37,8 @@ import { join } from 'node:path';
|
|
|
37
37
|
import { homedir } from 'node:os';
|
|
38
38
|
import { startAutoOAuthFlow, getStatus, refreshTokens, loadCredentials } from './oauth.js';
|
|
39
39
|
import { startProxy, sanitizeError } from './proxy.js';
|
|
40
|
+
import { listAccountAliases, loadAllAccounts, addAccountViaOAuth, removeAccount } from './accounts.js';
|
|
41
|
+
import { listBackends, saveBackend, removeBackend } from './openai-backend.js';
|
|
40
42
|
const args = process.argv.slice(2);
|
|
41
43
|
const command = args[0] ?? 'proxy';
|
|
42
44
|
async function login() {
|
|
@@ -142,6 +144,204 @@ async function proxy() {
|
|
|
142
144
|
const model = modelArg ? modelArg.split('=')[1] : undefined;
|
|
143
145
|
await startProxy({ port, host, verbose, model, passthrough, preserveTools });
|
|
144
146
|
}
|
|
147
|
+
async function accounts() {
|
|
148
|
+
const sub = args[1];
|
|
149
|
+
if (!sub || sub === 'list') {
|
|
150
|
+
const aliases = await listAccountAliases();
|
|
151
|
+
console.log('');
|
|
152
|
+
console.log(' dario — Accounts');
|
|
153
|
+
console.log(' ────────────────');
|
|
154
|
+
console.log('');
|
|
155
|
+
if (aliases.length === 0) {
|
|
156
|
+
console.log(' No multi-account pool configured.');
|
|
157
|
+
console.log('');
|
|
158
|
+
console.log(' Pool mode activates automatically when ~/.dario/accounts/');
|
|
159
|
+
console.log(' has 2+ entries. Add the first with:');
|
|
160
|
+
console.log(' dario accounts add <alias>');
|
|
161
|
+
console.log('');
|
|
162
|
+
console.log(' Single-account dario (the default) keeps working as-is');
|
|
163
|
+
console.log(' with ~/.dario/credentials.json — you do not need to');
|
|
164
|
+
console.log(' migrate unless you want pool routing across accounts.');
|
|
165
|
+
console.log('');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const loaded = await loadAllAccounts();
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
console.log(` ${aliases.length} account${aliases.length === 1 ? '' : 's'} configured`);
|
|
171
|
+
if (aliases.length === 1) {
|
|
172
|
+
console.log(' (Pool mode needs 2+ accounts — single-account mode until another is added.)');
|
|
173
|
+
}
|
|
174
|
+
console.log('');
|
|
175
|
+
for (const a of loaded) {
|
|
176
|
+
const msLeft = Math.max(0, a.expiresAt - now);
|
|
177
|
+
const hours = Math.floor(msLeft / 3600000);
|
|
178
|
+
const mins = Math.floor((msLeft % 3600000) / 60000);
|
|
179
|
+
const expiry = msLeft > 0 ? `${hours}h ${mins}m` : 'expired';
|
|
180
|
+
console.log(` ${a.alias.padEnd(20)} token expires in ${expiry}`);
|
|
181
|
+
}
|
|
182
|
+
console.log('');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (sub === 'add') {
|
|
186
|
+
const alias = args[2];
|
|
187
|
+
if (!alias) {
|
|
188
|
+
console.error('');
|
|
189
|
+
console.error(' Usage: dario accounts add <alias>');
|
|
190
|
+
console.error('');
|
|
191
|
+
console.error(' <alias> is any label you want for the account (e.g. "work", "personal").');
|
|
192
|
+
console.error('');
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(alias)) {
|
|
196
|
+
console.error('[dario] Invalid alias. Use letters, numbers, dot, underscore, dash only.');
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
const existing = await listAccountAliases();
|
|
200
|
+
if (existing.includes(alias)) {
|
|
201
|
+
console.error(`[dario] Account "${alias}" already exists. Remove it first with \`dario accounts remove ${alias}\`.`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
console.log('');
|
|
205
|
+
console.log(` Adding account "${alias}" to the pool...`);
|
|
206
|
+
console.log('');
|
|
207
|
+
try {
|
|
208
|
+
const creds = await addAccountViaOAuth(alias);
|
|
209
|
+
const minutes = Math.round((creds.expiresAt - Date.now()) / 60000);
|
|
210
|
+
console.log('');
|
|
211
|
+
console.log(` Account "${alias}" added.`);
|
|
212
|
+
console.log(` Token expires in ${minutes} minutes (auto-refreshes in the background).`);
|
|
213
|
+
const total = (await listAccountAliases()).length;
|
|
214
|
+
if (total >= 2) {
|
|
215
|
+
console.log('');
|
|
216
|
+
console.log(' Pool mode is now active. Restart `dario proxy` to pick up the new account.');
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
console.log('');
|
|
220
|
+
console.log(' Add at least one more account to activate pool routing:');
|
|
221
|
+
console.log(' dario accounts add <another-alias>');
|
|
222
|
+
}
|
|
223
|
+
console.log('');
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
console.error('');
|
|
227
|
+
console.error(` Failed to add account: ${sanitizeError(err)}`);
|
|
228
|
+
console.error('');
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (sub === 'remove' || sub === 'rm') {
|
|
234
|
+
const alias = args[2];
|
|
235
|
+
if (!alias) {
|
|
236
|
+
console.error('');
|
|
237
|
+
console.error(' Usage: dario accounts remove <alias>');
|
|
238
|
+
console.error('');
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
const ok = await removeAccount(alias);
|
|
242
|
+
if (ok) {
|
|
243
|
+
console.log(`[dario] Account "${alias}" removed.`);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
console.error(`[dario] No account "${alias}" found.`);
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
console.error(`[dario] Unknown accounts subcommand: ${sub}`);
|
|
252
|
+
console.error('Usage: dario accounts [list|add <alias>|remove <alias>]');
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
async function backend() {
|
|
256
|
+
const sub = args[1];
|
|
257
|
+
if (!sub || sub === 'list') {
|
|
258
|
+
const all = await listBackends();
|
|
259
|
+
console.log('');
|
|
260
|
+
console.log(' dario — Backends');
|
|
261
|
+
console.log(' ────────────────');
|
|
262
|
+
console.log('');
|
|
263
|
+
if (all.length === 0) {
|
|
264
|
+
console.log(' No secondary backends configured.');
|
|
265
|
+
console.log('');
|
|
266
|
+
console.log(' Dario\'s Claude subscription path runs unchanged. To add an');
|
|
267
|
+
console.log(' OpenAI-compat backend (OpenAI, OpenRouter, Groq, local LiteLLM,');
|
|
268
|
+
console.log(' etc.), run:');
|
|
269
|
+
console.log(' dario backend add openai --key=sk-...');
|
|
270
|
+
console.log(' dario backend add openai --key=sk-... --base-url=https://api.groq.com/openai/v1');
|
|
271
|
+
console.log('');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
console.log(` ${all.length} backend${all.length === 1 ? '' : 's'} configured`);
|
|
275
|
+
console.log('');
|
|
276
|
+
for (const b of all) {
|
|
277
|
+
const redacted = b.apiKey.length > 8
|
|
278
|
+
? `${b.apiKey.slice(0, 3)}...${b.apiKey.slice(-4)}`
|
|
279
|
+
: '***';
|
|
280
|
+
console.log(` ${b.name.padEnd(16)} ${b.provider.padEnd(10)} ${b.baseUrl.padEnd(40)} ${redacted}`);
|
|
281
|
+
}
|
|
282
|
+
console.log('');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (sub === 'add') {
|
|
286
|
+
const name = args[2];
|
|
287
|
+
if (!name || name.startsWith('--')) {
|
|
288
|
+
console.error('');
|
|
289
|
+
console.error(' Usage: dario backend add <name> --key=<api-key> [--base-url=<url>]');
|
|
290
|
+
console.error('');
|
|
291
|
+
console.error(' Examples:');
|
|
292
|
+
console.error(' dario backend add openai --key=sk-proj-...');
|
|
293
|
+
console.error(' dario backend add groq --key=gsk_... --base-url=https://api.groq.com/openai/v1');
|
|
294
|
+
console.error(' dario backend add openrouter --key=sk-or-... --base-url=https://openrouter.ai/api/v1');
|
|
295
|
+
console.error('');
|
|
296
|
+
process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
|
|
299
|
+
console.error('[dario] Invalid backend name. Use letters, numbers, dot, underscore, dash only.');
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
const keyArg = args.find(a => a.startsWith('--key='));
|
|
303
|
+
const baseUrlArg = args.find(a => a.startsWith('--base-url='));
|
|
304
|
+
const apiKey = keyArg ? keyArg.split('=').slice(1).join('=') : '';
|
|
305
|
+
const baseUrl = baseUrlArg ? baseUrlArg.split('=').slice(1).join('=') : 'https://api.openai.com/v1';
|
|
306
|
+
if (!apiKey) {
|
|
307
|
+
console.error('[dario] --key=<api-key> is required.');
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
const creds = {
|
|
311
|
+
provider: 'openai', // v3.6.0: only openai-compat backends are supported
|
|
312
|
+
name,
|
|
313
|
+
apiKey,
|
|
314
|
+
baseUrl,
|
|
315
|
+
};
|
|
316
|
+
await saveBackend(creds);
|
|
317
|
+
console.log('');
|
|
318
|
+
console.log(` Backend "${name}" added (openai-compat, ${baseUrl}).`);
|
|
319
|
+
console.log(' Restart \`dario proxy\` to pick up the new routing.');
|
|
320
|
+
console.log('');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (sub === 'remove' || sub === 'rm') {
|
|
324
|
+
const name = args[2];
|
|
325
|
+
if (!name) {
|
|
326
|
+
console.error('');
|
|
327
|
+
console.error(' Usage: dario backend remove <name>');
|
|
328
|
+
console.error('');
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
const ok = await removeBackend(name);
|
|
332
|
+
if (ok) {
|
|
333
|
+
console.log(`[dario] Backend "${name}" removed.`);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
console.error(`[dario] No backend "${name}" found.`);
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
console.error(`[dario] Unknown backend subcommand: ${sub}`);
|
|
342
|
+
console.error('Usage: dario backend [list|add <name> --key=...|remove <name>]');
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
145
345
|
async function help() {
|
|
146
346
|
console.log(`
|
|
147
347
|
dario — Use your Claude subscription as an API.
|
|
@@ -152,6 +352,13 @@ async function help() {
|
|
|
152
352
|
dario status Check authentication status
|
|
153
353
|
dario refresh Force token refresh
|
|
154
354
|
dario logout Remove saved credentials
|
|
355
|
+
dario accounts list List accounts in the multi-account pool
|
|
356
|
+
dario accounts add NAME Add a new account to the pool (runs OAuth flow)
|
|
357
|
+
dario accounts remove N Remove an account from the pool
|
|
358
|
+
dario backend list List configured OpenAI-compat backends
|
|
359
|
+
dario backend add NAME --key=sk-... [--base-url=...]
|
|
360
|
+
Add an OpenAI-compat backend (OpenAI, OpenRouter, Groq, etc.)
|
|
361
|
+
dario backend remove N Remove an OpenAI-compat backend
|
|
155
362
|
|
|
156
363
|
Proxy options:
|
|
157
364
|
--model=MODEL Force a model for all requests
|
|
@@ -206,6 +413,8 @@ const commands = {
|
|
|
206
413
|
proxy,
|
|
207
414
|
refresh,
|
|
208
415
|
logout,
|
|
416
|
+
accounts,
|
|
417
|
+
backend,
|
|
209
418
|
help,
|
|
210
419
|
version,
|
|
211
420
|
'--help': help,
|
package/dist/index.d.ts
CHANGED
|
@@ -7,3 +7,11 @@
|
|
|
7
7
|
export { startAutoOAuthFlow, refreshTokens, getAccessToken, getStatus, loadCredentials } from './oauth.js';
|
|
8
8
|
export type { OAuthTokens, CredentialsFile } from './oauth.js';
|
|
9
9
|
export { startProxy, sanitizeError } from './proxy.js';
|
|
10
|
+
export { AccountPool, parseRateLimits } from './pool.js';
|
|
11
|
+
export type { PoolAccount, PoolStatus, RateLimitSnapshot, AccountIdentity } from './pool.js';
|
|
12
|
+
export { listAccountAliases, loadAccount, loadAllAccounts, saveAccount, removeAccount, refreshAccountToken, addAccountViaOAuth, getAccountsDir, } from './accounts.js';
|
|
13
|
+
export type { AccountCredentials } from './accounts.js';
|
|
14
|
+
export { Analytics } from './analytics.js';
|
|
15
|
+
export type { RequestRecord, AnalyticsSummary } from './analytics.js';
|
|
16
|
+
export { listBackends, saveBackend, removeBackend, getOpenAIBackend, isOpenAIModel, } from './openai-backend.js';
|
|
17
|
+
export type { BackendCredentials } from './openai-backend.js';
|
package/dist/index.js
CHANGED
|
@@ -6,3 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { startAutoOAuthFlow, refreshTokens, getAccessToken, getStatus, loadCredentials } from './oauth.js';
|
|
8
8
|
export { startProxy, sanitizeError } from './proxy.js';
|
|
9
|
+
// Multi-account pool API (pool activates automatically when ~/.dario/accounts/
|
|
10
|
+
// contains 2+ accounts; see README for the progression from single-account
|
|
11
|
+
// mode to pool mode).
|
|
12
|
+
export { AccountPool, parseRateLimits } from './pool.js';
|
|
13
|
+
export { listAccountAliases, loadAccount, loadAllAccounts, saveAccount, removeAccount, refreshAccountToken, addAccountViaOAuth, getAccountsDir, } from './accounts.js';
|
|
14
|
+
export { Analytics } from './analytics.js';
|
|
15
|
+
// Multi-provider backends (v3.6.0+). Secondary OpenAI-compat providers
|
|
16
|
+
// (OpenAI, OpenRouter, Groq, local LiteLLM, etc.) configured via
|
|
17
|
+
// `dario backend add`. The Claude subscription path is unchanged — these
|
|
18
|
+
// are additional routes for non-Claude models.
|
|
19
|
+
export { listBackends, saveBackend, removeBackend, getOpenAIBackend, isOpenAIModel, } from './openai-backend.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
export interface BackendCredentials {
|
|
3
|
+
provider: string;
|
|
4
|
+
name: string;
|
|
5
|
+
apiKey: string;
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function listBackends(): Promise<BackendCredentials[]>;
|
|
9
|
+
export declare function saveBackend(creds: BackendCredentials): Promise<void>;
|
|
10
|
+
export declare function removeBackend(name: string): Promise<boolean>;
|
|
11
|
+
/** Get the first openai-compat backend (v3.6.0 supports exactly one). */
|
|
12
|
+
export declare function getOpenAIBackend(): Promise<BackendCredentials | null>;
|
|
13
|
+
export declare function isOpenAIModel(model: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Forward a client request to the configured OpenAI-compat backend.
|
|
16
|
+
* Pass-through: the client is already speaking OpenAI format, we just swap
|
|
17
|
+
* the API key and the target URL. No template, no identity, no scrubbing.
|
|
18
|
+
*/
|
|
19
|
+
export declare function forwardToOpenAI(req: IncomingMessage, res: ServerResponse, body: Buffer, backend: BackendCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean): Promise<void>;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-compatible backend.
|
|
3
|
+
*
|
|
4
|
+
* When `dario backend add openai --key=sk-...` has been run, requests to
|
|
5
|
+
* `/v1/chat/completions` with a GPT-style model name are forwarded to the
|
|
6
|
+
* configured OpenAI-compat endpoint instead of being routed through the
|
|
7
|
+
* Claude template path. The Claude backend is unchanged.
|
|
8
|
+
*
|
|
9
|
+
* The `--base-url` flag is accepted so the same command works for any
|
|
10
|
+
* OpenAI-compatible provider (OpenAI, OpenRouter, Groq, LiteLLM, a local
|
|
11
|
+
* Ollama exposing OpenAI compat, etc.). Only one openai-compat backend can
|
|
12
|
+
* be active at a time in v3.6.0; multi-backend-per-provider routing lands
|
|
13
|
+
* in a follow-up release.
|
|
14
|
+
*/
|
|
15
|
+
import { readFile, writeFile, mkdir, unlink, readdir } from 'node:fs/promises';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
const DARIO_DIR = join(homedir(), '.dario');
|
|
19
|
+
const BACKENDS_DIR = join(DARIO_DIR, 'backends');
|
|
20
|
+
async function ensureDir() {
|
|
21
|
+
await mkdir(BACKENDS_DIR, { recursive: true, mode: 0o700 });
|
|
22
|
+
}
|
|
23
|
+
export async function listBackends() {
|
|
24
|
+
try {
|
|
25
|
+
await ensureDir();
|
|
26
|
+
const files = await readdir(BACKENDS_DIR);
|
|
27
|
+
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
|
28
|
+
const results = [];
|
|
29
|
+
for (const f of jsonFiles) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await readFile(join(BACKENDS_DIR, f), 'utf-8');
|
|
32
|
+
results.push(JSON.parse(raw));
|
|
33
|
+
}
|
|
34
|
+
catch { /* skip unreadable */ }
|
|
35
|
+
}
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function saveBackend(creds) {
|
|
43
|
+
await ensureDir();
|
|
44
|
+
const path = join(BACKENDS_DIR, `${creds.name}.json`);
|
|
45
|
+
await writeFile(path, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
46
|
+
}
|
|
47
|
+
export async function removeBackend(name) {
|
|
48
|
+
const path = join(BACKENDS_DIR, `${name}.json`);
|
|
49
|
+
try {
|
|
50
|
+
await unlink(path);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Get the first openai-compat backend (v3.6.0 supports exactly one). */
|
|
58
|
+
export async function getOpenAIBackend() {
|
|
59
|
+
const all = await listBackends();
|
|
60
|
+
return all.find(b => b.provider === 'openai') ?? null;
|
|
61
|
+
}
|
|
62
|
+
// Model names that should route to the OpenAI backend when one is configured.
|
|
63
|
+
// Deliberately narrow — OpenAI and reasoning-series only. Custom GPT-shaped
|
|
64
|
+
// names from other providers (llama-*, mixtral-*) don't match by default;
|
|
65
|
+
// users pass them through as-is on the OpenAI-compat endpoint and they'll
|
|
66
|
+
// reach the configured baseUrl, which is correct for OpenRouter/Groq/etc.
|
|
67
|
+
const OPENAI_MODEL_PATTERNS = [
|
|
68
|
+
/^gpt-/i,
|
|
69
|
+
/^o1-/i,
|
|
70
|
+
/^o3-/i,
|
|
71
|
+
/^o4-/i,
|
|
72
|
+
/^chatgpt-/i,
|
|
73
|
+
/^text-davinci/i,
|
|
74
|
+
/^text-embedding-/i,
|
|
75
|
+
];
|
|
76
|
+
export function isOpenAIModel(model) {
|
|
77
|
+
return OPENAI_MODEL_PATTERNS.some(p => p.test(model));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Forward a client request to the configured OpenAI-compat backend.
|
|
81
|
+
* Pass-through: the client is already speaking OpenAI format, we just swap
|
|
82
|
+
* the API key and the target URL. No template, no identity, no scrubbing.
|
|
83
|
+
*/
|
|
84
|
+
export async function forwardToOpenAI(req, res, body, backend, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose) {
|
|
85
|
+
const target = `${backend.baseUrl.replace(/\/$/, '')}/chat/completions`;
|
|
86
|
+
const clientBeta = req.headers['anthropic-beta'];
|
|
87
|
+
// Headers: drop anything Anthropic-specific, keep only the essentials
|
|
88
|
+
// OpenAI-compat endpoints care about. Streaming is driven by the body, not
|
|
89
|
+
// a header, so we don't need to parse it here.
|
|
90
|
+
const headers = {
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
'Authorization': `Bearer ${backend.apiKey}`,
|
|
93
|
+
'Accept': req.headers.accept?.toString() ?? 'application/json',
|
|
94
|
+
};
|
|
95
|
+
// Some openai-compat providers (OpenRouter) want their own custom headers
|
|
96
|
+
// for attribution. If the client sent an x-title or http-referer, forward
|
|
97
|
+
// those through so the upstream provider sees them.
|
|
98
|
+
for (const h of ['x-title', 'http-referer', 'x-openrouter-app']) {
|
|
99
|
+
const v = req.headers[h];
|
|
100
|
+
if (typeof v === 'string')
|
|
101
|
+
headers[h] = v;
|
|
102
|
+
}
|
|
103
|
+
// Drop Anthropic-specific headers entirely
|
|
104
|
+
void clientBeta;
|
|
105
|
+
const abort = new AbortController();
|
|
106
|
+
const timeout = setTimeout(() => abort.abort(), upstreamTimeoutMs);
|
|
107
|
+
try {
|
|
108
|
+
if (verbose) {
|
|
109
|
+
console.log(`[dario] → openai backend: ${target}`);
|
|
110
|
+
}
|
|
111
|
+
const upstream = await fetch(target, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers,
|
|
114
|
+
body: body.length > 0 ? new Uint8Array(body) : undefined,
|
|
115
|
+
signal: abort.signal,
|
|
116
|
+
});
|
|
117
|
+
const respHeaders = {
|
|
118
|
+
'Content-Type': upstream.headers.get('content-type') ?? 'application/json',
|
|
119
|
+
'Access-Control-Allow-Origin': corsOrigin,
|
|
120
|
+
...securityHeaders,
|
|
121
|
+
};
|
|
122
|
+
// Forward rate-limit + request-id headers from the upstream
|
|
123
|
+
for (const [key, value] of upstream.headers.entries()) {
|
|
124
|
+
if (key.startsWith('x-ratelimit') ||
|
|
125
|
+
key.startsWith('openai-') ||
|
|
126
|
+
key === 'request-id' ||
|
|
127
|
+
key === 'x-request-id') {
|
|
128
|
+
respHeaders[key] = value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
res.writeHead(upstream.status, respHeaders);
|
|
132
|
+
if (upstream.body) {
|
|
133
|
+
const reader = upstream.body.getReader();
|
|
134
|
+
try {
|
|
135
|
+
while (true) {
|
|
136
|
+
const { done, value } = await reader.read();
|
|
137
|
+
if (done)
|
|
138
|
+
break;
|
|
139
|
+
if (value)
|
|
140
|
+
res.write(Buffer.from(value));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
reader.releaseLock();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
res.end();
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
clearTimeout(timeout);
|
|
151
|
+
if (!res.headersSent) {
|
|
152
|
+
res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
153
|
+
res.end(JSON.stringify({
|
|
154
|
+
error: 'Upstream OpenAI-compat backend error',
|
|
155
|
+
message: err instanceof Error ? err.message : String(err),
|
|
156
|
+
backend: backend.name,
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
try {
|
|
161
|
+
res.end();
|
|
162
|
+
}
|
|
163
|
+
catch { /* already closed */ }
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
clearTimeout(timeout);
|
|
169
|
+
}
|
|
170
|
+
}
|