fix(daemon): collapse a duplicated /v1 prefix at ingress

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.
This commit is contained in:
redshift
2026-10-02 00:22:16 +08:00
parent 9305516177
commit 518847b32d
3 changed files with 95 additions and 0 deletions
+14
View File
@@ -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;
+42
View File
@@ -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);
});
});
+39
View File
@@ -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;
}