Skip to content
Open
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
34 changes: 34 additions & 0 deletions src/__tests__/url.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions src/url.ts
Original file line number Diff line number Diff line change
@@ -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);
}