Files
routstrd/src/daemon/http/request-path.ts
T
redshift 518847b32d 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.
2026-10-02 00:22:16 +08:00

40 lines
1.8 KiB
TypeScript

/**
* 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;
}