@askalf/dario 1.2.0 → 2.0.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 +85 -16
- package/dist/cli.js +15 -56
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/oauth.d.ts +3 -11
- package/dist/oauth.js +79 -24
- package/dist/proxy.js +185 -12
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -2,15 +2,23 @@
|
|
|
2
2
|
<h1 align="center">dario</h1>
|
|
3
3
|
<p align="center"><strong>Use your Claude subscription as an API.</strong></p>
|
|
4
4
|
<p align="center">
|
|
5
|
-
|
|
5
|
+
No API key needed. Your Claude Max/Pro subscription becomes a local API endpoint<br/>that any tool, SDK, or framework can use.
|
|
6
6
|
</p>
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/@askalf/dario"><img src="https://img.shields.io/npm/v/@askalf/dario?color=blue" alt="npm version"></a>
|
|
11
|
+
<a href="https://github.com/askalf/dario/actions/workflows/ci.yml"><img src="https://github.com/askalf/dario/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
12
|
+
<a href="https://github.com/askalf/dario/actions/workflows/codeql.yml"><img src="https://github.com/askalf/dario/actions/workflows/codeql.yml/badge.svg" alt="CodeQL"></a>
|
|
13
|
+
<a href="https://github.com/askalf/dario/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/@askalf/dario" alt="License"></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/@askalf/dario"><img src="https://img.shields.io/npm/dm/@askalf/dario" alt="Downloads"></a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
9
17
|
<p align="center">
|
|
10
18
|
<a href="#quick-start">Quick Start</a> •
|
|
11
|
-
<a href="#
|
|
12
|
-
<a href="#usage-examples">Examples</a> •
|
|
19
|
+
<a href="#openai-compatibility">OpenAI Compat</a> •
|
|
13
20
|
<a href="#cli-backend">CLI Backend</a> •
|
|
21
|
+
<a href="#usage-examples">Examples</a> •
|
|
14
22
|
<a href="#faq">FAQ</a>
|
|
15
23
|
</p>
|
|
16
24
|
|
|
@@ -40,7 +48,9 @@ You pay $100-200/mo for Claude Max or Pro. But that subscription only works on c
|
|
|
40
48
|
|
|
41
49
|
### Prerequisites
|
|
42
50
|
|
|
43
|
-
[Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview)
|
|
51
|
+
[Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) installed and logged in (recommended). Dario detects your existing Claude Code credentials automatically.
|
|
52
|
+
|
|
53
|
+
If Claude Code isn't installed, dario runs its own OAuth flow — opens your browser, you authorize, done.
|
|
44
54
|
|
|
45
55
|
### Install
|
|
46
56
|
|
|
@@ -60,9 +70,8 @@ npx @askalf/dario login
|
|
|
60
70
|
dario login
|
|
61
71
|
```
|
|
62
72
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
If Claude Code credentials aren't found, dario falls back to a manual OAuth flow.
|
|
73
|
+
- **With Claude Code installed:** Detects your credentials automatically and starts the proxy. No browser needed.
|
|
74
|
+
- **Without Claude Code:** Opens your browser to Claude's OAuth page. Authorize, and dario captures the token automatically via a local callback server. Then run `dario proxy` to start the server.
|
|
66
75
|
|
|
67
76
|
### Start the proxy
|
|
68
77
|
|
|
@@ -148,6 +157,37 @@ Combine with `--cli` for rate-limit-proof Opus:
|
|
|
148
157
|
dario proxy --cli --model=opus
|
|
149
158
|
```
|
|
150
159
|
|
|
160
|
+
## OpenAI Compatibility
|
|
161
|
+
|
|
162
|
+
Dario speaks both Anthropic and OpenAI API formats. Any tool built for OpenAI works with your Claude subscription — Cursor, Continue, LiteLLM, anything.
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
# Use with any OpenAI SDK or tool
|
|
166
|
+
export OPENAI_BASE_URL=http://localhost:3456/v1
|
|
167
|
+
export OPENAI_API_KEY=dario
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from openai import OpenAI
|
|
172
|
+
|
|
173
|
+
client = OpenAI(base_url="http://localhost:3456/v1", api_key="dario")
|
|
174
|
+
response = client.chat.completions.create(
|
|
175
|
+
model="claude-opus-4-6", # or use "gpt-4" — auto-maps to Opus
|
|
176
|
+
messages=[{"role": "user", "content": "Hello!"}]
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Model mapping (automatic):
|
|
181
|
+
|
|
182
|
+
| OpenAI model | Maps to |
|
|
183
|
+
|---|---|
|
|
184
|
+
| `gpt-4`, `gpt-4o`, `o1`, `o3` | `claude-opus-4-6` |
|
|
185
|
+
| `o1-mini`, `o3-mini` | `claude-sonnet-4-6` |
|
|
186
|
+
| `gpt-3.5-turbo`, `gpt-4o-mini` | `claude-haiku-4-5` |
|
|
187
|
+
| Any `claude-*` model | Passed through directly |
|
|
188
|
+
|
|
189
|
+
Streaming, system prompts, temperature, and stop sequences all translate automatically.
|
|
190
|
+
|
|
151
191
|
## Usage Examples
|
|
152
192
|
|
|
153
193
|
### curl
|
|
@@ -251,7 +291,7 @@ ANTHROPIC_BASE_URL=http://localhost:3456 ANTHROPIC_API_KEY=dario your-tool-here
|
|
|
251
291
|
└──────────┘ └─────────────────┘ └──────────────────┘
|
|
252
292
|
```
|
|
253
293
|
|
|
254
|
-
1. **`dario login`** — Detects your existing Claude Code credentials (`~/.claude/.credentials.json`) and starts the proxy automatically. If Claude Code isn't installed,
|
|
294
|
+
1. **`dario login`** — Detects your existing Claude Code credentials (`~/.claude/.credentials.json`) and starts the proxy automatically. If Claude Code isn't installed, runs a PKCE OAuth flow with a local callback server to capture the token automatically.
|
|
255
295
|
|
|
256
296
|
2. **`dario proxy`** — Starts an HTTP server on localhost that implements the Anthropic Messages API. In direct mode, it swaps your API key for an OAuth bearer token. In CLI mode, it routes through the Claude Code binary.
|
|
257
297
|
|
|
@@ -281,7 +321,8 @@ ANTHROPIC_BASE_URL=http://localhost:3456 ANTHROPIC_API_KEY=dario your-tool-here
|
|
|
281
321
|
|
|
282
322
|
### Direct API Mode
|
|
283
323
|
- All Claude models (Opus 4.6, Sonnet 4.6, Haiku 4.5)
|
|
284
|
-
-
|
|
324
|
+
- **OpenAI-compatible** (`/v1/chat/completions`) — works with any OpenAI SDK or tool
|
|
325
|
+
- Streaming and non-streaming (both Anthropic and OpenAI SSE formats)
|
|
285
326
|
- Tool use / function calling
|
|
286
327
|
- System prompts and multi-turn conversations
|
|
287
328
|
- Prompt caching and extended thinking
|
|
@@ -298,7 +339,9 @@ ANTHROPIC_BASE_URL=http://localhost:3456 ANTHROPIC_API_KEY=dario your-tool-here
|
|
|
298
339
|
|
|
299
340
|
| Path | Description |
|
|
300
341
|
|------|-------------|
|
|
301
|
-
| `POST /v1/messages` | Anthropic Messages API
|
|
342
|
+
| `POST /v1/messages` | Anthropic Messages API |
|
|
343
|
+
| `POST /v1/chat/completions` | OpenAI-compatible Chat API |
|
|
344
|
+
| `GET /v1/models` | Model list (works with both SDKs) |
|
|
302
345
|
| `GET /health` | Proxy health + OAuth status + request count |
|
|
303
346
|
| `GET /status` | Detailed OAuth token status |
|
|
304
347
|
|
|
@@ -321,11 +364,11 @@ curl http://localhost:3456/health
|
|
|
321
364
|
|
|
322
365
|
| Concern | How dario handles it |
|
|
323
366
|
|---------|---------------------|
|
|
324
|
-
| Credential storage |
|
|
367
|
+
| Credential storage | Reads from Claude Code (`~/.claude/.credentials.json`) or its own store (`~/.dario/credentials.json`) with `0600` permissions |
|
|
325
368
|
| OAuth flow | PKCE (Proof Key for Code Exchange) — no client secret needed |
|
|
326
369
|
| Token transmission | OAuth tokens never leave localhost. Only forwarded to `api.anthropic.com` over HTTPS |
|
|
327
370
|
| Network exposure | Proxy binds to `127.0.0.1` only — not accessible from other machines |
|
|
328
|
-
| SSRF protection |
|
|
371
|
+
| SSRF protection | Hardcoded allowlist of API paths — only `/v1/messages`, `/v1/models`, `/v1/complete` are proxied |
|
|
329
372
|
| Token rotation | Refresh tokens rotate on every use (single-use) |
|
|
330
373
|
| Error sanitization | Token patterns redacted from all error messages |
|
|
331
374
|
| Data collection | Zero. No telemetry, no analytics, no phoning home |
|
|
@@ -342,7 +385,7 @@ Claude Max and Claude Pro. Any plan that lets you use Claude Code.
|
|
|
342
385
|
Should work if your plan includes Claude Code access. Not tested yet — please open an issue with results.
|
|
343
386
|
|
|
344
387
|
**Do I need Claude Code installed?**
|
|
345
|
-
|
|
388
|
+
Recommended but not required. If Claude Code is installed and logged in, `dario login` picks up your credentials automatically. Without Claude Code, dario runs its own OAuth flow to authenticate directly. Note: `--cli` mode requires Claude Code (`npm install -g @anthropic-ai/claude-code`).
|
|
346
389
|
|
|
347
390
|
**What happens when my token expires?**
|
|
348
391
|
Dario auto-refreshes tokens 30 minutes before expiry. You should never see an auth error in normal use. If something goes wrong, `dario refresh` forces an immediate refresh.
|
|
@@ -354,7 +397,7 @@ Use `--cli` mode: `dario proxy --cli`. This routes through the Claude Code binar
|
|
|
354
397
|
Claude subscriptions have rolling 5-hour and 7-day usage windows shared across claude.ai and Claude Code. See [Anthropic's docs](https://support.claude.com/en/articles/11647753-how-do-usage-and-length-limits-work) for details. In Claude Code, use `/usage` to check your current limits, or configure the [statusline](https://code.claude.com/docs/en/statusline) to show real-time 5h and 7d utilization percentages.
|
|
355
398
|
|
|
356
399
|
**Can I run this on a server?**
|
|
357
|
-
Dario binds to localhost by default. For server use, you'd need to handle the initial
|
|
400
|
+
Dario binds to localhost by default. For server use, you'd need to handle the initial login on a machine with a browser, then copy `~/.claude/.credentials.json` (or `~/.dario/credentials.json`) to your server. Auto-refresh will keep it alive from there.
|
|
358
401
|
|
|
359
402
|
**Why "dario"?**
|
|
360
403
|
Named after [Dario Amodei](https://en.wikipedia.org/wiki/Dario_Amodei), CEO of Anthropic.
|
|
@@ -380,13 +423,39 @@ const status = await getStatus();
|
|
|
380
423
|
console.log(status.expiresIn); // "11h 42m"
|
|
381
424
|
```
|
|
382
425
|
|
|
426
|
+
## Trust & Transparency
|
|
427
|
+
|
|
428
|
+
Dario handles your OAuth tokens. Here's why you can trust it:
|
|
429
|
+
|
|
430
|
+
| Signal | Status |
|
|
431
|
+
|--------|--------|
|
|
432
|
+
| **Source code** | ~1000 lines of TypeScript — small enough to read in one sitting |
|
|
433
|
+
| **Dependencies** | 1 production dep (`@anthropic-ai/sdk`). Verify: `npm ls --production` |
|
|
434
|
+
| **npm provenance** | Every release is [SLSA attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions |
|
|
435
|
+
| **Security scanning** | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) runs on every push and weekly |
|
|
436
|
+
| **Credential handling** | Tokens never logged, redacted from errors, stored with 0600 permissions |
|
|
437
|
+
| **Network scope** | Binds to 127.0.0.1 only. Upstream traffic goes exclusively to `api.anthropic.com` over HTTPS |
|
|
438
|
+
| **No telemetry** | Zero analytics, tracking, or data collection of any kind |
|
|
439
|
+
| **Audit trail** | [CHANGELOG.md](CHANGELOG.md) documents every release |
|
|
440
|
+
| **Branch protection** | CI must pass before merge. CODEOWNERS enforces review |
|
|
441
|
+
|
|
442
|
+
Verify the npm package matches this repo:
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
# Check provenance attestation
|
|
446
|
+
npm audit signatures 2>/dev/null; npm view @askalf/dario dist.integrity
|
|
447
|
+
|
|
448
|
+
# Check dependency tree (should be minimal)
|
|
449
|
+
cd $(npm root -g)/@askalf/dario && npm ls --production
|
|
450
|
+
```
|
|
451
|
+
|
|
383
452
|
## Contributing
|
|
384
453
|
|
|
385
|
-
PRs welcome. The codebase is ~
|
|
454
|
+
PRs welcome. The codebase is ~1000 lines of TypeScript across 4 files:
|
|
386
455
|
|
|
387
456
|
| File | Purpose |
|
|
388
457
|
|------|---------|
|
|
389
|
-
| `src/oauth.ts` | Token storage, refresh logic, Claude Code credential detection |
|
|
458
|
+
| `src/oauth.ts` | Token storage, refresh logic, Claude Code credential detection, auto OAuth flow |
|
|
390
459
|
| `src/proxy.ts` | HTTP proxy server + CLI backend |
|
|
391
460
|
| `src/cli.ts` | CLI entry point |
|
|
392
461
|
| `src/index.ts` | Library exports |
|
package/dist/cli.js
CHANGED
|
@@ -9,23 +9,13 @@
|
|
|
9
9
|
* dario refresh — Force token refresh
|
|
10
10
|
* dario logout — Remove saved credentials
|
|
11
11
|
*/
|
|
12
|
-
import { createInterface } from 'node:readline';
|
|
13
12
|
import { readFile, unlink } from 'node:fs/promises';
|
|
14
13
|
import { join } from 'node:path';
|
|
15
14
|
import { homedir } from 'node:os';
|
|
16
|
-
import {
|
|
15
|
+
import { startAutoOAuthFlow, getStatus, refreshTokens } from './oauth.js';
|
|
17
16
|
import { startProxy } from './proxy.js';
|
|
18
17
|
const args = process.argv.slice(2);
|
|
19
18
|
const command = args[0] ?? (process.stdin.isTTY ? 'proxy' : 'proxy');
|
|
20
|
-
function ask(question) {
|
|
21
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
22
|
-
return new Promise(resolve => {
|
|
23
|
-
rl.question(question, answer => {
|
|
24
|
-
rl.close();
|
|
25
|
-
resolve(answer.trim());
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
19
|
async function login() {
|
|
30
20
|
console.log('');
|
|
31
21
|
console.log(' dario — Claude Login');
|
|
@@ -49,38 +39,9 @@ async function login() {
|
|
|
49
39
|
catch { /* no Claude Code credentials, fall through to OAuth */ }
|
|
50
40
|
console.log(' No Claude Code credentials found. Starting OAuth flow...');
|
|
51
41
|
console.log('');
|
|
52
|
-
const { authUrl, codeVerifier } = startOAuthFlow();
|
|
53
|
-
console.log(' Step 1: Open this URL in your browser:');
|
|
54
|
-
console.log('');
|
|
55
|
-
console.log(` ${authUrl}`);
|
|
56
|
-
console.log('');
|
|
57
|
-
console.log(' Step 2: Log in to your Claude account and authorize.');
|
|
58
|
-
console.log('');
|
|
59
|
-
console.log(' Step 3: After authorization, you\'ll be redirected to a page');
|
|
60
|
-
console.log(' that shows a code. Copy the FULL URL from your browser\'s');
|
|
61
|
-
console.log(' address bar (it contains the authorization code).');
|
|
62
|
-
console.log('');
|
|
63
|
-
const input = await ask(' Paste the redirect URL or authorization code: ');
|
|
64
|
-
// Extract code from URL or use raw input
|
|
65
|
-
let code = input;
|
|
66
|
-
try {
|
|
67
|
-
const url = new URL(input);
|
|
68
|
-
// Only extract from trusted Anthropic redirect URLs
|
|
69
|
-
if (url.hostname === 'platform.claude.com' || url.hostname === 'claude.ai') {
|
|
70
|
-
code = url.searchParams.get('code') ?? input;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
// Not a URL, use as-is (raw code)
|
|
75
|
-
}
|
|
76
|
-
if (!code || code.length < 10 || code.length > 2048) {
|
|
77
|
-
console.error(' Invalid authorization code.');
|
|
78
|
-
process.exit(1);
|
|
79
|
-
}
|
|
80
42
|
try {
|
|
81
|
-
const tokens = await
|
|
43
|
+
const tokens = await startAutoOAuthFlow();
|
|
82
44
|
const expiresIn = Math.round((tokens.expiresAt - Date.now()) / 60000);
|
|
83
|
-
console.log('');
|
|
84
45
|
console.log(' Login successful!');
|
|
85
46
|
console.log(` Token expires in ${expiresIn} minutes (auto-refreshes).`);
|
|
86
47
|
console.log('');
|
|
@@ -160,7 +121,7 @@ async function help() {
|
|
|
160
121
|
dario — Use your Claude subscription as an API.
|
|
161
122
|
|
|
162
123
|
Usage:
|
|
163
|
-
dario login
|
|
124
|
+
dario login Detect credentials + start proxy (or run OAuth)
|
|
164
125
|
dario proxy [options] Start the API proxy server
|
|
165
126
|
dario status Check authentication status
|
|
166
127
|
dario refresh Force token refresh
|
|
@@ -169,27 +130,25 @@ async function help() {
|
|
|
169
130
|
Proxy options:
|
|
170
131
|
--model=MODEL Force a model for all requests
|
|
171
132
|
Shortcuts: opus, sonnet, haiku
|
|
133
|
+
Full IDs: claude-opus-4-6, claude-sonnet-4-6
|
|
172
134
|
Default: passthrough (client decides)
|
|
173
135
|
--cli Use Claude CLI as backend (bypasses rate limits)
|
|
174
136
|
--port=PORT Port to listen on (default: 3456)
|
|
175
137
|
--verbose, -v Log all requests
|
|
176
138
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
3. Point any Anthropic SDK at http://localhost:3456
|
|
181
|
-
|
|
182
|
-
Example with OpenClaw, or any tool that uses the Anthropic API:
|
|
183
|
-
ANTHROPIC_BASE_URL=http://localhost:3456 ANTHROPIC_API_KEY=dario openclaw start
|
|
139
|
+
Quick start:
|
|
140
|
+
dario login # auto-detects Claude Code credentials
|
|
141
|
+
dario proxy # or: dario proxy --cli --model=opus
|
|
184
142
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
143
|
+
Then point any Anthropic SDK at http://localhost:3456:
|
|
144
|
+
export ANTHROPIC_BASE_URL=http://localhost:3456
|
|
145
|
+
export ANTHROPIC_API_KEY=dario
|
|
188
146
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
147
|
+
Examples:
|
|
148
|
+
curl http://localhost:3456/v1/messages \\
|
|
149
|
+
-H "Content-Type: application/json" \\
|
|
150
|
+
-H "anthropic-version: 2023-06-01" \\
|
|
151
|
+
-d '{"model":"claude-opus-4-6","max_tokens":1024,"messages":[{"role":"user","content":"Hello"}]}'
|
|
193
152
|
|
|
194
153
|
Your subscription handles the billing. No API key needed.
|
|
195
154
|
Tokens auto-refresh in the background — set it and forget it.
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
* Use this if you want to embed dario in your own app
|
|
5
5
|
* instead of running the CLI.
|
|
6
6
|
*/
|
|
7
|
-
export {
|
|
7
|
+
export { startAutoOAuthFlow, refreshTokens, getAccessToken, getStatus, loadCredentials } from './oauth.js';
|
|
8
8
|
export type { OAuthTokens, CredentialsFile } from './oauth.js';
|
|
9
9
|
export { startProxy } from './proxy.js';
|
package/dist/index.js
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* Use this if you want to embed dario in your own app
|
|
5
5
|
* instead of running the CLI.
|
|
6
6
|
*/
|
|
7
|
-
export {
|
|
7
|
+
export { startAutoOAuthFlow, refreshTokens, getAccessToken, getStatus, loadCredentials } from './oauth.js';
|
|
8
8
|
export { startProxy } from './proxy.js';
|
package/dist/oauth.d.ts
CHANGED
|
@@ -15,18 +15,10 @@ export interface CredentialsFile {
|
|
|
15
15
|
}
|
|
16
16
|
export declare function loadCredentials(): Promise<CredentialsFile | null>;
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* Automatic OAuth flow using a local callback server (same as Claude Code).
|
|
19
|
+
* Opens browser, captures the authorization code automatically.
|
|
20
20
|
*/
|
|
21
|
-
export declare function
|
|
22
|
-
authUrl: string;
|
|
23
|
-
state: string;
|
|
24
|
-
codeVerifier: string;
|
|
25
|
-
};
|
|
26
|
-
/**
|
|
27
|
-
* Exchange authorization code for tokens and save them.
|
|
28
|
-
*/
|
|
29
|
-
export declare function exchangeCode(code: string, codeVerifier: string): Promise<OAuthTokens>;
|
|
21
|
+
export declare function startAutoOAuthFlow(): Promise<OAuthTokens>;
|
|
30
22
|
/**
|
|
31
23
|
* Refresh the access token using the refresh token.
|
|
32
24
|
* Retries with exponential backoff on transient failures.
|
package/dist/oauth.js
CHANGED
|
@@ -12,7 +12,7 @@ import { homedir } from 'node:os';
|
|
|
12
12
|
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
13
13
|
const OAUTH_AUTHORIZE_URL = 'https://platform.claude.com/oauth/authorize';
|
|
14
14
|
const OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
|
15
|
-
const
|
|
15
|
+
const OAUTH_SCOPES = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
|
|
16
16
|
// Refresh 30 min before expiry
|
|
17
17
|
const REFRESH_BUFFER_MS = 30 * 60 * 1000;
|
|
18
18
|
// In-memory credential cache — avoids disk reads on every request
|
|
@@ -72,46 +72,101 @@ async function saveCredentials(creds) {
|
|
|
72
72
|
credentialsCacheTime = Date.now();
|
|
73
73
|
}
|
|
74
74
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
75
|
+
* Automatic OAuth flow using a local callback server (same as Claude Code).
|
|
76
|
+
* Opens browser, captures the authorization code automatically.
|
|
77
77
|
*/
|
|
78
|
-
export function
|
|
78
|
+
export async function startAutoOAuthFlow() {
|
|
79
|
+
const { createServer } = await import('node:http');
|
|
79
80
|
const { codeVerifier, codeChallenge } = generatePKCE();
|
|
80
81
|
const state = base64url(randomBytes(16));
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const server = createServer((req, res) => {
|
|
84
|
+
const url = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
|
|
85
|
+
if (url.pathname !== '/callback') {
|
|
86
|
+
res.writeHead(404);
|
|
87
|
+
res.end();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const code = url.searchParams.get('code');
|
|
91
|
+
const returnedState = url.searchParams.get('state');
|
|
92
|
+
if (!code) {
|
|
93
|
+
res.writeHead(400);
|
|
94
|
+
res.end('No authorization code received');
|
|
95
|
+
server.close();
|
|
96
|
+
reject(new Error('No authorization code received'));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (returnedState !== state) {
|
|
100
|
+
res.writeHead(400);
|
|
101
|
+
res.end('Invalid state parameter');
|
|
102
|
+
server.close();
|
|
103
|
+
reject(new Error('Invalid state parameter'));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Redirect browser to success page
|
|
107
|
+
res.writeHead(302, { Location: 'https://platform.claude.com/oauth/code/success?app=claude-code' });
|
|
108
|
+
res.end();
|
|
109
|
+
// Exchange the code for tokens
|
|
110
|
+
server.close();
|
|
111
|
+
exchangeCodeWithRedirect(code, codeVerifier, state, port)
|
|
112
|
+
.then(resolve)
|
|
113
|
+
.catch(reject);
|
|
114
|
+
});
|
|
115
|
+
let port = 0;
|
|
116
|
+
server.listen(0, 'localhost', () => {
|
|
117
|
+
const addr = server.address();
|
|
118
|
+
port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
119
|
+
const params = new URLSearchParams({
|
|
120
|
+
code: 'true',
|
|
121
|
+
client_id: OAUTH_CLIENT_ID,
|
|
122
|
+
response_type: 'code',
|
|
123
|
+
redirect_uri: `http://localhost:${port}/callback`,
|
|
124
|
+
scope: OAUTH_SCOPES,
|
|
125
|
+
code_challenge: codeChallenge,
|
|
126
|
+
code_challenge_method: 'S256',
|
|
127
|
+
state,
|
|
128
|
+
});
|
|
129
|
+
const authUrl = `${OAUTH_AUTHORIZE_URL}?${params.toString()}`;
|
|
130
|
+
// Open browser
|
|
131
|
+
console.log(' Opening browser to sign in...');
|
|
132
|
+
console.log(` If the browser didn't open, visit: ${authUrl}`);
|
|
133
|
+
console.log('');
|
|
134
|
+
// Open browser using platform-specific commands (no external deps)
|
|
135
|
+
const { exec } = require('node:child_process');
|
|
136
|
+
const cmd = process.platform === 'win32' ? `start "" "${authUrl}"`
|
|
137
|
+
: process.platform === 'darwin' ? `open "${authUrl}"`
|
|
138
|
+
: `xdg-open "${authUrl}"`;
|
|
139
|
+
exec(cmd, () => { });
|
|
140
|
+
});
|
|
141
|
+
server.on('error', (err) => {
|
|
142
|
+
reject(new Error(`Failed to start OAuth callback server: ${err.message}`));
|
|
143
|
+
});
|
|
144
|
+
// Timeout after 5 minutes
|
|
145
|
+
setTimeout(() => {
|
|
146
|
+
server.close();
|
|
147
|
+
reject(new Error('OAuth flow timed out. Try again with `dario login`.'));
|
|
148
|
+
}, 300_000);
|
|
90
149
|
});
|
|
91
|
-
return {
|
|
92
|
-
authUrl: `${OAUTH_AUTHORIZE_URL}?${params.toString()}`,
|
|
93
|
-
state,
|
|
94
|
-
codeVerifier,
|
|
95
|
-
};
|
|
96
150
|
}
|
|
97
151
|
/**
|
|
98
|
-
* Exchange
|
|
152
|
+
* Exchange code using the localhost redirect URI.
|
|
99
153
|
*/
|
|
100
|
-
|
|
154
|
+
async function exchangeCodeWithRedirect(code, codeVerifier, state, port) {
|
|
101
155
|
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
102
156
|
method: 'POST',
|
|
103
|
-
headers: { 'Content-Type': 'application/
|
|
104
|
-
body:
|
|
157
|
+
headers: { 'Content-Type': 'application/json' },
|
|
158
|
+
body: JSON.stringify({
|
|
105
159
|
grant_type: 'authorization_code',
|
|
106
160
|
client_id: OAUTH_CLIENT_ID,
|
|
107
161
|
code,
|
|
108
|
-
redirect_uri:
|
|
162
|
+
redirect_uri: `http://localhost:${port}/callback`,
|
|
109
163
|
code_verifier: codeVerifier,
|
|
164
|
+
state,
|
|
110
165
|
}),
|
|
111
166
|
signal: AbortSignal.timeout(30000),
|
|
112
167
|
});
|
|
113
168
|
if (!res.ok) {
|
|
114
|
-
throw new Error(`Token exchange failed (${res.status}).
|
|
169
|
+
throw new Error(`Token exchange failed (${res.status}). Try again with \`dario login\`.`);
|
|
115
170
|
}
|
|
116
171
|
const data = await res.json();
|
|
117
172
|
const tokens = {
|
package/dist/proxy.js
CHANGED
|
@@ -55,6 +55,128 @@ const MODEL_ALIASES = {
|
|
|
55
55
|
'sonnet': 'claude-sonnet-4-6',
|
|
56
56
|
'haiku': 'claude-haiku-4-5',
|
|
57
57
|
};
|
|
58
|
+
// OpenAI model name → Anthropic model name
|
|
59
|
+
const OPENAI_MODEL_MAP = {
|
|
60
|
+
'gpt-4': 'claude-opus-4-6',
|
|
61
|
+
'gpt-4o': 'claude-opus-4-6',
|
|
62
|
+
'gpt-4-turbo': 'claude-opus-4-6',
|
|
63
|
+
'gpt-4o-mini': 'claude-haiku-4-5',
|
|
64
|
+
'gpt-3.5-turbo': 'claude-haiku-4-5',
|
|
65
|
+
'o1': 'claude-opus-4-6',
|
|
66
|
+
'o1-mini': 'claude-sonnet-4-6',
|
|
67
|
+
'o1-preview': 'claude-opus-4-6',
|
|
68
|
+
'o3': 'claude-opus-4-6',
|
|
69
|
+
'o3-mini': 'claude-sonnet-4-6',
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Translate OpenAI chat completion request → Anthropic Messages request.
|
|
73
|
+
*/
|
|
74
|
+
function openaiToAnthropic(body, modelOverride) {
|
|
75
|
+
const messages = body.messages;
|
|
76
|
+
if (!messages)
|
|
77
|
+
return body;
|
|
78
|
+
// Extract system messages
|
|
79
|
+
const systemMessages = messages.filter(m => m.role === 'system');
|
|
80
|
+
const nonSystemMessages = messages.filter(m => m.role !== 'system');
|
|
81
|
+
// Map model name
|
|
82
|
+
const requestModel = String(body.model || '');
|
|
83
|
+
const model = modelOverride || OPENAI_MODEL_MAP[requestModel] || requestModel;
|
|
84
|
+
const result = {
|
|
85
|
+
model,
|
|
86
|
+
messages: nonSystemMessages.map(m => ({
|
|
87
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
88
|
+
content: m.content,
|
|
89
|
+
})),
|
|
90
|
+
max_tokens: body.max_tokens ?? body.max_completion_tokens ?? 8192,
|
|
91
|
+
};
|
|
92
|
+
if (systemMessages.length > 0) {
|
|
93
|
+
result.system = systemMessages.map(m => typeof m.content === 'string' ? m.content : JSON.stringify(m.content)).join('\n');
|
|
94
|
+
}
|
|
95
|
+
if (body.stream)
|
|
96
|
+
result.stream = true;
|
|
97
|
+
if (body.temperature != null)
|
|
98
|
+
result.temperature = body.temperature;
|
|
99
|
+
if (body.top_p != null)
|
|
100
|
+
result.top_p = body.top_p;
|
|
101
|
+
if (body.stop)
|
|
102
|
+
result.stop_sequences = Array.isArray(body.stop) ? body.stop : [body.stop];
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Translate Anthropic Messages response → OpenAI chat completion response.
|
|
107
|
+
*/
|
|
108
|
+
function anthropicToOpenai(body) {
|
|
109
|
+
const content = body.content;
|
|
110
|
+
const text = content?.find(c => c.type === 'text')?.text ?? '';
|
|
111
|
+
const usage = body.usage;
|
|
112
|
+
return {
|
|
113
|
+
id: `chatcmpl-${(body.id || '').replace('msg_', '')}`,
|
|
114
|
+
object: 'chat.completion',
|
|
115
|
+
created: Math.floor(Date.now() / 1000),
|
|
116
|
+
model: body.model,
|
|
117
|
+
choices: [{
|
|
118
|
+
index: 0,
|
|
119
|
+
message: { role: 'assistant', content: text },
|
|
120
|
+
finish_reason: body.stop_reason === 'end_turn' ? 'stop' : body.stop_reason === 'max_tokens' ? 'length' : 'stop',
|
|
121
|
+
}],
|
|
122
|
+
usage: {
|
|
123
|
+
prompt_tokens: usage?.input_tokens ?? 0,
|
|
124
|
+
completion_tokens: usage?.output_tokens ?? 0,
|
|
125
|
+
total_tokens: (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0),
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Translate Anthropic SSE stream → OpenAI SSE stream.
|
|
131
|
+
*/
|
|
132
|
+
function translateStreamChunk(line) {
|
|
133
|
+
if (!line.startsWith('data: '))
|
|
134
|
+
return null;
|
|
135
|
+
const json = line.slice(6).trim();
|
|
136
|
+
if (json === '[DONE]')
|
|
137
|
+
return 'data: [DONE]\n\n';
|
|
138
|
+
try {
|
|
139
|
+
const event = JSON.parse(json);
|
|
140
|
+
if (event.type === 'content_block_delta') {
|
|
141
|
+
const delta = event.delta;
|
|
142
|
+
if (delta?.type === 'text_delta' && delta.text) {
|
|
143
|
+
return `data: ${JSON.stringify({
|
|
144
|
+
id: 'chatcmpl-dario',
|
|
145
|
+
object: 'chat.completion.chunk',
|
|
146
|
+
created: Math.floor(Date.now() / 1000),
|
|
147
|
+
model: 'claude',
|
|
148
|
+
choices: [{ index: 0, delta: { content: delta.text }, finish_reason: null }],
|
|
149
|
+
})}\n\n`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (event.type === 'message_stop') {
|
|
153
|
+
return `data: ${JSON.stringify({
|
|
154
|
+
id: 'chatcmpl-dario',
|
|
155
|
+
object: 'chat.completion.chunk',
|
|
156
|
+
created: Math.floor(Date.now() / 1000),
|
|
157
|
+
model: 'claude',
|
|
158
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
|
159
|
+
})}\n\ndata: [DONE]\n\n`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch { /* skip unparseable */ }
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* OpenAI-compatible models list.
|
|
167
|
+
*/
|
|
168
|
+
function openaiModelsList() {
|
|
169
|
+
const models = ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'];
|
|
170
|
+
return {
|
|
171
|
+
object: 'list',
|
|
172
|
+
data: models.map(id => ({
|
|
173
|
+
id,
|
|
174
|
+
object: 'model',
|
|
175
|
+
created: 1700000000,
|
|
176
|
+
owned_by: 'anthropic',
|
|
177
|
+
})),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
58
180
|
function sanitizeError(err) {
|
|
59
181
|
const msg = err instanceof Error ? err.message : String(err);
|
|
60
182
|
// Never leak tokens in error messages
|
|
@@ -176,8 +298,10 @@ export async function startProxy(opts = {}) {
|
|
|
176
298
|
res.end();
|
|
177
299
|
return;
|
|
178
300
|
}
|
|
301
|
+
// Strip query parameters for endpoint matching
|
|
302
|
+
const urlPath = req.url?.split('?')[0] ?? '';
|
|
179
303
|
// Health check
|
|
180
|
-
if (
|
|
304
|
+
if (urlPath === '/health' || urlPath === '/') {
|
|
181
305
|
const s = await getStatus();
|
|
182
306
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
183
307
|
res.end(JSON.stringify({
|
|
@@ -189,27 +313,34 @@ export async function startProxy(opts = {}) {
|
|
|
189
313
|
return;
|
|
190
314
|
}
|
|
191
315
|
// Status endpoint
|
|
192
|
-
if (
|
|
316
|
+
if (urlPath === '/status') {
|
|
193
317
|
const s = await getStatus();
|
|
194
318
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
195
319
|
res.end(JSON.stringify(s));
|
|
196
320
|
return;
|
|
197
321
|
}
|
|
322
|
+
// OpenAI-compatible models list
|
|
323
|
+
if (urlPath === '/v1/models' && req.method === 'GET') {
|
|
324
|
+
requestCount++;
|
|
325
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': CORS_ORIGIN });
|
|
326
|
+
res.end(JSON.stringify(openaiModelsList()));
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// Detect OpenAI-format requests
|
|
330
|
+
const isOpenAI = urlPath === '/v1/chat/completions';
|
|
198
331
|
// Allowlisted API paths — only these are proxied (prevents SSRF)
|
|
199
|
-
const rawPath = req.url?.split('?')[0] ?? '';
|
|
200
332
|
const allowedPaths = {
|
|
201
333
|
'/v1/messages': `${ANTHROPIC_API}/v1/messages`,
|
|
202
|
-
'/v1/models': `${ANTHROPIC_API}/v1/models`,
|
|
203
334
|
'/v1/complete': `${ANTHROPIC_API}/v1/complete`,
|
|
204
335
|
};
|
|
205
|
-
const targetBase = allowedPaths[
|
|
336
|
+
const targetBase = isOpenAI ? `${ANTHROPIC_API}/v1/messages` : allowedPaths[urlPath];
|
|
206
337
|
if (!targetBase) {
|
|
207
338
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
208
339
|
res.end(JSON.stringify({ error: 'Forbidden', message: 'Path not allowed' }));
|
|
209
340
|
return;
|
|
210
341
|
}
|
|
211
|
-
// Only allow POST (Messages API) and GET (models)
|
|
212
|
-
if (req.method !== 'POST'
|
|
342
|
+
// Only allow POST (Messages/Chat API) and GET (models)
|
|
343
|
+
if (req.method !== 'POST') {
|
|
213
344
|
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
214
345
|
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
215
346
|
return;
|
|
@@ -232,7 +363,7 @@ export async function startProxy(opts = {}) {
|
|
|
232
363
|
}
|
|
233
364
|
const body = Buffer.concat(chunks);
|
|
234
365
|
// CLI backend mode: route through claude --print
|
|
235
|
-
if (useCli &&
|
|
366
|
+
if (useCli && urlPath === '/v1/messages' && req.method === 'POST' && body.length > 0) {
|
|
236
367
|
const cliResult = await handleViaCli(body, modelOverride, verbose);
|
|
237
368
|
requestCount++;
|
|
238
369
|
res.writeHead(cliResult.status, {
|
|
@@ -242,9 +373,18 @@ export async function startProxy(opts = {}) {
|
|
|
242
373
|
res.end(cliResult.body);
|
|
243
374
|
return;
|
|
244
375
|
}
|
|
245
|
-
//
|
|
376
|
+
// Translate OpenAI → Anthropic format if needed
|
|
246
377
|
let finalBody = body.length > 0 ? body : undefined;
|
|
247
|
-
if (
|
|
378
|
+
if (isOpenAI && body.length > 0) {
|
|
379
|
+
try {
|
|
380
|
+
const parsed = JSON.parse(body.toString());
|
|
381
|
+
const translated = openaiToAnthropic(parsed, modelOverride);
|
|
382
|
+
finalBody = Buffer.from(JSON.stringify(translated));
|
|
383
|
+
}
|
|
384
|
+
catch { /* not JSON, send as-is */ }
|
|
385
|
+
}
|
|
386
|
+
else if (modelOverride && body.length > 0) {
|
|
387
|
+
// Override model in request body if --model flag was set
|
|
248
388
|
try {
|
|
249
389
|
const parsed = JSON.parse(body.toString());
|
|
250
390
|
parsed.model = modelOverride;
|
|
@@ -320,12 +460,33 @@ export async function startProxy(opts = {}) {
|
|
|
320
460
|
if (isStream && upstream.body) {
|
|
321
461
|
// Stream SSE chunks through
|
|
322
462
|
const reader = upstream.body.getReader();
|
|
463
|
+
const decoder = new TextDecoder();
|
|
323
464
|
try {
|
|
465
|
+
let buffer = '';
|
|
324
466
|
while (true) {
|
|
325
467
|
const { done, value } = await reader.read();
|
|
326
468
|
if (done)
|
|
327
469
|
break;
|
|
328
|
-
|
|
470
|
+
if (isOpenAI) {
|
|
471
|
+
// Translate Anthropic SSE → OpenAI SSE
|
|
472
|
+
buffer += decoder.decode(value, { stream: true });
|
|
473
|
+
const lines = buffer.split('\n');
|
|
474
|
+
buffer = lines.pop() ?? '';
|
|
475
|
+
for (const line of lines) {
|
|
476
|
+
const translated = translateStreamChunk(line);
|
|
477
|
+
if (translated)
|
|
478
|
+
res.write(translated);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
res.write(value);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// Flush remaining buffer
|
|
486
|
+
if (isOpenAI && buffer.trim()) {
|
|
487
|
+
const translated = translateStreamChunk(buffer);
|
|
488
|
+
if (translated)
|
|
489
|
+
res.write(translated);
|
|
329
490
|
}
|
|
330
491
|
}
|
|
331
492
|
catch (err) {
|
|
@@ -337,7 +498,19 @@ export async function startProxy(opts = {}) {
|
|
|
337
498
|
else {
|
|
338
499
|
// Buffer and forward
|
|
339
500
|
const responseBody = await upstream.text();
|
|
340
|
-
|
|
501
|
+
if (isOpenAI && upstream.status >= 200 && upstream.status < 300) {
|
|
502
|
+
// Translate Anthropic response → OpenAI format
|
|
503
|
+
try {
|
|
504
|
+
const parsed = JSON.parse(responseBody);
|
|
505
|
+
res.end(JSON.stringify(anthropicToOpenai(parsed)));
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
res.end(responseBody);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
res.end(responseBody);
|
|
513
|
+
}
|
|
341
514
|
// Quick token estimate for logging
|
|
342
515
|
if (verbose && responseBody) {
|
|
343
516
|
try {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Use your Claude subscription as an API.
|
|
3
|
+
"version": "2.0.0",
|
|
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": {
|
|
7
7
|
"dario": "./dist/cli.js"
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
23
|
"build": "tsc",
|
|
24
|
+
"audit": "npm audit --production --audit-level=high",
|
|
24
25
|
"prepublishOnly": "npm run build",
|
|
25
26
|
"start": "node dist/cli.js",
|
|
26
27
|
"dev": "tsx src/cli.ts"
|
|
@@ -40,7 +41,7 @@
|
|
|
40
41
|
"cli",
|
|
41
42
|
"developer-tools"
|
|
42
43
|
],
|
|
43
|
-
"author": "askalf",
|
|
44
|
+
"author": "askalf (https://github.com/askalf)",
|
|
44
45
|
"license": "MIT",
|
|
45
46
|
"repository": {
|
|
46
47
|
"type": "git",
|