Skip to content
Merged
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
18 changes: 14 additions & 4 deletions src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isExpiringSoon,
} from "./credentials.ts";
import { transformRequestBody, createToolNameUnprefixStream } from "./transforms.ts";
import { rewriteOrigin, isInsecure } from "./proxy.ts";

interface AuthState {
type: string;
Expand All @@ -24,6 +25,11 @@ interface AuthState {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ClientApi = any;

/** Bun extends RequestInit with a tls option for custom certificate handling. */
type BunFetchRequestInit = RequestInit & {
tls?: { rejectUnauthorized: boolean };
};

function isLongContextError(body: string): boolean {
return (
body.includes("Extra usage is required for long context requests") ||
Expand Down Expand Up @@ -86,25 +92,29 @@ export function createCustomFetch(getAuth: () => Promise<AuthState>, client: Cli
reqHeaders.set("x-app", "cli");
reqHeaders.delete("x-api-key");

const reqInput = addBetaParam(input);
const reqInput = rewriteOrigin(addBetaParam(input));

log.debug("Outgoing request", {
model: modelId,
betaCount: merged.split(",").length,
});

const tlsOpts = isInsecure() ? { tls: { rejectUnauthorized: false } } : {};

let response = await fetch(reqInput, {
...init,
body,
headers: reqHeaders,
});
...tlsOpts,
} as BunFetchRequestInit);

if (response.status === 429 || response.status === 529 || response.status === 401) {
response = await handleRetryableError(response, auth, client, reqInput, {
...init,
body,
headers: reqHeaders,
});
...tlsOpts,
} as BunFetchRequestInit);
}

if (response.body) {
Expand Down Expand Up @@ -279,7 +289,7 @@ async function handleRetryableError(
headers.set("authorization", `Bearer ${freshCreds.access}`);

log.info("Retrying with fresh credentials");
return fetch(reqInput, { ...reqInit, headers });
return fetch(reqInput, { ...reqInit, headers } as BunFetchRequestInit);
}

return new Response(responseBody, {
Expand Down
120 changes: 120 additions & 0 deletions src/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { log } from "./logger.ts";

let _resolved: URL | null | undefined;
let _logged = false;

/**
* Parse ANTHROPIC_BASE_URL from the environment.
* Returns a valid HTTP(S) URL or null if unset/invalid.
*
* Rejects URLs with embedded credentials or non-empty paths for safety.
* Result is cached for the process lifetime.
*/
export function resolveBaseUrl(): URL | null {
if (_resolved !== undefined) return _resolved;

const raw = process.env.ANTHROPIC_BASE_URL?.trim();
if (!raw) {
_resolved = null;
return null;
}

try {
const url = new URL(raw);
if (url.protocol !== "http:" && url.protocol !== "https:") {
log.warn("ANTHROPIC_BASE_URL has unsupported protocol, ignoring", {
protocol: url.protocol,
});
_resolved = null;
return null;
}
if (url.username || url.password) {
log.warn("ANTHROPIC_BASE_URL contains credentials, ignoring for safety");
_resolved = null;
return null;
}
if (url.pathname !== "/" && url.pathname !== "") {
log.warn("ANTHROPIC_BASE_URL contains a path which would be ignored — use origin only", {
url: raw,
hint: `Try ${url.origin} instead`,
});
_resolved = null;
return null;
}
_resolved = url;

if (!_logged) {
log.info("Proxy configured", {
baseUrl: url.origin,
insecure: isInsecure(),
});
_logged = true;
}

return url;
} catch {
log.warn("ANTHROPIC_BASE_URL is not a valid URL, ignoring", { raw });
_resolved = null;
return null;
}
}

/**
* Reset cached state. Only needed for tests.
*/
export function resetProxyCache(): void {
_resolved = undefined;
_logged = false;
}

/**
* Check if TLS verification should be skipped for custom API endpoints.
* Only effective when ANTHROPIC_BASE_URL is also set — prevents accidental
* use against the production Anthropic API.
*/
export function isInsecure(): boolean {
if (!resolveBaseUrl()) return false;
const raw = process.env.ANTHROPIC_INSECURE?.trim();
return raw === "1" || raw === "true";
}

/**
* Rewrite the origin (protocol + host) of a request URL when
* ANTHROPIC_BASE_URL is configured. Preserves the original path
* and query parameters.
*
* Returns the input unchanged when no base URL is set.
*/
export function rewriteOrigin(input: RequestInfo | URL): RequestInfo | URL {
const baseUrl = resolveBaseUrl();
if (!baseUrl) return input;

try {
let reqUrl: URL;
if (typeof input === "string") {
reqUrl = new URL(input);
} else if (input instanceof URL) {
reqUrl = new URL(input.toString());
} else if (input instanceof Request) {
reqUrl = new URL(input.url);
} else {
return input;
}

const original = reqUrl.href;
reqUrl.protocol = baseUrl.protocol;
reqUrl.host = baseUrl.host;

if (reqUrl.href === original) return input;

log.debug("Rewrote request origin", {
from: new URL(original).host,
to: baseUrl.host,
});

return input instanceof Request ? new Request(reqUrl.toString(), input) : reqUrl;
} catch (e) {
log.warn("Failed to rewrite request origin", { error: String(e) });
return input;
}
}
158 changes: 158 additions & 0 deletions tests/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { afterEach, describe, expect, it } from "bun:test";
import { resolveBaseUrl, isInsecure, rewriteOrigin, resetProxyCache } from "../src/proxy.ts";

describe("resolveBaseUrl", () => {
afterEach(() => {
delete process.env.ANTHROPIC_BASE_URL;
resetProxyCache();
});

it("returns null when env var is unset", () => {
expect(resolveBaseUrl()).toBeNull();
});

it("returns null when env var is empty", () => {
process.env.ANTHROPIC_BASE_URL = " ";
expect(resolveBaseUrl()).toBeNull();
});

it("parses a valid HTTPS URL", () => {
process.env.ANTHROPIC_BASE_URL = "https://proxy.example.com";
const url = resolveBaseUrl();
expect(url).not.toBeNull();
expect(url!.host).toBe("proxy.example.com");
expect(url!.protocol).toBe("https:");
});

it("parses a valid HTTP URL", () => {
process.env.ANTHROPIC_BASE_URL = "http://localhost:8080";
const url = resolveBaseUrl();
expect(url).not.toBeNull();
expect(url!.host).toBe("localhost:8080");
expect(url!.protocol).toBe("http:");
});

it("rejects URLs with unsupported protocols", () => {
process.env.ANTHROPIC_BASE_URL = "ftp://proxy.example.com";
expect(resolveBaseUrl()).toBeNull();
});

it("rejects URLs with embedded credentials", () => {
process.env.ANTHROPIC_BASE_URL = "https://user:pass@proxy.example.com";
expect(resolveBaseUrl()).toBeNull();
});

it("rejects invalid URL strings", () => {
process.env.ANTHROPIC_BASE_URL = "not a url";
expect(resolveBaseUrl()).toBeNull();
});

it("rejects URLs with a path component", () => {
process.env.ANTHROPIC_BASE_URL = "https://proxy.example.com/anthropic";
expect(resolveBaseUrl()).toBeNull();
});

it("trims whitespace", () => {
process.env.ANTHROPIC_BASE_URL = " https://proxy.example.com ";
const url = resolveBaseUrl();
expect(url).not.toBeNull();
expect(url!.host).toBe("proxy.example.com");
});
});

describe("isInsecure", () => {
afterEach(() => {
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.ANTHROPIC_INSECURE;
resetProxyCache();
});

it("returns false when ANTHROPIC_BASE_URL is unset", () => {
process.env.ANTHROPIC_INSECURE = "1";
expect(isInsecure()).toBe(false);
});

it("returns false when ANTHROPIC_INSECURE is unset", () => {
process.env.ANTHROPIC_BASE_URL = "https://proxy.example.com";
expect(isInsecure()).toBe(false);
});

it('returns true when both set and INSECURE is "1"', () => {
process.env.ANTHROPIC_BASE_URL = "https://proxy.example.com";
process.env.ANTHROPIC_INSECURE = "1";
expect(isInsecure()).toBe(true);
});

it('returns true when both set and INSECURE is "true"', () => {
process.env.ANTHROPIC_BASE_URL = "https://proxy.example.com";
process.env.ANTHROPIC_INSECURE = "true";
expect(isInsecure()).toBe(true);
});

it("returns false for other INSECURE values", () => {
process.env.ANTHROPIC_BASE_URL = "https://proxy.example.com";
process.env.ANTHROPIC_INSECURE = "yes";
expect(isInsecure()).toBe(false);
});
});

describe("rewriteOrigin", () => {
afterEach(() => {
delete process.env.ANTHROPIC_BASE_URL;
resetProxyCache();
});

it("returns input unchanged when no base URL is set", () => {
const input = "https://api.anthropic.com/v1/messages";
expect(rewriteOrigin(input)).toBe(input);
});

it("rewrites origin for a string URL", () => {
process.env.ANTHROPIC_BASE_URL = "https://proxy.example.com";
const result = rewriteOrigin("https://api.anthropic.com/v1/messages?beta=true");

expect(result).toBeInstanceOf(URL);
const url = result as URL;
expect(url.host).toBe("proxy.example.com");
expect(url.pathname).toBe("/v1/messages");
expect(url.searchParams.get("beta")).toBe("true");
});

it("rewrites origin for a URL object", () => {
process.env.ANTHROPIC_BASE_URL = "http://localhost:8080";
const input = new URL("https://api.anthropic.com/v1/messages");
const result = rewriteOrigin(input) as URL;

expect(result.host).toBe("localhost:8080");
expect(result.protocol).toBe("http:");
expect(result.pathname).toBe("/v1/messages");
});

it("rewrites origin for a Request object", () => {
process.env.ANTHROPIC_BASE_URL = "https://proxy.example.com";
const input = new Request("https://api.anthropic.com/v1/messages", {
method: "POST",
});
const result = rewriteOrigin(input);

expect(result).toBeInstanceOf(Request);
const req = result as Request;
expect(new URL(req.url).host).toBe("proxy.example.com");
expect(req.method).toBe("POST");
});

it("preserves path and query parameters", () => {
process.env.ANTHROPIC_BASE_URL = "https://proxy.example.com";
const result = rewriteOrigin("https://api.anthropic.com/v1/messages?beta=true&foo=bar") as URL;

expect(result.pathname).toBe("/v1/messages");
expect(result.searchParams.get("beta")).toBe("true");
expect(result.searchParams.get("foo")).toBe("bar");
});

it("returns input when same origin (no-op)", () => {
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com";
const input = "https://api.anthropic.com/v1/messages";
expect(rewriteOrigin(input)).toBe(input);
});
});
Loading