mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
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:
+34
-5
@@ -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]),
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user