diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 8979aa7..8bfb8ce 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -22,6 +22,7 @@ import { receiveCashuToken } from "../wallet"; import { getClientsFromStore } from "../../utils/clients"; import { getUsageSummary } from "./usage-summary"; import { applyDefaultOutputTokenLimit } from "./request-body"; +import { collapseDuplicatedV1 } from "./request-path"; import { buildCooldownsOutput, type StoredCooldownEntry, @@ -447,6 +448,19 @@ export function createDaemonRequestHandler(deps: { const host = req.headers.host || "localhost"; const url = new URL(req.url || "/", `http://${host}`); + // The node canonicalizes exactly ONE optional "v1/" segment (routstr-core + // `_canonical_api_path`). A client whose base URL already ends in /v1 while + // its transport appends /v1 itself — the Anthropic SDKs do, the OpenAI ones + // do not — would otherwise get a paid upstream 404 from every provider in + // the pool. Collapse the duplicate before any routing or payment decision. + const canonicalPath = collapseDuplicatedV1(url.pathname); + if (canonicalPath !== url.pathname) { + logger.warn( + `[daemon] Collapsing duplicated '/v1' in request path: ${url.pathname} -> ${canonicalPath}`, + ); + url.pathname = canonicalPath; + } + if (req.method === "GET" && url.pathname === "/health") { sendJson(res, 200, { ok: true }); return; diff --git a/src/daemon/http/request-path.test.ts b/src/daemon/http/request-path.test.ts new file mode 100644 index 0000000..a2759c3 --- /dev/null +++ b/src/daemon/http/request-path.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "bun:test"; +import { collapseDuplicatedV1 } from "./request-path"; + +describe("collapseDuplicatedV1", () => { + it("collapses the doubled prefix an Anthropic-style client produces", () => { + // base URL ends in /v1 and the Anthropic SDK appends /v1/messages itself + expect(collapseDuplicatedV1("/v1/v1/messages")).toBe("/v1/messages"); + expect(collapseDuplicatedV1("/v1/v1/messages/count_tokens")).toBe( + "/v1/messages/count_tokens", + ); + expect(collapseDuplicatedV1("/v1/v1/chat/completions")).toBe("/v1/chat/completions"); + }); + + it("collapses every repetition, including a bare or trailing-slash path", () => { + expect(collapseDuplicatedV1("/v1/v1/v1/messages")).toBe("/v1/messages"); + expect(collapseDuplicatedV1("/v1/v1")).toBe("/v1"); + expect(collapseDuplicatedV1("/v1/v1/")).toBe("/v1/"); + }); + + it("leaves canonical and unprefixed paths untouched", () => { + for (const path of [ + "/v1/messages", + "/v1/chat/completions", + "/v1/responses", + "/v1/systemone", + "/chat/completions", + "/responses", + "/v1/models", + "/v1/v1x/messages", + "/v1/v2/v1/messages", + "/health", + "/", + ]) { + expect(collapseDuplicatedV1(path)).toBe(path); + } + }); + + it("is idempotent", () => { + const once = collapseDuplicatedV1("/v1/v1/v1/messages"); + expect(collapseDuplicatedV1(once)).toBe(once); + }); +}); diff --git a/src/daemon/http/request-path.ts b/src/daemon/http/request-path.ts new file mode 100644 index 0000000..be7a759 --- /dev/null +++ b/src/daemon/http/request-path.ts @@ -0,0 +1,39 @@ +/** + * Request-path canonicalization for the proxied path. + * + * routstr-core reduces an incoming path to its allowlist key by stripping + * exactly ONE optional leading `v1/` segment (`_canonical_api_path` in + * `routstr/proxy.py`), then requires an exact match against its endpoint + * table. Both `/chat/completions` and `/v1/chat/completions` therefore reach + * the same handler, and no spelling with the prefix repeated ever does: + * `/v1/v1/messages` canonicalizes to `v1/messages`, which is not an endpoint, + * so the node answers `404 Path '/v1/v1/messages' not found`. + * + * That second prefix is not hypothetical — it is what an Anthropic-style + * client produces when its base URL already ends in `/v1`, because the + * Anthropic SDKs append `/v1/messages` themselves (the OpenAI SDKs append only + * `/chat/completions` and leave the prefix to the configured base URL). One + * such client turns every request into a paid upstream 404 on every provider in + * the pool, so collapse the duplicate here, before any routing or payment + * decision. + */ + +/** + * Collapse a repeated leading `/v1` segment to a single one. + * + * `/v1/v1/messages` and `/v1/v1/v1/messages` both become `/v1/messages`; + * `/v1/messages`, `/chat/completions`, and every other spelling are returned + * unchanged. Only the repeated `v1` segment is touched: the node owns the + * endpoint allowlist, and rewriting anything else here would make this + * ingress answer for paths it knows nothing about. + * + * Never throws and is idempotent: each pass shortens the path, so a path + * that does not start with a doubled `/v1` is returned as-is. + */ +export function collapseDuplicatedV1(pathname: string): string { + let path = pathname; + while (path.startsWith("/v1/v1/") || path === "/v1/v1") { + path = path.slice(3); + } + return path; +}