fix(pi): version the provider base URL and root the Anthropic models

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.
This commit is contained in:
redshift
2026-10-04 15:07:24 +08:00
parent ec755bc84c
commit 9f26a72155
2 changed files with 111 additions and 24 deletions
+48 -18
View File
@@ -19,6 +19,7 @@ export type ThinkingLevelMap = Partial<Record<PiThinkingLevel, string | null>>;
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<string, PiModelEntry>(
(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.
+63 -6
View File
@@ -41,6 +41,12 @@ const MOCK_DEPS: Partial<PiIntegrationDeps> = {
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<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");
// 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);
});
});