diff --git a/src/integrations/pi.ts b/src/integrations/pi.ts index 9f13045..3dd9ea1 100644 --- a/src/integrations/pi.ts +++ b/src/integrations/pi.ts @@ -70,11 +70,20 @@ export async function installPiIntegration( // 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 // 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( (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 previous = existingModels.get(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. if (previous?.reasoning !== undefined) entry.reasoning = previous.reasoning; 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; }); diff --git a/tests/integrations/pi.test.ts b/tests/integrations/pi.test.ts new file mode 100644 index 0000000..b82b0cf --- /dev/null +++ b/tests/integrations/pi.test.ts @@ -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> }>; + }; + 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> }>; + }; + 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> }>; + }; + 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 }); + }); +});