fix(ingress): send forwarded API paths with the /v1 prefix

The daemon forwards the caller's pathname verbatim, and a provider node
forwards that spelling to its own upstream. The node accepts `/x` and
`/v1/x` as the same endpoint, but the upstream does not necessarily: an
upstream whose base URL carries no version prefix serves only the
versioned route. Tinfoil is one, so every request routed to a Tinfoil
model from a client whose base URL is the daemon root — the pi integration
is configured that way on purpose, because the Anthropic transports append
`/v1/messages` themselves — came back

  404 {"error":{"message":"Not found.","type":"invalid_request_error"}}

while the identical request to `/v1/chat/completions` succeeded. The node's
error text named the model, which sent the search in the wrong direction.

Normalize the forwarded path to the versioned spelling that every provider
in the pool accepts, scoped to the endpoints a node actually forwards so
the ingress never versions a path it does not recognise. `url.pathname` is
left alone: the daemon still matches its own routes against the caller's
spelling, and the request-response log records both when they differ.
This commit is contained in:
routstr-dev
2026-10-04 20:51:16 +08:00
committed by redshift
parent 5eb2ee68c7
commit e2b1e0e47a
3 changed files with 117 additions and 4 deletions
+14 -3
View File
@@ -22,7 +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 { collapseDuplicatedV1, ensureV1Prefix } from "./request-path";
import {
buildCooldownsOutput,
type StoredCooldownEntry,
@@ -2063,12 +2063,23 @@ export function createDaemonRequestHandler(deps: {
await deps.ensureProvidersBootstrapped();
const reqId = randomBytes(4).toString("hex");
const reqLogger = makeSdkLogger(`req:${reqId}`, `model:${modelId}`);
reqLogger.log(`Routing request with path: ${url.pathname}`);
// Provider nodes accept an endpoint with or without a leading `/v1`, but
// they forward the caller's spelling to their own upstream, and some
// upstreams serve only the versioned route. Normalize at the last hop
// routstrd controls, so every client spelling reaches the same upstream
// URL. `url.pathname` stays untouched: the ingress matches its own
// routes against it.
const forwardedPath = ensureV1Prefix(url.pathname);
reqLogger.log(
forwardedPath === url.pathname
? `Routing request with path: ${url.pathname}`
: `Routing request with path: ${url.pathname} (forwarding as ${forwardedPath})`,
);
const response = await routeRequests({
modelId,
requestBody,
path: url.pathname,
path: forwardedPath,
forcedProvider,
autoModelPath: deps.autoModelPath === true,
headers: incomingHeaders,
+56 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { collapseDuplicatedV1 } from "./request-path";
import { collapseDuplicatedV1, ensureV1Prefix } from "./request-path";
describe("collapseDuplicatedV1", () => {
it("collapses the doubled prefix an Anthropic-style client produces", () => {
@@ -40,3 +40,58 @@ describe("collapseDuplicatedV1", () => {
expect(collapseDuplicatedV1(once)).toBe(once);
});
});
describe("ensureV1Prefix", () => {
it("versions a bare endpoint the node forwards", () => {
// Tinfoil's router serves only /v1/..., and the node forwards the caller's
// spelling: a bare path was answered with a paid upstream 404.
expect(ensureV1Prefix("/chat/completions")).toBe("/v1/chat/completions");
expect(ensureV1Prefix("/completions")).toBe("/v1/completions");
expect(ensureV1Prefix("/responses")).toBe("/v1/responses");
expect(ensureV1Prefix("/messages")).toBe("/v1/messages");
expect(ensureV1Prefix("/messages/count_tokens")).toBe("/v1/messages/count_tokens");
expect(ensureV1Prefix("/embeddings")).toBe("/v1/embeddings");
expect(ensureV1Prefix("/systemone")).toBe("/v1/systemone");
});
it("leaves an already-versioned path alone, so it is idempotent", () => {
for (const path of [
"/v1/chat/completions",
"/v1/messages",
"/v1/responses",
"/v1/systemone",
"/v1/messages/count_tokens",
]) {
expect(ensureV1Prefix(path)).toBe(path);
expect(ensureV1Prefix(ensureV1Prefix(path))).toBe(path);
}
});
it("leaves the daemon's own routes and unknown paths untouched", () => {
// The ingress matches these against its own handlers, and the node's
// allowlist is exact — routstrd must not invent a path for either.
for (const path of [
"/health",
"/models",
"/models/glm-5.3/providers",
"/wallet/receive",
"/keys/api",
"/clients",
"/v1/models",
"/v1",
"/",
"/openai/chat/completions",
"/v1/v2/messages",
"/chat/completions/extra",
]) {
expect(ensureV1Prefix(path)).toBe(path);
}
});
it("preserves a trailing slash and a query string", () => {
expect(ensureV1Prefix("/chat/completions/")).toBe("/v1/chat/completions/");
expect(ensureV1Prefix("/chat/completions?provider=ai.redsh1ft.com")).toBe(
"/v1/chat/completions?provider=ai.redsh1ft.com",
);
});
});
+47
View File
@@ -37,3 +37,50 @@ export function collapseDuplicatedV1(pathname: string): string {
}
return path;
}
/**
* Endpoints a provider node forwards, in the canonical spelling that omits
* `v1/`.
*
* Mirrors `_ALLOWED_ENDPOINTS` in routstr-core's proxy: the node reduces a
* request path to one of these keys after stripping exactly one optional
* `v1/`, and forwards anything else to no one. Kept as a local list for the
* same reason as `OPENAI_JSON_BODY_PATH_SUFFIXES` in `request-body.ts` —
* routstrd must not version a path it does not recognise, because a routstr
* node is not the only possible destination for a forwarded request.
*/
const FORWARDED_ENDPOINTS = new Set([
"chat/completions",
"completions",
"responses",
"messages",
"messages/count_tokens",
"embeddings",
"systemone",
]);
/**
* Ensure a forwarded API path carries the `/v1` prefix.
*
* The node accepts an endpoint with or without `v1/` (`_canonical_api_path`)
* and forwards the caller's spelling to its own upstream, so the spelling is
* not free: an upstream whose base URL carries no version prefix serves only
* the versioned route. Tinfoil is one — a bare `/chat/completions` reached
* `https://inference.tinfoil.sh/chat/completions` and came back
* `404 {"error":{"message":"Not found."}}`, while the same request with the
* prefix succeeded. A client cannot know which upstream sits behind a node, so
* routstrd sends the versioned spelling that every provider in the pool
* accepts.
*
* Applied to the forwarded path only — never to `url.pathname`, which the
* ingress still matches its own routes against — and only to the endpoints
* above. Idempotent, so a client that already sends `/v1/...` is unaffected;
* a query string and a trailing slash are preserved.
*/
export function ensureV1Prefix(pathname: string): string {
const [path = ""] = pathname.split("?");
if (path.startsWith("/v1/")) return pathname;
const endpoint = path.replace(/^\/+/, "").replace(/\/+$/, "");
if (!FORWARDED_ENDPOINTS.has(endpoint)) return pathname;
return `/v1${pathname}`;
}