From 8cc8037f6bb88ea2535c9048fa4b9cc1b00f85ab Mon Sep 17 00:00:00 2001 From: quasigod Date: Thu, 6 Aug 2026 18:12:34 -0400 Subject: [PATCH] fix(local): surface model reasoning capabilities --- src/backend/local/local-model-config.ts | 40 ++++++ .../local-pi-provider-catalog.test.ts | 124 ++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/src/backend/local/local-model-config.ts b/src/backend/local/local-model-config.ts index cb150de688..3428aac671 100644 --- a/src/backend/local/local-model-config.ts +++ b/src/backend/local/local-model-config.ts @@ -1,4 +1,6 @@ import type { Api, Model } from "@earendil-works/pi-ai"; +import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; +import type { ModelReasoningEffort } from "@/agent/model"; import { DEFAULT_PI_PROVIDER, isUnselectedLocalModelHandle, @@ -48,6 +50,11 @@ interface LocalModelListEntry { model_endpoint_type: string; name: string; provider_type: string; + /** Reasoning efforts this model supports, so the UI can offer a level picker. */ + reasoning_capabilities?: { + supported_efforts?: ModelReasoningEffort[] | null; + mandatory?: boolean; + }; } interface ListLocalModelsOptions { @@ -247,6 +254,30 @@ export async function resolveAvailableLocalModelForTurn(input: { }; } +// Translates a pi-ai Model's reasoning capabilities (reasoning flag + +// thinkingLevelMap) into Letta's reasoning_capabilities contract so the UI can +// offer a level picker for locally-hosted pi-ai models. pi-ai exposes the +// canonical supported list via getSupportedThinkingLevels, which honors +// `reasoning: false` and null thinkingLevelMap entries. The one naming +// mismatch: pi calls the reasoning-disabled level "off", Letta calls it "none" +// (the picker renders it as "Off", a 0-bar state distinct from the +// provider-default null option). A model that cannot disable reasoning +// reports `mandatory: true`. +function reasoningCapabilitiesForPiModel( + model: Model | undefined, +): LocalModelListEntry["reasoning_capabilities"] { + if (!model?.reasoning) return undefined; + const supportedLevels = getSupportedThinkingLevels(model); + const mandatory = !supportedLevels.includes("off"); + const supportedEfforts = supportedLevels.map((level) => + level === "off" ? "none" : (level as ModelReasoningEffort), + ); + return { + supported_efforts: supportedEfforts, + ...(mandatory ? { mandatory: true } : {}), + }; +} + export async function listLocalModels( storageDir?: string, options: ListLocalModelsOptions = {}, @@ -277,6 +308,8 @@ export async function listLocalModels( maxOutputTokens?: number; modelEndpointType?: string; name?: string; + /** Full pi-ai Model published by a runtime-managed provider. */ + model?: Model; } = {}, ) => { const handle = @@ -312,6 +345,9 @@ export async function listLocalModels( const providerType = options.modelEndpointType ?? localProviderTypeForModelConfig(provider); const name = options.name ?? catalogModel?.name ?? modelId; + const reasoningCapabilities = reasoningCapabilitiesForPiModel( + options.model ?? catalogModel, + ); models.push({ display_name: name, handle, @@ -321,6 +357,9 @@ export async function listLocalModels( model_endpoint_type: providerType, name, provider_type: providerType, + ...(reasoningCapabilities + ? { reasoning_capabilities: reasoningCapabilities } + : {}), }); }; @@ -398,6 +437,7 @@ export async function listLocalModels( maxContextWindow: model.contextWindow, maxOutputTokens: model.maxTokens, name: model.name, + model, }); } } diff --git a/src/providers/local-pi-provider-catalog.test.ts b/src/providers/local-pi-provider-catalog.test.ts index 3da1c6a80a..cfb98399de 100644 --- a/src/providers/local-pi-provider-catalog.test.ts +++ b/src/providers/local-pi-provider-catalog.test.ts @@ -431,6 +431,60 @@ describe("local pi provider catalog", () => { } }); + test("local model listing surfaces reasoning capabilities for mod providers", async () => { + const storageDir = await mkdtemp(join(tmpdir(), "local-acme-reasoning-")); + try { + // Register the way a provider mod does: through registerPiProvider with + // an owner (the mod engine passes { id: owner.id, path: owner.path }). + registerPiProvider( + "acme", + { + baseUrl: "https://api.acme.dev/v1", + apiKey: "ACME_API_KEY", + api: "openai-completions", + listModels() { + return [ + { + id: "acme-reasoner", + name: "Acme Reasoner", + reasoning: true, + thinkingLevelMap: { + minimal: null, + low: null, + medium: null, + high: "high", + max: "max", + }, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }, + ]; + }, + }, + { id: "mod-owner", path: "/tmp/mod-owner" }, + ); + await createOrUpdateLocalProvider({ + storageDir, + providerType: "acme", + providerName: "acme", + apiKey: "acme-key", + }); + + const models = await listLocalModels(storageDir); + const reasoner = models.find( + (model) => model.handle === "acme/acme-reasoner", + ); + + expect(reasoner?.reasoning_capabilities).toEqual({ + supported_efforts: ["none", "high", "max"], + }); + } finally { + await rm(storageDir, { recursive: true, force: true }); + } + }); + test("local model listing passes mod OAuth api keys to listModels", async () => { const storageDir = await mkdtemp(join(tmpdir(), "local-kilo-oauth-")); try { @@ -520,4 +574,74 @@ describe("local pi provider catalog", () => { await rm(storageDir, { recursive: true, force: true }); } }); + + test("local model listing surfaces pi-ai reasoning capabilities", async () => { + const storageDir = await mkdtemp(join(tmpdir(), "local-pi-provider-")); + try { + await createOrUpdateLocalProvider({ + storageDir, + providerType: "deepseek", + providerName: "deepseek", + apiKey: "deepseek-key", + }); + + const flash = (await listLocalModels(storageDir)).find( + (model) => model.handle === "deepseek/deepseek-v4-flash", + ); + // deepseek-v4-flash declares reasoning: true with thinkingLevelMap + // { high, max } and reasoning-able-off, so the UI should expose a level + // picker for it (null default + none/Off + high + max), never mandatory. + expect(flash?.reasoning_capabilities).toEqual({ + supported_efforts: ["none", "high", "max"], + }); + } finally { + await rm(storageDir, { recursive: true, force: true }); + } + }); + + test("local model listing marks reasoning as mandatory when off is unsupported", async () => { + const storageDir = await mkdtemp(join(tmpdir(), "local-pi-provider-")); + try { + await createOrUpdateLocalProvider({ + storageDir, + providerType: "opencode", + providerName: "opencode", + apiKey: "opencode-key", + }); + + const fable = (await listLocalModels(storageDir)).find( + (model) => model.handle === "opencode/claude-fable-5", + ); + // claude-fable-5 is an adaptive-thinking model that cannot disable + // reasoning (no "off" in its supported levels), so the UI must not offer + // an Off option and must mark the capability mandatory. + expect(fable?.reasoning_capabilities).toEqual({ + supported_efforts: ["minimal", "low", "medium", "high", "xhigh", "max"], + mandatory: true, + }); + } finally { + await rm(storageDir, { recursive: true, force: true }); + } + }); + + test("local model listing omits reasoning capabilities for non-reasoning models", async () => { + const storageDir = await mkdtemp(join(tmpdir(), "local-pi-provider-")); + try { + await createOrUpdateLocalProvider({ + storageDir, + providerType: "groq", + providerName: "groq", + apiKey: "groq-key", + }); + + const llama = (await listLocalModels(storageDir)).find( + (model) => model.handle === "groq/llama-3.1-8b-instant", + ); + // Non-reasoning models emit no reasoning_capabilities: the UI falls back + // to no picker / default effort for them. + expect(llama?.reasoning_capabilities).toBeUndefined(); + } finally { + await rm(storageDir, { recursive: true, force: true }); + } + }); });