fix(pi): point the provider at the daemon root and pin the transport per family

The generated models.json set the provider base URL to `<daemon>/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).
This commit is contained in:
redshift
2026-10-02 00:22:22 +08:00
parent 518847b32d
commit c70d2f0f2f
2 changed files with 106 additions and 6 deletions
+34 -5
View File
@@ -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<string, PiModelEntry>(
(piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]),
);
+72 -1
View File
@@ -35,6 +35,12 @@ const MOCK_DEPS: Partial<PiIntegrationDeps> = {
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<string, { baseUrl?: string; api?: string }>;
};
// 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<string, { models: Array<Record<string, unknown>> }>;
};
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" },