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 { getClientsFromStore } from "../../utils/clients";
|
||||||
import { getUsageSummary } from "./usage-summary";
|
import { getUsageSummary } from "./usage-summary";
|
||||||
import { applyDefaultOutputTokenLimit } from "./request-body";
|
import { applyDefaultOutputTokenLimit } from "./request-body";
|
||||||
|
import { collapseDuplicatedV1 } from "./request-path";
|
||||||
import {
|
import {
|
||||||
buildCooldownsOutput,
|
buildCooldownsOutput,
|
||||||
type StoredCooldownEntry,
|
type StoredCooldownEntry,
|
||||||
@@ -447,6 +448,19 @@ export function createDaemonRequestHandler(deps: {
|
|||||||
const host = req.headers.host || "localhost";
|
const host = req.headers.host || "localhost";
|
||||||
const url = new URL(req.url || "/", `http://${host}`);
|
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") {
|
if (req.method === "GET" && url.pathname === "/health") {
|
||||||
sendJson(res, 200, { ok: true });
|
sendJson(res, 200, { ok: true });
|
||||||
return;
|
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