mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
fix: pin supportsDeveloperRole=false for deepseek* models in pi integration
Pi sends the system prompt as role "developer" (OpenAI's newer spelling) for reasoning models on unrecognized providers — its heuristics only see routstrd's local URL and can't know DeepSeek sits behind it. Strict upstream deserializers reject the whole chat body with a hard 400: messages[0].role: unknown variant `developer`, expected one of `system`, `user`, `assistant`, `tool`, ... installPiIntegration rebuilds the routstr provider on every refresh, so the pin must be generated: deepseek* models always get compat.supportsDeveloperRole=false, with any other user-curated compat keys preserved. Non-deepseek models keep the previous preserve-as-is behavior.
This commit is contained in:
+17
-2
@@ -70,11 +70,20 @@ export async function installPiIntegration(
|
|||||||
// Rebuild every model entry from scratch from the daemon, so the generated
|
// Rebuild every model entry from scratch from the daemon, so the generated
|
||||||
// models.json is always a faithful projection of the daemon's state. The only
|
// models.json is always a faithful projection of the daemon's state. The only
|
||||||
// exception is thinking/reasoning config (reasoning, thinkingLevelMap, compat),
|
// exception is thinking/reasoning config (reasoning, thinkingLevelMap, compat),
|
||||||
// which the daemon does not provide and the user curates by hand — preserve it.
|
// which the daemon does not provide and the user curates by hand — preserve it
|
||||||
|
// (except for the deepseek* compat pin below, which is managed for the user).
|
||||||
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]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// DeepSeek-backed models reject the `developer` role (OpenAI's newer
|
||||||
|
// spelling of `system`) on strict upstreams with a hard 400. Pi sends
|
||||||
|
// `developer` for reasoning models on unrecognized providers because its
|
||||||
|
// provider heuristics only see the local daemon URL and can't know
|
||||||
|
// DeepSeek sits behind it — force the universally-accepted `system`
|
||||||
|
// spelling for every deepseek* model.
|
||||||
|
const isDeepSeekModel = (id: string): boolean => id.startsWith("deepseek");
|
||||||
|
|
||||||
const providerModels: PiModelEntry[] = models.map((model) => {
|
const providerModels: PiModelEntry[] = models.map((model) => {
|
||||||
const previous = existingModels.get(model.id);
|
const previous = existingModels.get(model.id);
|
||||||
const entry: PiModelEntry = { id: model.id };
|
const entry: PiModelEntry = { id: model.id };
|
||||||
@@ -97,7 +106,13 @@ export async function installPiIntegration(
|
|||||||
// Preserve user-curated thinking fields from the previous entry.
|
// Preserve user-curated thinking fields from the previous entry.
|
||||||
if (previous?.reasoning !== undefined) entry.reasoning = previous.reasoning;
|
if (previous?.reasoning !== undefined) entry.reasoning = previous.reasoning;
|
||||||
if (previous?.thinkingLevelMap !== undefined) entry.thinkingLevelMap = previous.thinkingLevelMap;
|
if (previous?.thinkingLevelMap !== undefined) entry.thinkingLevelMap = previous.thinkingLevelMap;
|
||||||
if (previous?.compat !== undefined) entry.compat = previous.compat;
|
if (isDeepSeekModel(model.id)) {
|
||||||
|
// Authoritative for deepseek* models: keep any other user-set compat
|
||||||
|
// keys, but always pin supportsDeveloperRole to false.
|
||||||
|
entry.compat = { ...(previous?.compat ?? {}), supportsDeveloperRole: false };
|
||||||
|
} else if (previous?.compat !== undefined) {
|
||||||
|
entry.compat = previous.compat;
|
||||||
|
}
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it, mock } from "bun:test";
|
||||||
|
import { mkdtempSync, readFileSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import type { IntegrationConfig } from "../../src/integrations/registry";
|
||||||
|
import { installPiIntegration } from "../../src/integrations/pi";
|
||||||
|
import type { RoutstrdConfig } from "../../src/utils/config";
|
||||||
|
|
||||||
|
// Install the mock before importing modules that pull it in transitively.
|
||||||
|
mock.module("../../src/utils/daemon-client", () => ({
|
||||||
|
callDaemon: async () => ({
|
||||||
|
output: {
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "deepseek-v4.1-flash",
|
||||||
|
name: "DeepSeek V4.1 Flash",
|
||||||
|
context_length: 1048576,
|
||||||
|
architecture: { input_modalities: ["text"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "glm-5.3",
|
||||||
|
name: "GLM 5.3",
|
||||||
|
context_length: 262144,
|
||||||
|
architecture: { input_modalities: ["text", "image"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getDaemonBaseUrl: (config: RoutstrdConfig) =>
|
||||||
|
`http://127.0.0.1:${config.port}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CONFIG: RoutstrdConfig = { port: 8008 } as RoutstrdConfig;
|
||||||
|
|
||||||
|
function makeIntegration(configPath: string): IntegrationConfig {
|
||||||
|
return { clientId: "pi-agent", name: "Pi Agent", configPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readProvider(configPath: string) {
|
||||||
|
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as {
|
||||||
|
providers: Record<string, { models: Array<Record<string, unknown>> }>;
|
||||||
|
};
|
||||||
|
return parsed.providers["routstr"];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("installPiIntegration", () => {
|
||||||
|
it("pins supportsDeveloperRole=false for deepseek* models", async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pi-models-"));
|
||||||
|
const configPath = join(dir, "models.json");
|
||||||
|
await installPiIntegration(CONFIG, "key", makeIntegration(configPath));
|
||||||
|
|
||||||
|
const provider = await readProvider(configPath);
|
||||||
|
const deepseek = provider.models.find((m) => m.id === "deepseek-v4.1-flash");
|
||||||
|
expect(deepseek?.compat).toEqual({ supportsDeveloperRole: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves non-deepseek models without a compat block", async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pi-models-"));
|
||||||
|
const configPath = join(dir, "models.json");
|
||||||
|
await installPiIntegration(CONFIG, "key", makeIntegration(configPath));
|
||||||
|
|
||||||
|
const provider = await readProvider(configPath);
|
||||||
|
const glm = provider.models.find((m) => m.id === "glm-5.3");
|
||||||
|
expect(glm?.compat).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves user compat keys on deepseek* models while pinning the role", async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pi-models-"));
|
||||||
|
const configPath = join(dir, "models.json");
|
||||||
|
await installPiIntegration(CONFIG, "key", makeIntegration(configPath));
|
||||||
|
|
||||||
|
// Simulate a user-curated refresh: seed reasoning/compat, run again.
|
||||||
|
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as {
|
||||||
|
providers: Record<string, { models: Array<Record<string, unknown>> }>;
|
||||||
|
};
|
||||||
|
const deepseek = parsed.providers["routstr"].models.find(
|
||||||
|
(m) => m.id === "deepseek-v4.1-flash",
|
||||||
|
);
|
||||||
|
deepseek!.reasoning = true;
|
||||||
|
deepseek!.compat = { supportsStrictMode: true };
|
||||||
|
const { writeFileSync } = await import("fs");
|
||||||
|
writeFileSync(configPath, JSON.stringify(parsed));
|
||||||
|
|
||||||
|
await installPiIntegration(CONFIG, "key", makeIntegration(configPath));
|
||||||
|
const provider = await readProvider(configPath);
|
||||||
|
const updated = provider.models.find((m) => m.id === "deepseek-v4.1-flash");
|
||||||
|
expect(updated?.compat).toEqual({
|
||||||
|
supportsStrictMode: true,
|
||||||
|
supportsDeveloperRole: false,
|
||||||
|
});
|
||||||
|
expect(updated?.reasoning).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves compat untouched for non-deepseek models across refreshes", async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pi-models-"));
|
||||||
|
const configPath = join(dir, "models.json");
|
||||||
|
await installPiIntegration(CONFIG, "key", makeIntegration(configPath));
|
||||||
|
|
||||||
|
const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as {
|
||||||
|
providers: Record<string, { models: Array<Record<string, unknown>> }>;
|
||||||
|
};
|
||||||
|
const glm = parsed.providers["routstr"].models.find((m) => m.id === "glm-5.3");
|
||||||
|
glm!.compat = { supportsDeveloperRole: true };
|
||||||
|
const { writeFileSync } = await import("fs");
|
||||||
|
writeFileSync(configPath, JSON.stringify(parsed));
|
||||||
|
|
||||||
|
await installPiIntegration(CONFIG, "key", makeIntegration(configPath));
|
||||||
|
const provider = await readProvider(configPath);
|
||||||
|
const updated = provider.models.find((m) => m.id === "glm-5.3");
|
||||||
|
expect(updated?.compat).toEqual({ supportsDeveloperRole: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user