From 518847b32d8dbaedf0bc8e62a85e485b99610adc Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:22:16 +0800 Subject: [PATCH] fix(daemon): collapse a duplicated /v1 prefix at ingress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit routstr-core reduces a path to its allowlist key by stripping exactly one optional `v1/` segment, then requires an exact endpoint match. `/v1/messages` and `/messages` therefore reach the same handler, but `/v1/v1/messages` canonicalizes to `v1/messages` and is answered with a 404. A client whose base URL already ends in `/v1` while its transport appends `/v1` itself produces that doubled prefix on every request — the Anthropic SDKs add `/v1/messages`, the OpenAI ones add only their endpoint. Each such request became a paid upstream 404 on every provider in the pool (and, with the SDK's current failure accounting, a cooldown strike against each of them). Collapse the repeated segment before any routing or payment decision. Only the duplicated `v1` is touched: the node owns the endpoint allowlist, so the ingress must not rewrite other spellings into routes it knows nothing about. --- src/daemon/http/index.ts | 14 ++++++++++ src/daemon/http/request-path.test.ts | 42 ++++++++++++++++++++++++++++ src/daemon/http/request-path.ts | 39 ++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 src/daemon/http/request-path.test.ts create mode 100644 src/daemon/http/request-path.ts 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; +}