From 7efa6f60b5e14790a81ed0d9d6ba6c93421994fa Mon Sep 17 00:00:00 2001 From: paulbettner Date: Sun, 8 Feb 2026 21:27:27 -0600 Subject: [PATCH] fix: /model selection for shared-handle tiers (#859) --- src/agent/model.ts | 49 ++++++++++++++++++++++++++ src/cli/App.tsx | 42 +++++++++++++++++++--- src/tests/model-tier-selection.test.ts | 22 ++++++++++++ 3 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 src/tests/model-tier-selection.test.ts diff --git a/src/agent/model.ts b/src/agent/model.ts index f0a0e83..408b255 100644 --- a/src/agent/model.ts +++ b/src/agent/model.ts @@ -78,6 +78,55 @@ export function getModelInfo(modelIdentifier: string) { return null; } +/** + * Get model info by handle + llm_config. + * + * This exists because many model "tiers" (e.g. gpt-5.2-none/low/medium/high) + * share the same handle and differ only by updateArgs like reasoning_effort. + * + * When resuming a session we want `/model` to highlight the tier that actually + * matches the agent configuration. + */ +export function getModelInfoForLlmConfig( + modelHandle: string, + llmConfig?: { + reasoning_effort?: string | null; + enable_reasoner?: boolean | null; + } | null, +) { + // Try ID/handle direct resolution first. + const direct = getModelInfo(modelHandle); + + // Collect all candidates that share this handle. + const candidates = models.filter((m) => m.handle === modelHandle); + if (candidates.length === 0) { + return direct; + } + + const effort = llmConfig?.reasoning_effort ?? null; + if (effort) { + const match = candidates.find( + (m) => + (m.updateArgs as { reasoning_effort?: unknown } | undefined) + ?.reasoning_effort === effort, + ); + if (match) return match; + } + + // Anthropic-style toggle (best-effort; llm_config may not always include it) + if (llmConfig?.enable_reasoner === false) { + const match = candidates.find( + (m) => + (m.updateArgs as { enable_reasoner?: unknown } | undefined) + ?.enable_reasoner === false, + ); + if (match) return match; + } + + // Fall back to whatever models.json considers the default for this handle. + return direct ?? candidates[0] ?? null; +} + /** * Get updateArgs for a model by ID or handle * @param modelIdentifier - Can be either a model ID (e.g., "opus-4.5") or a full handle (e.g., "anthropic/claude-opus-4-5") diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 9a4ec13..981a1b0 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -2412,7 +2412,14 @@ export default function App({ agent.llm_config.model_endpoint_type && agent.llm_config.model ? `${agent.llm_config.model_endpoint_type}/${agent.llm_config.model}` : agent.llm_config.model; - const modelInfo = getModelInfo(agentModelHandle || ""); + const { getModelInfoForLlmConfig } = await import("../agent/model"); + const modelInfo = getModelInfoForLlmConfig( + agentModelHandle || "", + agent.llm_config as unknown as { + reasoning_effort?: string | null; + enable_reasoner?: boolean | null; + }, + ); if (modelInfo) { setCurrentModelId(modelInfo.id); } else { @@ -3303,28 +3310,53 @@ export default function App({ const client = await getClient(); const agent = await client.agents.retrieve(agentIdRef.current); - // Check if the model has changed by comparing llm_config + // Keep model UI in sync with the agent configuration. + // Note: many tiers share the same handle (e.g. gpt-5.2-none/high), so we + // must also treat reasoning settings as model-affecting. const currentModel = llmConfigRef.current?.model; const currentEndpoint = llmConfigRef.current?.model_endpoint_type; + const currentEffort = llmConfigRef.current?.reasoning_effort; + const currentEnableReasoner = ( + llmConfigRef.current as unknown as { + enable_reasoner?: boolean | null; + } + )?.enable_reasoner; + const agentModel = agent.llm_config.model; const agentEndpoint = agent.llm_config.model_endpoint_type; + const agentEffort = agent.llm_config.reasoning_effort; + const agentEnableReasoner = ( + agent.llm_config as unknown as { + enable_reasoner?: boolean | null; + } + )?.enable_reasoner; if ( currentModel !== agentModel || - currentEndpoint !== agentEndpoint + currentEndpoint !== agentEndpoint || + currentEffort !== agentEffort || + currentEnableReasoner !== agentEnableReasoner ) { // Model has changed - update local state setLlmConfig(agent.llm_config); // Derive model ID from llm_config for ModelSelector // Try to find matching model by handle in models.json - const { getModelInfo } = await import("../agent/model"); + const { getModelInfoForLlmConfig } = await import( + "../agent/model" + ); const agentModelHandle = agent.llm_config.model_endpoint_type && agent.llm_config.model ? `${agent.llm_config.model_endpoint_type}/${agent.llm_config.model}` : agent.llm_config.model; - const modelInfo = getModelInfo(agentModelHandle || ""); + const modelInfo = getModelInfoForLlmConfig( + agentModelHandle || "", + agent.llm_config as unknown as { + reasoning_effort?: string | null; + enable_reasoner?: boolean | null; + }, + ); if (modelInfo) { setCurrentModelId(modelInfo.id); } else { diff --git a/src/tests/model-tier-selection.test.ts b/src/tests/model-tier-selection.test.ts new file mode 100644 index 0000000..7cbccba --- /dev/null +++ b/src/tests/model-tier-selection.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; + +import { getModelInfoForLlmConfig } from "../agent/model"; + +describe("getModelInfoForLlmConfig", () => { + test("selects gpt-5.2 tier by reasoning_effort", () => { + const handle = "openai/gpt-5.2"; + + const high = getModelInfoForLlmConfig(handle, { reasoning_effort: "high" }); + expect(high?.id).toBe("gpt-5.2-high"); + + const none = getModelInfoForLlmConfig(handle, { reasoning_effort: "none" }); + expect(none?.id).toBe("gpt-5.2-none"); + }); + + test("falls back to first handle match when effort missing", () => { + const handle = "openai/gpt-5.2"; + const info = getModelInfoForLlmConfig(handle, null); + // models.json order currently lists gpt-5.2-none first. + expect(info?.id).toBe("gpt-5.2-none"); + }); +});