mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
Merge pull request #106 from Routstr/feat/pi-thinking-levels
feat(integrations): derive pi thinking levels from the daemon's reasoning metadata
This commit is contained in:
+137
-47
@@ -5,17 +5,144 @@ 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<Record<PiThinkingLevel, string | null>>;
|
||||
|
||||
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<string, string | null>;
|
||||
thinkingLevelMap?: ThinkingLevelMap;
|
||||
compat?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** 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<PiThinkingLevel, string> = {
|
||||
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 };
|
||||
}
|
||||
|
||||
const isDeepSeekModel = (id: string): boolean => id.startsWith("deepseek");
|
||||
|
||||
/** 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 — except
|
||||
// for deepseek* models, where the role spelling below is authoritative.
|
||||
if (isDeepSeekModel(model.id)) {
|
||||
// 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, keeping any other user-set keys.
|
||||
entry.compat = { ...(previous?.compat ?? {}), supportsDeveloperRole: false };
|
||||
} else if (previous?.compat !== undefined) {
|
||||
entry.compat = previous.compat;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
type PiProviderConfig = {
|
||||
baseUrl?: string;
|
||||
api?: string;
|
||||
@@ -68,54 +195,17 @@ 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
|
||||
// (except for the deepseek* compat pin below, which is managed for the user).
|
||||
// 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.
|
||||
const existingModels = new Map<string, PiModelEntry>(
|
||||
(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 };
|
||||
|
||||
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 (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;
|
||||
});
|
||||
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"] = {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -2,8 +2,16 @@ 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 {
|
||||
IntegrationConfig,
|
||||
RoutstrModel,
|
||||
} from "../../src/integrations/registry";
|
||||
import {
|
||||
buildPiModelEntry,
|
||||
deriveThinkingFields,
|
||||
installPiIntegration,
|
||||
type PiModelEntry,
|
||||
} from "../../src/integrations/pi";
|
||||
import type { RoutstrdConfig } from "../../src/utils/config";
|
||||
|
||||
// Install the mock before importing modules that pull it in transitively.
|
||||
@@ -110,3 +118,199 @@ describe("installPiIntegration", () => {
|
||||
expect(updated?.compat).toEqual({ supportsDeveloperRole: true });
|
||||
});
|
||||
});
|
||||
|
||||
const model = (over: Partial<RoutstrModel>): 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user