From c70d2f0f2fa574a666d81df79998ec4554a8fee7 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:22:22 +0800 Subject: [PATCH] fix(pi): point the provider at the daemon root and pin the transport per family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated models.json set the provider base URL to `/v1`, which is only safe for transports that append their endpoint without a version segment. A model carrying `api: "anthropic-messages"` makes the Anthropic SDK append `/v1/messages` itself, so the effective path became `/v1/v1/messages` — a 404 from every provider in the pool. Use the daemon root, the one base URL that resolves for every transport: gpt-* -> {root}/responses -> `responses` claude* -> {root}/v1/messages -> `messages` other -> {root}/chat/completions -> `chat/completions` and pin `api` by family the way gpt-* already was, extending the pin to claude* models. The pins override a curated value — a stale per-model `api` picks an endpoint that does not match the provider's intent and is invisible in models.json — while models outside the pinned families keep whatever the user curated. getDaemonBaseUrl() strips a trailing slash, so the root cannot double the separator either (routstr-core rejects empty path segments). --- src/integrations/pi.ts | 39 ++++++++++++++++--- tests/integrations/pi.test.ts | 73 ++++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/src/integrations/pi.ts b/src/integrations/pi.ts index 4fb0ea0..1cb22e3 100644 --- a/src/integrations/pi.ts +++ b/src/integrations/pi.ts @@ -92,6 +92,13 @@ export function deriveThinkingFields( const isDeepSeekModel = (id: string): boolean => id.startsWith("deepseek"); const isGptModel = (id: string): boolean => id.startsWith("gpt-"); +/** + * Anthropic-served models (claude-opus-5.5, claude-sonnet-5, claude-fable-5.1, + * ...). routstr nodes proxy the Anthropic-native `messages` route, so pi's + * Anthropic transport can talk to the node's own endpoint instead of the + * OpenAI-shaped translation. + */ +const isClaudeModel = (id: string): boolean => id.startsWith("claude"); /** Project one daemon model onto a pi config entry. */ export function buildPiModelEntry( @@ -115,11 +122,22 @@ export function buildPiModelEntry( if (mods.includes("image")) input.push("image"); entry.input = input; - // gpt-* models are served through OpenAI's Responses API; the provider-level - // `api: "openai-completions"` stays the default for everything else. For - // non-gpt models keep whatever per-model api the user curated. + // 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` + // 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. if (isGptModel(model.id)) { entry.api = "openai-responses"; + } else if (isClaudeModel(model.id)) { + entry.api = "anthropic-messages"; } else if (previous?.api !== undefined) { entry.api = previous.api; } @@ -185,7 +203,16 @@ export async function installPiIntegration( console.log("\nInstalling routstr models in pi models.json..."); console.log(`Using API key for ${name}`); - const baseUrl = `${getDaemonBaseUrlFn(config)}/v1`; + // 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); let piConfig: PiConfig = {}; @@ -219,7 +246,9 @@ export async function installPiIntegration( // models.json is always a faithful projection of the daemon's state. // Thinking fields are derived from the model's published reasoning allowlist; // when the daemon has none, the user's hand-curated values are preserved. - // `compat` stays user-curated, except for the deepseek* pin applied below. + // `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. const existingModels = new Map( (piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]), ); diff --git a/tests/integrations/pi.test.ts b/tests/integrations/pi.test.ts index 32a4240..44a433d 100644 --- a/tests/integrations/pi.test.ts +++ b/tests/integrations/pi.test.ts @@ -35,6 +35,12 @@ const MOCK_DEPS: Partial = { context_length: 262144, architecture: { input_modalities: ["text", "image"] }, }, + { + id: "claude-opus-5.5", + name: "Claude Opus 5.5", + context_length: 1000000, + architecture: { input_modalities: ["text", "image"] }, + }, ], }, }), @@ -56,6 +62,56 @@ async function readProvider(configPath: string) { } describe("installPiIntegration", () => { + it("points the provider at the daemon root, with no /v1", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-models-")); + const configPath = join(dir, "models.json"); + await installPiIntegration(CONFIG, "key", makeIntegration(configPath), MOCK_DEPS); + + 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"); + expect(parsed.providers["routstr"].api).toBe("openai-completions"); + }); + + it("pins anthropic-messages 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); + + const provider = await readProvider(configPath); + const claude = provider.models.find((m) => m.id === "claude-opus-5.5"); + expect(claude?.api).toBe("anthropic-messages"); + // 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); + }); + + it("replaces a stale hand-written api override on claude* models", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-models-")); + const configPath = join(dir, "models.json"); + await installPiIntegration(CONFIG, "key", makeIntegration(configPath), MOCK_DEPS); + + const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as { + providers: Record> }>; + }; + const claude = parsed.providers["routstr"].models.find( + (m) => m.id === "claude-opus-5.5", + ); + claude!.api = "openai-completions"; + const { writeFileSync } = await import("fs"); + writeFileSync(configPath, JSON.stringify(parsed)); + + await installPiIntegration(CONFIG, "key", makeIntegration(configPath), MOCK_DEPS); + const provider = await readProvider(configPath); + expect(provider.models.find((m) => m.id === "claude-opus-5.5")?.api).toBe( + "anthropic-messages", + ); + }); + it("pins supportsDeveloperRole=false for deepseek* models", async () => { const dir = mkdtempSync(join(tmpdir(), "pi-models-")); const configPath = join(dir, "models.json"); @@ -327,7 +383,22 @@ describe("buildPiModelEntry", () => { expect(entry.api).toBe("openai-responses"); }); - it("preserves a user-curated api on non-gpt models and omits it otherwise", () => { + it("pins api=anthropic-messages for claude* models, overriding any curated value", () => { + for (const id of [ + "claude-opus-5.5", + "claude-sonnet-5.5", + "claude-fable-5.1", + "claude-haiku-4.5", + ]) { + const entry = buildPiModelEntry(model({ id }), { + id, + api: "openai-completions", + }); + expect(entry.api).toBe("anthropic-messages"); + } + }); + + it("preserves a user-curated api on models outside the pinned families", () => { const curated = buildPiModelEntry( model({ id: "glm-5.3" }), { id: "glm-5.3", api: "openai-responses" },