mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
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:
+48
-18
@@ -19,6 +19,7 @@ export type ThinkingLevelMap = Partial<Record<PiThinkingLevel, string | null>>;
|
|||||||
export type PiModelEntry = {
|
export type PiModelEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
api?: string;
|
api?: string;
|
||||||
|
baseUrl?: string;
|
||||||
contextWindow?: number;
|
contextWindow?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
input?: string[];
|
input?: string[];
|
||||||
@@ -100,10 +101,19 @@ const isGptModel = (id: string): boolean => id.startsWith("gpt-");
|
|||||||
*/
|
*/
|
||||||
const isClaudeModel = (id: string): boolean => id.startsWith("claude");
|
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. */
|
/** Project one daemon model onto a pi config entry. */
|
||||||
export function buildPiModelEntry(
|
export function buildPiModelEntry(
|
||||||
model: RoutstrModel,
|
model: RoutstrModel,
|
||||||
previous?: PiModelEntry,
|
previous?: PiModelEntry,
|
||||||
|
options: PiModelEntryOptions = {},
|
||||||
): PiModelEntry {
|
): PiModelEntry {
|
||||||
const entry: PiModelEntry = { id: model.id };
|
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`
|
// 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
|
// is shared, and the transports disagree about what a base URL means: the
|
||||||
// OpenAI SDKs append only their endpoint (`/chat/completions`, `/responses`)
|
// OpenAI SDKs append only their endpoint (`/chat/completions`, `/responses`)
|
||||||
// while Anthropic SDKs append `/v1/messages` themselves. The provider base
|
// and expect the version segment to already be in the base URL, while
|
||||||
// URL is therefore the daemon ROOT (see installPiIntegration), which is the
|
// Anthropic SDKs append `/v1/messages` themselves. The provider base URL is
|
||||||
// one spelling that resolves correctly for every transport:
|
// therefore the versioned one (`{root}/v1`, see installPiIntegration), which
|
||||||
// gpt-* -> {root}/responses -> `responses`
|
// is what the OpenAI transports need:
|
||||||
// claude* -> {root}/v1/messages -> `messages`
|
// gpt-* -> {root}/v1/responses -> `responses`
|
||||||
// other -> {root}/chat/completions -> `chat/completions`
|
// other -> {root}/v1/chat/completions -> `chat/completions`
|
||||||
// Pins override a curated value: a stale `anthropic-messages` left on a
|
// 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
|
// non-claude model picks an endpoint the daemon is not expecting, and the
|
||||||
// user cannot see from models.json which family needs which transport.
|
// user cannot see from models.json which family needs which transport.
|
||||||
@@ -142,6 +152,20 @@ export function buildPiModelEntry(
|
|||||||
entry.api = previous.api;
|
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);
|
const derived = deriveThinkingFields(model);
|
||||||
if (derived) {
|
if (derived) {
|
||||||
entry.reasoning = derived.reasoning;
|
entry.reasoning = derived.reasoning;
|
||||||
@@ -203,16 +227,19 @@ export async function installPiIntegration(
|
|||||||
console.log("\nInstalling routstr models in pi models.json...");
|
console.log("\nInstalling routstr models in pi models.json...");
|
||||||
console.log(`Using API key for ${name}`);
|
console.log(`Using API key for ${name}`);
|
||||||
|
|
||||||
// The daemon ROOT, deliberately without `/v1`. Every transport appends its
|
// The provider base URL is the versioned one, because that is what the
|
||||||
// own endpoint path, and only the Anthropic ones add a version prefix
|
// OpenAI-shaped transports need: their SDKs append only their endpoint
|
||||||
// (`/v1/messages`); a base URL that already carries `/v1` therefore yields
|
// (`/chat/completions`, `/responses`) and expect the version segment to come
|
||||||
// the doubled `/v1/v1/messages`, which routstr-core rejects with a 404 from
|
// from the base URL. Both the default `openai-completions` transport and the
|
||||||
// every provider in the pool (it canonicalizes exactly one optional `v1/`).
|
// `openai-responses` one therefore land on an allowed route:
|
||||||
// At the root, openai-completions -> `/chat/completions`, openai-responses
|
// other -> {root}/v1/chat/completions
|
||||||
// -> `/responses` and anthropic-messages -> `/v1/messages` all land on an
|
// gpt-* -> {root}/v1/responses
|
||||||
// allowed route. getDaemonBaseUrl() strips any trailing slash, so no path
|
// Anthropic-served models override this per model with the ROOT, since the
|
||||||
// can be double-slashed either.
|
// Anthropic SDK appends `/v1/messages` itself (see buildPiModelEntry); a
|
||||||
const baseUrl = getDaemonBaseUrlFn(config);
|
// 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 = {};
|
let piConfig: PiConfig = {};
|
||||||
|
|
||||||
@@ -248,13 +275,16 @@ export async function installPiIntegration(
|
|||||||
// when the daemon has none, the user's hand-curated values are preserved.
|
// 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
|
// `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>(
|
const existingModels = new Map<string, PiModelEntry>(
|
||||||
(piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]),
|
(piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const providerModels: PiModelEntry[] = models.map((model) =>
|
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.
|
// Rebuild provider from scratch too; only write routstrd-managed fields.
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ const MOCK_DEPS: Partial<PiIntegrationDeps> = {
|
|||||||
context_length: 1000000,
|
context_length: 1000000,
|
||||||
architecture: { input_modalities: ["text", "image"] },
|
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", () => {
|
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 dir = mkdtempSync(join(tmpdir(), "pi-models-"));
|
||||||
const configPath = join(dir, "models.json");
|
const configPath = join(dir, "models.json");
|
||||||
await installPiIntegration(CONFIG, "key", makeIntegration(configPath), MOCK_DEPS);
|
await installPiIntegration(CONFIG, "key", makeIntegration(configPath), MOCK_DEPS);
|
||||||
@@ -70,14 +76,14 @@ describe("installPiIntegration", () => {
|
|||||||
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as {
|
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as {
|
||||||
providers: Record<string, { baseUrl?: string; api?: string }>;
|
providers: Record<string, { baseUrl?: string; api?: string }>;
|
||||||
};
|
};
|
||||||
// Every transport appends its own endpoint (/chat/completions,
|
// The OpenAI transports append only their endpoint (/chat/completions,
|
||||||
// /responses, /v1/messages). A base URL carrying /v1 would double the
|
// /responses) and need the version segment in the base URL; Anthropic
|
||||||
// prefix for Anthropic models and 404 on every provider.
|
// models override this per model with the root (see the next test).
|
||||||
expect(parsed.providers["routstr"].baseUrl).toBe("http://127.0.0.1:8008");
|
expect(parsed.providers["routstr"].baseUrl).toBe("http://127.0.0.1:8008/v1");
|
||||||
expect(parsed.providers["routstr"].api).toBe("openai-completions");
|
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 dir = mkdtempSync(join(tmpdir(), "pi-models-"));
|
||||||
const configPath = join(dir, "models.json");
|
const configPath = join(dir, "models.json");
|
||||||
await installPiIntegration(CONFIG, "key", makeIntegration(configPath), MOCK_DEPS);
|
await installPiIntegration(CONFIG, "key", makeIntegration(configPath), MOCK_DEPS);
|
||||||
@@ -85,9 +91,24 @@ describe("installPiIntegration", () => {
|
|||||||
const provider = await readProvider(configPath);
|
const provider = await readProvider(configPath);
|
||||||
const claude = provider.models.find((m) => m.id === "claude-opus-5.5");
|
const claude = provider.models.find((m) => m.id === "claude-opus-5.5");
|
||||||
expect(claude?.api).toBe("anthropic-messages");
|
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.
|
// Non-claude, non-gpt models fall through to the provider default.
|
||||||
const glm = provider.models.find((m) => m.id === "glm-5.3");
|
const glm = provider.models.find((m) => m.id === "glm-5.3");
|
||||||
expect("api" in glm!).toBe(false);
|
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 () => {
|
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" }));
|
const plain = buildPiModelEntry(model({ id: "glm-5.3" }));
|
||||||
expect("api" in plain).toBe(false);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user