Skip to content

Commit 4487e6e

Browse files
iskhakovtclaude
andcommitted
fix: preserve base URL path prefix in record proxy
The URL constructor drops the base path when the pathname is absolute: new URL("/v1/chat/completions", "https://openrouter.ai/api") resolves to https://openrouter.ai/v1/chat/completions — losing /api. Extract resolveUpstreamUrl() that normalizes inputs for RFC 3986 relative resolution (trailing slash on base, strip leading slash from pathname). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 604e447 commit 4487e6e

4 files changed

Lines changed: 54 additions & 1 deletion

File tree

src/__tests__/url.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, it, expect } from "vitest";
2+
import { resolveUpstreamUrl } from "../url.js";
3+
4+
describe("resolveUpstreamUrl", () => {
5+
it("preserves base path prefix", () => {
6+
expect(resolveUpstreamUrl("https://openrouter.ai/api", "/v1/chat/completions").href).toBe(
7+
"https://openrouter.ai/api/v1/chat/completions",
8+
);
9+
});
10+
11+
it("works with root-path providers", () => {
12+
expect(resolveUpstreamUrl("https://api.openai.com", "/v1/chat/completions").href).toBe(
13+
"https://api.openai.com/v1/chat/completions",
14+
);
15+
});
16+
17+
it("handles trailing slash on base", () => {
18+
expect(resolveUpstreamUrl("https://openrouter.ai/api/", "/v1/messages").href).toBe(
19+
"https://openrouter.ai/api/v1/messages",
20+
);
21+
});
22+
23+
it("handles no leading slash on pathname", () => {
24+
expect(resolveUpstreamUrl("https://api.anthropic.com", "v1/messages").href).toBe(
25+
"https://api.anthropic.com/v1/messages",
26+
);
27+
});
28+
29+
it("handles both trailing and no leading slash", () => {
30+
expect(resolveUpstreamUrl("https://openrouter.ai/api/", "v1/embeddings").href).toBe(
31+
"https://openrouter.ai/api/v1/embeddings",
32+
);
33+
});
34+
});

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ export type { ChaosAction } from "./types.js";
9090
// Recorder
9191
export { proxyAndRecord } from "./recorder.js";
9292

93+
// URL
94+
export { resolveUpstreamUrl } from "./url.js";
95+
9396
// Stream Collapse
9497
export {
9598
collapseOpenAISSE,

src/recorder.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { getLastMessageByRole, getTextContent } from "./router.js";
1515
import type { Logger } from "./logger.js";
1616
import { collapseStreamingResponse } from "./stream-collapse.js";
1717
import { writeErrorResponse } from "./sse-writer.js";
18+
import { resolveUpstreamUrl } from "./url.js";
1819

1920
/**
2021
* Proxy an unmatched request to the real upstream provider, record the
@@ -48,7 +49,7 @@ export async function proxyAndRecord(
4849
const fixturePath = record.fixturePath ?? "./fixtures/recorded";
4950
let target: URL;
5051
try {
51-
target = new URL(pathname, upstreamUrl);
52+
target = resolveUpstreamUrl(upstreamUrl, pathname);
5253
} catch {
5354
defaults.logger.error(`Invalid upstream URL for provider "${providerKey}": ${upstreamUrl}`);
5455
writeErrorResponse(

src/url.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Resolve an upstream URL by joining a base URL with a request pathname.
3+
*
4+
* Uses RFC 3986 relative resolution: the base URL's path prefix is preserved
5+
* by ensuring a trailing slash (marking it as a "directory") and stripping the
6+
* leading slash from the pathname (making it relative, not absolute).
7+
*
8+
* Without this, `new URL("/v1/chat/completions", "https://openrouter.ai/api")`
9+
* resolves to `https://openrouter.ai/v1/chat/completions` — losing the `/api` prefix.
10+
*/
11+
export function resolveUpstreamUrl(base: string, pathname: string): URL {
12+
const normalizedBase = base.endsWith("/") ? base : base + "/";
13+
const relativePath = pathname.startsWith("/") ? pathname.slice(1) : pathname;
14+
return new URL(relativePath, normalizedBase);
15+
}

0 commit comments

Comments
 (0)