fix(ai): derive OpenRouter reasoning controls (#8614)

Fixes #8454

Co-authored-by: Petr Houška <houskape@gmail.com>
This commit is contained in:
David Brailovsky 2026-08-25 12:11:21 +02:00 • committed by GitHub
parent c1729a0f75
commit 650e7a6126
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 160 additions and 2 deletions

View file

@ -34,6 +34,7 @@
- Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)).
- Fixed Google Generative AI and Vertex AI custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)).
- Fixed Xiaomi model catalog generation retaining shut-down MiMo V2 model names after models.dev marked them deprecated ([#8187](https://github.com/earendil-works/pi/issues/8187)).
- Fixed OpenRouter reasoning controls by deriving `off` support and available effort levels from OpenRouter's model metadata, preventing reasoning-mandatory models from receiving `effort: "none"` ([#8454](https://github.com/earendil-works/pi/issues/8454)).
## [0.84.2] - 2026-08-14

View file

@ -4,6 +4,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSy
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { getEffortThinkingLevelMap, type ModelsDevReasoningOption } from "./models-dev-reasoning-options.ts";
import {
getOpenRouterThinkingLevelMap,
type OpenRouterReasoningMetadata,
} from "./openrouter-reasoning-options.ts";
import {
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
@ -127,6 +131,25 @@ interface NvidiaNimModelListItem {
id: string;
}
interface OpenRouterModelListItem {
id: string;
name: string;
supported_parameters?: string[];
architecture?: { modality?: string };
pricing?: {
prompt?: string;
completion?: string;
input_cache_read?: string;
input_cache_write?: string;
};
top_provider?: {
context_length?: number;
max_completion_tokens?: number;
};
context_length?: number;
reasoning?: OpenRouterReasoningMetadata;
}
interface AiGatewayModel {
id: string;
name?: string;
@ -1051,11 +1074,11 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
console.log("Fetching models from OpenRouter API...");
const response = await fetch("https://openrouter.ai/api/v1/models");
if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`);
const data = await response.json();
const data = (await response.json()) as { data?: OpenRouterModelListItem[] };
const models: Model<any>[] = [];
for (const model of data.data) {
for (const model of data.data ?? []) {
// Only include models that support tools
if (!model.supported_parameters?.includes("tools")) continue;
@ -1078,6 +1101,7 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
const cacheWriteCost = roundCost(parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000);
const contextWindow = model.top_provider?.context_length || model.context_length || 4096;
const thinkingLevelMap = getOpenRouterThinkingLevelMap(model.reasoning);
const normalizedModel: Model<any> = {
id: modelKey,
@ -1086,6 +1110,7 @@ async function fetchOpenRouterModels(): Promise<Model<any>[]> {
baseUrl: "https://openrouter.ai/api/v1",
provider,
reasoning: model.supported_parameters?.includes("reasoning") || false,
...(thinkingLevelMap && { thinkingLevelMap }),
input,
cost: {
input: inputCost,

View file

@ -0,0 +1,23 @@
import type { ThinkingLevel, ThinkingLevelMap } from "../src/types.ts";
import { getEffortThinkingLevelMap } from "./models-dev-reasoning-options.ts";
export interface OpenRouterReasoningMetadata {
mandatory?: boolean;
default_enabled?: boolean;
supported_efforts?: Array<ThinkingLevel | "none">;
default_effort?: ThinkingLevel | "none";
}
/** Convert OpenRouter's reasoning metadata into Pi model capabilities. */
export function getOpenRouterThinkingLevelMap(
reasoning: OpenRouterReasoningMetadata | undefined,
): ThinkingLevelMap | undefined {
if (!reasoning) return undefined;
if (!reasoning.supported_efforts?.length) return reasoning.mandatory === true ? { off: null } : undefined;
// OpenRouter's supported_efforts uses the same effort values as models.dev reasoning_options,
// so both sources can share the same Pi thinking-level conversion.
const map = getEffortThinkingLevelMap([{ type: "effort", values: reasoning.supported_efforts }]);
if (!map) return reasoning.mandatory === true ? { off: null } : undefined;
return { ...map, off: reasoning.mandatory === true ? null : "none" };
}

View file

@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { getOpenRouterThinkingLevelMap } from "../scripts/openrouter-reasoning-options.ts";
import { streamSimple } from "../src/api/openai-completions.ts";
import type { Context, Model, ThinkingLevelMap } from "../src/types.ts";
const context: Context = {
messages: [{ role: "user", content: "Hello", timestamp: 0 }],
};
function openRouterModel(thinkingLevelMap?: ThinkingLevelMap): Model<"openai-completions"> {
return {
id: "stealth/ox-alpha",
name: "Ox Alpha",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://example.invalid/v1",
reasoning: true,
thinkingLevelMap,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
compat: { thinkingFormat: "openrouter" },
};
}
async function capturePayload(model: Model<"openai-completions">, reasoning?: "low") {
let payload: { reasoning?: { effort?: string } } | undefined;
await streamSimple(model, context, {
apiKey: "test",
reasoning,
onPayload: (request) => {
payload = request as { reasoning?: { effort?: string } };
throw new Error("payload captured");
},
}).result();
if (!payload) throw new Error("OpenRouter payload was not captured");
return payload;
}
describe("getOpenRouterThinkingLevelMap", () => {
it("marks mandatory reasoning and unsupported efforts unavailable", () => {
expect(
getOpenRouterThinkingLevelMap({
mandatory: true,
default_enabled: true,
supported_efforts: ["max", "high", "low"],
default_effort: "max",
}),
).toEqual({
off: null,
minimal: null,
low: "low",
medium: null,
high: "high",
xhigh: null,
max: "max",
});
});
it("still marks off unavailable when OpenRouter omits effort metadata", () => {
expect(getOpenRouterThinkingLevelMap({ mandatory: true })).toEqual({ off: null });
});
it("keeps off available while restricting optional models to supported efforts", () => {
expect(
getOpenRouterThinkingLevelMap({
mandatory: false,
default_enabled: true,
supported_efforts: ["high", "low"],
}),
).toEqual({
off: "none",
minimal: null,
low: "low",
medium: null,
high: "high",
xhigh: null,
max: null,
});
});
it("does not add metadata for optional models without effort controls", () => {
expect(getOpenRouterThinkingLevelMap({ mandatory: false })).toBeUndefined();
});
});
describe("OpenRouter mandatory reasoning payloads", () => {
const mandatoryMap = getOpenRouterThinkingLevelMap({
mandatory: true,
supported_efforts: ["max", "high", "low"],
});
it("omits reasoning when a background call does not request it", async () => {
expect(await capturePayload(openRouterModel(mandatoryMap))).not.toHaveProperty("reasoning");
});
it("still sends an explicitly selected supported effort", async () => {
expect(await capturePayload(openRouterModel(mandatoryMap), "low")).toMatchObject({
reasoning: { effort: "low" },
});
});
it("continues to explicitly disable reasoning for optional models", async () => {
expect(await capturePayload(openRouterModel())).toMatchObject({
reasoning: { effort: "none" },
});
});
});