From 9f26a7215547dffd6ac82735e63397de973c9eca Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:07:24 +0800 Subject: [PATCH] fix(pi): version the provider base URL and root the Anthropic models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider base URL was the daemon root, which made every OpenAI-shaped request reach the node as a bare `/chat/completions`. routstr-core accepts both spellings but forwards the path verbatim, and providers whose base URL carries no version segment (Tinfoil) answer the bare path with a 404 — so `tinfoil-*` models were unreachable from pi, at the cost of a spend/refund cycle per attempt. Root was chosen to stop the Anthropic transport from doubling `/v1`, which is a real problem but only affects one family. Restore the versioned base URL (as v0.4.11 had it) and move the exception to where it belongs: models served over the Anthropic transport now carry the ROOT as a per-model `baseUrl`, which pi honours over the provider's (definition.baseUrl ?? config.baseUrl). The OpenAI transports keep the versioned base URL they expect, so the default — inherited by every model the integration does not pin, including hand-added ones — is correct: other -> {root}/v1/chat/completions gpt-* -> {root}/v1/responses claude* -> {root}/v1/messages (per-model baseUrl = root) The override is keyed on the effective transport rather than the model name, so a user-curated `api: "anthropic-messages"` on a non-claude model is served correctly too. --- src/integrations/pi.ts | 66 ++++++++++++++++++++++++--------- tests/integrations/pi.test.ts | 69 ++++++++++++++++++++++++++++++++--- 2 files changed, 111 insertions(+), 24 deletions(-) diff --git a/src/integrations/pi.ts b/src/integrations/pi.ts index 1cb22e3..6247ed6 100644 --- a/src/integrations/pi.ts +++ b/src/integrations/pi.ts @@ -19,6 +19,7 @@ export type ThinkingLevelMap = Partial>; export type PiModelEntry = { id: string; api?: string; + baseUrl?: string; contextWindow?: number; name?: string; input?: string[]; @@ -100,10 +101,19 @@ const isGptModel = (id: string): boolean => id.startsWith("gpt-"); */ const isClaudeModel = (id: string): boolean => id.startsWith("claude"); +export type PiModelEntryOptions = { + /** + * The daemon ROOT, without `/v1`. Assigned as the per-model `baseUrl` for + * models served over the Anthropic transport; see `buildPiModelEntry`. + */ + anthropicBaseUrl?: string; +}; + /** Project one daemon model onto a pi config entry. */ export function buildPiModelEntry( model: RoutstrModel, previous?: PiModelEntry, + options: PiModelEntryOptions = {}, ): PiModelEntry { const entry: PiModelEntry = { id: model.id }; @@ -125,12 +135,12 @@ export function buildPiModelEntry( // Per-model transport pins. `api` is per-model while the provider `baseUrl` // is shared, and the transports disagree about what a base URL means: the // OpenAI SDKs append only their endpoint (`/chat/completions`, `/responses`) - // while Anthropic SDKs append `/v1/messages` themselves. The provider base - // URL is therefore the daemon ROOT (see installPiIntegration), which is the - // one spelling that resolves correctly for every transport: - // gpt-* -> {root}/responses -> `responses` - // claude* -> {root}/v1/messages -> `messages` - // other -> {root}/chat/completions -> `chat/completions` + // and expect the version segment to already be in the base URL, while + // Anthropic SDKs append `/v1/messages` themselves. The provider base URL is + // therefore the versioned one (`{root}/v1`, see installPiIntegration), which + // is what the OpenAI transports need: + // gpt-* -> {root}/v1/responses -> `responses` + // other -> {root}/v1/chat/completions -> `chat/completions` // Pins override a curated value: a stale `anthropic-messages` left on a // non-claude model picks an endpoint the daemon is not expecting, and the // user cannot see from models.json which family needs which transport. @@ -142,6 +152,20 @@ export function buildPiModelEntry( entry.api = previous.api; } + // Anthropic-served models need the ROOT base URL, because their SDK appends + // the version segment itself: `{root}` + `/v1/messages` reaches the daemon's + // `messages` route, while the inherited `{root}/v1` would double the prefix + // to `/v1/v1/messages` — which routstr-core rejects (it canonicalizes exactly + // one optional `v1/`) with a 404 from every provider in the pool. + // + // Keyed on the transport actually in effect rather than on the model name, so + // a user-curated `api: "anthropic-messages"` on a non-claude model is served + // correctly too. Everything else inherits the provider base URL. + const effectiveApi = entry.api ?? "openai-completions"; + if (options.anthropicBaseUrl && effectiveApi === "anthropic-messages") { + entry.baseUrl = options.anthropicBaseUrl; + } + const derived = deriveThinkingFields(model); if (derived) { entry.reasoning = derived.reasoning; @@ -203,16 +227,19 @@ export async function installPiIntegration( console.log("\nInstalling routstr models in pi models.json..."); console.log(`Using API key for ${name}`); - // The daemon ROOT, deliberately without `/v1`. Every transport appends its - // own endpoint path, and only the Anthropic ones add a version prefix - // (`/v1/messages`); a base URL that already carries `/v1` therefore yields - // the doubled `/v1/v1/messages`, which routstr-core rejects with a 404 from - // every provider in the pool (it canonicalizes exactly one optional `v1/`). - // At the root, openai-completions -> `/chat/completions`, openai-responses - // -> `/responses` and anthropic-messages -> `/v1/messages` all land on an - // allowed route. getDaemonBaseUrl() strips any trailing slash, so no path - // can be double-slashed either. - const baseUrl = getDaemonBaseUrlFn(config); + // The provider base URL is the versioned one, because that is what the + // OpenAI-shaped transports need: their SDKs append only their endpoint + // (`/chat/completions`, `/responses`) and expect the version segment to come + // from the base URL. Both the default `openai-completions` transport and the + // `openai-responses` one therefore land on an allowed route: + // other -> {root}/v1/chat/completions + // gpt-* -> {root}/v1/responses + // Anthropic-served models override this per model with the ROOT, since the + // Anthropic SDK appends `/v1/messages` itself (see buildPiModelEntry); a + // shared `{root}/v1` would double the prefix for them. getDaemonBaseUrl() + // strips any trailing slash, so no path can be double-slashed either. + const rootBaseUrl = getDaemonBaseUrlFn(config); + const baseUrl = `${rootBaseUrl}/v1`; let piConfig: PiConfig = {}; @@ -248,13 +275,16 @@ export async function installPiIntegration( // when the daemon has none, the user's hand-curated values are preserved. // `compat` stays user-curated, except for the deepseek* pin applied below; // `api` is pinned per family (see buildPiModelEntry), since the family - // decides which transport — and so which endpoint — the model is served by. + // decides which transport — and so which endpoint and base URL — the model + // is served by. const existingModels = new Map( (piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]), ); const providerModels: PiModelEntry[] = models.map((model) => - buildPiModelEntry(model, existingModels.get(model.id)), + buildPiModelEntry(model, existingModels.get(model.id), { + anthropicBaseUrl: rootBaseUrl, + }), ); // Rebuild provider from scratch too; only write routstrd-managed fields. diff --git a/tests/integrations/pi.test.ts b/tests/integrations/pi.test.ts index 44a433d..d594126 100644 --- a/tests/integrations/pi.test.ts +++ b/tests/integrations/pi.test.ts @@ -41,6 +41,12 @@ const MOCK_DEPS: Partial = { context_length: 1000000, architecture: { input_modalities: ["text", "image"] }, }, + { + id: "gpt-6.1-sol", + name: "GPT-6.1 Sol", + context_length: 922000, + architecture: { input_modalities: ["text", "image"] }, + }, ], }, }), @@ -62,7 +68,7 @@ async function readProvider(configPath: string) { } describe("installPiIntegration", () => { - it("points the provider at the daemon root, with no /v1", async () => { + it("points the provider at the versioned daemon base URL", async () => { const dir = mkdtempSync(join(tmpdir(), "pi-models-")); const configPath = join(dir, "models.json"); await installPiIntegration(CONFIG, "key", makeIntegration(configPath), MOCK_DEPS); @@ -70,14 +76,14 @@ describe("installPiIntegration", () => { const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as { providers: Record; }; - // Every transport appends its own endpoint (/chat/completions, - // /responses, /v1/messages). A base URL carrying /v1 would double the - // prefix for Anthropic models and 404 on every provider. - expect(parsed.providers["routstr"].baseUrl).toBe("http://127.0.0.1:8008"); + // The OpenAI transports append only their endpoint (/chat/completions, + // /responses) and need the version segment in the base URL; Anthropic + // models override this per model with the root (see the next test). + expect(parsed.providers["routstr"].baseUrl).toBe("http://127.0.0.1:8008/v1"); expect(parsed.providers["routstr"].api).toBe("openai-completions"); }); - it("pins anthropic-messages for claude* models", async () => { + it("pins anthropic-messages and the root base URL for claude* models", async () => { const dir = mkdtempSync(join(tmpdir(), "pi-models-")); const configPath = join(dir, "models.json"); await installPiIntegration(CONFIG, "key", makeIntegration(configPath), MOCK_DEPS); @@ -85,9 +91,24 @@ describe("installPiIntegration", () => { const provider = await readProvider(configPath); const claude = provider.models.find((m) => m.id === "claude-opus-5.5"); expect(claude?.api).toBe("anthropic-messages"); + // The Anthropic SDK appends /v1/messages itself, so the model must not + // inherit the provider's /v1 base URL. + expect(claude?.baseUrl).toBe("http://127.0.0.1:8008"); // Non-claude, non-gpt models fall through to the provider default. const glm = provider.models.find((m) => m.id === "glm-5.3"); expect("api" in glm!).toBe(false); + expect("baseUrl" in glm!).toBe(false); + }); + + it("keeps the provider base URL on openai-responses models", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-models-")); + const configPath = join(dir, "models.json"); + await installPiIntegration(CONFIG, "key", makeIntegration(configPath), MOCK_DEPS); + + const provider = await readProvider(configPath); + const gpt = provider.models.find((m) => m.id === "gpt-6.1-sol"); + expect(gpt?.api).toBe("openai-responses"); + expect("baseUrl" in gpt!).toBe(false); }); it("replaces a stale hand-written api override on claude* models", async () => { @@ -408,4 +429,40 @@ describe("buildPiModelEntry", () => { const plain = buildPiModelEntry(model({ id: "glm-5.3" })); expect("api" in plain).toBe(false); }); + + it("gives anthropic-messages models the root base URL", () => { + const entry = buildPiModelEntry(model({ id: "claude-opus-5.5" }), undefined, { + anthropicBaseUrl: "http://127.0.0.1:8008", + }); + + expect(entry.api).toBe("anthropic-messages"); + expect(entry.baseUrl).toBe("http://127.0.0.1:8008"); + }); + + it("leaves other transports on the inherited base URL", () => { + for (const id of ["glm-5.3", "gpt-6.1-sol", "deepseek-v4.1-flash"]) { + const entry = buildPiModelEntry(model({ id }), undefined, { + anthropicBaseUrl: "http://127.0.0.1:8008", + }); + expect("baseUrl" in entry).toBe(false); + } + }); + + it("keys the root base URL on the effective transport, not the model name", () => { + // A user-curated anthropic-messages transport on a non-claude model is + // served over the Anthropic SDK, so it needs the root too. + const curated = buildPiModelEntry( + model({ id: "glm-5.3" }), + { id: "glm-5.3", api: "anthropic-messages" }, + { anthropicBaseUrl: "http://127.0.0.1:8008" }, + ); + expect(curated.baseUrl).toBe("http://127.0.0.1:8008"); + }); + + it("omits the per-model base URL when no root is supplied", () => { + const entry = buildPiModelEntry(model({ id: "claude-opus-5.5" })); + + expect(entry.api).toBe("anthropic-messages"); + expect("baseUrl" in entry).toBe(false); + }); });