diff --git a/README.md b/README.md index 72d8c84..8569965 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`: diff --git a/package.json b/package.json index 9b44fe8..2f105ec 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ ".env.example" ], "engines": { - "node": ">=18" + "node": ">=20.12" }, "publishConfig": { "access": "public" @@ -63,7 +63,6 @@ }, "dependencies": { "cors": "^2.8.5", - "dotenv": "^16.4.5", "express": "^4.21.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 080b9a5..0542c82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: cors: specifier: ^2.8.5 version: 2.8.6 - dotenv: - specifier: ^16.4.5 - version: 16.6.1 express: specifier: ^4.21.0 version: 4.22.1 @@ -603,10 +600,6 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} - engines: {node: '>=12'} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1609,8 +1602,6 @@ snapshots: destroy@1.2.0: {} - dotenv@16.6.1: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 diff --git a/src/proxy.ts b/src/proxy.ts index 63c1a80..b588e99 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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", diff --git a/tests/integration/lib/anthropic-compat.ts b/tests/integration/lib/anthropic-compat.ts index da20167..82a4783 100644 --- a/tests/integration/lib/anthropic-compat.ts +++ b/tests/integration/lib/anthropic-compat.ts @@ -1,5 +1,3 @@ -import "dotenv/config"; - export type TestMode = "non-stream" | "stream"; export interface AnthropicCompatibilityCase { diff --git a/tests/integration/lib/providers.ts b/tests/integration/lib/providers.ts index 6a699ea..ec0167d 100644 --- a/tests/integration/lib/providers.ts +++ b/tests/integration/lib/providers.ts @@ -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"; diff --git a/tests/integration/provider-cli-e2e.ts b/tests/integration/provider-cli-e2e.ts index 85f138d..a4797ca 100644 --- a/tests/integration/provider-cli-e2e.ts +++ b/tests/integration/provider-cli-e2e.ts @@ -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"; diff --git a/tests/integration/proxy-local.test.ts b/tests/integration/proxy-local.test.ts index 938ac87..7da2350 100644 --- a/tests/integration/proxy-local.test.ts +++ b/tests/integration/proxy-local.test.ts @@ -4,6 +4,9 @@ import http, { 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 { @@ -361,8 +364,10 @@ async function createHarness(envOverrides: EnvOverrides = {}): Promise { }); }); + 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; + 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); + 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;