@sunflower0305/claude-proxy 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/.env.example +26 -0
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/proxy.d.ts +15 -0
- package/dist/proxy.js +419 -0
- package/package.json +75 -0
package/.env.example
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Choose your provider: qwen | deepseek | glm | minimax | kimi
|
|
2
|
+
PROVIDER=qwen
|
|
3
|
+
|
|
4
|
+
# Proxy server port (default: 8080)
|
|
5
|
+
PROXY_PORT=8080
|
|
6
|
+
|
|
7
|
+
# API keys - set the one(s) you need
|
|
8
|
+
QWEN_API_KEY=your-qwen-api-key
|
|
9
|
+
DEEPSEEK_API_KEY=your-deepseek-key
|
|
10
|
+
GLM_API_KEY=your-glm-key
|
|
11
|
+
MINIMAX_API_KEY=your-minimax-key
|
|
12
|
+
KIMI_API_KEY=your-kimi-key
|
|
13
|
+
|
|
14
|
+
# Optional model overrides
|
|
15
|
+
QWEN_MODEL=qwen-plus
|
|
16
|
+
DEEPSEEK_MODEL=deepseek-chat
|
|
17
|
+
GLM_MODEL=glm-5
|
|
18
|
+
MINIMAX_MODEL=MiniMax-M2.7-highspeed
|
|
19
|
+
KIMI_MODEL=kimi-k2.5
|
|
20
|
+
|
|
21
|
+
# Optional upstream Anthropic-compatible base URL overrides
|
|
22
|
+
# QWEN_ANTHROPIC_BASE_URL=https://dashscope.aliyuncs.com/apps/anthropic
|
|
23
|
+
# DEEPSEEK_ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
|
|
24
|
+
# GLM_ANTHROPIC_BASE_URL=https://open.bigmodel.cn/api/anthropic
|
|
25
|
+
# MINIMAX_ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic
|
|
26
|
+
# KIMI_ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sunflower0305
|
|
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,141 @@
|
|
|
1
|
+
# claude-proxy
|
|
2
|
+
|
|
3
|
+
`claude-proxy` is published on npm as `@sunflower0305/claude-proxy`. It is a lightweight Express proxy that lets Claude Code or the Claude Agent SDK talk to domestic Chinese LLM providers through Anthropic-compatible `/v1/messages` endpoints.
|
|
4
|
+
|
|
5
|
+
It currently supports `qwen`, `deepseek`, `glm`, `minimax`, and `kimi`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Run without installing:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx @sunflower0305/claude-proxy
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or install globally:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g @sunflower0305/claude-proxy
|
|
19
|
+
claude-proxy
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Configure
|
|
23
|
+
|
|
24
|
+
The proxy reads configuration from environment variables. You can export them in your shell or create a `.env` file in the directory where you run `claude-proxy`.
|
|
25
|
+
|
|
26
|
+
Example `.env`:
|
|
27
|
+
|
|
28
|
+
```dotenv
|
|
29
|
+
PROVIDER=qwen
|
|
30
|
+
PROXY_PORT=8080
|
|
31
|
+
QWEN_API_KEY=your-qwen-api-key
|
|
32
|
+
QWEN_MODEL=qwen-plus
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Available variables:
|
|
36
|
+
|
|
37
|
+
| Variable | Purpose |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `PROVIDER` | Active provider. Defaults to `qwen`. |
|
|
40
|
+
| `PROXY_PORT` | Local server port. Defaults to `8080`. |
|
|
41
|
+
| `QWEN_API_KEY` | API key for Qwen. |
|
|
42
|
+
| `DEEPSEEK_API_KEY` | API key for DeepSeek. |
|
|
43
|
+
| `GLM_API_KEY` | API key for GLM. |
|
|
44
|
+
| `MINIMAX_API_KEY` | API key for MiniMax. |
|
|
45
|
+
| `KIMI_API_KEY` | API key for Kimi. |
|
|
46
|
+
| `QWEN_ANTHROPIC_BASE_URL`, `DEEPSEEK_ANTHROPIC_BASE_URL`, `GLM_ANTHROPIC_BASE_URL`, `MINIMAX_ANTHROPIC_BASE_URL`, `KIMI_ANTHROPIC_BASE_URL` | Override the upstream Anthropic-compatible base URL for a provider. |
|
|
47
|
+
| `QWEN_MODEL`, `DEEPSEEK_MODEL`, `GLM_MODEL`, `MINIMAX_MODEL`, `KIMI_MODEL` | Override the default upstream model for a provider. |
|
|
48
|
+
|
|
49
|
+
You can use the bundled example as a starting point:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
cp node_modules/@sunflower0305/claude-proxy/.env.example .env
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
If you installed globally, create `.env` manually or export the variables in your shell before starting the proxy.
|
|
56
|
+
|
|
57
|
+
## Start The Proxy
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
claude-proxy
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
When the server starts, it listens on `http://localhost:8080` by default.
|
|
64
|
+
|
|
65
|
+
Point Claude Code or the Claude Agent SDK at the proxy:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
export ANTHROPIC_BASE_URL=http://localhost:8080
|
|
69
|
+
export ANTHROPIC_API_KEY=any-string-works
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Example SDK usage:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
76
|
+
|
|
77
|
+
const client = new Anthropic({
|
|
78
|
+
baseURL: "http://localhost:8080",
|
|
79
|
+
apiKey: "any-string",
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Runtime Endpoints
|
|
84
|
+
|
|
85
|
+
| Method | Path | Description |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| `POST` | `/v1/messages` | Main Anthropic Messages API proxy endpoint |
|
|
88
|
+
| `GET` | `/v1/models` | Lists supported Claude-facing model ids |
|
|
89
|
+
| `GET` | `/health` | Health check |
|
|
90
|
+
| `GET` / `POST` | `/api/provider` | Read or switch the active provider |
|
|
91
|
+
|
|
92
|
+
Health check:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
curl http://localhost:8080/health
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Switch provider at runtime:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
curl -X POST http://localhost:8080/api/provider \
|
|
102
|
+
-H "Content-Type: application/json" \
|
|
103
|
+
-d '{"provider":"deepseek"}'
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Library Usage
|
|
107
|
+
|
|
108
|
+
The package also keeps the programmatic Express entrypoint:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { createApp } from "@sunflower0305/claude-proxy";
|
|
112
|
+
|
|
113
|
+
const app = createApp();
|
|
114
|
+
app.listen(8080);
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
From source:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm install
|
|
123
|
+
npm run dev
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Build and local package verification:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npm run build
|
|
130
|
+
env npm_config_cache=/tmp/claude-proxy-npm-cache npm pack --dry-run
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Local integration test:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npm run test:proxy-local
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Proxy
|
|
4
|
+
*
|
|
5
|
+
* Proxies Anthropic Messages API requests to provider-native
|
|
6
|
+
* Anthropic-compatible endpoints without translating protocols.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* export ANTHROPIC_BASE_URL=http://localhost:8080
|
|
10
|
+
* export ANTHROPIC_API_KEY=any-key-works
|
|
11
|
+
*/
|
|
12
|
+
import "dotenv/config";
|
|
13
|
+
import express from "express";
|
|
14
|
+
export declare function createApp(): express.Express;
|
|
15
|
+
export declare const app: express.Express;
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Proxy
|
|
4
|
+
*
|
|
5
|
+
* Proxies Anthropic Messages API requests to provider-native
|
|
6
|
+
* Anthropic-compatible endpoints without translating protocols.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* export ANTHROPIC_BASE_URL=http://localhost:8080
|
|
10
|
+
* export ANTHROPIC_API_KEY=any-key-works
|
|
11
|
+
*/
|
|
12
|
+
import "dotenv/config";
|
|
13
|
+
import cors from "cors";
|
|
14
|
+
import express from "express";
|
|
15
|
+
import { realpathSync } from "node:fs";
|
|
16
|
+
import { Readable } from "node:stream";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
const DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
|
|
19
|
+
const HOP_BY_HOP_RESPONSE_HEADERS = new Set([
|
|
20
|
+
"connection",
|
|
21
|
+
"content-encoding",
|
|
22
|
+
"content-length",
|
|
23
|
+
"keep-alive",
|
|
24
|
+
"proxy-authenticate",
|
|
25
|
+
"proxy-authorization",
|
|
26
|
+
"te",
|
|
27
|
+
"trailer",
|
|
28
|
+
"transfer-encoding",
|
|
29
|
+
"upgrade",
|
|
30
|
+
]);
|
|
31
|
+
function pickEnv(...keys) {
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
const value = process.env[key]?.trim();
|
|
34
|
+
if (value)
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const PROVIDERS = {
|
|
40
|
+
deepseek: {
|
|
41
|
+
baseUrl: pickEnv("DEEPSEEK_ANTHROPIC_BASE_URL") ||
|
|
42
|
+
"https://api.deepseek.com/anthropic",
|
|
43
|
+
apiKey: process.env.DEEPSEEK_API_KEY || "",
|
|
44
|
+
model: pickEnv("DEEPSEEK_MODEL") || "deepseek-chat",
|
|
45
|
+
},
|
|
46
|
+
qwen: {
|
|
47
|
+
baseUrl: pickEnv("QWEN_ANTHROPIC_BASE_URL") ||
|
|
48
|
+
"https://dashscope.aliyuncs.com/apps/anthropic",
|
|
49
|
+
apiKey: process.env.QWEN_API_KEY || "",
|
|
50
|
+
model: pickEnv("QWEN_MODEL") || "qwen-plus",
|
|
51
|
+
},
|
|
52
|
+
glm: {
|
|
53
|
+
baseUrl: pickEnv("GLM_ANTHROPIC_BASE_URL") ||
|
|
54
|
+
"https://open.bigmodel.cn/api/anthropic",
|
|
55
|
+
apiKey: process.env.GLM_API_KEY || "",
|
|
56
|
+
model: pickEnv("GLM_MODEL") || "glm-5",
|
|
57
|
+
},
|
|
58
|
+
minimax: {
|
|
59
|
+
baseUrl: pickEnv("MINIMAX_ANTHROPIC_BASE_URL") ||
|
|
60
|
+
"https://api.minimaxi.com/anthropic",
|
|
61
|
+
apiKey: process.env.MINIMAX_API_KEY || "",
|
|
62
|
+
model: pickEnv("MINIMAX_MODEL") || "MiniMax-M2.7-highspeed",
|
|
63
|
+
},
|
|
64
|
+
kimi: {
|
|
65
|
+
baseUrl: pickEnv("KIMI_ANTHROPIC_BASE_URL") || "https://api.moonshot.cn/anthropic",
|
|
66
|
+
apiKey: process.env.KIMI_API_KEY || "",
|
|
67
|
+
model: pickEnv("KIMI_MODEL") || "kimi-k2.5",
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
function isProviderKey(value) {
|
|
71
|
+
return Boolean(value && value in PROVIDERS);
|
|
72
|
+
}
|
|
73
|
+
let currentProvider = isProviderKey(process.env.PROVIDER)
|
|
74
|
+
? process.env.PROVIDER
|
|
75
|
+
: "qwen";
|
|
76
|
+
let requestSequence = 0;
|
|
77
|
+
function getConfig(provider = currentProvider) {
|
|
78
|
+
return PROVIDERS[provider] || PROVIDERS.qwen;
|
|
79
|
+
}
|
|
80
|
+
const initialConfig = getConfig();
|
|
81
|
+
if (!initialConfig.apiKey) {
|
|
82
|
+
console.warn(`Warning: API key not configured for provider: ${currentProvider}`);
|
|
83
|
+
console.warn("Please set the appropriate environment variable in .env");
|
|
84
|
+
}
|
|
85
|
+
console.log(`Using ${currentProvider} as backend`);
|
|
86
|
+
console.log(`Model: ${initialConfig.model}`);
|
|
87
|
+
function getTargetModel(requestedModel) {
|
|
88
|
+
if (typeof requestedModel !== "string" || !requestedModel) {
|
|
89
|
+
return getConfig().model;
|
|
90
|
+
}
|
|
91
|
+
const normalizedModel = requestedModel.toLowerCase();
|
|
92
|
+
if (normalizedModel === "opus" ||
|
|
93
|
+
normalizedModel === "sonnet" ||
|
|
94
|
+
normalizedModel === "haiku") {
|
|
95
|
+
return getConfig().model;
|
|
96
|
+
}
|
|
97
|
+
if (normalizedModel.startsWith("claude-") &&
|
|
98
|
+
(normalizedModel.includes("-opus") ||
|
|
99
|
+
normalizedModel.includes("-sonnet") ||
|
|
100
|
+
normalizedModel.includes("-haiku"))) {
|
|
101
|
+
return getConfig().model;
|
|
102
|
+
}
|
|
103
|
+
return requestedModel;
|
|
104
|
+
}
|
|
105
|
+
function getHeaderValue(value) {
|
|
106
|
+
if (Array.isArray(value))
|
|
107
|
+
return value.join(",");
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
function buildUpstreamHeaders(req, stream, apiKey) {
|
|
111
|
+
const headers = {
|
|
112
|
+
"content-type": "application/json",
|
|
113
|
+
"x-api-key": apiKey,
|
|
114
|
+
"anthropic-version": getHeaderValue(req.headers["anthropic-version"]) ||
|
|
115
|
+
DEFAULT_ANTHROPIC_VERSION,
|
|
116
|
+
accept: getHeaderValue(req.headers.accept) ||
|
|
117
|
+
(stream ? "text/event-stream" : "application/json"),
|
|
118
|
+
};
|
|
119
|
+
const anthropicBeta = getHeaderValue(req.headers["anthropic-beta"]);
|
|
120
|
+
if (anthropicBeta) {
|
|
121
|
+
headers["anthropic-beta"] = anthropicBeta;
|
|
122
|
+
}
|
|
123
|
+
return headers;
|
|
124
|
+
}
|
|
125
|
+
function getUpstreamUrl(baseUrl) {
|
|
126
|
+
return `${baseUrl.replace(/\/$/, "")}/v1/messages`;
|
|
127
|
+
}
|
|
128
|
+
function buildUpstreamBody(body, targetModel) {
|
|
129
|
+
const normalized = typeof body === "object" && body !== null
|
|
130
|
+
? { ...body }
|
|
131
|
+
: {};
|
|
132
|
+
normalized.model = targetModel;
|
|
133
|
+
return normalized;
|
|
134
|
+
}
|
|
135
|
+
function copyUpstreamHeaders(upstream, res) {
|
|
136
|
+
for (const [key, value] of upstream.headers.entries()) {
|
|
137
|
+
if (HOP_BY_HOP_RESPONSE_HEADERS.has(key.toLowerCase()))
|
|
138
|
+
continue;
|
|
139
|
+
res.setHeader(key, value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function createProxyError(message) {
|
|
143
|
+
return {
|
|
144
|
+
type: "error",
|
|
145
|
+
error: {
|
|
146
|
+
type: "internal_error",
|
|
147
|
+
message,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function createRequestTrace(requestedModel, targetModel, stream) {
|
|
152
|
+
return {
|
|
153
|
+
requestId: `req-${++requestSequence}`,
|
|
154
|
+
provider: currentProvider,
|
|
155
|
+
requestedModel: String(requestedModel || getConfig().model),
|
|
156
|
+
targetModel,
|
|
157
|
+
stream,
|
|
158
|
+
startedAt: Date.now(),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function logTimingEvent(trace, phase, extra = {}) {
|
|
162
|
+
console.log(`[ProxyTiming] ${JSON.stringify({
|
|
163
|
+
request_id: trace.requestId,
|
|
164
|
+
provider: trace.provider,
|
|
165
|
+
requested_model: trace.requestedModel,
|
|
166
|
+
target_model: trace.targetModel,
|
|
167
|
+
stream: trace.stream,
|
|
168
|
+
phase,
|
|
169
|
+
elapsed_ms: Date.now() - trace.startedAt,
|
|
170
|
+
at: new Date().toISOString(),
|
|
171
|
+
...extra,
|
|
172
|
+
})}`);
|
|
173
|
+
}
|
|
174
|
+
async function handleNonStreamingRequest(req, res) {
|
|
175
|
+
const config = getConfig();
|
|
176
|
+
const targetModel = getTargetModel(req.body?.model);
|
|
177
|
+
const requestBody = buildUpstreamBody(req.body, targetModel);
|
|
178
|
+
const trace = createRequestTrace(req.body?.model, targetModel, false);
|
|
179
|
+
console.log(`\n[${new Date().toISOString()}] ${String(req.body?.model || config.model)} -> ${targetModel} (non-streaming)`);
|
|
180
|
+
logTimingEvent(trace, "start");
|
|
181
|
+
try {
|
|
182
|
+
const upstream = await fetch(getUpstreamUrl(config.baseUrl), {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: buildUpstreamHeaders(req, false, config.apiKey),
|
|
185
|
+
body: JSON.stringify(requestBody),
|
|
186
|
+
});
|
|
187
|
+
logTimingEvent(trace, "upstream_headers", {
|
|
188
|
+
status: upstream.status,
|
|
189
|
+
content_type: upstream.headers.get("content-type") || "",
|
|
190
|
+
});
|
|
191
|
+
const payload = Buffer.from(await upstream.arrayBuffer());
|
|
192
|
+
copyUpstreamHeaders(upstream, res);
|
|
193
|
+
res.status(upstream.status).send(payload);
|
|
194
|
+
logTimingEvent(trace, "completed", {
|
|
195
|
+
status: upstream.status,
|
|
196
|
+
bytes: payload.byteLength,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
console.error("Request error:", error);
|
|
201
|
+
logTimingEvent(trace, "error", { message: error?.message || String(error) });
|
|
202
|
+
res.status(500).json(createProxyError(error.message));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function handleStreamingRequest(req, res) {
|
|
206
|
+
const config = getConfig();
|
|
207
|
+
const targetModel = getTargetModel(req.body?.model);
|
|
208
|
+
const requestBody = buildUpstreamBody(req.body, targetModel);
|
|
209
|
+
const trace = createRequestTrace(req.body?.model, targetModel, true);
|
|
210
|
+
const abortController = new AbortController();
|
|
211
|
+
let clientClosed = false;
|
|
212
|
+
let streamCompleted = false;
|
|
213
|
+
let sawFirstChunk = false;
|
|
214
|
+
console.log(`\n[${new Date().toISOString()}] ${String(req.body?.model || config.model)} -> ${targetModel} (streaming)`);
|
|
215
|
+
logTimingEvent(trace, "start");
|
|
216
|
+
res.on("close", () => {
|
|
217
|
+
if (streamCompleted)
|
|
218
|
+
return;
|
|
219
|
+
clientClosed = true;
|
|
220
|
+
abortController.abort();
|
|
221
|
+
logTimingEvent(trace, "client_aborted");
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
const upstream = await fetch(getUpstreamUrl(config.baseUrl), {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: buildUpstreamHeaders(req, true, config.apiKey),
|
|
227
|
+
body: JSON.stringify(requestBody),
|
|
228
|
+
signal: abortController.signal,
|
|
229
|
+
});
|
|
230
|
+
logTimingEvent(trace, "upstream_headers", {
|
|
231
|
+
status: upstream.status,
|
|
232
|
+
content_type: upstream.headers.get("content-type") || "",
|
|
233
|
+
});
|
|
234
|
+
copyUpstreamHeaders(upstream, res);
|
|
235
|
+
res.status(upstream.status);
|
|
236
|
+
if (!upstream.body) {
|
|
237
|
+
streamCompleted = true;
|
|
238
|
+
res.end();
|
|
239
|
+
logTimingEvent(trace, "completed", {
|
|
240
|
+
status: upstream.status,
|
|
241
|
+
bytes: 0,
|
|
242
|
+
no_body: true,
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const upstreamStream = Readable.fromWeb(upstream.body);
|
|
247
|
+
upstreamStream.on("data", (chunk) => {
|
|
248
|
+
if (sawFirstChunk)
|
|
249
|
+
return;
|
|
250
|
+
sawFirstChunk = true;
|
|
251
|
+
const chunkSize = Buffer.isBuffer(chunk)
|
|
252
|
+
? chunk.byteLength
|
|
253
|
+
: Buffer.byteLength(String(chunk));
|
|
254
|
+
logTimingEvent(trace, "first_chunk", {
|
|
255
|
+
status: upstream.status,
|
|
256
|
+
chunk_bytes: chunkSize,
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
upstreamStream.on("error", (error) => {
|
|
260
|
+
if (clientClosed)
|
|
261
|
+
return;
|
|
262
|
+
console.error("Upstream stream error:", error);
|
|
263
|
+
logTimingEvent(trace, "error", {
|
|
264
|
+
status: upstream.status,
|
|
265
|
+
message: error?.message || String(error),
|
|
266
|
+
});
|
|
267
|
+
if (!res.writableEnded)
|
|
268
|
+
res.end();
|
|
269
|
+
});
|
|
270
|
+
upstreamStream.pipe(res);
|
|
271
|
+
await new Promise((resolve, reject) => {
|
|
272
|
+
upstreamStream.on("end", () => {
|
|
273
|
+
streamCompleted = true;
|
|
274
|
+
logTimingEvent(trace, "completed", {
|
|
275
|
+
status: upstream.status,
|
|
276
|
+
});
|
|
277
|
+
resolve();
|
|
278
|
+
});
|
|
279
|
+
upstreamStream.on("error", reject);
|
|
280
|
+
res.on("close", () => resolve());
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
const wasAborted = error?.name === "AbortError" || abortController.signal.aborted;
|
|
285
|
+
if (clientClosed || wasAborted) {
|
|
286
|
+
console.warn("[Proxy] Client disconnected, streaming aborted");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
console.error("Request error:", error);
|
|
290
|
+
logTimingEvent(trace, "error", { message: error?.message || String(error) });
|
|
291
|
+
if (!res.headersSent) {
|
|
292
|
+
res.status(500).json(createProxyError(error.message));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (!res.writableEnded)
|
|
296
|
+
res.end();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
export function createApp() {
|
|
300
|
+
const app = express();
|
|
301
|
+
app.use(cors());
|
|
302
|
+
app.use(express.json({ limit: "50mb" }));
|
|
303
|
+
app.get("/", (_req, res) => {
|
|
304
|
+
const config = getConfig();
|
|
305
|
+
res.json({
|
|
306
|
+
name: "claude-proxy",
|
|
307
|
+
status: "running",
|
|
308
|
+
provider: currentProvider,
|
|
309
|
+
model: config.model,
|
|
310
|
+
endpoints: {
|
|
311
|
+
messages: "POST /v1/messages",
|
|
312
|
+
health: "GET /health",
|
|
313
|
+
models: "GET /v1/models",
|
|
314
|
+
provider: "GET|POST /api/provider",
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
app.post("/v1/messages", async (req, res) => {
|
|
319
|
+
if (req.body?.stream) {
|
|
320
|
+
await handleStreamingRequest(req, res);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
await handleNonStreamingRequest(req, res);
|
|
324
|
+
});
|
|
325
|
+
app.get("/health", (_req, res) => {
|
|
326
|
+
const config = getConfig();
|
|
327
|
+
res.json({ status: "ok", provider: currentProvider, model: config.model });
|
|
328
|
+
});
|
|
329
|
+
app.get("/v1/models", (_req, res) => {
|
|
330
|
+
res.json({
|
|
331
|
+
data: [
|
|
332
|
+
{ id: "claude-opus-4-6", object: "model" },
|
|
333
|
+
{ id: "claude-sonnet-4-6", object: "model" },
|
|
334
|
+
{ id: "claude-haiku-4-5", object: "model" },
|
|
335
|
+
],
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
app.get("/api/provider", (_req, res) => {
|
|
339
|
+
const config = getConfig();
|
|
340
|
+
res.json({
|
|
341
|
+
provider: currentProvider,
|
|
342
|
+
model: config.model,
|
|
343
|
+
baseUrl: config.baseUrl,
|
|
344
|
+
availableProviders: Object.keys(PROVIDERS),
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
app.post("/api/provider", (req, res) => {
|
|
348
|
+
const { provider, model } = (req.body ?? {});
|
|
349
|
+
let targetProvider = provider;
|
|
350
|
+
if (!targetProvider && model) {
|
|
351
|
+
const normalizedModel = model.toLowerCase();
|
|
352
|
+
if (normalizedModel.includes("kimi")) {
|
|
353
|
+
targetProvider = "kimi";
|
|
354
|
+
}
|
|
355
|
+
else if (normalizedModel.includes("qwen"))
|
|
356
|
+
targetProvider = "qwen";
|
|
357
|
+
else if (normalizedModel.includes("deepseek"))
|
|
358
|
+
targetProvider = "deepseek";
|
|
359
|
+
else if (normalizedModel.includes("glm"))
|
|
360
|
+
targetProvider = "glm";
|
|
361
|
+
else if (normalizedModel.includes("minimax")) {
|
|
362
|
+
targetProvider = "minimax";
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (!isProviderKey(targetProvider)) {
|
|
366
|
+
res.status(400).json({
|
|
367
|
+
error: `Unknown provider: ${targetProvider}`,
|
|
368
|
+
available: Object.keys(PROVIDERS),
|
|
369
|
+
});
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const targetConfig = getConfig(targetProvider);
|
|
373
|
+
if (!targetConfig.apiKey) {
|
|
374
|
+
res.status(400).json({
|
|
375
|
+
error: `API key not set for: ${targetProvider}`,
|
|
376
|
+
});
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const oldProvider = currentProvider;
|
|
380
|
+
currentProvider = targetProvider;
|
|
381
|
+
console.log(`Provider: ${oldProvider} -> ${currentProvider}`);
|
|
382
|
+
res.json({
|
|
383
|
+
success: true,
|
|
384
|
+
provider: currentProvider,
|
|
385
|
+
model: targetConfig.model,
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
return app;
|
|
389
|
+
}
|
|
390
|
+
export const app = createApp();
|
|
391
|
+
const PORT = parseInt(process.env.PROXY_PORT || "8080", 10);
|
|
392
|
+
function isMainModule() {
|
|
393
|
+
const entryPath = process.argv[1];
|
|
394
|
+
if (!entryPath)
|
|
395
|
+
return false;
|
|
396
|
+
try {
|
|
397
|
+
return realpathSync(entryPath) === realpathSync(fileURLToPath(import.meta.url));
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (isMainModule()) {
|
|
404
|
+
app.listen(PORT, () => {
|
|
405
|
+
const cfg = getConfig();
|
|
406
|
+
console.log(`
|
|
407
|
+
╔════════════════════════════════════════════════╗
|
|
408
|
+
║ claude-proxy ║
|
|
409
|
+
╠════════════════════════════════════════════════╣
|
|
410
|
+
║ http://localhost:${PORT}
|
|
411
|
+
║ Backend: ${currentProvider} (${cfg.model})
|
|
412
|
+
╠════════════════════════════════════════════════╣
|
|
413
|
+
║ Set these env vars in your app: ║
|
|
414
|
+
║ ANTHROPIC_BASE_URL=http://localhost:${PORT}
|
|
415
|
+
║ ANTHROPIC_API_KEY=any-string-works ║
|
|
416
|
+
╚════════════════════════════════════════════════╝
|
|
417
|
+
`);
|
|
418
|
+
});
|
|
419
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sunflower0305/claude-proxy",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A proxy that lets Claude Agent SDK use domestic Chinese LLMs (DeepSeek, Qwen, GLM, MiniMax) as backend",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./dist/proxy.js",
|
|
8
|
+
"types": "./dist/proxy.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"claude-proxy": "./dist/proxy.js"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/proxy.d.ts",
|
|
15
|
+
"import": "./dist/proxy.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist/",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
".env.example"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/sunflower0305/claude-proxy.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/sunflower0305/claude-proxy#readme",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/sunflower0305/claude-proxy/issues"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"anthropic",
|
|
40
|
+
"claude",
|
|
41
|
+
"proxy",
|
|
42
|
+
"express",
|
|
43
|
+
"llm",
|
|
44
|
+
"qwen",
|
|
45
|
+
"deepseek",
|
|
46
|
+
"glm",
|
|
47
|
+
"minimax",
|
|
48
|
+
"kimi"
|
|
49
|
+
],
|
|
50
|
+
"scripts": {
|
|
51
|
+
"dev": "tsx watch src/proxy.ts",
|
|
52
|
+
"build": "tsc",
|
|
53
|
+
"start": "node dist/proxy.js",
|
|
54
|
+
"prepack": "npm run build",
|
|
55
|
+
"prepublishOnly": "npm run test:proxy-local",
|
|
56
|
+
"test": "vitest run",
|
|
57
|
+
"test:proxy-local": "vitest run tests/integration/proxy-local.test.ts",
|
|
58
|
+
"test:provider-anthropic": "node --experimental-strip-types tests/integration/provider-anthropic.ts",
|
|
59
|
+
"test:provider-cli-e2e": "node --experimental-strip-types tests/integration/provider-cli-e2e.ts",
|
|
60
|
+
"report:provider-cli-e2e": "node --experimental-strip-types tests/integration/report-provider-cli-e2e.ts"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"cors": "^2.8.5",
|
|
64
|
+
"dotenv": "^16.4.5",
|
|
65
|
+
"express": "^4.21.0"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@types/cors": "^2.8.17",
|
|
69
|
+
"@types/express": "^4.17.21",
|
|
70
|
+
"@types/node": "^22.0.0",
|
|
71
|
+
"tsx": "^4.19.0",
|
|
72
|
+
"typescript": "^5.5.0",
|
|
73
|
+
"vitest": "^3.2.4"
|
|
74
|
+
}
|
|
75
|
+
}
|