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 isDeepSeekModel = (id: string): boolean => id.startsWith("deepseek");
|
||||||
const isGptModel = (id: string): boolean => id.startsWith("gpt-");
|
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. */
|
/** Project one daemon model onto a pi config entry. */
|
||||||
export function buildPiModelEntry(
|
export function buildPiModelEntry(
|
||||||
@@ -115,11 +122,22 @@ export function buildPiModelEntry(
|
|||||||
if (mods.includes("image")) input.push("image");
|
if (mods.includes("image")) input.push("image");
|
||||||
entry.input = input;
|
entry.input = input;
|
||||||
|
|
||||||
// gpt-* models are served through OpenAI's Responses API; the provider-level
|
// Per-model transport pins. `api` is per-model while the provider `baseUrl`
|
||||||
// `api: "openai-completions"` stays the default for everything else. For
|
// is shared, and the transports disagree about what a base URL means: the
|
||||||
// non-gpt models keep whatever per-model api the user curated.
|
// 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)) {
|
if (isGptModel(model.id)) {
|
||||||
entry.api = "openai-responses";
|
entry.api = "openai-responses";
|
||||||
|
} else if (isClaudeModel(model.id)) {
|
||||||
|
entry.api = "anthropic-messages";
|
||||||
} else if (previous?.api !== undefined) {
|
} else if (previous?.api !== undefined) {
|
||||||
entry.api = previous.api;
|
entry.api = previous.api;
|
||||||
}
|
}
|
||||||
@@ -185,7 +203,16 @@ 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}`);
|
||||||
|
|
||||||
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 = {};
|
let piConfig: PiConfig = {};
|
||||||
|
|
||||||
@@ -219,7 +246,9 @@ export async function installPiIntegration(
|
|||||||
// models.json is always a faithful projection of the daemon's state.
|
// models.json is always a faithful projection of the daemon's state.
|
||||||
// Thinking fields are derived from the model's published reasoning allowlist;
|
// Thinking fields are derived from the model's published reasoning allowlist;
|
||||||
// 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
|
||||||
|
// decides which transport — and so which endpoint — 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]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ const MOCK_DEPS: Partial<PiIntegrationDeps> = {
|
|||||||
context_length: 262144,
|
context_length: 262144,
|
||||||
architecture: { input_modalities: ["text", "image"] },
|
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", () => {
|
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 () => {
|
it("pins supportsDeveloperRole=false for deepseek* 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");
|
||||||
@@ -327,7 +383,22 @@ describe("buildPiModelEntry", () => {
|
|||||||
expect(entry.api).toBe("openai-responses");
|
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(
|
const curated = buildPiModelEntry(
|
||||||
model({ id: "glm-5.3" }),
|
model({ id: "glm-5.3" }),
|
||||||
{ id: "glm-5.3", api: "openai-responses" },
|
{ id: "glm-5.3", api: "openai-responses" },
|
||||||
|
|||||||
Reference in New Issue
Block a user