diff --git a/src/__tests__/url.test.ts b/src/__tests__/url.test.ts new file mode 100644 index 0000000..d2d7637 --- /dev/null +++ b/src/__tests__/url.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { resolveUpstreamUrl } from "../url.js"; + +describe("resolveUpstreamUrl", () => { + it("preserves base path prefix", () => { + expect(resolveUpstreamUrl("https://openrouter.ai/api", "/v1/chat/completions").href).toBe( + "https://openrouter.ai/api/v1/chat/completions", + ); + }); + + it("works with root-path providers", () => { + expect(resolveUpstreamUrl("https://api.openai.com", "/v1/chat/completions").href).toBe( + "https://api.openai.com/v1/chat/completions", + ); + }); + + it("handles trailing slash on base", () => { + expect(resolveUpstreamUrl("https://openrouter.ai/api/", "/v1/messages").href).toBe( + "https://openrouter.ai/api/v1/messages", + ); + }); + + it("handles no leading slash on pathname", () => { + expect(resolveUpstreamUrl("https://api.anthropic.com", "v1/messages").href).toBe( + "https://api.anthropic.com/v1/messages", + ); + }); + + it("handles both trailing and no leading slash", () => { + expect(resolveUpstreamUrl("https://openrouter.ai/api/", "v1/embeddings").href).toBe( + "https://openrouter.ai/api/v1/embeddings", + ); + }); +}); diff --git a/src/index.ts b/src/index.ts index ddb960a..d18b8ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,6 +90,9 @@ export type { ChaosAction } from "./types.js"; // Recorder export { proxyAndRecord } from "./recorder.js"; +// URL +export { resolveUpstreamUrl } from "./url.js"; + // Stream Collapse export { collapseOpenAISSE, diff --git a/src/recorder.ts b/src/recorder.ts index ef34c00..3f08d3d 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -15,6 +15,7 @@ import { getLastMessageByRole, getTextContent } from "./router.js"; import type { Logger } from "./logger.js"; import { collapseStreamingResponse } from "./stream-collapse.js"; import { writeErrorResponse } from "./sse-writer.js"; +import { resolveUpstreamUrl } from "./url.js"; /** * Proxy an unmatched request to the real upstream provider, record the @@ -48,7 +49,7 @@ export async function proxyAndRecord( const fixturePath = record.fixturePath ?? "./fixtures/recorded"; let target: URL; try { - target = new URL(pathname, upstreamUrl); + target = resolveUpstreamUrl(upstreamUrl, pathname); } catch { defaults.logger.error(`Invalid upstream URL for provider "${providerKey}": ${upstreamUrl}`); writeErrorResponse( diff --git a/src/url.ts b/src/url.ts new file mode 100644 index 0000000..86d26bf --- /dev/null +++ b/src/url.ts @@ -0,0 +1,15 @@ +/** + * Resolve an upstream URL by joining a base URL with a request pathname. + * + * Uses RFC 3986 relative resolution: the base URL's path prefix is preserved + * by ensuring a trailing slash (marking it as a "directory") and stripping the + * leading slash from the pathname (making it relative, not absolute). + * + * Without this, `new URL("/v1/chat/completions", "https://openrouter.ai/api")` + * resolves to `https://openrouter.ai/v1/chat/completions` — losing the `/api` prefix. + */ +export function resolveUpstreamUrl(base: string, pathname: string): URL { + const normalizedBase = base.endsWith("/") ? base : base + "/"; + const relativePath = pathname.startsWith("/") ? pathname.slice(1) : pathname; + return new URL(relativePath, normalizedBase); +}