mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(ai): derive OpenRouter reasoning controls (#8614)
Fixes #8454 Co-authored-by: Petr Houška <houskape@gmail.com>
This commit is contained in:
parent
c1729a0f75
commit
650e7a6126
4 changed files with 160 additions and 2 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
23
packages/ai/scripts/openrouter-reasoning-options.ts
Normal file
23
packages/ai/scripts/openrouter-reasoning-options.ts
Normal 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" };
|
||||
}
|
||||
109
packages/ai/test/openrouter-reasoning-options.test.ts
Normal file
109
packages/ai/test/openrouter-reasoning-options.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue