mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user