@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.
@@ -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
+ }