anymodel 1.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/LICENSE +21 -0
- package/README.md +142 -0
- package/cli.mjs +125 -0
- package/package.json +19 -0
- package/providers/ollama.mjs +37 -0
- package/providers/openrouter.mjs +27 -0
- package/proxy.mjs +245 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 antonoly
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# anymodel
|
|
2
|
+
|
|
3
|
+
**Run Claude Code with any AI model — OpenRouter, Ollama, or any LLM provider.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/anymodel)
|
|
6
|
+
[](https://github.com/antonoly/anymodel/blob/main/LICENSE)
|
|
7
|
+
[](https://nodejs.org)
|
|
8
|
+
|
|
9
|
+
Use Claude Code's powerful agentic coding with **any model** — GPT-4o, Gemini, Llama, Mistral, DeepSeek, and hundreds more. anymodel is a lightweight proxy that sits between Claude Code and your preferred AI provider, translating requests on the fly.
|
|
10
|
+
|
|
11
|
+
**Zero dependencies.** Just Node.js.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx anymodel
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
That's it. anymodel auto-detects your available provider and starts a local proxy.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Auto-detect provider (checks OPENROUTER_API_KEY, then local Ollama)
|
|
25
|
+
npx anymodel
|
|
26
|
+
|
|
27
|
+
# Explicitly use OpenRouter
|
|
28
|
+
npx anymodel openrouter
|
|
29
|
+
|
|
30
|
+
# Use local Ollama
|
|
31
|
+
npx anymodel ollama
|
|
32
|
+
|
|
33
|
+
# Specify a model
|
|
34
|
+
npx anymodel --model google/gemini-2.5-flash
|
|
35
|
+
|
|
36
|
+
# Custom port
|
|
37
|
+
npx anymodel --port 8080
|
|
38
|
+
|
|
39
|
+
# Combine options
|
|
40
|
+
npx anymodel openrouter --model deepseek/deepseek-r1 --port 3000
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then in another terminal:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
ANTHROPIC_BASE_URL=http://localhost:9090 claude
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## How It Works
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
┌─────────────┐ ┌──────────────┐ ┌──────────────────┐
|
|
53
|
+
│ Claude Code │────>│ anymodel │────>│ OpenRouter / │
|
|
54
|
+
│ │<────│ :9090 │<────│ Ollama / etc. │
|
|
55
|
+
└─────────────┘ └──────────────┘ └──────────────────┘
|
|
56
|
+
│
|
|
57
|
+
│ (non-/v1/messages)
|
|
58
|
+
v
|
|
59
|
+
┌──────────────┐
|
|
60
|
+
│ Anthropic │
|
|
61
|
+
│ API │
|
|
62
|
+
└──────────────┘
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
anymodel intercepts `/v1/messages` requests from Claude Code and routes them to your chosen provider. All other requests (auth, config) pass through to Anthropic's API unchanged.
|
|
66
|
+
|
|
67
|
+
The proxy automatically:
|
|
68
|
+
- Strips Anthropic-specific fields (`cache_control`, `betas`, `metadata`, `thinking`, etc.)
|
|
69
|
+
- Normalizes `tool_choice` format for cross-provider compatibility
|
|
70
|
+
- Retries failed requests with exponential backoff (3 attempts, max 8s delay)
|
|
71
|
+
- Streams responses back in real-time
|
|
72
|
+
|
|
73
|
+
## Supported Providers
|
|
74
|
+
|
|
75
|
+
| Provider | Command | Requirements |
|
|
76
|
+
|----------|---------|-------------|
|
|
77
|
+
| [OpenRouter](https://openrouter.ai) | `anymodel openrouter` | `OPENROUTER_API_KEY` |
|
|
78
|
+
| [Ollama](https://ollama.ai) | `anymodel ollama` | Ollama running locally |
|
|
79
|
+
|
|
80
|
+
### OpenRouter
|
|
81
|
+
|
|
82
|
+
Access 200+ models through a single API. [Get your API key](https://openrouter.ai/keys).
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
export OPENROUTER_API_KEY=sk-or-v1-...
|
|
86
|
+
npx anymodel openrouter --model google/gemini-2.5-flash
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Popular models: `google/gemini-2.5-flash`, `deepseek/deepseek-r1`, `meta-llama/llama-4-maverick`, `openai/gpt-4o`
|
|
90
|
+
|
|
91
|
+
### Ollama
|
|
92
|
+
|
|
93
|
+
Run models locally with zero cloud dependency.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
ollama serve
|
|
97
|
+
npx anymodel ollama --model llama3
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Configuration
|
|
101
|
+
|
|
102
|
+
### Environment Variables
|
|
103
|
+
|
|
104
|
+
| Variable | Description | Default |
|
|
105
|
+
|----------|-------------|---------|
|
|
106
|
+
| `OPENROUTER_API_KEY` | OpenRouter API key | — |
|
|
107
|
+
| `OPENROUTER_MODEL` | Default model override | passthrough |
|
|
108
|
+
| `PROXY_PORT` | Proxy listen port | `9090` |
|
|
109
|
+
|
|
110
|
+
### .env File
|
|
111
|
+
|
|
112
|
+
anymodel auto-loads a `.env` file from the current directory:
|
|
113
|
+
|
|
114
|
+
```env
|
|
115
|
+
OPENROUTER_API_KEY=sk-or-v1-...
|
|
116
|
+
OPENROUTER_MODEL=google/gemini-2.5-flash
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## CLI Reference
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
anymodel [provider] [options]
|
|
123
|
+
|
|
124
|
+
Providers:
|
|
125
|
+
openrouter Route through OpenRouter
|
|
126
|
+
ollama Route through local Ollama
|
|
127
|
+
|
|
128
|
+
Options:
|
|
129
|
+
--model, -m Model to use (e.g., google/gemini-2.5-flash)
|
|
130
|
+
--port, -p Proxy port (default: 9090)
|
|
131
|
+
--help, -h Show help
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Links
|
|
135
|
+
|
|
136
|
+
- [anymodel.dev](https://anymodel.dev) — Project homepage
|
|
137
|
+
- [OpenRouter](https://openrouter.ai) — Multi-model API gateway
|
|
138
|
+
- [GitHub](https://github.com/antonoly/anymodel) — Source code
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// anymodel CLI — Run Claude Code with any AI model
|
|
4
|
+
// Usage:
|
|
5
|
+
// npx anymodel # auto-detect provider
|
|
6
|
+
// npx anymodel openrouter # use OpenRouter
|
|
7
|
+
// npx anymodel ollama # use local Ollama
|
|
8
|
+
// npx anymodel --model google/gemini-2.5-flash --port 8080
|
|
9
|
+
|
|
10
|
+
import { createProxy, loadEnv } from './proxy.mjs';
|
|
11
|
+
|
|
12
|
+
const PROVIDERS = ['openrouter', 'ollama'];
|
|
13
|
+
|
|
14
|
+
export function parseArgs(argv) {
|
|
15
|
+
const opts = { provider: 'auto', port: 9090, model: null, help: false };
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const arg = argv[i];
|
|
19
|
+
if (arg === '--help' || arg === '-h') {
|
|
20
|
+
opts.help = true;
|
|
21
|
+
} else if (arg === '--model' || arg === '-m') {
|
|
22
|
+
opts.model = argv[++i] || null;
|
|
23
|
+
} else if (arg === '--port' || arg === '-p') {
|
|
24
|
+
opts.port = parseInt(argv[++i], 10) || 9090;
|
|
25
|
+
} else if (!arg.startsWith('-') && PROVIDERS.includes(arg)) {
|
|
26
|
+
opts.provider = arg;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return opts;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function detectProvider() {
|
|
34
|
+
if (process.env.OPENROUTER_API_KEY) return 'openrouter';
|
|
35
|
+
|
|
36
|
+
const { default: ollama } = await import('./providers/ollama.mjs');
|
|
37
|
+
if (await ollama.detect()) return 'ollama';
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function printHelp() {
|
|
43
|
+
console.log(`
|
|
44
|
+
\x1b[35m anymodel\x1b[0m — Run Claude Code with any AI model
|
|
45
|
+
|
|
46
|
+
\x1b[1mUsage:\x1b[0m
|
|
47
|
+
anymodel [provider] [options]
|
|
48
|
+
|
|
49
|
+
\x1b[1mProviders:\x1b[0m
|
|
50
|
+
openrouter Route through OpenRouter (needs OPENROUTER_API_KEY)
|
|
51
|
+
ollama Route through local Ollama instance
|
|
52
|
+
|
|
53
|
+
\x1b[1mOptions:\x1b[0m
|
|
54
|
+
--model, -m Model to use (e.g., google/gemini-2.5-flash)
|
|
55
|
+
--port, -p Proxy port (default: 9090)
|
|
56
|
+
--help, -h Show this help
|
|
57
|
+
|
|
58
|
+
\x1b[1mExamples:\x1b[0m
|
|
59
|
+
anymodel # auto-detect provider
|
|
60
|
+
anymodel openrouter # use OpenRouter
|
|
61
|
+
anymodel ollama --model llama3 # use Ollama with llama3
|
|
62
|
+
anymodel --model google/gemini-2.5-flash # specific model via OpenRouter
|
|
63
|
+
|
|
64
|
+
\x1b[1mEnvironment:\x1b[0m
|
|
65
|
+
OPENROUTER_API_KEY Your OpenRouter API key (https://openrouter.ai/keys)
|
|
66
|
+
OPENROUTER_MODEL Default model override
|
|
67
|
+
PROXY_PORT Default port override
|
|
68
|
+
|
|
69
|
+
https://anymodel.dev
|
|
70
|
+
`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function main() {
|
|
74
|
+
loadEnv();
|
|
75
|
+
|
|
76
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
77
|
+
|
|
78
|
+
if (opts.help) {
|
|
79
|
+
printHelp();
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Resolve provider
|
|
84
|
+
let providerName = opts.provider;
|
|
85
|
+
if (providerName === 'auto') {
|
|
86
|
+
providerName = await detectProvider();
|
|
87
|
+
if (!providerName) {
|
|
88
|
+
console.error('\x1b[31mError: Could not auto-detect a provider.\x1b[0m');
|
|
89
|
+
console.error('');
|
|
90
|
+
console.error(' Set OPENROUTER_API_KEY for OpenRouter:');
|
|
91
|
+
console.error(' export OPENROUTER_API_KEY=sk-or-...');
|
|
92
|
+
console.error(' Get your key at https://openrouter.ai/keys');
|
|
93
|
+
console.error('');
|
|
94
|
+
console.error(' Or start Ollama for local models:');
|
|
95
|
+
console.error(' ollama serve');
|
|
96
|
+
console.error('');
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
console.log(`\x1b[36m[AUTO]\x1b[0m Detected provider: ${providerName}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Validate OpenRouter has API key
|
|
103
|
+
if (providerName === 'openrouter' && !process.env.OPENROUTER_API_KEY) {
|
|
104
|
+
console.error('\x1b[31mError: OPENROUTER_API_KEY environment variable is required\x1b[0m');
|
|
105
|
+
console.error('Get your key at https://openrouter.ai/keys');
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Load provider
|
|
110
|
+
const { default: provider } = await import(`./providers/${providerName}.mjs`);
|
|
111
|
+
|
|
112
|
+
// Model override: CLI flag > env var > none
|
|
113
|
+
const model = opts.model || process.env.OPENROUTER_MODEL || null;
|
|
114
|
+
const port = opts.port || parseInt(process.env.PROXY_PORT, 10) || 9090;
|
|
115
|
+
|
|
116
|
+
createProxy(provider, { port, model });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Only run main when executed directly (not imported for testing)
|
|
120
|
+
const isMain = process.argv[1] && (
|
|
121
|
+
process.argv[1].endsWith('/cli.mjs') ||
|
|
122
|
+
process.argv[1].endsWith('\\cli.mjs') ||
|
|
123
|
+
process.argv[1].endsWith('/anymodel')
|
|
124
|
+
);
|
|
125
|
+
if (isMain) main();
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "anymodel",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Run Claude Code with any AI model — OpenRouter, Ollama, or any LLM provider",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "anymodel": "./cli.mjs" },
|
|
7
|
+
"main": "proxy.mjs",
|
|
8
|
+
"files": ["cli.mjs", "proxy.mjs", "providers/", "README.md", "LICENSE"],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test test/*.test.mjs",
|
|
11
|
+
"start": "node cli.mjs"
|
|
12
|
+
},
|
|
13
|
+
"keywords": ["claude", "claude-code", "openrouter", "ollama", "llm", "proxy", "ai", "cli"],
|
|
14
|
+
"author": "antonoly",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": { "type": "git", "url": "https://github.com/antonoly/anymodel" },
|
|
17
|
+
"homepage": "https://anymodel.dev",
|
|
18
|
+
"engines": { "node": ">=18.0.0" }
|
|
19
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Ollama provider for anymodel
|
|
2
|
+
// Routes requests to local Ollama instance (OpenAI-compatible endpoint)
|
|
3
|
+
|
|
4
|
+
import http from 'http';
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
name: 'ollama',
|
|
8
|
+
|
|
9
|
+
buildRequest(url, payload) {
|
|
10
|
+
return {
|
|
11
|
+
hostname: 'localhost',
|
|
12
|
+
port: 11434,
|
|
13
|
+
path: url,
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: {
|
|
16
|
+
'content-type': 'application/json',
|
|
17
|
+
'content-length': Buffer.byteLength(payload),
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
displayInfo(model) {
|
|
23
|
+
return model ? `(${model} @ localhost:11434)` : '(localhost:11434)';
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
// Check if Ollama is running locally
|
|
27
|
+
detect() {
|
|
28
|
+
return new Promise(resolve => {
|
|
29
|
+
const req = http.get('http://localhost:11434', res => {
|
|
30
|
+
res.resume();
|
|
31
|
+
resolve(true);
|
|
32
|
+
});
|
|
33
|
+
req.on('error', () => resolve(false));
|
|
34
|
+
req.setTimeout(1000, () => { req.destroy(); resolve(false); });
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// OpenRouter provider for anymodel
|
|
2
|
+
// Routes requests to openrouter.ai/api with Bearer auth
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: 'openrouter',
|
|
6
|
+
|
|
7
|
+
buildRequest(url, payload, apiKey) {
|
|
8
|
+
return {
|
|
9
|
+
hostname: 'openrouter.ai',
|
|
10
|
+
port: 443,
|
|
11
|
+
path: '/api' + url,
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: {
|
|
14
|
+
'content-type': 'application/json',
|
|
15
|
+
'authorization': `Bearer ${apiKey}`,
|
|
16
|
+
'anthropic-version': '2023-06-01',
|
|
17
|
+
'content-length': Buffer.byteLength(payload),
|
|
18
|
+
'http-referer': 'https://github.com/antonoly/anymodel',
|
|
19
|
+
'x-title': 'anymodel',
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
displayInfo(model) {
|
|
25
|
+
return model ? `(${model})` : '(passthrough model)';
|
|
26
|
+
},
|
|
27
|
+
};
|
package/proxy.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Core proxy server for anymodel
|
|
2
|
+
// Routes /v1/messages → provider, everything else → api.anthropic.com
|
|
3
|
+
|
|
4
|
+
import http from 'http';
|
|
5
|
+
import https from 'https';
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
|
|
8
|
+
const pkg = JSON.parse(readFileSync(new URL('package.json', import.meta.url), 'utf8'));
|
|
9
|
+
|
|
10
|
+
export const MAX_RETRIES = 3;
|
|
11
|
+
|
|
12
|
+
// ANSI colors
|
|
13
|
+
const C = {
|
|
14
|
+
cyan: s => `\x1b[36m${s}\x1b[0m`,
|
|
15
|
+
green: s => `\x1b[32m${s}\x1b[0m`,
|
|
16
|
+
red: s => `\x1b[31m${s}\x1b[0m`,
|
|
17
|
+
yellow: s => `\x1b[33m${s}\x1b[0m`,
|
|
18
|
+
magenta: s => `\x1b[35m${s}\x1b[0m`,
|
|
19
|
+
bold: s => `\x1b[1m${s}\x1b[0m`,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Strip Anthropic-specific fields that break non-Anthropic providers
|
|
23
|
+
export function sanitizeBody(body) {
|
|
24
|
+
delete body.betas;
|
|
25
|
+
delete body.metadata;
|
|
26
|
+
delete body.speed;
|
|
27
|
+
delete body.output_config;
|
|
28
|
+
delete body.context_management;
|
|
29
|
+
delete body.thinking;
|
|
30
|
+
|
|
31
|
+
// Strip cache_control from system blocks
|
|
32
|
+
if (Array.isArray(body.system)) {
|
|
33
|
+
body.system = body.system.map(block => {
|
|
34
|
+
if (block && typeof block === 'object' && block.cache_control) {
|
|
35
|
+
const { cache_control, ...rest } = block;
|
|
36
|
+
return rest;
|
|
37
|
+
}
|
|
38
|
+
return block;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Strip cache_control from message content blocks
|
|
43
|
+
if (Array.isArray(body.messages)) {
|
|
44
|
+
for (const msg of body.messages) {
|
|
45
|
+
if (Array.isArray(msg.content)) {
|
|
46
|
+
msg.content = msg.content.map(block => {
|
|
47
|
+
if (block && typeof block === 'object' && block.cache_control) {
|
|
48
|
+
const { cache_control, ...rest } = block;
|
|
49
|
+
return rest;
|
|
50
|
+
}
|
|
51
|
+
return block;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Strip Anthropic-only tool fields
|
|
58
|
+
if (Array.isArray(body.tools)) {
|
|
59
|
+
body.tools = body.tools.map(tool => {
|
|
60
|
+
const { cache_control, defer_loading, eager_input_streaming, strict, ...rest } = tool;
|
|
61
|
+
return rest;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Normalize tool_choice: providers expect object, Claude Code may send string
|
|
66
|
+
if (typeof body.tool_choice === 'string') {
|
|
67
|
+
body.tool_choice = { type: body.tool_choice };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return body;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Calculate exponential backoff delay, capped at 8s
|
|
74
|
+
export function calcDelay(attempt) {
|
|
75
|
+
return Math.min(1000 * Math.pow(2, attempt - 1), 8000);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check if a URL path should be routed to the provider
|
|
79
|
+
export function isProviderRoute(url) {
|
|
80
|
+
return url.startsWith('/v1/messages');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function sleep(ms) {
|
|
84
|
+
return new Promise(r => setTimeout(r, ms));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Load .env file if present (from given dir, or cwd)
|
|
88
|
+
export function loadEnv(dir) {
|
|
89
|
+
try {
|
|
90
|
+
const envPath = dir ? `${dir}/.env` : `${process.cwd()}/.env`;
|
|
91
|
+
const envFile = readFileSync(envPath, 'utf8');
|
|
92
|
+
for (const line of envFile.split('\n')) {
|
|
93
|
+
const trimmed = line.trim();
|
|
94
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
95
|
+
const eq = trimmed.indexOf('=');
|
|
96
|
+
if (eq > 0) {
|
|
97
|
+
const key = trimmed.slice(0, eq).trim();
|
|
98
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
99
|
+
// Strip surrounding quotes
|
|
100
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
101
|
+
val = val.slice(1, -1);
|
|
102
|
+
}
|
|
103
|
+
if (!process.env[key]) process.env[key] = val;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sendRequest(provider, url, payload) {
|
|
110
|
+
const opts = provider.buildRequest(url, payload, process.env.OPENROUTER_API_KEY);
|
|
111
|
+
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const transport = opts.port === 443 || opts.protocol === 'https:' ? https : http;
|
|
114
|
+
const req = transport.request(opts, upstream => resolve(upstream));
|
|
115
|
+
req.on('error', reject);
|
|
116
|
+
req.write(payload);
|
|
117
|
+
req.end();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function handleMessages(req, res, provider, model) {
|
|
122
|
+
const chunks = [];
|
|
123
|
+
req.on('data', c => chunks.push(c));
|
|
124
|
+
await new Promise(r => req.on('end', r));
|
|
125
|
+
const raw = Buffer.concat(chunks);
|
|
126
|
+
|
|
127
|
+
let parsed;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(raw.toString());
|
|
130
|
+
} catch {
|
|
131
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
132
|
+
res.end(JSON.stringify({ error: { type: 'invalid_request', message: 'Invalid JSON' } }));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const originalModel = parsed.model;
|
|
137
|
+
if (model) parsed.model = model;
|
|
138
|
+
|
|
139
|
+
sanitizeBody(parsed);
|
|
140
|
+
|
|
141
|
+
const payload = JSON.stringify(parsed);
|
|
142
|
+
const modelDisplay = model ? `${originalModel} \u2192 ${model}` : originalModel;
|
|
143
|
+
console.log(`${C.cyan(`[${provider.name.toUpperCase()}]`)} ${req.method} ${req.url} model=${modelDisplay} stream=${parsed.stream}`);
|
|
144
|
+
|
|
145
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
146
|
+
try {
|
|
147
|
+
const upstream = await sendRequest(provider, req.url, payload);
|
|
148
|
+
|
|
149
|
+
if (upstream.statusCode === 429 || upstream.statusCode >= 500) {
|
|
150
|
+
const errChunks = [];
|
|
151
|
+
upstream.on('data', c => errChunks.push(c));
|
|
152
|
+
await new Promise(r => upstream.on('end', r));
|
|
153
|
+
const errBody = Buffer.concat(errChunks).toString();
|
|
154
|
+
|
|
155
|
+
const delay = calcDelay(attempt);
|
|
156
|
+
console.log(`${C.red(`[${provider.name.toUpperCase()}]`)} ${upstream.statusCode} on attempt ${attempt}/${MAX_RETRIES}, retrying in ${delay}ms`);
|
|
157
|
+
console.log(`${C.red(`[${provider.name.toUpperCase()}]`)} ${errBody.slice(0, 200)}`);
|
|
158
|
+
|
|
159
|
+
if (attempt === MAX_RETRIES) {
|
|
160
|
+
res.writeHead(upstream.statusCode, upstream.headers);
|
|
161
|
+
res.end(errBody);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
await sleep(delay);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (upstream.statusCode !== 200) {
|
|
169
|
+
const errChunks = [];
|
|
170
|
+
upstream.on('data', c => errChunks.push(c));
|
|
171
|
+
await new Promise(r => upstream.on('end', r));
|
|
172
|
+
const errBody = Buffer.concat(errChunks).toString();
|
|
173
|
+
console.log(`${C.red(`[${provider.name.toUpperCase()}]`)} ${upstream.statusCode}: ${errBody.slice(0, 300)}`);
|
|
174
|
+
res.writeHead(upstream.statusCode, upstream.headers);
|
|
175
|
+
res.end(errBody);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
console.log(`${C.green(`[${provider.name.toUpperCase()}]`)} 200 \u2190 streaming response (attempt ${attempt})`);
|
|
180
|
+
res.writeHead(200, upstream.headers);
|
|
181
|
+
upstream.pipe(res);
|
|
182
|
+
return;
|
|
183
|
+
|
|
184
|
+
} catch (e) {
|
|
185
|
+
console.error(`${C.red(`[${provider.name.toUpperCase()}]`)} Connection error on attempt ${attempt}: ${e.message}`);
|
|
186
|
+
if (attempt === MAX_RETRIES) {
|
|
187
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
188
|
+
res.end(JSON.stringify({ error: { type: 'proxy_error', message: e.message } }));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
await sleep(calcDelay(attempt));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function proxyToAnthropic(req, res) {
|
|
197
|
+
const body = [];
|
|
198
|
+
req.on('data', c => body.push(c));
|
|
199
|
+
req.on('end', () => {
|
|
200
|
+
const opts = {
|
|
201
|
+
hostname: 'api.anthropic.com',
|
|
202
|
+
port: 443,
|
|
203
|
+
path: req.url,
|
|
204
|
+
method: req.method,
|
|
205
|
+
headers: { ...req.headers, host: 'api.anthropic.com' },
|
|
206
|
+
};
|
|
207
|
+
const pr = https.request(opts, upstream => {
|
|
208
|
+
res.writeHead(upstream.statusCode, upstream.headers);
|
|
209
|
+
upstream.pipe(res);
|
|
210
|
+
});
|
|
211
|
+
pr.on('error', e => { res.writeHead(502); res.end(e.message); });
|
|
212
|
+
if (body.length) pr.write(Buffer.concat(body));
|
|
213
|
+
pr.end();
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function createProxy(provider, { port = 9090, model } = {}) {
|
|
218
|
+
const server = http.createServer((req, res) => {
|
|
219
|
+
if (isProviderRoute(req.url)) {
|
|
220
|
+
handleMessages(req, res, provider, model);
|
|
221
|
+
} else {
|
|
222
|
+
console.log(`${C.yellow('[ANTHROPIC]')} ${req.method} ${req.url}`);
|
|
223
|
+
proxyToAnthropic(req, res);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
server.listen(port, () => {
|
|
228
|
+
console.log('');
|
|
229
|
+
console.log(C.magenta(` anymodel v${pkg.version}`));
|
|
230
|
+
console.log('');
|
|
231
|
+
console.log(` ${C.cyan('\u2194')} Proxy on :${port}`);
|
|
232
|
+
console.log(` /v1/messages \u2192 ${C.bold(provider.name)} ${provider.displayInfo(model)}`);
|
|
233
|
+
console.log(` everything else \u2192 api.anthropic.com`);
|
|
234
|
+
console.log(` Retries: ${MAX_RETRIES} with exponential backoff`);
|
|
235
|
+
if (model) {
|
|
236
|
+
console.log(` Model override: ${C.cyan(model)}`);
|
|
237
|
+
}
|
|
238
|
+
console.log('');
|
|
239
|
+
console.log(` ${C.green('Run in another terminal:')}`);
|
|
240
|
+
console.log(` ${C.bold(`ANTHROPIC_BASE_URL=http://localhost:${port} claude`)}`);
|
|
241
|
+
console.log('');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return server;
|
|
245
|
+
}
|