@askalf/dario 1.2.1 → 2.1.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
@@ -16,9 +16,9 @@
16
16
 
17
17
  <p align="center">
18
18
  <a href="#quick-start">Quick Start</a> &bull;
19
- <a href="#how-it-works">How It Works</a> &bull;
20
- <a href="#usage-examples">Examples</a> &bull;
19
+ <a href="#openai-compatibility">OpenAI Compat</a> &bull;
21
20
  <a href="#cli-backend">CLI Backend</a> &bull;
21
+ <a href="#usage-examples">Examples</a> &bull;
22
22
  <a href="#faq">FAQ</a>
23
23
  </p>
24
24
 
@@ -157,6 +157,21 @@ Combine with `--cli` for rate-limit-proof Opus:
157
157
  dario proxy --cli --model=opus
158
158
  ```
159
159
 
160
+ ## OpenAI Compatibility
161
+
162
+ Dario implements `/v1/chat/completions` — any tool built for the OpenAI API works with your Claude subscription. No code changes needed.
163
+
164
+ ```bash
165
+ dario proxy --model=opus
166
+
167
+ export OPENAI_BASE_URL=http://localhost:3456/v1
168
+ export OPENAI_API_KEY=dario
169
+
170
+ # Cursor, Continue, LiteLLM, any OpenAI SDK — all work
171
+ ```
172
+
173
+ Use `--model=opus` to force the model regardless of what the client sends. Or pass `claude-opus-4-6` as the model name directly — Claude model names work as-is.
174
+
160
175
  ## Usage Examples
161
176
 
162
177
  ### curl
@@ -290,7 +305,8 @@ ANTHROPIC_BASE_URL=http://localhost:3456 ANTHROPIC_API_KEY=dario your-tool-here
290
305
 
291
306
  ### Direct API Mode
292
307
  - All Claude models (Opus 4.6, Sonnet 4.6, Haiku 4.5)
293
- - Streaming and non-streaming
308
+ - **OpenAI-compatible** (`/v1/chat/completions`) — works with any OpenAI SDK or tool
309
+ - Streaming and non-streaming (both Anthropic and OpenAI SSE formats)
294
310
  - Tool use / function calling
295
311
  - System prompts and multi-turn conversations
296
312
  - Prompt caching and extended thinking
@@ -307,7 +323,9 @@ ANTHROPIC_BASE_URL=http://localhost:3456 ANTHROPIC_API_KEY=dario your-tool-here
307
323
 
308
324
  | Path | Description |
309
325
  |------|-------------|
310
- | `POST /v1/messages` | Anthropic Messages API (main endpoint) |
326
+ | `POST /v1/messages` | Anthropic Messages API |
327
+ | `POST /v1/chat/completions` | OpenAI-compatible Chat API |
328
+ | `GET /v1/models` | Model list (works with both SDKs) |
311
329
  | `GET /health` | Proxy health + OAuth status + request count |
312
330
  | `GET /status` | Detailed OAuth token status |
313
331
 
package/dist/cli.js CHANGED
@@ -13,9 +13,9 @@ import { readFile, unlink } from 'node:fs/promises';
13
13
  import { join } from 'node:path';
14
14
  import { homedir } from 'node:os';
15
15
  import { startAutoOAuthFlow, getStatus, refreshTokens } from './oauth.js';
16
- import { startProxy } from './proxy.js';
16
+ import { startProxy, sanitizeError } from './proxy.js';
17
17
  const args = process.argv.slice(2);
18
- const command = args[0] ?? (process.stdin.isTTY ? 'proxy' : 'proxy');
18
+ const command = args[0] ?? 'proxy';
19
19
  async function login() {
20
20
  console.log('');
21
21
  console.log(' dario — Claude Login');
@@ -50,7 +50,7 @@ async function login() {
50
50
  }
51
51
  catch (err) {
52
52
  console.error('');
53
- console.error(` Login failed: ${err instanceof Error ? err.message : err}`);
53
+ console.error(` Login failed: ${sanitizeError(err)}`);
54
54
  console.error(' Try again with `dario login`.');
55
55
  process.exit(1);
56
56
  }
@@ -89,7 +89,7 @@ async function refresh() {
89
89
  console.log(`[dario] Token refreshed. Expires in ${expiresIn} minutes.`);
90
90
  }
91
91
  catch (err) {
92
- console.error(`[dario] Refresh failed: ${err instanceof Error ? err.message : err}`);
92
+ console.error(`[dario] Refresh failed: ${sanitizeError(err)}`);
93
93
  process.exit(1);
94
94
  }
95
95
  }
@@ -172,7 +172,6 @@ if (!handler) {
172
172
  process.exit(1);
173
173
  }
174
174
  handler().catch(err => {
175
- const msg = err instanceof Error ? err.message : String(err);
176
- console.error('Fatal error:', msg.replace(/sk-ant-[a-zA-Z0-9_-]+/g, '[REDACTED]'));
175
+ console.error('Fatal error:', sanitizeError(err));
177
176
  process.exit(1);
178
177
  });
package/dist/index.d.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  */
7
7
  export { startAutoOAuthFlow, refreshTokens, getAccessToken, getStatus, loadCredentials } from './oauth.js';
8
8
  export type { OAuthTokens, CredentialsFile } from './oauth.js';
9
- export { startProxy } from './proxy.js';
9
+ export { startProxy, sanitizeError } from './proxy.js';
package/dist/index.js CHANGED
@@ -5,4 +5,4 @@
5
5
  * instead of running the CLI.
6
6
  */
7
7
  export { startAutoOAuthFlow, refreshTokens, getAccessToken, getStatus, loadCredentials } from './oauth.js';
8
- export { startProxy } from './proxy.js';
8
+ export { startProxy, sanitizeError } from './proxy.js';
package/dist/proxy.d.ts CHANGED
@@ -13,5 +13,6 @@ interface ProxyOptions {
13
13
  model?: string;
14
14
  cliBackend?: boolean;
15
15
  }
16
+ export declare function sanitizeError(err: unknown): string;
16
17
  export declare function startProxy(opts?: ProxyOptions): Promise<void>;
17
18
  export {};
package/dist/proxy.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * No API key needed — your Claude subscription pays for it.
9
9
  */
10
10
  import { createServer } from 'node:http';
11
- import { randomUUID } from 'node:crypto';
11
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
12
12
  import { execSync, spawn } from 'node:child_process';
13
13
  import { arch, platform, version as nodeVersion } from 'node:process';
14
14
  import { getAccessToken, getStatus } from './oauth.js';
@@ -17,7 +17,6 @@ const DEFAULT_PORT = 3456;
17
17
  const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — generous for large prompts, prevents abuse
18
18
  const UPSTREAM_TIMEOUT_MS = 300_000; // 5 min — matches Anthropic SDK default
19
19
  const LOCALHOST = '127.0.0.1';
20
- const CORS_ORIGIN = 'http://localhost';
21
20
  // Detect installed Claude Code version at startup
22
21
  function detectClaudeVersion() {
23
22
  try {
@@ -55,10 +54,139 @@ const MODEL_ALIASES = {
55
54
  'sonnet': 'claude-sonnet-4-6',
56
55
  'haiku': 'claude-haiku-4-5',
57
56
  };
58
- function sanitizeError(err) {
57
+ // OpenAI model name → Anthropic model name
58
+ const OPENAI_MODEL_MAP = {
59
+ 'gpt-4.1': 'claude-opus-4-6',
60
+ 'gpt-4.1-mini': 'claude-sonnet-4-6',
61
+ 'gpt-4.1-nano': 'claude-haiku-4-5',
62
+ 'gpt-4o': 'claude-opus-4-6',
63
+ 'gpt-4o-mini': 'claude-haiku-4-5',
64
+ 'gpt-4-turbo': 'claude-opus-4-6',
65
+ 'gpt-4': 'claude-opus-4-6',
66
+ 'gpt-3.5-turbo': 'claude-haiku-4-5',
67
+ 'o3': 'claude-opus-4-6',
68
+ 'o3-mini': 'claude-sonnet-4-6',
69
+ 'o4-mini': 'claude-sonnet-4-6',
70
+ 'o1': 'claude-opus-4-6',
71
+ 'o1-mini': 'claude-sonnet-4-6',
72
+ 'o1-pro': 'claude-opus-4-6',
73
+ };
74
+ /**
75
+ * Translate OpenAI chat completion request → Anthropic Messages request.
76
+ */
77
+ function openaiToAnthropic(body, modelOverride) {
78
+ const messages = body.messages;
79
+ if (!messages)
80
+ return body;
81
+ // Extract system messages
82
+ const systemMessages = messages.filter(m => m.role === 'system');
83
+ const nonSystemMessages = messages.filter(m => m.role !== 'system');
84
+ // Map model name
85
+ const requestModel = String(body.model || '');
86
+ const model = modelOverride || OPENAI_MODEL_MAP[requestModel] || requestModel;
87
+ const result = {
88
+ model,
89
+ messages: nonSystemMessages.map(m => ({
90
+ role: m.role === 'assistant' ? 'assistant' : 'user',
91
+ content: m.content,
92
+ })),
93
+ max_tokens: body.max_tokens ?? body.max_completion_tokens ?? 8192,
94
+ };
95
+ if (systemMessages.length > 0) {
96
+ result.system = systemMessages.map(m => typeof m.content === 'string' ? m.content : JSON.stringify(m.content)).join('\n');
97
+ }
98
+ if (body.stream)
99
+ result.stream = true;
100
+ if (body.temperature != null)
101
+ result.temperature = body.temperature;
102
+ if (body.top_p != null)
103
+ result.top_p = body.top_p;
104
+ if (body.stop)
105
+ result.stop_sequences = Array.isArray(body.stop) ? body.stop : [body.stop];
106
+ return result;
107
+ }
108
+ /**
109
+ * Translate Anthropic Messages response → OpenAI chat completion response.
110
+ */
111
+ function anthropicToOpenai(body) {
112
+ const content = body.content;
113
+ const text = content?.find(c => c.type === 'text')?.text ?? '';
114
+ const usage = body.usage;
115
+ return {
116
+ id: `chatcmpl-${(body.id || '').replace('msg_', '')}`,
117
+ object: 'chat.completion',
118
+ created: Math.floor(Date.now() / 1000),
119
+ model: body.model,
120
+ choices: [{
121
+ index: 0,
122
+ message: { role: 'assistant', content: text },
123
+ finish_reason: body.stop_reason === 'end_turn' ? 'stop' : body.stop_reason === 'max_tokens' ? 'length' : 'stop',
124
+ }],
125
+ usage: {
126
+ prompt_tokens: usage?.input_tokens ?? 0,
127
+ completion_tokens: usage?.output_tokens ?? 0,
128
+ total_tokens: (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0),
129
+ },
130
+ };
131
+ }
132
+ /**
133
+ * Translate Anthropic SSE stream → OpenAI SSE stream.
134
+ */
135
+ function translateStreamChunk(line) {
136
+ if (!line.startsWith('data: '))
137
+ return null;
138
+ const json = line.slice(6).trim();
139
+ if (json === '[DONE]')
140
+ return 'data: [DONE]\n\n';
141
+ try {
142
+ const event = JSON.parse(json);
143
+ if (event.type === 'content_block_delta') {
144
+ const delta = event.delta;
145
+ if (delta?.type === 'text_delta' && delta.text) {
146
+ return `data: ${JSON.stringify({
147
+ id: 'chatcmpl-dario',
148
+ object: 'chat.completion.chunk',
149
+ created: Math.floor(Date.now() / 1000),
150
+ model: 'claude',
151
+ choices: [{ index: 0, delta: { content: delta.text }, finish_reason: null }],
152
+ })}\n\n`;
153
+ }
154
+ }
155
+ if (event.type === 'message_stop') {
156
+ return `data: ${JSON.stringify({
157
+ id: 'chatcmpl-dario',
158
+ object: 'chat.completion.chunk',
159
+ created: Math.floor(Date.now() / 1000),
160
+ model: 'claude',
161
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
162
+ })}\n\ndata: [DONE]\n\n`;
163
+ }
164
+ }
165
+ catch { /* skip unparseable */ }
166
+ return null;
167
+ }
168
+ /**
169
+ * OpenAI-compatible models list.
170
+ */
171
+ function openaiModelsList() {
172
+ const models = ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'];
173
+ return {
174
+ object: 'list',
175
+ data: models.map(id => ({
176
+ id,
177
+ object: 'model',
178
+ created: 1700000000,
179
+ owned_by: 'anthropic',
180
+ })),
181
+ };
182
+ }
183
+ export function sanitizeError(err) {
59
184
  const msg = err instanceof Error ? err.message : String(err);
60
- // Never leak tokens in error messages
61
- return msg.replace(/sk-ant-[a-zA-Z0-9_-]+/g, '[REDACTED]');
185
+ // Never leak tokens, JWTs, or bearer values in error messages
186
+ return msg
187
+ .replace(/sk-ant-[a-zA-Z0-9_-]+/g, '[REDACTED]')
188
+ .replace(/eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, '[REDACTED_JWT]')
189
+ .replace(/Bearer\s+[a-zA-Z0-9_-]+/gi, 'Bearer [REDACTED]');
62
190
  }
63
191
  /**
64
192
  * CLI Backend: route requests through `claude --print` instead of direct API.
@@ -164,11 +292,28 @@ export async function startProxy(opts = {}) {
164
292
  const useCli = opts.cliBackend ?? false;
165
293
  let requestCount = 0;
166
294
  let tokenCostEstimate = 0;
295
+ // Optional proxy authentication
296
+ const apiKey = process.env.DARIO_API_KEY;
297
+ const corsOrigin = `http://localhost:${port}`;
298
+ function checkAuth(req) {
299
+ if (!apiKey)
300
+ return true; // no key set = open access
301
+ const provided = req.headers['x-api-key']
302
+ || req.headers.authorization?.replace(/^Bearer\s+/i, '');
303
+ if (!provided)
304
+ return false;
305
+ try {
306
+ return timingSafeEqual(Buffer.from(provided), Buffer.from(apiKey));
307
+ }
308
+ catch {
309
+ return false;
310
+ }
311
+ }
167
312
  const server = createServer(async (req, res) => {
168
313
  // CORS preflight
169
314
  if (req.method === 'OPTIONS') {
170
315
  res.writeHead(204, {
171
- 'Access-Control-Allow-Origin': CORS_ORIGIN,
316
+ 'Access-Control-Allow-Origin': corsOrigin,
172
317
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
173
318
  'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key, anthropic-version, anthropic-beta',
174
319
  'Access-Control-Max-Age': '86400',
@@ -190,6 +335,12 @@ export async function startProxy(opts = {}) {
190
335
  }));
191
336
  return;
192
337
  }
338
+ // Auth gate — everything below health requires auth when DARIO_API_KEY is set
339
+ if (!checkAuth(req)) {
340
+ res.writeHead(401, { 'Content-Type': 'application/json' });
341
+ res.end(JSON.stringify({ error: 'Unauthorized', message: 'Invalid or missing API key' }));
342
+ return;
343
+ }
193
344
  // Status endpoint
194
345
  if (urlPath === '/status') {
195
346
  const s = await getStatus();
@@ -197,20 +348,28 @@ export async function startProxy(opts = {}) {
197
348
  res.end(JSON.stringify(s));
198
349
  return;
199
350
  }
351
+ // OpenAI-compatible models list
352
+ if (urlPath === '/v1/models' && req.method === 'GET') {
353
+ requestCount++;
354
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': corsOrigin });
355
+ res.end(JSON.stringify(openaiModelsList()));
356
+ return;
357
+ }
358
+ // Detect OpenAI-format requests
359
+ const isOpenAI = urlPath === '/v1/chat/completions';
200
360
  // Allowlisted API paths — only these are proxied (prevents SSRF)
201
361
  const allowedPaths = {
202
362
  '/v1/messages': `${ANTHROPIC_API}/v1/messages`,
203
- '/v1/models': `${ANTHROPIC_API}/v1/models`,
204
363
  '/v1/complete': `${ANTHROPIC_API}/v1/complete`,
205
364
  };
206
- const targetBase = allowedPaths[urlPath];
365
+ const targetBase = isOpenAI ? `${ANTHROPIC_API}/v1/messages` : allowedPaths[urlPath];
207
366
  if (!targetBase) {
208
367
  res.writeHead(403, { 'Content-Type': 'application/json' });
209
368
  res.end(JSON.stringify({ error: 'Forbidden', message: 'Path not allowed' }));
210
369
  return;
211
370
  }
212
- // Only allow POST (Messages API) and GET (models)
213
- if (req.method !== 'POST' && req.method !== 'GET') {
371
+ // Only allow POST (Messages/Chat API) and GET (models)
372
+ if (req.method !== 'POST') {
214
373
  res.writeHead(405, { 'Content-Type': 'application/json' });
215
374
  res.end(JSON.stringify({ error: 'Method not allowed' }));
216
375
  return;
@@ -238,14 +397,23 @@ export async function startProxy(opts = {}) {
238
397
  requestCount++;
239
398
  res.writeHead(cliResult.status, {
240
399
  'Content-Type': cliResult.contentType,
241
- 'Access-Control-Allow-Origin': CORS_ORIGIN,
400
+ 'Access-Control-Allow-Origin': corsOrigin,
242
401
  });
243
402
  res.end(cliResult.body);
244
403
  return;
245
404
  }
246
- // Override model in request body if --model flag was set
405
+ // Translate OpenAI Anthropic format if needed
247
406
  let finalBody = body.length > 0 ? body : undefined;
248
- if (modelOverride && body.length > 0) {
407
+ if (isOpenAI && body.length > 0) {
408
+ try {
409
+ const parsed = JSON.parse(body.toString());
410
+ const translated = openaiToAnthropic(parsed, modelOverride);
411
+ finalBody = Buffer.from(JSON.stringify(translated));
412
+ }
413
+ catch { /* not JSON, send as-is */ }
414
+ }
415
+ else if (modelOverride && body.length > 0) {
416
+ // Override model in request body if --model flag was set
249
417
  try {
250
418
  const parsed = JSON.parse(body.toString());
251
419
  parsed.model = modelOverride;
@@ -308,7 +476,7 @@ export async function startProxy(opts = {}) {
308
476
  // Forward response headers
309
477
  const responseHeaders = {
310
478
  'Content-Type': contentType || 'application/json',
311
- 'Access-Control-Allow-Origin': CORS_ORIGIN,
479
+ 'Access-Control-Allow-Origin': corsOrigin,
312
480
  };
313
481
  // Forward rate limit headers (including unified subscription headers)
314
482
  for (const [key, value] of upstream.headers.entries()) {
@@ -321,12 +489,33 @@ export async function startProxy(opts = {}) {
321
489
  if (isStream && upstream.body) {
322
490
  // Stream SSE chunks through
323
491
  const reader = upstream.body.getReader();
492
+ const decoder = new TextDecoder();
324
493
  try {
494
+ let buffer = '';
325
495
  while (true) {
326
496
  const { done, value } = await reader.read();
327
497
  if (done)
328
498
  break;
329
- res.write(value);
499
+ if (isOpenAI) {
500
+ // Translate Anthropic SSE → OpenAI SSE
501
+ buffer += decoder.decode(value, { stream: true });
502
+ const lines = buffer.split('\n');
503
+ buffer = lines.pop() ?? '';
504
+ for (const line of lines) {
505
+ const translated = translateStreamChunk(line);
506
+ if (translated)
507
+ res.write(translated);
508
+ }
509
+ }
510
+ else {
511
+ res.write(value);
512
+ }
513
+ }
514
+ // Flush remaining buffer
515
+ if (isOpenAI && buffer.trim()) {
516
+ const translated = translateStreamChunk(buffer);
517
+ if (translated)
518
+ res.write(translated);
330
519
  }
331
520
  }
332
521
  catch (err) {
@@ -338,7 +527,19 @@ export async function startProxy(opts = {}) {
338
527
  else {
339
528
  // Buffer and forward
340
529
  const responseBody = await upstream.text();
341
- res.end(responseBody);
530
+ if (isOpenAI && upstream.status >= 200 && upstream.status < 300) {
531
+ // Translate Anthropic response → OpenAI format
532
+ try {
533
+ const parsed = JSON.parse(responseBody);
534
+ res.end(JSON.stringify(anthropicToOpenai(parsed)));
535
+ }
536
+ catch {
537
+ res.end(responseBody);
538
+ }
539
+ }
540
+ else {
541
+ res.end(responseBody);
542
+ }
342
543
  // Quick token estimate for logging
343
544
  if (verbose && responseBody) {
344
545
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "1.2.1",
3
+ "version": "2.1.0",
4
4
  "description": "Use your Claude subscription as an API. No API key needed. Local proxy for Claude Max/Pro subscriptions.",
5
5
  "type": "module",
6
6
  "bin": {