diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 503d754..2e7be4f 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -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, diff --git a/src/daemon/http/request-path.test.ts b/src/daemon/http/request-path.test.ts index a2759c3..5377d7b 100644 --- a/src/daemon/http/request-path.test.ts +++ b/src/daemon/http/request-path.test.ts @@ -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", + ); + }); +}); diff --git a/src/daemon/http/request-path.ts b/src/daemon/http/request-path.ts index be7a759..c07814d 100644 --- a/src/daemon/http/request-path.ts +++ b/src/daemon/http/request-path.ts @@ -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}`; +}