From 154c690bdf50cda028db0cc8edb86fb5d7d4f0f6 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:45:23 +0200 Subject: [PATCH] feat(integrations): derive pi thinking levels from the daemon's reasoning metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated models.json previously left reasoning/thinkingLevelMap to hand curation, so a fresh install advertised no thinking levels at all and a stale hand-written map could disagree with what the model accepts. routstr-core now publishes a per-model `reasoning` object (OpenRouter shape) with `supported_efforts`. Derive pi's thinking fields from it: - write every pi level explicitly, because pi reads an omitted level as "provider default" for levels through `high` and as "unsupported" for `xhigh`/`max` — a partial map would advertise levels the model rejects - map pi's `off` to the provider's `none`, and never offer `off` on models that report `mandatory: true` - return null when the daemon publishes no allowlist (models that only report `mandatory`, or no reasoning at all), and keep the user's hand-curated values in that case Extract buildPiModelEntry so the projection is testable, and cover the derivation plus the preserve/overwrite behaviour in tests. --- src/integrations/pi.ts | 155 ++++++++++++++++++++------ src/integrations/registry.ts | 14 +++ tests/integrations/pi.test.ts | 203 ++++++++++++++++++++++++++++++++++ 3 files changed, 340 insertions(+), 32 deletions(-) create mode 100644 tests/integrations/pi.test.ts diff --git a/src/integrations/pi.ts b/src/integrations/pi.ts index 9f13045..a03ae82 100644 --- a/src/integrations/pi.ts +++ b/src/integrations/pi.ts @@ -5,17 +5,131 @@ import type { RoutstrdConfig } from "../utils/config"; import type { IntegrationConfig, RoutstrModel } from "./registry"; import { callDaemon, getDaemonBaseUrl } from "../utils/daemon-client"; -type PiModelEntry = { +export type PiThinkingLevel = + | "off" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "max"; + +export type ThinkingLevelMap = Partial>; + +export type PiModelEntry = { id: string; contextWindow?: number; name?: string; input?: string[]; - // Thinking/reasoning config is user-curated and preserved across refreshes. reasoning?: boolean; - thinkingLevelMap?: Record; + thinkingLevelMap?: ThinkingLevelMap; compat?: Record; }; +/** pi thinking levels, in pi's documented order. */ +export const PI_THINKING_LEVELS: readonly PiThinkingLevel[] = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]; + +/** + * pi level -> the value sent to the provider. routstr-core publishes its + * allowlist in this same vocabulary (`none`/`minimal`/`low`/`medium`/`high`/ + * `xhigh`/`max`), so the mapping is identity apart from `off`. + */ +const THINKING_LEVEL_VALUES: Record = { + off: "none", + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: "max", +}; + +/** + * Build pi's thinking fields from the daemon's per-model `reasoning` object. + * + * Every level is written explicitly: pi treats an omitted level as "use the + * provider's default mapping" for standard levels (through `high`) and as + * "unsupported" for `xhigh`/`max`, so a partial map would silently advertise + * levels the model rejects. `null` hides the level in pi's UI. + * + * Returns null when the daemon publishes no effort allowlist (models whose + * upstream only reports `mandatory`, or that report no reasoning at all). + * Those are not guessable, so the caller keeps whatever the user curated. + */ +export function deriveThinkingFields( + model: RoutstrModel, +): { reasoning: true; thinkingLevelMap: ThinkingLevelMap } | null { + const reasoning = model.reasoning; + if (!reasoning) return null; + + const supported = (reasoning.supported_efforts ?? []) + .filter((effort): effort is string => typeof effort === "string") + .map((effort) => effort.trim().toLowerCase()) + .filter(Boolean); + if (supported.length === 0) return null; + + const allowed = new Set(supported); + // routstr-core strips `none` from mandatory models, so never offer `off` there. + if (reasoning.mandatory === true) allowed.delete("none"); + + const thinkingLevelMap: ThinkingLevelMap = {}; + for (const level of PI_THINKING_LEVELS) { + const value = THINKING_LEVEL_VALUES[level]; + thinkingLevelMap[level] = allowed.has(value) ? value : null; + } + + return { reasoning: true, thinkingLevelMap }; +} + +/** Project one daemon model onto a pi config entry. */ +export function buildPiModelEntry( + model: RoutstrModel, + previous?: PiModelEntry, +): PiModelEntry { + const entry: PiModelEntry = { id: model.id }; + + if (model.context_length !== undefined && model.context_length > 0) { + entry.contextWindow = model.context_length; + } + + if (model.name) { + entry.name = model.name; + } + + // Map the daemon's input modalities to Pi's ["text", "image"] vocabulary. + const mods = model.architecture?.input_modalities ?? []; + const input: string[] = []; + if (mods.includes("text")) input.push("text"); + if (mods.includes("image")) input.push("image"); + entry.input = input; + + const derived = deriveThinkingFields(model); + if (derived) { + entry.reasoning = derived.reasoning; + entry.thinkingLevelMap = derived.thinkingLevelMap; + } else { + // No allowlist to derive from: keep the user's hand-curated fields rather + // than guessing which levels the model accepts. + if (previous?.reasoning !== undefined) entry.reasoning = previous.reasoning; + if (previous?.thinkingLevelMap !== undefined) { + entry.thinkingLevelMap = previous.thinkingLevelMap; + } + } + + // `compat` is never published by the daemon; it stays user-curated. + if (previous?.compat !== undefined) entry.compat = previous.compat; + + return entry; +} + type PiProviderConfig = { baseUrl?: string; api?: string; @@ -68,39 +182,16 @@ 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. + // 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. const existingModels = new Map( (piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]), ); - const providerModels: PiModelEntry[] = models.map((model) => { - const previous = existingModels.get(model.id); - const entry: PiModelEntry = { id: model.id }; - - if (model.context_length !== undefined && model.context_length > 0) { - entry.contextWindow = model.context_length; - } - - if (model.name) { - entry.name = model.name; - } - - // Map the daemon's input modalities to Pi's ["text", "image"] vocabulary. - const mods = model.architecture?.input_modalities ?? []; - const input: string[] = []; - if (mods.includes("text")) input.push("text"); - if (mods.includes("image")) input.push("image"); - entry.input = input; - - // 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; - - return entry; - }); + const providerModels: PiModelEntry[] = models.map((model) => + buildPiModelEntry(model, existingModels.get(model.id)), + ); // Rebuild provider from scratch too; only write routstrd-managed fields. piConfig.providers["routstr"] = { diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 00935d2..1446b75 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -14,6 +14,19 @@ export interface IntegrationConfig { configPath: string; } +/** + * Per-model reasoning metadata as published by routstr-core (OpenRouter shape). + * Models with no reasoning support omit the whole object, and models whose + * upstream publishes no effort allowlist carry only `mandatory`. + */ +export type RoutstrReasoning = { + mandatory?: boolean | null; + default_enabled?: boolean | null; + supported_efforts?: string[] | null; + default_effort?: string | null; + supports_max_tokens?: boolean | null; +}; + export type RoutstrModel = { id: string; name?: string; @@ -27,6 +40,7 @@ export type RoutstrModel = { context_length?: number; max_completion_tokens?: number; }; + reasoning?: RoutstrReasoning | null; }; export type IntegrationFn = ( diff --git a/tests/integrations/pi.test.ts b/tests/integrations/pi.test.ts new file mode 100644 index 0000000..77ff87b --- /dev/null +++ b/tests/integrations/pi.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "bun:test"; +import type { RoutstrModel } from "../../src/integrations/registry"; +import { + buildPiModelEntry, + deriveThinkingFields, + type PiModelEntry, +} from "../../src/integrations/pi"; + +const model = (over: Partial): RoutstrModel => ({ + id: "test-model", + ...over, +}); + +describe("deriveThinkingFields", () => { + it("writes every level explicitly, mapping off to the provider's none", () => { + const derived = deriveThinkingFields( + model({ + reasoning: { + mandatory: false, + default_enabled: true, + supported_efforts: ["max", "xhigh", "high", "medium", "low", "none"], + default_effort: "medium", + }, + }), + ); + + expect(derived).toEqual({ + reasoning: true, + thinkingLevelMap: { + off: "none", + minimal: null, + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: "max", + }, + }); + }); + + it("hides off on mandatory models and nulls the gaps in the allowlist", () => { + const derived = deriveThinkingFields( + model({ + reasoning: { + mandatory: true, + default_enabled: true, + supported_efforts: ["max", "high", "low"], + default_effort: "max", + }, + }), + ); + + expect(derived?.thinkingLevelMap).toEqual({ + off: null, + minimal: null, + low: "low", + medium: null, + high: "high", + xhigh: null, + max: "max", + }); + }); + + it("never offers off on a mandatory model even if none is listed", () => { + const derived = deriveThinkingFields( + model({ reasoning: { mandatory: true, supported_efforts: ["high", "none"] } }), + ); + + expect(derived?.thinkingLevelMap).toEqual({ + off: null, + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: null, + max: null, + }); + }); + + it("keeps a sparse allowlist sparse", () => { + const derived = deriveThinkingFields( + model({ reasoning: { mandatory: false, supported_efforts: ["high", "minimal"] } }), + ); + + expect(derived?.thinkingLevelMap).toEqual({ + off: null, + minimal: "minimal", + low: null, + medium: null, + high: "high", + xhigh: null, + max: null, + }); + }); + + it("returns null when the upstream publishes no allowlist", () => { + expect(deriveThinkingFields(model({ reasoning: { mandatory: false } }))).toBeNull(); + expect(deriveThinkingFields(model({ reasoning: null }))).toBeNull(); + expect(deriveThinkingFields(model({}))).toBeNull(); + expect( + deriveThinkingFields(model({ reasoning: { supported_efforts: [] } })), + ).toBeNull(); + }); + + it("ignores unknown levels and normalizes case and padding", () => { + const derived = deriveThinkingFields( + model({ reasoning: { supported_efforts: [" HIGH ", "auto", "low"] } }), + ); + + expect(derived?.thinkingLevelMap).toEqual({ + off: null, + minimal: null, + low: "low", + medium: null, + high: "high", + xhigh: null, + max: null, + }); + }); +}); + +describe("buildPiModelEntry", () => { + it("derives thinking fields alongside the rest of the entry", () => { + const entry = buildPiModelEntry( + model({ + id: "gpt-5.6-sol", + name: "OpenAI: GPT-5.6 Sol", + context_length: 1050000, + architecture: { input_modalities: ["file", "image", "text"] }, + reasoning: { supported_efforts: ["max", "high", "none"] }, + }), + ); + + expect(entry).toEqual({ + id: "gpt-5.6-sol", + name: "OpenAI: GPT-5.6 Sol", + contextWindow: 1050000, + input: ["text", "image"], + reasoning: true, + thinkingLevelMap: { + off: "none", + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: null, + max: "max", + }, + }); + }); + + it("overwrites a stale hand-written map when the daemon has an allowlist", () => { + const previous: PiModelEntry = { + id: "glm-5.3", + reasoning: true, + thinkingLevelMap: { off: "none", low: "low" }, + compat: { supportsReasoningEffort: true }, + }; + + const entry = buildPiModelEntry( + model({ id: "glm-5.3", reasoning: { mandatory: true, supported_efforts: ["max", "high", "low"] } }), + previous, + ); + + expect(entry.thinkingLevelMap).toEqual({ + off: null, + minimal: null, + low: "low", + medium: null, + high: "high", + xhigh: null, + max: "max", + }); + // compat is never published by the daemon, so it survives. + expect(entry.compat).toEqual({ supportsReasoningEffort: true }); + }); + + it("preserves hand-curated thinking fields when the daemon cannot decide", () => { + const previous: PiModelEntry = { + id: "minimax-m3", + reasoning: true, + thinkingLevelMap: { off: null }, + compat: { supportsReasoningEffort: false }, + }; + + const entry = buildPiModelEntry( + model({ id: "minimax-m3", reasoning: { mandatory: false } }), + previous, + ); + + expect(entry.reasoning).toBe(true); + expect(entry.thinkingLevelMap).toEqual({ off: null }); + expect(entry.compat).toEqual({ supportsReasoningEffort: false }); + }); + + it("omits thinking fields entirely when neither side has any", () => { + const entry = buildPiModelEntry(model({ id: "gemma-4-uncensored" })); + + expect(entry).toEqual({ id: "gemma-4-uncensored", input: [] }); + expect("reasoning" in entry).toBe(false); + expect("thinkingLevelMap" in entry).toBe(false); + }); +});