From fb3f578513975eb0fd9a3371ffbb9ddd62018144 Mon Sep 17 00:00:00 2001 From: Fred Tan Date: Thu, 22 Oct 2026 20:18:20 +0800 Subject: [PATCH] fix(catalog): expose real thinking efforts to omp's effort picker omp populates its effort picker from `model.thinking` (not the `thinkingLevelMap` that earendil pi-ai exposes). Without it, omp falls back to a generic `minimal`..`high` ladder, so `DeepSeek-V4-Pro` offers `low`/`medium` it does not supported and hides `max`. Map `thinkint_config` into omp's `ThinkingConfig` shape (mode "effort", ordered efforts, `defaultLevel`, `requiresEffort = !disabled`) and attach it to each registered model. earendil pi keeps reading `thinkingLevelMap` and ignores the new field. --- src/__tests__/catalog.test.ts | 55 +++++++++++++++++++++++++++++++++++ src/catalog.ts | 47 ++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/src/__tests__/catalog.test.ts b/src/__tests__/catalog.test.ts index c8c2a90..83d97ae 100644 --- a/src/__tests__/catalog.test.ts +++ b/src/__tests__/catalog.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildThinkingConfig, contextWindowFromCatalog, DEFAULT_CONTEXT_WINDOW, getCachedModelConfig, @@ -169,3 +170,57 @@ describe("ZERO_COST", () => { expect(Object.isFrozen(ZERO_COST)).toBe(true); }); }); + +describe("buildThinkingConfig", () => { + it("maps an effort-based model with a disabled branch", () => { + const entry = { + thinking_config: { + disabled: { description: "Disable thinking" }, + enabled: { + efforts: { high: {}, max: { is_default: true } }, + is_default: true, + }, + }, + }; + expect(buildThinkingConfig(entry)).toEqual({ + mode: "effort", + efforts: ["high", "max"], + defaultLevel: "max", + requiresEffort: false, + }); + }); + + it("marks reasoning mandatory when there is no disabled branch", () => { + const entry = { + thinking_config: { + enabled: { + efforts: { high: {}, max: { is_default: true } }, + is_default: true, + }, + }, + }; + expect(buildThinkingConfig(entry)).toMatchObject({ + mode: "effort", + efforts: ["high", "max"], + defaultLevel: "max", + requiresEffort: true, + }); + }); + + it("orders efforts least to most intensive", () => { + const entry = { + thinking_config: { + enabled: { + efforts: { xhigh: {}, high: { is_default: true }, low: {}, max: {}, medium: {} }, + is_default: true, + }, + }, + }; + expect(buildThinkingConfig(entry)?.efforts).toEqual(["low", "medium", "high", "xhigh", "max"]); + }); + + it("returns undefined for toggle=only models and non-reasoning entries", () => { + expect(buildThinkingConfig({ thinking_config: { enabled: { is_default: true } } })).toBeUndefined(); + expect(buildThinkingConfig({})).toBeUndefined(); + }); +}); diff --git a/src/catalog.ts b/src/catalog.ts index 77c0928..9be443c 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -48,6 +48,21 @@ export interface QoderModelEntry { [key: string]: unknown; } +/** + * omp fork's `ThinkingConfig` shape. The fork reads `model.thinking` (not the + * `thinkingLevelMap` that earendil pi-ai exposes), and when it is absent omp + * fills a generic default ladder (`minimal`..`high`) instead of the model's + * real efforts - so `DeepSeek-V4-Pro` would show `low`/`medium` it does not + * support. Defining this inline because the plugin compiles against + * @earendil-works/pi-ai, which has no such field. + */ +export interface QoderThinkingConfig { + mode: "effort"; + efforts: readonly ThinkingLevel[]; + defaultLevel?: ThinkingLevel; + requiresEffort?: boolean; +} + export interface QoderModelDef { id: string; upstreamKey?: string; @@ -58,6 +73,7 @@ export interface QoderModelDef { reasoning: boolean; supportsEffort: boolean; thinkingLevelMap?: ThinkingLevelMap; + thinking?: QoderThinkingConfig; input: ("text" | "image")[]; cost: typeof ZERO_COST; contextWindow: number; @@ -526,6 +542,35 @@ function buildThinkingLevelMap(entry: QoderModelEntry): ThinkingLevelMap | undef return undefined; } +/** + * Map Qoder's `thinking_config` to omp's `thinking` metadata so omp's effort + * picker lists the actual supported levels (it reads `thinking.efforts`, not + * `thinkingLevelMap`). Only effort-based models have a meaningful ladder; + * toggle-only models (no `efforts`) return undefined so omp falls back to its + * generic behavior. + */ +export function buildThinkingConfig(entry: QoderModelEntry): QoderThinkingConfig | undefined { + const tc = entry.thinking_config; + const efforts = tc?.enabled?.efforts; + if (!efforts || typeof efforts !== "object") return undefined; + + const supported = new Set(Object.keys(efforts)); + const ordered = PI_THINKING_LEVELS.filter((level) => supported.has(level)); + if (ordered.length === 0) return undefined; + + const defaultLevel = PI_THINKING_LEVELS.find((level) => efforts[level]?.is_default); + + return { + mode: "effort", + efforts: ordered, + ...(defaultLevel ? { defaultLevel } : {}), + // Qoder's catalog marks a `disabled` branch when thinking can be turned + // off. Without it reasoning is mandatory upstream, so omp clamps "off" to + // the lowest supported effort instead of disabling. + requiresEffort: !tc?.disabled, + }; +} + export function getCachedModels(mode: QoderMode): QoderModelDef[] { const data = readParsedModelCache(mode); if (data && Array.isArray(data.models)) { @@ -678,6 +723,7 @@ export async function updateQoderModelsCache( const isReasoning = !!entry.is_reasoning || !!entry.thinking_config; const supportsEffort = !!entry.thinking_config?.enabled?.efforts; const thinkingLevelMap = buildThinkingLevelMap(entry); + const thinking = buildThinkingConfig(entry); // Both regions expose display_name (whitespace-stripped) as the sole // pi-visible id. The config stores the upstream key under that id for // request-time use. @@ -694,6 +740,7 @@ export async function updateQoderModelsCache( reasoning: isReasoning, supportsEffort, thinkingLevelMap, + thinking, input: isVL ? ["text", "image"] : ["text"], cost: ZERO_COST, contextWindow: ctxLen,