@sunflower0305/claude-proxy 1.0.0 → 1.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/.env.example CHANGED
@@ -14,9 +14,9 @@ KIMI_API_KEY=your-kimi-key
14
14
  # Optional model overrides
15
15
  QWEN_MODEL=qwen-plus
16
16
  DEEPSEEK_MODEL=deepseek-chat
17
- GLM_MODEL=glm-5
17
+ GLM_MODEL=glm-5.1
18
18
  MINIMAX_MODEL=MiniMax-M2.7-highspeed
19
- KIMI_MODEL=kimi-k2.5
19
+ KIMI_MODEL=kimi-k2.6
20
20
 
21
21
  # Optional upstream Anthropic-compatible base URL overrides
22
22
  # QWEN_ANTHROPIC_BASE_URL=https://dashscope.aliyuncs.com/apps/anthropic
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # claude-proxy
2
2
 
3
+ [![CI](https://github.com/sunflower0305/claude-proxy/actions/workflows/ci.yml/badge.svg)](https://github.com/sunflower0305/claude-proxy/actions/workflows/ci.yml)
4
+ [![Coverage Status](https://coveralls.io/repos/github/sunflower0305/claude-proxy/badge.svg?branch=master)](https://coveralls.io/github/sunflower0305/claude-proxy?branch=master)
5
+ [![npm version](https://img.shields.io/npm/v/%40sunflower0305%2Fclaude-proxy)](https://www.npmjs.com/package/@sunflower0305/claude-proxy)
6
+ [![License](https://img.shields.io/github/license/sunflower0305/claude-proxy)](https://github.com/sunflower0305/claude-proxy/blob/master/LICENSE)
7
+
3
8
  `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
9
 
5
10
  It currently supports `qwen`, `deepseek`, `glm`, `minimax`, and `kimi`.
@@ -114,6 +119,24 @@ const app = createApp();
114
119
  app.listen(8080);
115
120
  ```
116
121
 
122
+ ## Release Verification
123
+
124
+ `v1.0.0` was verified on April 15, 2026 after publishing `@sunflower0305/claude-proxy` to npm.
125
+
126
+ Verified items:
127
+
128
+ - `npm install @sunflower0305/claude-proxy` completed successfully in a clean temporary directory
129
+ - the published `claude-proxy` CLI started correctly from the installed package
130
+ - `GET /health` and `GET /v1/models` returned `200 OK`
131
+ - end-to-end proxying against a local mock Anthropic-compatible upstream passed for both non-streaming and streaming `POST /v1/messages`
132
+ - end-to-end proxying against the real Qwen Anthropic-compatible upstream passed for both non-streaming and streaming `POST /v1/messages`
133
+
134
+ Observed behavior during verification:
135
+
136
+ - model remapping worked as expected, including `claude-sonnet-4-6 -> qwen-plus`
137
+ - the real Qwen verification returned a valid assistant response for both buffered JSON and SSE streaming modes
138
+ - the published package included the expected CLI entrypoint, `dist/` build output, `README.md`, `LICENSE`, and `.env.example`
139
+
117
140
  ## Development
118
141
 
119
142
  From source:
@@ -123,6 +146,18 @@ npm install
123
146
  npm run dev
124
147
  ```
125
148
 
149
+ ## CI And Releases
150
+
151
+ GitHub Actions currently provides a CI baseline only:
152
+
153
+ - install dependencies with `pnpm`
154
+ - run `npm run build`
155
+ - run `npm run test:proxy-local`
156
+ - run `npm run test:coverage`
157
+ - upload `coverage/lcov.info` to Coveralls without blocking the workflow if the upload service is temporarily unavailable
158
+
159
+ Publishing to npm remains a manual step. The package still relies on `prepack` and `prepublishOnly` in `package.json` to build and verify the artifact before release.
160
+
126
161
  Build and local package verification:
127
162
 
128
163
  ```bash
@@ -136,6 +171,8 @@ Local integration test:
136
171
  npm run test:proxy-local
137
172
  ```
138
173
 
174
+ Release notes for `v1.0.0` are available in [docs/releases/1.0.0.md](/Users/joe/ai/claude-proxy/docs/releases/1.0.0.md).
175
+
139
176
  ## License
140
177
 
141
178
  MIT
package/dist/app.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import express from "express";
2
+ import { type ProxyRuntime } from "./runtime";
3
+ export declare function createApp(runtime?: ProxyRuntime): express.Express;
package/dist/app.js ADDED
@@ -0,0 +1,79 @@
1
+ import cors from "cors";
2
+ import express from "express";
3
+ import { handleMessagesRequest } from "./messages";
4
+ import { isProviderKey } from "./providers";
5
+ import { createRuntime } from "./runtime";
6
+ const SUPPORTED_MODELS = [
7
+ { id: "claude-opus-4-6", object: "model" },
8
+ { id: "claude-sonnet-4-6", object: "model" },
9
+ { id: "claude-haiku-4-5", object: "model" },
10
+ ];
11
+ export function createApp(runtime = createRuntime()) {
12
+ const app = express();
13
+ app.use(cors());
14
+ app.use(express.json({ limit: "50mb" }));
15
+ app.get("/", (_req, res) => {
16
+ const config = runtime.getCurrentConfig();
17
+ res.json({
18
+ name: "claude-proxy",
19
+ status: "running",
20
+ provider: runtime.getCurrentProvider(),
21
+ model: config.model,
22
+ endpoints: {
23
+ messages: "POST /v1/messages",
24
+ health: "GET /health",
25
+ models: "GET /v1/models",
26
+ provider: "GET|POST /api/provider",
27
+ },
28
+ });
29
+ });
30
+ app.post("/v1/messages", async (req, res) => {
31
+ await handleMessagesRequest(req, res, runtime);
32
+ });
33
+ app.get("/health", (_req, res) => {
34
+ const config = runtime.getCurrentConfig();
35
+ res.json({
36
+ status: "ok",
37
+ provider: runtime.getCurrentProvider(),
38
+ model: config.model,
39
+ });
40
+ });
41
+ app.get("/v1/models", (_req, res) => {
42
+ res.json({ data: SUPPORTED_MODELS });
43
+ });
44
+ app.get("/api/provider", (_req, res) => {
45
+ const config = runtime.getCurrentConfig();
46
+ res.json({
47
+ provider: runtime.getCurrentProvider(),
48
+ model: config.model,
49
+ baseUrl: config.baseUrl,
50
+ availableProviders: [...runtime.providerKeys],
51
+ });
52
+ });
53
+ app.post("/api/provider", (req, res) => {
54
+ const { provider, model } = (req.body ?? {});
55
+ const targetProvider = provider ?? runtime.inferProviderFromModel(model);
56
+ if (!isProviderKey(targetProvider)) {
57
+ res.status(400).json({
58
+ error: `Unknown provider: ${targetProvider}`,
59
+ available: [...runtime.providerKeys],
60
+ });
61
+ return;
62
+ }
63
+ const targetConfig = runtime.getConfig(targetProvider);
64
+ if (!targetConfig.apiKey) {
65
+ res.status(400).json({
66
+ error: `API key not set for: ${targetProvider}`,
67
+ });
68
+ return;
69
+ }
70
+ const switchResult = runtime.setCurrentProvider(targetProvider);
71
+ console.log(`Provider: ${switchResult.previousProvider} -> ${switchResult.provider}`);
72
+ res.json({
73
+ success: true,
74
+ provider: switchResult.provider,
75
+ model: switchResult.config.model,
76
+ });
77
+ });
78
+ return app;
79
+ }
@@ -0,0 +1,3 @@
1
+ import type express from "express";
2
+ import type { ProxyRuntime } from "./runtime";
3
+ export declare function handleMessagesRequest(req: express.Request, res: express.Response, runtime: ProxyRuntime): Promise<void>;
@@ -0,0 +1,221 @@
1
+ import { Readable } from "node:stream";
2
+ const DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
3
+ const HOP_BY_HOP_RESPONSE_HEADERS = new Set([
4
+ "connection",
5
+ "content-encoding",
6
+ "content-length",
7
+ "keep-alive",
8
+ "proxy-authenticate",
9
+ "proxy-authorization",
10
+ "te",
11
+ "trailer",
12
+ "transfer-encoding",
13
+ "upgrade",
14
+ ]);
15
+ function getHeaderValue(value) {
16
+ if (Array.isArray(value))
17
+ return value.join(",");
18
+ return value;
19
+ }
20
+ function buildUpstreamHeaders(req, stream, apiKey) {
21
+ const headers = {
22
+ "content-type": "application/json",
23
+ "x-api-key": apiKey,
24
+ "anthropic-version": getHeaderValue(req.headers["anthropic-version"]) ||
25
+ DEFAULT_ANTHROPIC_VERSION,
26
+ accept: getHeaderValue(req.headers.accept) ||
27
+ (stream ? "text/event-stream" : "application/json"),
28
+ };
29
+ const anthropicBeta = getHeaderValue(req.headers["anthropic-beta"]);
30
+ if (anthropicBeta) {
31
+ headers["anthropic-beta"] = anthropicBeta;
32
+ }
33
+ return headers;
34
+ }
35
+ function getUpstreamUrl(baseUrl) {
36
+ return `${baseUrl.replace(/\/$/, "")}/v1/messages`;
37
+ }
38
+ function buildUpstreamBody(body, targetModel) {
39
+ const normalized = typeof body === "object" && body !== null
40
+ ? { ...body }
41
+ : {};
42
+ normalized.model = targetModel;
43
+ return normalized;
44
+ }
45
+ function copyUpstreamHeaders(upstream, res) {
46
+ for (const [key, value] of upstream.headers.entries()) {
47
+ if (HOP_BY_HOP_RESPONSE_HEADERS.has(key.toLowerCase()))
48
+ continue;
49
+ res.setHeader(key, value);
50
+ }
51
+ }
52
+ function createProxyError(message) {
53
+ return {
54
+ type: "error",
55
+ error: {
56
+ type: "internal_error",
57
+ message,
58
+ },
59
+ };
60
+ }
61
+ function toErrorMessage(error) {
62
+ if (error instanceof Error && error.message)
63
+ return error.message;
64
+ return String(error);
65
+ }
66
+ function logTimingEvent(trace, phase, extra = {}) {
67
+ console.log(`[ProxyTiming] ${JSON.stringify({
68
+ request_id: trace.requestId,
69
+ provider: trace.provider,
70
+ requested_model: trace.requestedModel,
71
+ target_model: trace.targetModel,
72
+ stream: trace.stream,
73
+ phase,
74
+ elapsed_ms: Date.now() - trace.startedAt,
75
+ at: new Date().toISOString(),
76
+ ...extra,
77
+ })}`);
78
+ }
79
+ function prepareUpstreamRequest(req, runtime, stream) {
80
+ const config = runtime.getCurrentConfig();
81
+ const targetModel = runtime.getTargetModel(req.body?.model);
82
+ return {
83
+ apiKey: config.apiKey,
84
+ providerModel: config.model,
85
+ requestBody: buildUpstreamBody(req.body, targetModel),
86
+ requestedModel: req.body?.model,
87
+ targetModel,
88
+ trace: runtime.createRequestTrace(req.body?.model, targetModel, stream),
89
+ upstreamUrl: getUpstreamUrl(config.baseUrl),
90
+ };
91
+ }
92
+ async function handleNonStreamingRequest(req, res, runtime) {
93
+ const prepared = prepareUpstreamRequest(req, runtime, false);
94
+ console.log(`\n[${new Date().toISOString()}] ${String(prepared.requestedModel || prepared.providerModel)} -> ${prepared.targetModel} (non-streaming)`);
95
+ logTimingEvent(prepared.trace, "start");
96
+ try {
97
+ const upstream = await fetch(prepared.upstreamUrl, {
98
+ method: "POST",
99
+ headers: buildUpstreamHeaders(req, false, prepared.apiKey),
100
+ body: JSON.stringify(prepared.requestBody),
101
+ });
102
+ logTimingEvent(prepared.trace, "upstream_headers", {
103
+ status: upstream.status,
104
+ content_type: upstream.headers.get("content-type") || "",
105
+ });
106
+ const payload = Buffer.from(await upstream.arrayBuffer());
107
+ copyUpstreamHeaders(upstream, res);
108
+ res.status(upstream.status).send(payload);
109
+ logTimingEvent(prepared.trace, "completed", {
110
+ status: upstream.status,
111
+ bytes: payload.byteLength,
112
+ });
113
+ }
114
+ catch (error) {
115
+ const message = toErrorMessage(error);
116
+ console.error("Request error:", error);
117
+ logTimingEvent(prepared.trace, "error", { message });
118
+ res.status(500).json(createProxyError(message));
119
+ }
120
+ }
121
+ async function handleStreamingRequest(req, res, runtime) {
122
+ const prepared = prepareUpstreamRequest(req, runtime, true);
123
+ const abortController = new AbortController();
124
+ let clientClosed = false;
125
+ let streamCompleted = false;
126
+ let sawFirstChunk = false;
127
+ console.log(`\n[${new Date().toISOString()}] ${String(prepared.requestedModel || prepared.providerModel)} -> ${prepared.targetModel} (streaming)`);
128
+ logTimingEvent(prepared.trace, "start");
129
+ res.on("close", () => {
130
+ if (streamCompleted)
131
+ return;
132
+ clientClosed = true;
133
+ abortController.abort();
134
+ logTimingEvent(prepared.trace, "client_aborted");
135
+ });
136
+ try {
137
+ const upstream = await fetch(prepared.upstreamUrl, {
138
+ method: "POST",
139
+ headers: buildUpstreamHeaders(req, true, prepared.apiKey),
140
+ body: JSON.stringify(prepared.requestBody),
141
+ signal: abortController.signal,
142
+ });
143
+ logTimingEvent(prepared.trace, "upstream_headers", {
144
+ status: upstream.status,
145
+ content_type: upstream.headers.get("content-type") || "",
146
+ });
147
+ copyUpstreamHeaders(upstream, res);
148
+ res.status(upstream.status);
149
+ if (!upstream.body) {
150
+ streamCompleted = true;
151
+ res.end();
152
+ logTimingEvent(prepared.trace, "completed", {
153
+ status: upstream.status,
154
+ bytes: 0,
155
+ no_body: true,
156
+ });
157
+ return;
158
+ }
159
+ const upstreamStream = Readable.fromWeb(upstream.body);
160
+ upstreamStream.on("data", (chunk) => {
161
+ if (sawFirstChunk)
162
+ return;
163
+ sawFirstChunk = true;
164
+ const chunkSize = Buffer.isBuffer(chunk)
165
+ ? chunk.byteLength
166
+ : Buffer.byteLength(String(chunk));
167
+ logTimingEvent(prepared.trace, "first_chunk", {
168
+ status: upstream.status,
169
+ chunk_bytes: chunkSize,
170
+ });
171
+ });
172
+ upstreamStream.on("error", (error) => {
173
+ if (clientClosed)
174
+ return;
175
+ console.error("Upstream stream error:", error);
176
+ logTimingEvent(prepared.trace, "error", {
177
+ status: upstream.status,
178
+ message: toErrorMessage(error),
179
+ });
180
+ if (!res.writableEnded)
181
+ res.end();
182
+ });
183
+ upstreamStream.pipe(res);
184
+ await new Promise((resolve, reject) => {
185
+ upstreamStream.on("end", () => {
186
+ streamCompleted = true;
187
+ logTimingEvent(prepared.trace, "completed", {
188
+ status: upstream.status,
189
+ });
190
+ resolve();
191
+ });
192
+ upstreamStream.on("error", reject);
193
+ res.on("close", () => resolve());
194
+ });
195
+ }
196
+ catch (error) {
197
+ const wasAborted = error instanceof Error && error.name === "AbortError"
198
+ ? true
199
+ : abortController.signal.aborted;
200
+ if (clientClosed || wasAborted) {
201
+ console.warn("[Proxy] Client disconnected, streaming aborted");
202
+ return;
203
+ }
204
+ const message = toErrorMessage(error);
205
+ console.error("Request error:", error);
206
+ logTimingEvent(prepared.trace, "error", { message });
207
+ if (!res.headersSent) {
208
+ res.status(500).json(createProxyError(message));
209
+ return;
210
+ }
211
+ if (!res.writableEnded)
212
+ res.end();
213
+ }
214
+ }
215
+ export async function handleMessagesRequest(req, res, runtime) {
216
+ if (req.body?.stream) {
217
+ await handleStreamingRequest(req, res, runtime);
218
+ return;
219
+ }
220
+ await handleNonStreamingRequest(req, res, runtime);
221
+ }
@@ -0,0 +1,11 @@
1
+ export interface ProviderConfig {
2
+ baseUrl: string;
3
+ apiKey: string;
4
+ model: string;
5
+ }
6
+ export declare const PROVIDER_KEYS: readonly ["deepseek", "qwen", "glm", "minimax", "kimi"];
7
+ export type ProviderKey = (typeof PROVIDER_KEYS)[number];
8
+ export type ProviderMap = Record<ProviderKey, ProviderConfig>;
9
+ export declare const DEFAULT_PROVIDER: ProviderKey;
10
+ export declare function isProviderKey(value: string | undefined): value is ProviderKey;
11
+ export declare function loadProviders(env?: NodeJS.ProcessEnv): ProviderMap;
@@ -0,0 +1,53 @@
1
+ export const PROVIDER_KEYS = [
2
+ "deepseek",
3
+ "qwen",
4
+ "glm",
5
+ "minimax",
6
+ "kimi",
7
+ ];
8
+ export const DEFAULT_PROVIDER = "qwen";
9
+ function pickEnv(env, ...keys) {
10
+ for (const key of keys) {
11
+ const value = env[key]?.trim();
12
+ if (value)
13
+ return value;
14
+ }
15
+ return undefined;
16
+ }
17
+ export function isProviderKey(value) {
18
+ return Boolean(value && PROVIDER_KEYS.includes(value));
19
+ }
20
+ export function loadProviders(env = process.env) {
21
+ return {
22
+ deepseek: {
23
+ baseUrl: pickEnv(env, "DEEPSEEK_ANTHROPIC_BASE_URL") ||
24
+ "https://api.deepseek.com/anthropic",
25
+ apiKey: env.DEEPSEEK_API_KEY || "",
26
+ model: pickEnv(env, "DEEPSEEK_MODEL") || "deepseek-chat",
27
+ },
28
+ qwen: {
29
+ baseUrl: pickEnv(env, "QWEN_ANTHROPIC_BASE_URL") ||
30
+ "https://dashscope.aliyuncs.com/apps/anthropic",
31
+ apiKey: env.QWEN_API_KEY || "",
32
+ model: pickEnv(env, "QWEN_MODEL") || "qwen-plus",
33
+ },
34
+ glm: {
35
+ baseUrl: pickEnv(env, "GLM_ANTHROPIC_BASE_URL") ||
36
+ "https://open.bigmodel.cn/api/anthropic",
37
+ apiKey: env.GLM_API_KEY || "",
38
+ model: pickEnv(env, "GLM_MODEL") || "glm-5",
39
+ },
40
+ minimax: {
41
+ baseUrl: pickEnv(env, "MINIMAX_ANTHROPIC_BASE_URL") ||
42
+ "https://api.minimaxi.com/anthropic",
43
+ apiKey: env.MINIMAX_API_KEY || "",
44
+ model: pickEnv(env, "MINIMAX_MODEL") || "MiniMax-M2.7-highspeed",
45
+ },
46
+ kimi: {
47
+ baseUrl: pickEnv(env, "KIMI_ANTHROPIC_BASE_URL") ||
48
+ "https://api.moonshot.cn/anthropic",
49
+ apiKey: env.KIMI_API_KEY || "",
50
+ model: pickEnv(env, "KIMI_MODEL") || "kimi-k2.5",
51
+ },
52
+ };
53
+ }
package/dist/proxy.js CHANGED
@@ -53,7 +53,7 @@ const PROVIDERS = {
53
53
  baseUrl: pickEnv("GLM_ANTHROPIC_BASE_URL") ||
54
54
  "https://open.bigmodel.cn/api/anthropic",
55
55
  apiKey: process.env.GLM_API_KEY || "",
56
- model: pickEnv("GLM_MODEL") || "glm-5",
56
+ model: pickEnv("GLM_MODEL") || "glm-5.1",
57
57
  },
58
58
  minimax: {
59
59
  baseUrl: pickEnv("MINIMAX_ANTHROPIC_BASE_URL") ||
@@ -64,43 +64,17 @@ const PROVIDERS = {
64
64
  kimi: {
65
65
  baseUrl: pickEnv("KIMI_ANTHROPIC_BASE_URL") || "https://api.moonshot.cn/anthropic",
66
66
  apiKey: process.env.KIMI_API_KEY || "",
67
- model: pickEnv("KIMI_MODEL") || "kimi-k2.5",
67
+ model: pickEnv("KIMI_MODEL") || "kimi-k2.6",
68
68
  },
69
69
  };
70
70
  function isProviderKey(value) {
71
71
  return Boolean(value && value in PROVIDERS);
72
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");
73
+ function getInitialProvider() {
74
+ return isProviderKey(process.env.PROVIDER) ? process.env.PROVIDER : "qwen";
84
75
  }
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;
76
+ function getProviderConfig(provider) {
77
+ return PROVIDERS[provider] || PROVIDERS.qwen;
104
78
  }
105
79
  function getHeaderValue(value) {
106
80
  if (Array.isArray(value))
@@ -148,15 +122,15 @@ function createProxyError(message) {
148
122
  },
149
123
  };
150
124
  }
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
- };
125
+ function inferProviderFromModel(model) {
126
+ if (!model)
127
+ return undefined;
128
+ const normalizedModel = model.toLowerCase();
129
+ for (const key of Object.keys(PROVIDERS)) {
130
+ if (normalizedModel.includes(key))
131
+ return key;
132
+ }
133
+ return undefined;
160
134
  }
161
135
  function logTimingEvent(trace, phase, extra = {}) {
162
136
  console.log(`[ProxyTiming] ${JSON.stringify({
@@ -171,132 +145,167 @@ function logTimingEvent(trace, phase, extra = {}) {
171
145
  ...extra,
172
146
  })}`);
173
147
  }
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
- });
148
+ export function createApp() {
149
+ let currentProvider = getInitialProvider();
150
+ let requestSequence = 0;
151
+ function getConfig(provider = currentProvider) {
152
+ return getProviderConfig(provider);
198
153
  }
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));
154
+ function getTargetModel(requestedModel) {
155
+ if (typeof requestedModel !== "string" || !requestedModel) {
156
+ return getConfig().model;
157
+ }
158
+ const normalizedModel = requestedModel.toLowerCase();
159
+ if (normalizedModel === "opus" ||
160
+ normalizedModel === "sonnet" ||
161
+ normalizedModel === "haiku") {
162
+ return getConfig().model;
163
+ }
164
+ if (normalizedModel.startsWith("claude-") &&
165
+ (normalizedModel.includes("-opus") ||
166
+ normalizedModel.includes("-sonnet") ||
167
+ normalizedModel.includes("-haiku"))) {
168
+ return getConfig().model;
169
+ }
170
+ return requestedModel;
203
171
  }
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", {
172
+ function createRequestTrace(requestedModel, targetModel, stream) {
173
+ return {
174
+ requestId: `req-${++requestSequence}`,
175
+ provider: currentProvider,
176
+ requestedModel: String(requestedModel || getConfig().model),
177
+ targetModel,
178
+ stream,
179
+ startedAt: Date.now(),
180
+ };
181
+ }
182
+ async function handleNonStreamingRequest(req, res) {
183
+ const config = getConfig();
184
+ const targetModel = getTargetModel(req.body?.model);
185
+ const requestBody = buildUpstreamBody(req.body, targetModel);
186
+ const trace = createRequestTrace(req.body?.model, targetModel, false);
187
+ logTimingEvent(trace, "start");
188
+ try {
189
+ const upstream = await fetch(getUpstreamUrl(config.baseUrl), {
190
+ method: "POST",
191
+ headers: buildUpstreamHeaders(req, false, config.apiKey),
192
+ body: JSON.stringify(requestBody),
193
+ });
194
+ logTimingEvent(trace, "upstream_headers", {
240
195
  status: upstream.status,
241
- bytes: 0,
242
- no_body: true,
196
+ content_type: upstream.headers.get("content-type") || "",
243
197
  });
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", {
198
+ const payload = Buffer.from(await upstream.arrayBuffer());
199
+ copyUpstreamHeaders(upstream, res);
200
+ res.status(upstream.status).send(payload);
201
+ logTimingEvent(trace, "completed", {
255
202
  status: upstream.status,
256
- chunk_bytes: chunkSize,
203
+ bytes: payload.byteLength,
257
204
  });
258
- });
259
- upstreamStream.on("error", (error) => {
260
- if (clientClosed)
261
- return;
262
- console.error("Upstream stream error:", error);
205
+ }
206
+ catch (error) {
207
+ console.error("Request error:", error);
263
208
  logTimingEvent(trace, "error", {
264
- status: upstream.status,
265
209
  message: error?.message || String(error),
266
210
  });
267
- if (!res.writableEnded)
268
- res.end();
211
+ res.status(500).json(createProxyError(error.message));
212
+ }
213
+ }
214
+ async function handleStreamingRequest(req, res) {
215
+ const config = getConfig();
216
+ const targetModel = getTargetModel(req.body?.model);
217
+ const requestBody = buildUpstreamBody(req.body, targetModel);
218
+ const trace = createRequestTrace(req.body?.model, targetModel, true);
219
+ const abortController = new AbortController();
220
+ let clientClosed = false;
221
+ let streamCompleted = false;
222
+ let sawFirstChunk = false;
223
+ logTimingEvent(trace, "start");
224
+ res.on("close", () => {
225
+ if (streamCompleted)
226
+ return;
227
+ clientClosed = true;
228
+ abortController.abort();
229
+ logTimingEvent(trace, "client_aborted");
269
230
  });
270
- upstreamStream.pipe(res);
271
- await new Promise((resolve, reject) => {
272
- upstreamStream.on("end", () => {
231
+ try {
232
+ const upstream = await fetch(getUpstreamUrl(config.baseUrl), {
233
+ method: "POST",
234
+ headers: buildUpstreamHeaders(req, true, config.apiKey),
235
+ body: JSON.stringify(requestBody),
236
+ signal: abortController.signal,
237
+ });
238
+ logTimingEvent(trace, "upstream_headers", {
239
+ status: upstream.status,
240
+ content_type: upstream.headers.get("content-type") || "",
241
+ });
242
+ copyUpstreamHeaders(upstream, res);
243
+ res.status(upstream.status);
244
+ if (!upstream.body) {
273
245
  streamCompleted = true;
246
+ res.end();
274
247
  logTimingEvent(trace, "completed", {
275
248
  status: upstream.status,
249
+ bytes: 0,
250
+ no_body: true,
251
+ });
252
+ return;
253
+ }
254
+ const upstreamStream = Readable.fromWeb(upstream.body);
255
+ upstreamStream.on("data", (chunk) => {
256
+ if (sawFirstChunk)
257
+ return;
258
+ sawFirstChunk = true;
259
+ const chunkSize = Buffer.isBuffer(chunk)
260
+ ? chunk.byteLength
261
+ : Buffer.byteLength(String(chunk));
262
+ logTimingEvent(trace, "first_chunk", {
263
+ status: upstream.status,
264
+ chunk_bytes: chunkSize,
276
265
  });
277
- resolve();
278
266
  });
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;
267
+ upstreamStream.on("error", (error) => {
268
+ if (clientClosed)
269
+ return;
270
+ console.error("Upstream stream error:", error);
271
+ logTimingEvent(trace, "error", {
272
+ status: upstream.status,
273
+ message: error?.message || String(error),
274
+ });
275
+ if (!res.writableEnded)
276
+ res.end();
277
+ });
278
+ upstreamStream.pipe(res);
279
+ await new Promise((resolve, reject) => {
280
+ upstreamStream.on("end", () => {
281
+ streamCompleted = true;
282
+ logTimingEvent(trace, "completed", {
283
+ status: upstream.status,
284
+ });
285
+ resolve();
286
+ });
287
+ upstreamStream.on("error", reject);
288
+ res.on("close", () => resolve());
289
+ });
288
290
  }
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;
291
+ catch (error) {
292
+ const wasAborted = error?.name === "AbortError" || abortController.signal.aborted;
293
+ if (clientClosed || wasAborted) {
294
+ console.warn("[Proxy] Client disconnected, streaming aborted");
295
+ return;
296
+ }
297
+ console.error("Request error:", error);
298
+ logTimingEvent(trace, "error", {
299
+ message: error?.message || String(error),
300
+ });
301
+ if (!res.headersSent) {
302
+ res.status(500).json(createProxyError(error.message));
303
+ return;
304
+ }
305
+ if (!res.writableEnded)
306
+ res.end();
294
307
  }
295
- if (!res.writableEnded)
296
- res.end();
297
308
  }
298
- }
299
- export function createApp() {
300
309
  const app = express();
301
310
  app.use(cors());
302
311
  app.use(express.json({ limit: "50mb" }));
@@ -346,22 +355,7 @@ export function createApp() {
346
355
  });
347
356
  app.post("/api/provider", (req, res) => {
348
357
  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
- }
358
+ const targetProvider = provider ?? inferProviderFromModel(model);
365
359
  if (!isProviderKey(targetProvider)) {
366
360
  res.status(400).json({
367
361
  error: `Unknown provider: ${targetProvider}`,
@@ -394,26 +388,33 @@ function isMainModule() {
394
388
  if (!entryPath)
395
389
  return false;
396
390
  try {
397
- return realpathSync(entryPath) === realpathSync(fileURLToPath(import.meta.url));
391
+ return (realpathSync(entryPath) === realpathSync(fileURLToPath(import.meta.url)));
398
392
  }
399
393
  catch {
400
394
  return false;
401
395
  }
402
396
  }
403
397
  if (isMainModule()) {
398
+ const initialProvider = getInitialProvider();
399
+ const initialConfig = getProviderConfig(initialProvider);
400
+ if (!initialConfig.apiKey) {
401
+ console.warn(`Warning: API key not configured for provider: ${initialProvider}`);
402
+ console.warn("Please set the appropriate environment variable in .env");
403
+ }
404
+ console.log(`Using ${initialProvider} as backend`);
405
+ console.log(`Model: ${initialConfig.model}`);
404
406
  app.listen(PORT, () => {
405
- const cfg = getConfig();
406
407
  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
- ╚════════════════════════════════════════════════╝
408
+ ╔═══════════════════════════════════════════════════════╗
409
+ claude-proxy
410
+ ╠═══════════════════════════════════════════════════════╣
411
+ ║ http://localhost:${PORT}
412
+ ║ Backend: ${initialProvider} (${initialConfig.model})
413
+ ╠═══════════════════════════════════════════════════════╣
414
+ ║ Set these env vars in your app:
415
+ ║ ANTHROPIC_BASE_URL=http://localhost:${PORT}
416
+ ║ ANTHROPIC_API_KEY=any-string-works
417
+ ╚═══════════════════════════════════════════════════════╝
417
418
  `);
418
419
  });
419
420
  }
@@ -0,0 +1,27 @@
1
+ import { type ProviderConfig, type ProviderKey, type ProviderMap } from "./providers";
2
+ export interface RequestTrace {
3
+ requestId: string;
4
+ provider: ProviderKey;
5
+ requestedModel: string;
6
+ targetModel: string;
7
+ stream: boolean;
8
+ startedAt: number;
9
+ }
10
+ export interface ProxyRuntime {
11
+ readonly providerKeys: readonly ProviderKey[];
12
+ getCurrentProvider(): ProviderKey;
13
+ getCurrentConfig(): ProviderConfig;
14
+ getConfig(provider?: ProviderKey): ProviderConfig;
15
+ getTargetModel(requestedModel: unknown): string;
16
+ inferProviderFromModel(model: string | undefined): ProviderKey | undefined;
17
+ setCurrentProvider(provider: ProviderKey): {
18
+ previousProvider: ProviderKey;
19
+ provider: ProviderKey;
20
+ config: ProviderConfig;
21
+ };
22
+ createRequestTrace(requestedModel: unknown, targetModel: string, stream: boolean): RequestTrace;
23
+ getStartupWarning(): string | undefined;
24
+ }
25
+ export declare function resolveTargetModel(requestedModel: unknown, currentModel: string): string;
26
+ export declare function inferProviderFromModel(model: string | undefined): ProviderKey | undefined;
27
+ export declare function createRuntime(env?: NodeJS.ProcessEnv, providers?: ProviderMap): ProxyRuntime;
@@ -0,0 +1,83 @@
1
+ import { DEFAULT_PROVIDER, PROVIDER_KEYS, isProviderKey, loadProviders, } from "./providers";
2
+ export function resolveTargetModel(requestedModel, currentModel) {
3
+ if (typeof requestedModel !== "string" || !requestedModel) {
4
+ return currentModel;
5
+ }
6
+ const normalizedModel = requestedModel.toLowerCase();
7
+ if (normalizedModel === "opus" ||
8
+ normalizedModel === "sonnet" ||
9
+ normalizedModel === "haiku") {
10
+ return currentModel;
11
+ }
12
+ if (normalizedModel.startsWith("claude-") &&
13
+ (normalizedModel.includes("-opus") ||
14
+ normalizedModel.includes("-sonnet") ||
15
+ normalizedModel.includes("-haiku"))) {
16
+ return currentModel;
17
+ }
18
+ return requestedModel;
19
+ }
20
+ export function inferProviderFromModel(model) {
21
+ const normalizedModel = model?.toLowerCase();
22
+ if (!normalizedModel)
23
+ return undefined;
24
+ if (normalizedModel.includes("kimi"))
25
+ return "kimi";
26
+ if (normalizedModel.includes("qwen"))
27
+ return "qwen";
28
+ if (normalizedModel.includes("deepseek"))
29
+ return "deepseek";
30
+ if (normalizedModel.includes("glm"))
31
+ return "glm";
32
+ if (normalizedModel.includes("minimax"))
33
+ return "minimax";
34
+ return undefined;
35
+ }
36
+ export function createRuntime(env = process.env, providers = loadProviders(env)) {
37
+ let currentProvider = isProviderKey(env.PROVIDER)
38
+ ? env.PROVIDER
39
+ : DEFAULT_PROVIDER;
40
+ let requestSequence = 0;
41
+ function getConfig(provider = currentProvider) {
42
+ return providers[provider] || providers[DEFAULT_PROVIDER];
43
+ }
44
+ return {
45
+ providerKeys: PROVIDER_KEYS,
46
+ getCurrentProvider() {
47
+ return currentProvider;
48
+ },
49
+ getCurrentConfig() {
50
+ return getConfig();
51
+ },
52
+ getConfig,
53
+ getTargetModel(requestedModel) {
54
+ return resolveTargetModel(requestedModel, getConfig().model);
55
+ },
56
+ inferProviderFromModel,
57
+ setCurrentProvider(provider) {
58
+ const previousProvider = currentProvider;
59
+ currentProvider = provider;
60
+ return {
61
+ previousProvider,
62
+ provider: currentProvider,
63
+ config: getConfig(),
64
+ };
65
+ },
66
+ createRequestTrace(requestedModel, targetModel, stream) {
67
+ const config = getConfig();
68
+ return {
69
+ requestId: `req-${++requestSequence}`,
70
+ provider: currentProvider,
71
+ requestedModel: String(requestedModel || config.model),
72
+ targetModel,
73
+ stream,
74
+ startedAt: Date.now(),
75
+ };
76
+ },
77
+ getStartupWarning() {
78
+ if (getConfig().apiKey)
79
+ return undefined;
80
+ return `Warning: API key not configured for provider: ${currentProvider}`;
81
+ },
82
+ };
83
+ }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@sunflower0305/claude-proxy",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "A proxy that lets Claude Agent SDK use domestic Chinese LLMs (DeepSeek, Qwen, GLM, MiniMax) as backend",
6
6
  "license": "MIT",
7
7
  "main": "./dist/proxy.js",
8
8
  "types": "./dist/proxy.d.ts",
9
9
  "bin": {
10
- "claude-proxy": "./dist/proxy.js"
10
+ "claude-proxy": "dist/proxy.js"
11
11
  },
12
12
  "exports": {
13
13
  ".": {
@@ -54,6 +54,7 @@
54
54
  "prepack": "npm run build",
55
55
  "prepublishOnly": "npm run test:proxy-local",
56
56
  "test": "vitest run",
57
+ "test:coverage": "vitest run tests/integration/proxy-local.test.ts --coverage --coverage.reporter=lcov --coverage.reporter=text --pool threads",
57
58
  "test:proxy-local": "vitest run tests/integration/proxy-local.test.ts",
58
59
  "test:provider-anthropic": "node --experimental-strip-types tests/integration/provider-anthropic.ts",
59
60
  "test:provider-cli-e2e": "node --experimental-strip-types tests/integration/provider-cli-e2e.ts",
@@ -65,6 +66,7 @@
65
66
  "express": "^4.21.0"
66
67
  },
67
68
  "devDependencies": {
69
+ "@vitest/coverage-v8": "^3.2.4",
68
70
  "@types/cors": "^2.8.17",
69
71
  "@types/express": "^4.17.21",
70
72
  "@types/node": "^22.0.0",