From 444521af9fcb023e0fdedfa5ff254847a5817c00 Mon Sep 17 00:00:00 2001 From: Lu Wang Date: Wed, 24 Jun 2026 20:50:54 +0800 Subject: [PATCH 1/3] fix(anthropic): preserve base URL path prefix for PDF document requests completeAnthropicDocument built its request URL with new URL("/v1/messages", baseUrl), which treats the path as root-relative and discards any prefix on a custom ANTHROPIC_BASE_URL. A gateway exposed at https://host/anthropic would lose /anthropic and POST to https://host/v1/messages, returning 400/404. Join onto the base instead, mirroring completeGoogleDocument in google.ts. The default https://api.anthropic.com path is unchanged. Adds a regression test asserting the request URL for a path-prefixed override, a trailing-slash override, and the default base. --- src/llm/providers/anthropic.ts | 7 +- tests/llm.anthropic-document-url.test.ts | 84 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/llm.anthropic-document-url.test.ts diff --git a/src/llm/providers/anthropic.ts b/src/llm/providers/anthropic.ts index b7e11f5b..eb5bb8e0 100644 --- a/src/llm/providers/anthropic.ts +++ b/src/llm/providers/anthropic.ts @@ -184,7 +184,12 @@ export async function completeAnthropicDocument({ throw new Error("Internal error: expected a document attachment for Anthropic."); } const baseUrl = resolveBaseUrlOverride(anthropicBaseUrlOverride) ?? "https://api.anthropic.com"; - const url = new URL("/v1/messages", baseUrl); + // Join onto the base URL so a path prefix on the override survives. Using + // `new URL("/v1/messages", baseUrl)` would treat the absolute path as + // root-relative and discard any prefix (e.g. a custom Anthropic-compatible + // gateway exposed at `https://host/anthropic` would lose `/anthropic` and + // POST to `https://host/v1/messages`). Mirror completeGoogleDocument's join. + const url = new URL(`${baseUrl.replace(/\/$/, "")}/v1/messages`); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); const payload = { diff --git a/tests/llm.anthropic-document-url.test.ts b/tests/llm.anthropic-document-url.test.ts new file mode 100644 index 00000000..64314bc9 --- /dev/null +++ b/tests/llm.anthropic-document-url.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; + +// completeAnthropicDocument posts a base64 PDF document block to the Anthropic +// Messages API. The request URL must be built by JOINING `/v1/messages` onto the +// (optionally overridden) base URL so that a path prefix on a custom +// Anthropic-compatible gateway is preserved. Using `new URL("/v1/messages", base)` +// treats the path as root-relative and silently drops any prefix, so a gateway at +// `https://host/anthropic` would wrongly POST to `https://host/v1/messages` (404/400). + +const docInput = { + kind: "document" as const, + mediaType: "application/pdf" as const, + bytes: new Uint8Array([1, 2, 3]), + filename: "test.pdf", +}; + +function mockOkFetch(captured: string[]) { + return vi.fn(async (url: string) => { + captured.push(String(url)); + return { + ok: true, + status: 200, + text: async () => + JSON.stringify({ + content: [{ type: "text", text: "Summary result" }], + usage: { input_tokens: 10, output_tokens: 5 }, + }), + } as unknown as Response; + }); +} + +describe("completeAnthropicDocument request URL", () => { + it("preserves a path prefix on a custom gateway base URL", async () => { + const captured: string[] = []; + const { completeAnthropicDocument } = await import("../src/llm/providers/anthropic.js"); + + await completeAnthropicDocument({ + modelId: "claude-opus-4-x", + apiKey: "test-key", + promptText: "Summarize this document", + document: docInput, + maxOutputTokens: 256, + timeoutMs: 30000, + fetchImpl: mockOkFetch(captured) as unknown as typeof fetch, + anthropicBaseUrlOverride: "https://gateway.example/anthropic", + }); + + expect(captured.length).toBe(1); + expect(captured[0]).toBe("https://gateway.example/anthropic/v1/messages"); + }); + + it("tolerates a trailing slash on the override", async () => { + const captured: string[] = []; + const { completeAnthropicDocument } = await import("../src/llm/providers/anthropic.js"); + + await completeAnthropicDocument({ + modelId: "claude-opus-4-x", + apiKey: "test-key", + promptText: "Summarize this document", + document: docInput, + timeoutMs: 30000, + fetchImpl: mockOkFetch(captured) as unknown as typeof fetch, + anthropicBaseUrlOverride: "https://gateway.example/anthropic/", + }); + + expect(captured[0]).toBe("https://gateway.example/anthropic/v1/messages"); + }); + + it("defaults to api.anthropic.com when no override is given", async () => { + const captured: string[] = []; + const { completeAnthropicDocument } = await import("../src/llm/providers/anthropic.js"); + + await completeAnthropicDocument({ + modelId: "claude-opus-4-x", + apiKey: "test-key", + promptText: "Summarize this document", + document: docInput, + timeoutMs: 30000, + fetchImpl: mockOkFetch(captured) as unknown as typeof fetch, + }); + + expect(captured[0]).toBe("https://api.anthropic.com/v1/messages"); + }); +}); From db8df212b44a7d3948c67676d18b89ca582151d0 Mon Sep 17 00:00:00 2001 From: Lu Wang Date: Wed, 24 Jun 2026 22:55:18 +0800 Subject: [PATCH 2/3] test(anthropic): document /v1-base parity with the Anthropic SDK text path Adds a regression case asserting that an ANTHROPIC_BASE_URL already ending in /v1 resolves to /v1/v1/messages, matching what the streaming text path produces via @anthropic-ai/sdk (which concatenates baseURL + /v1/messages). This documents that the document path keeps parity with the SDK path rather than special-casing /v1 only for PDFs. --- tests/llm.anthropic-document-url.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/llm.anthropic-document-url.test.ts b/tests/llm.anthropic-document-url.test.ts index 64314bc9..940b77da 100644 --- a/tests/llm.anthropic-document-url.test.ts +++ b/tests/llm.anthropic-document-url.test.ts @@ -66,6 +66,30 @@ describe("completeAnthropicDocument request URL", () => { expect(captured[0]).toBe("https://gateway.example/anthropic/v1/messages"); }); + it("matches the Anthropic SDK/text path for an already-versioned base (no special-casing)", async () => { + // The streaming/text path stores ANTHROPIC_BASE_URL verbatim as the model + // baseUrl (see resolveAnthropicModel) and lets @anthropic-ai/sdk append + // `/v1/messages` via string concat. For a base that already ends in `/v1` + // the SDK therefore produces `/v1/v1/messages`. The document path must stay + // byte-for-byte consistent with that path rather than inventing a + // document-only `/v1`-as-root heuristic (which would make PDF requests + // diverge from text/streaming requests for the same configured base). + const captured: string[] = []; + const { completeAnthropicDocument } = await import("../src/llm/providers/anthropic.js"); + + await completeAnthropicDocument({ + modelId: "claude-opus-4-x", + apiKey: "test-key", + promptText: "Summarize this document", + document: docInput, + timeoutMs: 30000, + fetchImpl: mockOkFetch(captured) as unknown as typeof fetch, + anthropicBaseUrlOverride: "https://anthropic.example/v1", + }); + + expect(captured[0]).toBe("https://anthropic.example/v1/v1/messages"); + }); + it("defaults to api.anthropic.com when no override is given", async () => { const captured: string[] = []; const { completeAnthropicDocument } = await import("../src/llm/providers/anthropic.js"); From 3e92accf402c5ea9a5b45da0d11cb3acb3bde72b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 26 Jun 2026 07:40:22 +0100 Subject: [PATCH 3/3] chore: add PR 325 changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8ce1d4..3a88873e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.20.2 - Unreleased +### Fixes + +- Anthropic custom gateways: preserve path prefixes when sending PDF document requests (#325, thanks @wangwllu). + ## 0.20.1 - 2026-06-24 ### Fixes