Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ It currently supports `qwen`, `deepseek`, `glm`, `minimax`, and `kimi`.

## Install

Requires Node.js 20.12 or newer.

Run without installing:

```bash
Expand All @@ -29,7 +31,7 @@ claude-proxy

## Configure

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`.
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`; `.env` loading uses Node.js built-in `.env` file support.

Example `.env`:

Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
".env.example"
],
"engines": {
"node": ">=18"
"node": ">=20.12"
},
"publishConfig": {
"access": "public"
Expand Down Expand Up @@ -63,7 +63,6 @@
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0"
},
"devDependencies": {
Expand Down
9 changes: 0 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
* export ANTHROPIC_API_KEY=any-key-works
*/

import "dotenv/config";
import cors from "cors";
import express from "express";
import { realpathSync } from "node:fs";
import { existsSync, realpathSync } from "node:fs";
import { loadEnvFile } from "node:process";
import { Readable } from "node:stream";
import { fileURLToPath } from "node:url";

if (existsSync(".env")) loadEnvFile(".env");

const DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
const HOP_BY_HOP_RESPONSE_HEADERS = new Set([
"connection",
Expand Down
2 changes: 0 additions & 2 deletions tests/integration/lib/anthropic-compat.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import "dotenv/config";

export type TestMode = "non-stream" | "stream";

export interface AnthropicCompatibilityCase {
Expand Down
5 changes: 4 additions & 1 deletion tests/integration/lib/providers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import "dotenv/config";
import { existsSync } from "node:fs";
import { loadEnvFile } from "node:process";

if (existsSync(".env")) loadEnvFile(".env");

export type ProviderKey = "deepseek" | "qwen" | "glm" | "minimax" | "kimi";

Expand Down
1 change: 0 additions & 1 deletion tests/integration/provider-cli-e2e.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import "dotenv/config";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { spawn } from "node:child_process";
Expand Down
99 changes: 98 additions & 1 deletion tests/integration/proxy-local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
type ServerResponse,
} from "node:http";
import { once } from "node:events";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

interface RecordedRequest {
Expand Down Expand Up @@ -361,8 +364,10 @@
...envOverrides,
};

// Empty strings keep local .env files from refilling variables that a test
// intentionally models as absent; pickEnv treats trimmed empty strings as missing.
for (const key of testEnvKeys) {
setEnv(key, envValues[key]);
setEnv(key, envValues[key] ?? "");
}

vi.resetModules();
Expand Down Expand Up @@ -541,6 +546,98 @@
});
});

it("loads .env from the current working directory without overriding existing env", async () => {
await cleanupHarness?.close();
cleanupHarness = undefined;

const originalCwd = process.cwd();
const tempDir = await mkdtemp(path.join(tmpdir(), "claude-proxy-env-"));
const envBackup = Object.fromEntries(
testEnvKeys.map((key) => [key, process.env[key]])
) as Record<TestEnvKey, string | undefined>;
const recordedRequests: RecordedRequest[] = [];
let proxy: Server | undefined;
let upstream: Server | undefined;

try {
upstream = http.createServer(async (req, res) => {
const body = await readJsonBody(req);
recordedRequests.push({ headers: req.headers, body });
writeJson(res, 200, {
id: "msg_upstream",
type: "message",
role: "assistant",
model: body.model,
content: [{ type: "text", text: "OK" }],
stop_reason: "end_turn",
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
});
});
const upstreamPort = await startServer(upstream);

for (const key of testEnvKeys) {
setEnv(key, undefined);
}
process.env.DEEPSEEK_API_KEY = "existing-secret";

await writeFile(
path.join(tempDir, ".env"),
[
"PROVIDER=deepseek",
"DEEPSEEK_API_KEY=file-secret",
"DEEPSEEK_MODEL=env-file-model",
`DEEPSEEK_ANTHROPIC_BASE_URL=http://127.0.0.1:${upstreamPort}`,
"",
].join("\n"),
"utf8"
);

process.chdir(tempDir);
vi.resetModules();
const { createApp } = await import("../../src/proxy.ts");
const startedProxy = await startProxyServer(createApp);
proxy = startedProxy.proxy;

const providerResponse = await fetch(
`${startedProxy.proxyBaseUrl}/api/provider`
);
expect(providerResponse.status).toBe(200);
await expect(providerResponse.json()).resolves.toEqual({
provider: "deepseek",
model: "env-file-model",
baseUrl: `http://127.0.0.1:${upstreamPort}`,
availableProviders,
});

const messageResponse = await fetch(
`${startedProxy.proxyBaseUrl}/v1/messages`,
{
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": "client-placeholder",
},
body: JSON.stringify(buildRequestPayload()),
}
);

expect(messageResponse.status).toBe(200);
expect(recordedRequests.at(-1)?.headers["x-api-key"]).toBe(
"existing-secret"
);
} finally {
if (proxy) await closeServer(proxy);
if (upstream) await closeServer(upstream);
process.chdir(originalCwd);

Check failure on line 632 in tests/integration/proxy-local.test.ts

View workflow job for this annotation

GitHub Actions / build-and-test

tests/integration/proxy-local.test.ts > proxy local integration > loads .env from the current working directory without overriding existing env

TypeError: process.chdir() is not supported in workers ❯ tests/integration/proxy-local.test.ts:632:15 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_WORKER_UNSUPPORTED_OPERATION' }

Check failure on line 632 in tests/integration/proxy-local.test.ts

View workflow job for this annotation

GitHub Actions / build-and-test

tests/integration/proxy-local.test.ts > proxy local integration > loads .env from the current working directory without overriding existing env

TypeError: process.chdir() is not supported in workers ❯ tests/integration/proxy-local.test.ts:632:15 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_WORKER_UNSUPPORTED_OPERATION' }
for (const key of testEnvKeys) {
setEnv(key, envBackup[key]);
}
await rm(tempDir, { recursive: true, force: true });
vi.resetModules();
}
});

it("imports as a library when the process entrypoint is missing or invalid", async () => {
await cleanupHarness?.close();
cleanupHarness = undefined;
Expand Down
Loading