diff --git a/scripts/source-file-size-baseline.json b/scripts/source-file-size-baseline.json index f039512c81..ec7c55a372 100644 --- a/scripts/source-file-size-baseline.json +++ b/scripts/source-file-size-baseline.json @@ -40,12 +40,12 @@ "src/tools/impl/enter-worktree.ts": 1260, "src/tools/manager.ts": 3080, "src/tools/tool-execution-context.test.ts": 1138, - "src/types/protocol_v2.ts": 2958, + "src/types/protocol_v2.ts": 2954, "src/websocket/listen-client-concurrency.test.ts": 2686, "src/websocket/listen-client-protocol.test.ts": 5827, "src/websocket/listener/commands/memory.ts": 1114, "src/websocket/listener/file-commands.ts": 1053, "src/websocket/listener/lifecycle.ts": 1051, - "src/websocket/listener/protocol-inbound.ts": 2302, + "src/websocket/listener/protocol-inbound.ts": 2299, "src/websocket/listener/protocol-outbound.ts": 1085 } diff --git a/src/agent/available-models.ts b/src/agent/available-models.ts index 43ed4c9ac2..b7a6239672 100644 --- a/src/agent/available-models.ts +++ b/src/agent/available-models.ts @@ -1,7 +1,10 @@ import { getBackend } from "@/backend"; import { refreshByokProviders } from "@/backend/api/providers"; +import type { + ModelReasoningCapabilities, + ModelReasoningEffort, +} from "@/types/model-reasoning"; import { isOpenAICompatibleProxyEndpoint } from "@/utils/openai-endpoint"; -import type { ModelReasoningEffort } from "./model"; const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes @@ -14,12 +17,10 @@ export type AvailableModel = { providerCategory?: string; modelEndpoint?: string; openAICompatibleProxy?: boolean; + reasoningCapabilities?: ReasoningCapabilities; }; -export type ReasoningCapabilities = { - supported_efforts?: ModelReasoningEffort[] | null; - mandatory?: boolean; -}; +export type ReasoningCapabilities = ModelReasoningCapabilities; type CacheEntry = { handles: Set; @@ -112,7 +113,25 @@ export function getCachedOpenAICompatibleProxyHandles(): Set | null { } export function getCachedAvailableModels(): AvailableModel[] | null { - return cache?.models.map((model) => ({ ...model })) ?? null; + return ( + cache?.models.map((model) => ({ + ...model, + ...(model.reasoningCapabilities + ? { + reasoningCapabilities: { + ...model.reasoningCapabilities, + ...(Array.isArray(model.reasoningCapabilities.supported_efforts) + ? { + supported_efforts: [ + ...model.reasoningCapabilities.supported_efforts, + ], + } + : {}), + }, + } + : {}), + })) ?? null + ); } async function fetchFromNetwork(): Promise { @@ -180,6 +199,7 @@ async function fetchFromNetwork(): Promise { ...(providerCategory ? { providerCategory } : {}), ...(modelEndpoint ? { modelEndpoint } : {}), ...(isOpenAICompatibleProxy ? { openAICompatibleProxy: true } : {}), + ...(capabilities ? { reasoningCapabilities: capabilities } : {}), }; if (!modelsByHandle.has(model.handle)) { modelsByHandle.set(model.handle, availableModel); diff --git a/src/agent/model.ts b/src/agent/model.ts index a4907ea5e1..5bc67454fc 100644 --- a/src/agent/model.ts +++ b/src/agent/model.ts @@ -2,6 +2,11 @@ * Model resolution and handling utilities */ import { OPENAI_CODEX_PROVIDER_NAME } from "@/providers/openai-codex-constants"; +import type { + ModelReasoningCapabilities, + ModelReasoningEffort, + ModelReasoningSelection, +} from "@/types/model-reasoning"; import { getDefaultModel, models, resolveModel } from "./model-catalog"; import { CHATGPT_OAUTH_LLM_CONFIG_PROVIDER, @@ -16,6 +21,11 @@ import { // agent-presets package export); re-exported here so CLI code keeps a single // import surface for model utilities. export { getDefaultModel, models, resolveModel }; + +export type { + ModelReasoningEffort, + ModelReasoningSelection, +} from "@/types/model-reasoning"; export { mapModelHandleToLlmConfigPatch, normalizeKnownModelHandle, @@ -23,23 +33,6 @@ export { resolveModelHandleFromLlmConfig, } from "./model-handles"; -export type ModelReasoningEffort = - | "none" - | "minimal" - | "low" - | "medium" - | "high" - | "xhigh" - | "max"; - -/** Null means use the upstream provider's default and omit reasoning_effort. */ -export type ModelReasoningSelection = ModelReasoningEffort | null; - -type ReasoningCapabilities = { - supported_efforts?: ModelReasoningEffort[] | null; - mandatory?: boolean; -}; - const REASONING_EFFORT_ORDER: ModelReasoningEffort[] = [ "none", "minimal", @@ -65,6 +58,37 @@ export function isLocalModelHandle(modelHandle: string): boolean { ); } +export function resolveReasoningTierLookupHandle( + modelHandle: string, + providerType?: string | null, +): string { + const normalizedHandle = normalizeModelHandleForRegistry(modelHandle); + if (normalizedHandle && normalizedHandle !== modelHandle) { + return normalizedHandle; + } + if (isLocalModelHandle(modelHandle)) { + return modelHandle; + } + + const slashIndex = modelHandle.indexOf("/"); + const modelName = + slashIndex >= 0 && slashIndex < modelHandle.length - 1 + ? modelHandle.slice(slashIndex + 1) + : null; + if (!providerType || !modelName) { + return normalizedHandle ?? modelHandle; + } + + const registryProvider = + providerType === "chatgpt_oauth" + ? OPENAI_CODEX_PROVIDER_NAME + : providerType; + const provider = modelHandle.slice(0, slashIndex); + return provider === registryProvider + ? (normalizedHandle ?? modelHandle) + : `${registryProvider}/${modelName}`; +} + export function getLocalModelLabel(modelHandle: string): string { const providerPrefix = LOCAL_MODEL_HANDLE_PREFIXES.find((prefix) => modelHandle.startsWith(prefix), @@ -121,7 +145,7 @@ function displayRegistryHandleForServiceTier( export function getReasoningTierOptionsForHandle( modelHandle: string, contextWindow?: number, - reasoningCapabilities?: ReasoningCapabilities | null, + reasoningCapabilities?: ModelReasoningCapabilities | null, ): Array<{ effort: ModelReasoningEffort; modelId: string; @@ -182,7 +206,7 @@ export function getReasoningTierOptionsForHandle( export function getReasoningTierOptionsFromCapabilities( modelHandle: string, - capabilities?: ReasoningCapabilities | null, + capabilities?: ModelReasoningCapabilities | null, ): Array<{ effort: ModelReasoningEffort; modelId: string; @@ -216,7 +240,7 @@ export function getByokOpenAIReasoningTierOptions( options?: { registryHandle?: string; contextWindow?: number; - reasoningCapabilities?: ReasoningCapabilities | null; + reasoningCapabilities?: ModelReasoningCapabilities | null; }, ): Array<{ effort: ModelReasoningSelection; diff --git a/src/channels-public.ts b/src/channels-public.ts index 1cdb7cfbc0..fcb5188770 100644 --- a/src/channels-public.ts +++ b/src/channels-public.ts @@ -11,6 +11,11 @@ export { LettaStreamCoreError, LettaStreamNoAssistantMessageError, } from "./channels/core-stream"; +export type { + ChannelModelPickerData, + ChannelReasoningEffort, + ChannelReasoningSelection, +} from "./channels/model-picker-types"; export type { ChannelMessageActionAdapter, ChannelMessageActionContext, @@ -40,7 +45,6 @@ export type { ChannelAdapter, ChannelChatType, ChannelControlRequestEvent, - ChannelModelPickerData, ChannelRoute, ChannelThreadContext, ChannelThreadContextEntry, diff --git a/src/channels/commands.test.ts b/src/channels/commands.test.ts index 8ff912fcda..f6126bd23e 100644 --- a/src/channels/commands.test.ts +++ b/src/channels/commands.test.ts @@ -753,6 +753,10 @@ describe("channel slash commands", () => { ], availableHandles: ["openai/gpt-5", "anthropic/claude-sonnet-4-6"], recentHandles: ["anthropic/claude-sonnet-4-6"], + reasoningOptions: [ + { effort: "low", modelId: "gpt-low" }, + { effort: "high", modelId: "gpt-high" }, + ], }); expect(blocks).toBeDefined(); @@ -777,18 +781,23 @@ describe("channel slash commands", () => { type: "section", text: { type: "mrkdwn", - text: "Choose a model for this routed conversation:", + text: "Choose a model or reasoning level for this routed conversation:", }, }); expect(explanatoryBlock).not.toHaveProperty("accessory"); expect(actionsBlock?.type).toBe("actions"); - expect(elements).toHaveLength(1); + expect(elements).toHaveLength(2); expect(selectElement?.type).toBe("static_select"); expect(selectElement?.action_id).toBe("letta_channel_model_select"); expect(selectElement?.options?.map((option) => option.value)).toEqual([ "sonnet", "gpt", ]); + expect(elements?.[1]?.action_id).toBe("letta_channel_reasoning_select"); + expect(elements?.[1]?.options?.map((option) => option.value)).toEqual([ + "low", + "high", + ]); expect(contextBlock?.type).toBe("context"); expect(JSON.stringify(blocks)).toContain("Claude Sonnet 4.6"); }); diff --git a/src/channels/commands.ts b/src/channels/commands.ts index 416243f945..ec0166a910 100644 --- a/src/channels/commands.ts +++ b/src/channels/commands.ts @@ -7,11 +7,12 @@ import { canRunChannelCommand, } from "./access-control"; import { handleChannelFeedbackCommand } from "./feedback"; +import type { ChannelModelPickerData } from "./model-picker-types"; +import { channelModelCommandPrefix } from "./model-reasoning-command"; import { getChannelDisplayName } from "./plugin-registry"; import { buildDirectReplyOptions } from "./registry-presentation"; import type { ChannelAdapter, - ChannelModelPickerData, ChannelRoute, InboundChannelMessage, } from "./types"; @@ -281,6 +282,7 @@ const SLACK_MENTION_SLASH_COMMAND_EXAMPLES = [ "@agent /model", "@agent /model list", "@agent /model ", + "@agent /model reasoning ", "@agent /cancel", "@agent /chat", "@agent /feedback ", @@ -335,6 +337,7 @@ export function buildChannelHelpMessage(channelId: string): string { "@agent /model - show this thread's current model", "@agent /model list - show available models", "@agent /model - switch this thread's model", + "@agent /model reasoning - change reasoning for the current model", "@agent /status - show route and listener status", "@agent /cancel - cancel the current turn", "@agent /chat - show the web chat link", @@ -619,12 +622,8 @@ export function getFallbackModelEntries( return preferred.length > 0 ? preferred : Array.from(byHandle.values()); } -function modelCommandPrefix(channelId: string): "/model" | "@agent /model" { - return channelId === "slack" ? "@agent /model" : "/model"; -} - export function buildChannelModelNotFoundText(channelId: string): string { - return `Model not found. Use ${modelCommandPrefix(channelId)} list to see available models.`; + return `Model not found. Use ${channelModelCommandPrefix(channelId)} list to see available models.`; } export function buildChannelCurrentModelMessage( @@ -641,7 +640,7 @@ export function buildChannelCurrentModelMessage( params.modelHandle && params.modelHandle !== params.modelLabel ? ` (${params.modelHandle})` : ""; - const switchCommand = modelCommandPrefix(channelId); + const switchCommand = channelModelCommandPrefix(channelId); return [ `${displayName} current ${scope} model: ${params.modelLabel}${handleText}.`, `Use ${switchCommand} list to see available models, or ${switchCommand} to switch.`, @@ -654,7 +653,7 @@ function formatChannelModelEntry( ): string { const selector = entry.id || entry.handle; const handleText = entry.handle === entry.label ? "" : ` — ${entry.handle}`; - return `• ${entry.label}${handleText} (${modelCommandPrefix(channelId)} ${selector})`; + return `• ${entry.label}${handleText} (${channelModelCommandPrefix(channelId)} ${selector})`; } function appendModelEntrySection( diff --git a/src/channels/gateway-core.test.ts b/src/channels/gateway-core.test.ts index d0d55863d4..f7d02b9e0b 100644 --- a/src/channels/gateway-core.test.ts +++ b/src/channels/gateway-core.test.ts @@ -670,7 +670,11 @@ test("runtime registration exposes and updates model status without backend acce startResponse: { agent: { id: "agent-1", - llm_config: { model: "anthropic/claude-sonnet-4-6" }, + llm_config: { + model: "anthropic/claude-sonnet-4-6", + context_window: 950_000, + }, + model_settings: { provider_type: "anthropic" }, } as RuntimeStartResponseMessage["agent"], }, }); @@ -681,8 +685,14 @@ test("runtime registration exposes and updates model status without backend acce expect(gateway.getModelStatus(TEST_RUNTIME)).toEqual({ modelHandle: "anthropic/claude-sonnet-4-6", scope: "conversation", + contextWindow: 950_000, + providerType: "anthropic", }); + gateway.updateModelStatus(TEST_RUNTIME, "anthropic/claude-sonnet-4-6"); + expect(gateway.getModelStatus(TEST_RUNTIME)?.contextWindow).toBe(950_000); + expect(gateway.getModelStatus(TEST_RUNTIME)?.providerType).toBe("anthropic"); + gateway.updateModelStatus(TEST_RUNTIME, "openai/gpt-5"); expect(gateway.getModelStatus(TEST_RUNTIME)).toEqual({ modelHandle: "openai/gpt-5", diff --git a/src/channels/gateway-core.ts b/src/channels/gateway-core.ts index 49943d9494..d0c50b32c4 100644 --- a/src/channels/gateway-core.ts +++ b/src/channels/gateway-core.ts @@ -80,6 +80,8 @@ export interface ChannelGatewayRichDraft { export interface ChannelGatewayModelStatus { modelHandle: string | null; scope: "agent" | "conversation"; + contextWindow?: number; + providerType?: string; } type ActiveGatewayTurn = { @@ -118,6 +120,24 @@ function runtimeKey(runtime: RuntimeScope): string { return `${runtime.agent_id}:${runtime.conversation_id}`; } +function modelProviderType( + record: Record | null, +): string | undefined { + const modelSettings = record?.model_settings; + if (modelSettings && typeof modelSettings === "object") { + const providerType = (modelSettings as Record) + .provider_type; + if (typeof providerType === "string") return providerType; + } + const llmConfig = record?.llm_config; + if (llmConfig && typeof llmConfig === "object") { + const endpointType = (llmConfig as Record) + .model_endpoint_type; + if (typeof endpointType === "string") return endpointType; + } + return undefined; +} + function sourceKey(source: ChannelTurnSource): string { return [ source.channel, @@ -341,11 +361,31 @@ export class ChannelGateway { return this.states.get(runtimeKey(runtime))?.modelStatus ?? null; } - updateModelStatus(runtime: RuntimeScope, modelHandle: string | null): void { + updateModelStatus( + runtime: RuntimeScope, + modelHandle: string | null, + options?: { contextWindow?: number; providerType?: string }, + ): void { const state = this.getState(runtime); + const effectiveContextWindow = + options?.contextWindow ?? + (state.modelStatus?.modelHandle === modelHandle + ? state.modelStatus.contextWindow + : undefined); + const effectiveProviderType = + options?.providerType ?? + (state.modelStatus?.modelHandle === modelHandle + ? state.modelStatus.providerType + : undefined); state.modelStatus = { modelHandle, scope: runtime.conversation_id === "default" ? "agent" : "conversation", + ...(effectiveContextWindow !== undefined + ? { contextWindow: effectiveContextWindow } + : {}), + ...(effectiveProviderType !== undefined + ? { providerType: effectiveProviderType } + : {}), }; } @@ -461,6 +501,26 @@ export class ChannelGateway { typeof conversationRecord?.model === "string" ? conversationRecord.model : null; + const agentContextWindow = + typeof agentRecord?.context_window_limit === "number" + ? agentRecord.context_window_limit + : typeof response.agent?.llm_config?.context_window === "number" + ? response.agent.llm_config.context_window + : undefined; + const conversationContextWindow = + typeof conversationRecord?.context_window_limit === "number" + ? conversationRecord.context_window_limit + : undefined; + const contextWindow = + delivery.runtime.conversation_id === "default" + ? agentContextWindow + : (conversationContextWindow ?? agentContextWindow); + const agentProviderType = modelProviderType(agentRecord); + const conversationProviderType = modelProviderType(conversationRecord); + const providerType = + delivery.runtime.conversation_id === "default" + ? agentProviderType + : (conversationProviderType ?? agentProviderType); state.modelStatus = { modelHandle: delivery.runtime.conversation_id === "default" @@ -470,6 +530,8 @@ export class ChannelGateway { delivery.runtime.conversation_id === "default" ? "agent" : "conversation", + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(providerType !== undefined ? { providerType } : {}), }; }); state.registrationSignature = signature; diff --git a/src/channels/gateway-local.ts b/src/channels/gateway-local.ts index 87b0b4a684..9e2af1d4d8 100644 --- a/src/channels/gateway-local.ts +++ b/src/channels/gateway-local.ts @@ -27,6 +27,16 @@ import { } from "./commands"; import { ChannelGateway, type ChannelGatewayDelivery } from "./gateway-core"; import { buildGatewayMessageChannelTool } from "./message-channel-gateway-tool"; +import type { ChannelModelPickerData } from "./model-picker-types"; +import { + buildChannelModelReasoningUnsupportedMessage, + buildChannelModelReasoningUpdatedMessage, + buildChannelModelReasoningUpdateFailedMessage, +} from "./model-reasoning-command"; +import { + buildChannelReasoningOptions, + buildChannelReasoningUpdatePayload, +} from "./model-reasoning-options"; import { type ChannelsCommand, handleChannelsProtocolCommand, @@ -37,11 +47,7 @@ import type { ChannelRestoreAgentScope } from "./restore-scope"; import { createRoutedRuntimeRegistrationRefresher } from "./routed-runtime-registration"; import { subscribeChannelRoutesChanged } from "./routing"; import { handleChannelsSlashCommand } from "./slash-command"; -import type { - ChannelModelPickerData, - ChannelStartupLogger, - ChannelTurnSource, -} from "./types"; +import type { ChannelStartupLogger, ChannelTurnSource } from "./types"; export interface StartLocalChannelGatewayOptions { appServerUrl: string; @@ -60,6 +66,25 @@ export interface LocalChannelGatewayHandle { ): Promise; } +function providerTypeFromModelSettings( + modelSettings: Record | null | undefined, +): string | undefined { + const providerType = modelSettings?.provider_type; + return typeof providerType === "string" ? providerType : undefined; +} + +function requireCurrentModelStatus( + response: ListModelsResponseMessage, +): NonNullable { + if (!response.success) { + throw new Error(response.error ?? "Failed to load model status"); + } + if (!response.current_model) { + throw new Error("Listener did not return current model status"); + } + return response.current_model; +} + async function executeChannelServiceCommand( command: WsProtocolCommand, ): Promise { @@ -421,121 +446,233 @@ export async function startLocalChannelGateway( return response.success && response.aborted; }); - registry.setModelHandler(async ({ channelId, runtime, modelIdentifier }) => { - if (!modelIdentifier) { - try { - let current = gateway.getModelStatus(runtime); - if (!current) { - await gateway.registerRuntime( - runtime, - registry.resolveTurnSourcesForScope( - runtime.agent_id, - runtime.conversation_id, + registry.setModelHandler( + async ({ channelId, runtime, modelIdentifier, reasoningEffort }) => { + if (!modelIdentifier && reasoningEffort === undefined) { + try { + const listResponse = await client.request( + { + type: "list_models", + request_id: client.nextRequestId("channel-models"), + runtime, + }, + { predicate: isListModelsResponse }, + ); + const status = requireCurrentModelStatus(listResponse); + gateway.updateModelStatus(runtime, status.modelHandle, { + contextWindow: status.contextWindow, + providerType: status.providerType, + }); + const reasoningOptions = status.modelHandle + ? buildChannelReasoningOptions( + status.modelHandle, + listResponse.entries, + status.contextWindow, + status.providerType, + ) + : []; + const modelPicker: ChannelModelPickerData = { + current: status, + entries: listResponse.entries, + availableHandles: listResponse.available_handles, + recentHandles: settingsManager.getRecentModels(), + ...(reasoningOptions.length > 0 ? { reasoningOptions } : {}), + }; + return { + handled: true, + text: buildChannelCurrentModelMessage(channelId, status), + modelPicker, + }; + } catch (error) { + return { + handled: true, + text: buildChannelCurrentModelUnavailableMessage( + channelId, + error instanceof Error ? error.message : String(error), ), + }; + } + } + + if (reasoningEffort !== undefined) { + let modelLabel = "current model"; + try { + const listResponse = await client.request( + { + type: "list_models", + request_id: client.nextRequestId("channel-model-reasoning"), + runtime, + }, + { predicate: isListModelsResponse }, + ); + const status = requireCurrentModelStatus(listResponse); + modelLabel = status.modelLabel; + gateway.updateModelStatus(runtime, status.modelHandle, { + contextWindow: status.contextWindow, + providerType: status.providerType, + }); + if (!status.modelHandle) { + throw new Error("Runtime model handle is unavailable"); + } + const reasoningOptions = buildChannelReasoningOptions( + status.modelHandle, + listResponse.entries, + status.contextWindow, + status.providerType, + ); + const updatePayload = buildChannelReasoningUpdatePayload( + status.modelHandle, + reasoningEffort, + reasoningOptions, + ); + if (!updatePayload) { + return { + handled: true, + text: buildChannelModelReasoningUnsupportedMessage(channelId, { + modelLabel, + requested: reasoningEffort, + supported: reasoningOptions.map((option) => option.effort), + }), + }; + } + + const response = await client.request( + { + type: "update_model", + request_id: client.nextRequestId("channel-reasoning-update"), + runtime, + payload: updatePayload, + }, + { predicate: isUpdateModelResponse }, + ); + if (!response.success) { + return { + handled: true, + text: buildChannelModelReasoningUpdateFailedMessage(channelId, { + modelLabel, + reasoningEffort, + error: response.error ?? "Failed to update reasoning", + }), + }; + } + gateway.updateModelStatus( + runtime, + response.model_handle ?? status.modelHandle, + { + contextWindow: status.contextWindow, + providerType: + providerTypeFromModelSettings(response.model_settings) ?? + status.providerType, + }, ); - current = gateway.getModelStatus(runtime); + return { + handled: true, + text: buildChannelModelReasoningUpdatedMessage(channelId, { + modelLabel, + reasoningEffort, + appliedTo: response.applied_to, + }), + }; + } catch (error) { + return { + handled: true, + text: buildChannelModelReasoningUpdateFailedMessage(channelId, { + modelLabel, + reasoningEffort, + error: error instanceof Error ? error.message : String(error), + }), + }; } - if (!current) throw new Error("Runtime model status is unavailable"); - const status = { - ...current, - modelLabel: - (current.modelHandle && getModelInfo(current.modelHandle)?.label) || - current.modelHandle || - "unknown", + } + + if (!modelIdentifier) { + return { + handled: true, + text: buildChannelCurrentModelUnavailableMessage( + channelId, + "Model identifier is unavailable", + ), }; - const listResponse = await client.request( + } + + if (modelIdentifier.toLowerCase() === "list") { + const response = await client.request( { type: "list_models", - request_id: client.nextRequestId("channel-models"), + request_id: client.nextRequestId("channel-model-list"), }, { predicate: isListModelsResponse }, ); - const modelPicker: ChannelModelPickerData | undefined = - listResponse.success - ? { - current: status, - entries: listResponse.entries, - availableHandles: listResponse.available_handles, - recentHandles: settingsManager.getRecentModels(), - } - : undefined; return { handled: true, - text: buildChannelCurrentModelMessage(channelId, status), - ...(modelPicker ? { modelPicker } : {}), + text: response.success + ? buildChannelModelListMessage(channelId, { + entries: response.entries, + availableHandles: response.available_handles, + recentHandles: settingsManager.getRecentModels(), + }) + : buildChannelModelListUnavailableMessage( + channelId, + response.error ?? "Failed to list models", + ), }; - } catch (error) { + } + + const response = await client.request( + { + type: "update_model", + request_id: client.nextRequestId("channel-model-update"), + runtime, + payload: { + model_id: modelIdentifier, + model_handle: modelIdentifier, + }, + }, + { predicate: isUpdateModelResponse }, + ); + if (!response.success) { return { handled: true, - text: buildChannelCurrentModelUnavailableMessage( + text: buildChannelModelUpdateFailedMessage( channelId, - error instanceof Error ? error.message : String(error), + modelIdentifier, + response.error ?? "Failed to update model", ), }; } - } - - if (modelIdentifier.toLowerCase() === "list") { - const response = await client.request( - { - type: "list_models", - request_id: client.nextRequestId("channel-model-list"), - }, - { predicate: isListModelsResponse }, + settingsManager.addRecentModel(response.model_handle ?? modelIdentifier); + const selectedContextWindow = ( + getModelInfo(modelIdentifier)?.updateArgs as + | { context_window?: unknown } + | undefined + )?.context_window; + const selectedProviderType = providerTypeFromModelSettings( + response.model_settings, ); - return { - handled: true, - text: response.success - ? buildChannelModelListMessage(channelId, { - entries: response.entries, - availableHandles: response.available_handles, - recentHandles: settingsManager.getRecentModels(), - }) - : buildChannelModelListUnavailableMessage( - channelId, - response.error ?? "Failed to list models", - ), - }; - } - - const response = await client.request( - { - type: "update_model", - request_id: client.nextRequestId("channel-model-update"), + gateway.updateModelStatus( runtime, - payload: { - model_id: modelIdentifier, - model_handle: modelIdentifier, + response.model_handle ?? modelIdentifier, + { + ...(typeof selectedContextWindow === "number" + ? { contextWindow: selectedContextWindow } + : {}), + ...(selectedProviderType + ? { providerType: selectedProviderType } + : {}), }, - }, - { predicate: isUpdateModelResponse }, - ); - if (!response.success) { + ); return { handled: true, - text: buildChannelModelUpdateFailedMessage( - channelId, - modelIdentifier, - response.error ?? "Failed to update model", - ), + text: buildChannelModelUpdatedMessage(channelId, { + modelLabel: + getModelInfo(response.model_handle ?? modelIdentifier)?.label ?? + modelIdentifier, + modelHandle: response.model_handle ?? modelIdentifier, + appliedTo: response.applied_to, + }), }; - } - settingsManager.addRecentModel(response.model_handle ?? modelIdentifier); - gateway.updateModelStatus( - runtime, - response.model_handle ?? modelIdentifier, - ); - return { - handled: true, - text: buildChannelModelUpdatedMessage(channelId, { - modelLabel: - getModelInfo(response.model_handle ?? modelIdentifier)?.label ?? - modelIdentifier, - modelHandle: response.model_handle ?? modelIdentifier, - appliedTo: response.applied_to, - }), - }; - }); + }, + ); const executeRemoteCommand = ( runtime: RuntimeScope, diff --git a/src/channels/model-picker-types.ts b/src/channels/model-picker-types.ts new file mode 100644 index 0000000000..32e0c07b0e --- /dev/null +++ b/src/channels/model-picker-types.ts @@ -0,0 +1,23 @@ +import type { + ModelReasoningEffort, + ModelReasoningSelection, +} from "@/types/model-reasoning"; +import type { ListModelsResponseModelEntry } from "@/types/protocol_v2"; + +export type ChannelReasoningEffort = ModelReasoningEffort; +export type ChannelReasoningSelection = ModelReasoningSelection; + +export type ChannelModelPickerData = { + current: { + modelLabel: string; + modelHandle: string | null; + scope?: "agent" | "conversation"; + }; + entries: ListModelsResponseModelEntry[]; + availableHandles?: string[] | null; + recentHandles?: string[]; + reasoningOptions?: Array<{ + effort: ChannelReasoningSelection; + modelId: string; + }>; +}; diff --git a/src/channels/model-reasoning-command.test.ts b/src/channels/model-reasoning-command.test.ts new file mode 100644 index 0000000000..b6774fbe92 --- /dev/null +++ b/src/channels/model-reasoning-command.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { + buildChannelModelReasoningUnsupportedMessage, + buildChannelModelReasoningUpdatedMessage, + buildChannelModelReasoningUpdateFailedMessage, + buildChannelModelReasoningUsageMessage, + parseChannelModelArgs, +} from "./model-reasoning-command"; + +describe("channel model reasoning commands", () => { + test("parses model and reasoning selections", () => { + expect(parseChannelModelArgs("")).toEqual({ kind: "model" }); + expect(parseChannelModelArgs("openai/gpt-5")).toEqual({ + kind: "model", + modelIdentifier: "openai/gpt-5", + }); + expect(parseChannelModelArgs("reasoning HIGH")).toEqual({ + kind: "reasoning", + reasoningEffort: "high", + }); + expect(parseChannelModelArgs("reasoning default")).toEqual({ + kind: "reasoning", + reasoningEffort: null, + }); + expect(parseChannelModelArgs("reasoning ultra")).toEqual({ + kind: "invalid-reasoning", + }); + expect(parseChannelModelArgs("reasoning")).toEqual({ + kind: "invalid-reasoning", + }); + }); + + test("builds channel-safe reasoning guidance and results", () => { + expect(buildChannelModelReasoningUsageMessage("slack")).toContain( + "@agent /model reasoning", + ); + expect( + buildChannelModelReasoningUpdatedMessage("slack", { + modelLabel: "GPT-5", + reasoningEffort: "high", + }), + ).toBe("Slack updated this conversation's reasoning for GPT-5 to High."); + expect( + buildChannelModelReasoningUnsupportedMessage("slack", { + modelLabel: "GPT-5", + requested: "max", + supported: ["low", "high"], + }), + ).toContain("reasoning "); + expect( + buildChannelModelReasoningUpdateFailedMessage("slack", { + modelLabel: "GPT-5", + reasoningEffort: null, + error: "boom", + }), + ).toBe("Slack could not set GPT-5 reasoning to Default: boom"); + }); +}); diff --git a/src/channels/model-reasoning-command.ts b/src/channels/model-reasoning-command.ts new file mode 100644 index 0000000000..d62f23a67c --- /dev/null +++ b/src/channels/model-reasoning-command.ts @@ -0,0 +1,126 @@ +import type { + ChannelReasoningEffort, + ChannelReasoningSelection, +} from "./model-picker-types"; +import { getChannelDisplayName } from "./plugin-registry"; + +const CHANNEL_REASONING_EFFORTS: ChannelReasoningEffort[] = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]; + +function channelDisplayName(channelId: string): string { + try { + return getChannelDisplayName(channelId); + } catch { + return channelId; + } +} + +export type ParsedChannelModelArgs = + | { kind: "model"; modelIdentifier?: string } + | { kind: "reasoning"; reasoningEffort: ChannelReasoningSelection } + | { kind: "invalid-reasoning" }; + +export function parseChannelModelArgs(args: string): ParsedChannelModelArgs { + const tokens = args.trim().split(/\s+/).filter(Boolean); + if (tokens[0]?.toLowerCase() !== "reasoning") { + return { + kind: "model", + ...(args.trim() ? { modelIdentifier: args.trim() } : {}), + }; + } + if (tokens.length !== 2) { + return { kind: "invalid-reasoning" }; + } + + const requested = tokens[1]?.toLowerCase(); + if (requested === "default") { + return { kind: "reasoning", reasoningEffort: null }; + } + if ( + requested && + CHANNEL_REASONING_EFFORTS.includes(requested as ChannelReasoningEffort) + ) { + return { + kind: "reasoning", + reasoningEffort: requested as ChannelReasoningEffort, + }; + } + return { kind: "invalid-reasoning" }; +} + +export function channelModelCommandPrefix( + channelId: string, +): "/model" | "@agent /model" { + return channelId === "slack" ? "@agent /model" : "/model"; +} + +export function formatChannelReasoningSelection( + effort: ChannelReasoningSelection, +): string { + const labels: Record = { + none: "No reasoning", + minimal: "Minimal", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra high", + max: "Max", + }; + return effort === null ? "Default" : labels[effort]; +} + +export function buildChannelModelReasoningUsageMessage( + channelId: string, +): string { + return `Use ${channelModelCommandPrefix(channelId)} reasoning .`; +} + +export function buildChannelModelReasoningUnsupportedMessage( + channelId: string, + params: { + modelLabel: string; + requested: ChannelReasoningSelection; + supported: ChannelReasoningSelection[]; + }, +): string { + const displayName = channelDisplayName(channelId); + if (params.supported.length === 0) { + return `${displayName} model ${params.modelLabel} does not report configurable reasoning levels.`; + } + const supported = params.supported + .map((effort) => (effort === null ? "default" : effort)) + .join("|"); + return `${displayName} cannot set ${params.modelLabel} reasoning to ${formatChannelReasoningSelection(params.requested)}. Use ${channelModelCommandPrefix(channelId)} reasoning <${supported}>.`; +} + +export function buildChannelModelReasoningUpdatedMessage( + channelId: string, + params: { + modelLabel: string; + reasoningEffort: ChannelReasoningSelection; + appliedTo?: "agent" | "conversation"; + }, +): string { + const displayName = channelDisplayName(channelId); + const scope = params.appliedTo === "agent" ? "agent" : "conversation"; + return `${displayName} updated this ${scope}'s reasoning for ${params.modelLabel} to ${formatChannelReasoningSelection(params.reasoningEffort)}.`; +} + +export function buildChannelModelReasoningUpdateFailedMessage( + channelId: string, + params: { + modelLabel: string; + reasoningEffort: ChannelReasoningSelection; + error: string; + }, +): string { + const displayName = channelDisplayName(channelId); + return `${displayName} could not set ${params.modelLabel} reasoning to ${formatChannelReasoningSelection(params.reasoningEffort)}: ${params.error}`; +} diff --git a/src/channels/model-reasoning-options.test.ts b/src/channels/model-reasoning-options.test.ts new file mode 100644 index 0000000000..359cb3c9c4 --- /dev/null +++ b/src/channels/model-reasoning-options.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test"; +import type { ListModelsResponseModelEntry } from "@/types/protocol_v2"; +import { parseChannelModelArgs } from "./model-reasoning-command"; +import { + buildChannelReasoningOptions, + buildChannelReasoningUpdatePayload, +} from "./model-reasoning-options"; +import { buildSlackModelPickerBlocks } from "./slack/model-picker-blocks"; + +describe("buildChannelReasoningOptions", () => { + test("returns catalog reasoning tiers for a known model", () => { + const options = buildChannelReasoningOptions("openai/gpt-5.4", []); + + expect(options.map((option) => option.effort)).toContain("high"); + expect(options.every((option) => option.modelId !== "openai/gpt-5.4")).toBe( + true, + ); + }); + + test("returns direct effort choices for an OpenAI-compatible proxy", () => { + const options = buildChannelReasoningOptions("openai/gpt-5.4", [ + { + id: "openai/gpt-5.4", + handle: "openai/gpt-5.4", + label: "Custom GPT-5", + description: "", + updateArgs: { openai_compatible_proxy: true }, + reasoningCapabilities: { + supported_efforts: ["low", "high"], + mandatory: true, + }, + }, + ]); + + expect(options.map((option) => option.effort)).toEqual([ + null, + "low", + "high", + ]); + expect(options.every((option) => option.modelId === "openai/gpt-5.4")).toBe( + true, + ); + }); + + test("keeps a rendered proxy Default selection executable", () => { + const entry: ListModelsResponseModelEntry = { + id: "openai/gpt-5.4", + handle: "openai/gpt-5.4", + label: "Custom GPT-5", + description: "", + updateArgs: { openai_compatible_proxy: true }, + reasoningCapabilities: { supported_efforts: ["low", "high"] }, + }; + const reasoningOptions = buildChannelReasoningOptions(entry.handle, [ + entry, + ]); + const blocks = buildSlackModelPickerBlocks({ + current: { modelLabel: entry.label, modelHandle: entry.handle }, + entries: [entry], + reasoningOptions, + }) as Array<{ + type?: string; + elements?: Array<{ + action_id?: string; + options?: Array<{ value?: string }>; + }>; + }>; + const selectedValue = blocks + .find((block) => block.type === "actions") + ?.elements?.find( + (element) => element.action_id === "letta_channel_reasoning_select", + ) + ?.options?.find((option) => option.value === "default")?.value; + + const parsed = parseChannelModelArgs(`reasoning ${selectedValue}`); + expect(parsed).toEqual({ kind: "reasoning", reasoningEffort: null }); + if (parsed.kind !== "reasoning") throw new Error("Expected reasoning"); + expect( + buildChannelReasoningUpdatePayload( + entry.handle, + parsed.reasoningEffort, + reasoningOptions, + ), + ).toEqual({ + model_id: entry.handle, + model_handle: entry.handle, + reasoning_effort: null, + }); + }); + + test("keeps reasoning choices in the current context-window variant", () => { + const options = buildChannelReasoningOptions( + "anthropic/claude-opus-4-8", + [], + 950_000, + ); + + expect(options).not.toHaveLength(0); + expect(options.every((option) => option.modelId.includes("1m"))).toBe(true); + }); + + test("uses provider type to resolve user-specific ChatGPT handles", () => { + const options = buildChannelReasoningOptions( + "chatgpt-jin/gpt-5.5", + [], + undefined, + "chatgpt_oauth", + ); + + expect(options.map((option) => option.effort)).toContain("high"); + expect(options.find((option) => option.effort === "high")?.modelId).toBe( + "gpt-5.5-plus-pro-high", + ); + }); + + test("does not offer reasoning for an unsupported model", () => { + expect(buildChannelReasoningOptions("custom/plain-model", [])).toEqual([]); + }); +}); diff --git a/src/channels/model-reasoning-options.ts b/src/channels/model-reasoning-options.ts new file mode 100644 index 0000000000..643221e6c2 --- /dev/null +++ b/src/channels/model-reasoning-options.ts @@ -0,0 +1,54 @@ +import { + getByokOpenAIReasoningTierOptions, + getReasoningTierOptionsForHandle, + resolveReasoningTierLookupHandle, +} from "@/agent/model"; +import type { + ListModelsResponseModelEntry, + UpdateModelPayload, +} from "@/types/protocol_v2"; +import { OPENAI_COMPATIBLE_PROXY_UPDATE_ARG } from "@/utils/openai-endpoint"; +import type { + ChannelModelPickerData, + ChannelReasoningSelection, +} from "./model-picker-types"; + +export function buildChannelReasoningOptions( + modelHandle: string, + entries: ListModelsResponseModelEntry[], + contextWindow?: number, + providerType?: string | null, +): NonNullable { + const canonicalHandle = resolveReasoningTierLookupHandle( + modelHandle, + providerType, + ); + const proxyEntry = entries.find( + (entry) => + (entry.handle === modelHandle || entry.handle === canonicalHandle) && + entry.updateArgs?.[OPENAI_COMPATIBLE_PROXY_UPDATE_ARG] === true, + ); + if (proxyEntry) { + return getByokOpenAIReasoningTierOptions(modelHandle, { + registryHandle: canonicalHandle, + contextWindow, + reasoningCapabilities: proxyEntry.reasoningCapabilities, + }); + } + return getReasoningTierOptionsForHandle(canonicalHandle, contextWindow); +} + +export function buildChannelReasoningUpdatePayload( + modelHandle: string, + reasoningEffort: ChannelReasoningSelection, + options: NonNullable, +): UpdateModelPayload | null { + const selected = options.find((option) => option.effort === reasoningEffort); + return selected + ? { + model_id: selected.modelId, + model_handle: modelHandle, + reasoning_effort: reasoningEffort, + } + : null; +} diff --git a/src/channels/registry-command-routing.test.ts b/src/channels/registry-command-routing.test.ts index f722604b2c..2c79b19a47 100644 --- a/src/channels/registry-command-routing.test.ts +++ b/src/channels/registry-command-routing.test.ts @@ -471,7 +471,6 @@ describe("ChannelRegistry command routing", () => { enabled: true, createdAt: "2026-05-19T00:00:00.000Z", }); - const adapter = registry.getAdapter("slack", "acct-slack"); await adapter?.onMessage?.({ channel: "slack", @@ -700,9 +699,12 @@ describe("ChannelRegistry command routing", () => { modelCalls.push(params); return { handled: true, - text: params.modelIdentifier - ? `Switched to ${params.modelIdentifier}` - : "Model selector text", + text: + params.reasoningEffort !== undefined + ? `Reasoning ${params.reasoningEffort}` + : params.modelIdentifier + ? `Switched to ${params.modelIdentifier}` + : "Model selector text", }; }); registry.setReady(); @@ -736,31 +738,23 @@ describe("ChannelRegistry command routing", () => { }); const adapter = registry.getAdapter("slack", "acct-slack"); - await adapter?.onMessage?.({ + const modelMessage = { channel: "slack", accountId: "acct-slack", chatId: "C123", senderId: "U123", senderName: "Charles", - text: "/model", timestamp: Date.now(), - messageId: "1712800000.000200", threadId: "1712790000.000050", - chatType: "channel", - }); - await adapter?.onMessage?.({ - channel: "slack", - accountId: "acct-slack", - chatId: "C123", - senderId: "U123", - senderName: "Charles", - text: "/model openai/gpt-5", - timestamp: Date.now(), - messageId: "1712800000.000201", - threadId: "1712790000.000050", - chatType: "channel", - }); - + chatType: "channel" as const, + }; + for (const [text, messageId] of [ + ["/model", "1712800000.000200"], + ["/model openai/gpt-5", "1712800000.000201"], + ["/model reasoning high", "1712800000.000202"], + ] as const) { + await adapter?.onMessage?.({ ...modelMessage, text, messageId }); + } expect(delivered).toHaveLength(0); expect(modelCalls).toEqual([ { @@ -779,6 +773,14 @@ describe("ChannelRegistry command routing", () => { }, modelIdentifier: "openai/gpt-5", }, + { + channelId: "slack", + runtime: { + agent_id: "agent-1", + conversation_id: "conv-1", + }, + reasoningEffort: "high", + }, ]); expect(replies).toEqual([ { @@ -791,9 +793,13 @@ describe("ChannelRegistry command routing", () => { text: "Switched to openai/gpt-5", replyToMessageId: "1712800000.000201", }, + { + chatId: "C123", + text: "Reasoning high", + replyToMessageId: "1712800000.000202", + }, ]); }); - test("/model reports no route without invoking the model handler", async () => { const replies: Array<{ chatId: string; diff --git a/src/channels/registry-commands.ts b/src/channels/registry-commands.ts index 8aa142293b..0ff72be58e 100644 --- a/src/channels/registry-commands.ts +++ b/src/channels/registry-commands.ts @@ -19,6 +19,11 @@ import { buildChannelReloadUnavailableMessage, buildChannelResumedMessage, } from "./commands"; +import type { ChannelModelPickerData } from "./model-picker-types"; +import { + buildChannelModelReasoningUsageMessage, + parseChannelModelArgs, +} from "./model-reasoning-command"; import type { ChannelRegistryEvent } from "./registry-events"; import type { ChannelCancelHandler, @@ -37,7 +42,6 @@ import { } from "./routing"; import type { ChannelAccount, - ChannelModelPickerData, ChannelRoute, InboundChannelMessage, } from "./types"; @@ -367,13 +371,23 @@ export function createChannelCommandRouter(deps: { }; } + const modelArgs = parseChannelModelArgs(command.args); + if (modelArgs.kind === "invalid-reasoning") { + return { + handled: true, + text: buildChannelModelReasoningUsageMessage(msg.channel), + }; + } + return modelHandler({ channelId: msg.channel, runtime: { agent_id: route.agentId, conversation_id: route.conversationId, }, - modelIdentifier: command.args || undefined, + ...(modelArgs.kind === "model" + ? { modelIdentifier: modelArgs.modelIdentifier } + : { reasoningEffort: modelArgs.reasoningEffort }), }); } diff --git a/src/channels/registry-handlers.ts b/src/channels/registry-handlers.ts index 5ebcd97f5a..02b568ca40 100644 --- a/src/channels/registry-handlers.ts +++ b/src/channels/registry-handlers.ts @@ -1,7 +1,10 @@ import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents"; import type { - ChannelDefaultPermissionMode, ChannelModelPickerData, + ChannelReasoningSelection, +} from "./model-picker-types"; +import type { + ChannelDefaultPermissionMode, ChannelRoute, ChannelTurnSource, } from "./types"; @@ -27,6 +30,7 @@ export type ChannelModelHandler = (params: { channelId: string; runtime: { agent_id: string; conversation_id: string }; modelIdentifier?: string; + reasoningEffort?: ChannelReasoningSelection; }) => Promise<{ handled: boolean; text?: string; diff --git a/src/channels/slack/adapter.test.ts b/src/channels/slack/adapter.test.ts index 5ced4c1e53..c2e5f26904 100644 --- a/src/channels/slack/adapter.test.ts +++ b/src/channels/slack/adapter.test.ts @@ -172,6 +172,12 @@ test("slack adapter forwards model picker selections as /model commands", async if (!handler) { throw new Error("Expected model select action handler"); } + const reasoningHandler = app?.actionHandlers.get( + "letta_channel_reasoning_select", + ); + if (!reasoningHandler) { + throw new Error("Expected reasoning select action handler"); + } const ack = mock(async () => {}); await handler({ @@ -194,9 +200,32 @@ test("slack adapter forwards model picker selections as /model commands", async }, ack, }); + await reasoningHandler({ + body: { + user: { id: "U123", name: "Alice", team_id: "T123" }, + channel: { id: "C123", name: "eng" }, + container: { + channel_id: "C123", + message_ts: "1712800000.000100", + thread_ts: "1712800000.000200", + }, + message: { + ts: "1712800000.000100", + thread_ts: "1712800000.000200", + }, + }, + action: { + action_id: "letta_channel_reasoning_select", + action_ts: "1712800002.000300", + selected_option: { + value: "high", + }, + }, + ack, + }); - expect(ack).toHaveBeenCalledTimes(1); - expect(messages).toHaveLength(1); + expect(ack).toHaveBeenCalledTimes(2); + expect(messages).toHaveLength(2); expect(messages[0]).toMatchObject({ channel: "slack", accountId: "slack-test-account", @@ -208,6 +237,17 @@ test("slack adapter forwards model picker selections as /model commands", async threadId: "1712800000.000200", chatType: "channel", }); + expect(messages[1]).toMatchObject({ + channel: "slack", + accountId: "slack-test-account", + chatId: "C123", + senderId: "U123", + senderName: "Alice", + senderTeamId: "T123", + text: "/model reasoning high", + threadId: "1712800000.000200", + chatType: "channel", + }); }); test("slack adapter renders model picker blocks on direct replies", async () => { diff --git a/src/channels/slack/adapter.ts b/src/channels/slack/adapter.ts index f8cb66a366..a65dae8cf9 100644 --- a/src/channels/slack/adapter.ts +++ b/src/channels/slack/adapter.ts @@ -1,11 +1,11 @@ import type SlackApp from "@slack/bolt"; import { formatChannelControlRequestPrompt } from "@/channels/interactive"; +import type { ChannelModelPickerData } from "@/channels/model-picker-types"; import { buildSlackModelPickerBlocks } from "@/channels/slack/model-picker-blocks"; import type { ChannelAdapter, ChannelControlRequestEvent, ChannelMessageAttachment, - ChannelModelPickerData, ChannelTurnLifecycleEvent, ChannelTurnProgressEvent, ChannelTurnSource, diff --git a/src/channels/slack/ingress-controller.ts b/src/channels/slack/ingress-controller.ts index 708b4df0e8..7be276fef5 100644 --- a/src/channels/slack/ingress-controller.ts +++ b/src/channels/slack/ingress-controller.ts @@ -1,6 +1,9 @@ import type SlackApp from "@slack/bolt"; import { listChannelSlashCommands } from "@/channels/commands"; -import { SLACK_MODEL_SELECT_ACTION_ID } from "@/channels/slack/model-picker-blocks"; +import { + SLACK_MODEL_SELECT_ACTION_ID, + SLACK_REASONING_SELECT_ACTION_ID, +} from "@/channels/slack/model-picker-blocks"; import type { ChannelAdapter, InboundChannelMessage, @@ -396,15 +399,17 @@ export function createSlackIngressController(params: { }) => Promise, ) => void; }; - actionRegistrar.action?.( - SLACK_MODEL_SELECT_ACTION_ID, - async ({ body, action, ack }) => { + const registerModelCommandAction = ( + actionId: string, + commandForSelection: (selection: string) => string, + ): void => { + actionRegistrar.action?.(actionId, async ({ body, action, ack }) => { await ack(); const adapter = params.getAdapter(); - const selectedModel = resolveSlackSelectedModel(action, body); + const selection = resolveSlackSelectedModel(action, body); const channelId = resolveSlackActionChannelId(body); const user = resolveSlackActionUser(body); - if (!adapter.onMessage || !selectedModel || !channelId || !user.id) { + if (!adapter.onMessage || !selection || !channelId || !user.id) { return; } const actionRecord = getSlackActionRecord(action, body); @@ -417,7 +422,7 @@ export function createSlackIngressController(params: { senderTeamId: user.teamId, senderName: user.name, chatLabel: channelId, - text: `/model ${selectedModel}`, + text: commandForSelection(selection), timestamp: Date.now(), messageId: firstNonEmptyString( actionRecord?.action_ts, @@ -429,9 +434,17 @@ export function createSlackIngressController(params: { raw: body, }); } catch (error) { - console.error("[Slack] Error handling model select action:", error); + console.error("[Slack] Error handling model command action:", error); } - }, + }); + }; + registerModelCommandAction( + SLACK_MODEL_SELECT_ACTION_ID, + (selection) => `/model ${selection}`, + ); + registerModelCommandAction( + SLACK_REASONING_SELECT_ACTION_ID, + (selection) => `/model reasoning ${selection}`, ); const handleReaction = async ( diff --git a/src/channels/slack/model-picker-blocks.ts b/src/channels/slack/model-picker-blocks.ts index ab87d29202..d55062429e 100644 --- a/src/channels/slack/model-picker-blocks.ts +++ b/src/channels/slack/model-picker-blocks.ts @@ -4,13 +4,19 @@ import { getFallbackModelEntries, resolveModelHandles, } from "@/channels/commands"; -import type { ChannelModelPickerData } from "@/channels/types"; +import type { + ChannelModelPickerData, + ChannelReasoningSelection, +} from "@/channels/model-picker-types"; +import { formatChannelReasoningSelection } from "@/channels/model-reasoning-command"; const SLACK_MODEL_PICKER_OPTION_LIMIT = 100; const SLACK_MODEL_OPTION_TEXT_LIMIT = 75; const SLACK_MODEL_OPTION_VALUE_LIMIT = 75; export const SLACK_MODEL_SELECT_ACTION_ID = "letta_channel_model_select"; +export const SLACK_REASONING_SELECT_ACTION_ID = + "letta_channel_reasoning_select"; type SlackModelOption = { text: { type: "plain_text"; text: string; emoji?: boolean }; @@ -76,6 +82,19 @@ function buildSlackModelOption( return option; } +function buildSlackReasoningOption( + effort: ChannelReasoningSelection, +): SlackModelOption { + return { + text: { + type: "plain_text", + text: formatChannelReasoningSelection(effort), + emoji: true, + }, + value: effort ?? "default", + }; +} + /** * Renders generic channel model-picker data as Slack Block Kit blocks. * Slack-specific rendering lives here so the shared channel/listener layers @@ -134,6 +153,39 @@ export function buildSlackModelPickerBlocks( ? entry?.handle === currentHandle || option.value === currentHandle : false; }); + const reasoningOptions: SlackModelOption[] = []; + const seenReasoningValues = new Set(); + for (const option of params.reasoningOptions ?? []) { + const slackOption = buildSlackReasoningOption(option.effort); + if (seenReasoningValues.has(slackOption.value)) continue; + seenReasoningValues.add(slackOption.value); + reasoningOptions.push(slackOption); + } + const actionElements: unknown[] = [ + { + type: "static_select", + action_id: SLACK_MODEL_SELECT_ACTION_ID, + placeholder: { + type: "plain_text", + text: "Select a model", + emoji: true, + }, + options, + ...(initialOption ? { initial_option: initialOption } : {}), + }, + ]; + if (reasoningOptions.length > 0) { + actionElements.push({ + type: "static_select", + action_id: SLACK_REASONING_SELECT_ACTION_ID, + placeholder: { + type: "plain_text", + text: "Select reasoning", + emoji: true, + }, + options: reasoningOptions, + }); + } return [ { @@ -147,24 +199,15 @@ export function buildSlackModelPickerBlocks( type: "section", text: { type: "mrkdwn", - text: "Choose a model for this routed conversation:", + text: + reasoningOptions.length > 0 + ? "Choose a model or reasoning level for this routed conversation:" + : "Choose a model for this routed conversation:", }, }, { type: "actions", - elements: [ - { - type: "static_select", - action_id: SLACK_MODEL_SELECT_ACTION_ID, - placeholder: { - type: "plain_text", - text: "Select a model", - emoji: true, - }, - options, - ...(initialOption ? { initial_option: initialOption } : {}), - }, - ], + elements: actionElements, }, { type: "context", diff --git a/src/channels/types.ts b/src/channels/types.ts index 303c7b9566..4d44a0e48e 100644 --- a/src/channels/types.ts +++ b/src/channels/types.ts @@ -9,11 +9,8 @@ import type { WhatsAppMessagePrefixConfig } from "@/channels/whatsapp/message-prefix-config-types"; import type { PermissionMode } from "@/permissions/mode"; -import type { - ApprovalResponseBody, - ListModelsResponseModelEntry, - StopReasonType, -} from "@/types/protocol_v2"; +import type { ApprovalResponseBody, StopReasonType } from "@/types/protocol_v2"; +import type { ChannelModelPickerData } from "./model-picker-types"; import type { WhatsAppAttachmentPolicyConfig } from "./whatsapp/attachment-policy-types"; import type { WhatsAppWaitingBehavior } from "./whatsapp/waiting-behavior-config-types"; @@ -21,17 +18,6 @@ import type { WhatsAppWaitingBehavior } from "./whatsapp/waiting-behavior-config * Vendor-neutral model-picker payload produced by the generic channel * `/model` handler. Adapters decide how (or whether) to render it. */ -export type ChannelModelPickerData = { - current: { - modelLabel: string; - modelHandle: string | null; - scope?: "agent" | "conversation"; - }; - entries: ListModelsResponseModelEntry[]; - availableHandles?: string[] | null; - recentHandles?: string[]; -}; - /** * Default channel id used for wire compatibility when WS clients omit * `channel_id` on channel commands. Early protocol versions predate diff --git a/src/cli/app/use-reasoning-cycle.ts b/src/cli/app/use-reasoning-cycle.ts index a1563c22fc..e898e086a1 100644 --- a/src/cli/app/use-reasoning-cycle.ts +++ b/src/cli/app/use-reasoning-cycle.ts @@ -20,8 +20,8 @@ import { getReasoningTierOptionsForHandle, isLocalModelHandle, type ModelReasoningSelection, - normalizeModelHandleForRegistry, preservableContextWindow, + resolveReasoningTierLookupHandle, } from "@/agent/model"; import { formatErrorDetails } from "@/cli/helpers/error-formatter"; import { OPENAI_CODEX_PROVIDER_NAME } from "@/providers/openai-codex-provider"; @@ -109,18 +109,6 @@ function isProviderQualifiedModelHandle( return slashIndex > 0 && slashIndex < modelHandle.length - 1; } -function modelNameFromHandle(modelHandle: string): string | null { - const slashIndex = modelHandle.indexOf("/"); - if (slashIndex === -1 || slashIndex === modelHandle.length - 1) return null; - return modelHandle.slice(slashIndex + 1); -} - -function registryProviderForProviderType(providerType: string): string { - return providerType === "chatgpt_oauth" - ? OPENAI_CODEX_PROVIDER_NAME - : providerType; -} - export function resolveReasoningCycleModelHandle( llmConfig: LlmConfig | null | undefined, agentModel: string | null | undefined, @@ -156,28 +144,10 @@ export function resolveReasoningCycleTierLookupHandle( modelHandle: string, modelSettings: AgentState["model_settings"] | null | undefined, ): string { - const normalizedHandle = normalizeModelHandleForRegistry(modelHandle); - if (normalizedHandle && normalizedHandle !== modelHandle) { - return normalizedHandle; - } - - if (isLocalModelHandle(modelHandle)) { - return modelHandle; - } - - const providerType = providerTypeFromModelSettings(modelSettings); - const modelName = modelNameFromHandle(modelHandle); - if (!providerType || !modelName) { - return normalizedHandle ?? modelHandle; - } - - const registryProvider = registryProviderForProviderType(providerType); - const provider = modelHandle.split("/")[0]; - if (provider === registryProvider) { - return normalizedHandle ?? modelHandle; - } - - return `${registryProvider}/${modelName}`; + return resolveReasoningTierLookupHandle( + modelHandle, + providerTypeFromModelSettings(modelSettings), + ); } export function getReasoningCycleTierOptions(params: { diff --git a/src/types/model-reasoning.ts b/src/types/model-reasoning.ts new file mode 100644 index 0000000000..1ce4a2adbc --- /dev/null +++ b/src/types/model-reasoning.ts @@ -0,0 +1,24 @@ +export type ModelReasoningEffort = + | "none" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "max"; + +/** Null means use the upstream provider's default. */ +export type ModelReasoningSelection = ModelReasoningEffort | null; + +export type ModelReasoningCapabilities = { + supported_efforts?: ModelReasoningEffort[] | null; + mandatory?: boolean; +}; + +export type ModelRuntimeStatus = { + modelHandle: string | null; + modelLabel: string; + scope: "agent" | "conversation"; + contextWindow?: number; + providerType?: string; +}; diff --git a/src/types/protocol_v2.ts b/src/types/protocol_v2.ts index fbab848193..4e84b135ae 100644 --- a/src/types/protocol_v2.ts +++ b/src/types/protocol_v2.ts @@ -35,6 +35,11 @@ import type { AppServerInfoResponseMessage, } from "./app-server-info"; import type { ConversationForkBody } from "./conversation-fork-protocol"; +import type { + ModelReasoningCapabilities, + ModelReasoningEffort, + ModelRuntimeStatus, +} from "./model-reasoning"; import type { CronRunLogPage, CronTask } from "./schedule-protocol"; export type * from "./schedule-protocol"; @@ -1395,12 +1400,9 @@ export interface ListModelsCommand { type: "list_models"; /** Echoed back in the response for request correlation. */ request_id: string; - /** - * Bypass the listener's availability cache and refetch from the backend. - * Sent by user-initiated refreshes so they can never be answered with a - * stale-but-within-TTL snapshot. - */ + /** Bypass the listener's availability cache and refetch from the backend. */ force?: boolean; + runtime?: RuntimeScope; } export type ConnectProviderStorageTarget = "local"; @@ -1590,15 +1592,7 @@ export interface UpdateModelPayload { /** Optional direct handle override (e.g. "anthropic/claude-sonnet-4-6") */ model_handle?: string; /** Explicit effort for an OpenAI-compatible proxy; null restores provider Default. */ - reasoning_effort?: - | "none" - | "minimal" - | "low" - | "medium" - | "high" - | "xhigh" - | "max" - | null; + reasoning_effort?: ModelReasoningEffort | null; } export interface UpdateModelCommand { @@ -1619,6 +1613,7 @@ export interface ListModelsResponseModelEntry { isFeatured?: boolean; free?: boolean; updateArgs?: Record; + reasoningCapabilities?: ModelReasoningCapabilities; } export interface ListModelsResponseMessage { @@ -1630,6 +1625,7 @@ export interface ListModelsResponseMessage { available_handles?: string[] | null; /** BYOK provider name → base provider (e.g. "lc-anthropic" → "anthropic") */ byok_provider_aliases?: Record; + current_model?: ModelRuntimeStatus; error?: string; } diff --git a/src/websocket/listen-model-update.test.ts b/src/websocket/listen-model-update.test.ts index 2cce4c9fe2..fddb7f330c 100644 --- a/src/websocket/listen-model-update.test.ts +++ b/src/websocket/listen-model-update.test.ts @@ -17,6 +17,7 @@ import { } from "@/backend"; import { FakeHeadlessBackend } from "@/backend/dev/fake-headless-backend"; import { LocalBackend } from "@/backend/local"; +import { buildChannelReasoningOptions } from "@/channels/model-reasoning-options"; import { settingsManager } from "@/settings-manager"; import { __listenClientTestUtils } from "@/websocket/listen-client"; @@ -277,6 +278,25 @@ describe("listen-client applyModelUpdateForRuntime wiring", () => { expect(resolved?.updateArgs?.reasoning_effort).toBe("medium"); }); + test("applies a channel reasoning selection through the model update resolver", () => { + const selected = buildChannelReasoningOptions( + "chatgpt-jin/gpt-5.5", + [], + undefined, + "chatgpt_oauth", + ).find((option) => option.effort === "high"); + expect(selected).toBeDefined(); + + const resolved = __listenClientTestUtils.resolveModelForUpdate({ + model_id: selected?.modelId, + model_handle: "chatgpt-jin/gpt-5.5", + reasoning_effort: selected?.effort, + }); + + expect(resolved?.handle).toBe("chatgpt-jin/gpt-5.5"); + expect(resolved?.updateArgs?.reasoning_effort).toBe("high"); + }); + test("reports the current scoped model for channel /model without args", async () => { const storageDir = await mkdtemp(join(os.tmpdir(), "ws-current-model-")); const previousHome = process.env.HOME; @@ -316,6 +336,38 @@ describe("listen-client applyModelUpdateForRuntime wiring", () => { modelHandle: "anthropic/claude-sonnet-4-6", scope: "conversation", }); + + await backend.updateConversation(conversation.id, { + model: "openai/gpt-5.4", + context_window_limit: 272000, + model_settings: { provider_type: "openai" }, + } as Parameters[1]); + await expect( + __listenClientTestUtils.getCurrentModelStatusForRuntime({ + agentId: agent.id, + conversationId: conversation.id, + }), + ).resolves.toMatchObject({ + modelHandle: "openai/gpt-5.4", + scope: "conversation", + contextWindow: 272000, + providerType: "openai", + }); + const listResponse = + await __listenClientTestUtils.buildListModelsResponseWithStatus( + "models-current", + { + runtime: { + agent_id: agent.id, + conversation_id: conversation.id, + }, + }, + ); + expect(listResponse.current_model).toMatchObject({ + modelHandle: "openai/gpt-5.4", + contextWindow: 272000, + providerType: "openai", + }); } finally { if (previousHome === undefined) { delete process.env.HOME; @@ -626,7 +678,8 @@ describe("local channel gateway model command wiring", () => { const source = readLocalChannelGatewaySource(); expect(source).toContain("registry.setModelHandler"); - expect(source).toContain("gateway.getModelStatus(runtime)"); + expect(source).not.toContain("gateway.getModelStatus(runtime)"); + expect(source).toContain("requireCurrentModelStatus(listResponse)"); expect(source).toContain( "buildChannelCurrentModelMessage(channelId, status)", ); @@ -638,6 +691,11 @@ describe("local channel gateway model command wiring", () => { "settingsManager.addRecentModel(response.model_handle ?? modelIdentifier)", ); expect(source).toContain("gateway.updateModelStatus("); + expect(source).toContain("buildChannelReasoningOptions("); + expect(source).toContain( + 'client.nextRequestId("channel-reasoning-update")', + ); + expect(source).toContain("payload: updatePayload"); }); }); @@ -652,11 +710,11 @@ describe("listen-client list_models response wiring", () => { expect(source).toContain("buildByokProviderAliases(providers)"); }); - test("handler uses async pattern with buildListModelsResponse", () => { + test("handler includes authoritative status in scoped model lists", () => { const source = readModelToolsetCommandSource(); - // Handler should be wrapped in void (async () => { ... })() pattern - expect(source).toContain("buildListModelsResponse(parsed.request_id, {"); + expect(source).toContain("buildListModelsResponseWithStatus("); + expect(source).toContain("current_model: currentModel"); }); test("user-initiated force refresh bypasses the availability cache", () => { diff --git a/src/websocket/listener/client.ts b/src/websocket/listener/client.ts index 7ee064a4e5..08961d544a 100644 --- a/src/websocket/listener/client.ts +++ b/src/websocket/listener/client.ts @@ -20,6 +20,7 @@ import { buildListModelsEntries } from "./commands/model-catalog"; import { applyModelUpdateForRuntime, buildListModelsResponse, + buildListModelsResponseWithStatus, buildModelUpdateStatusMessage, getCurrentModelStatusForRuntime, resolveModelForUpdate, @@ -515,6 +516,7 @@ export const __listenClientTestUtils = { getOrCreateScopedRuntime, buildListModelsEntries, buildListModelsResponse, + buildListModelsResponseWithStatus, buildModelUpdateStatusMessage, getCurrentModelStatusForRuntime, resolveModelForUpdate, diff --git a/src/websocket/listener/commands/model-catalog.test.ts b/src/websocket/listener/commands/model-catalog.test.ts index 4a83ebd0c4..f6fc5ca8ad 100644 --- a/src/websocket/listener/commands/model-catalog.test.ts +++ b/src/websocket/listener/commands/model-catalog.test.ts @@ -38,6 +38,25 @@ describe("listener model catalog", () => { }); }); + test("preserves native reasoning capabilities for channel selectors", () => { + const nativeModel: AvailableModel = { + handle: "custom/reasoning-model", + label: "Reasoning Model", + openAICompatibleProxy: true, + reasoningCapabilities: { + supported_efforts: ["low", "high"], + mandatory: true, + }, + }; + + const entry = buildListModelsEntries([nativeModel]).at(-1); + + expect(entry?.reasoningCapabilities).toEqual({ + supported_efforts: ["low", "high"], + mandatory: true, + }); + }); + test("keeps curated variants instead of adding a duplicate native row", () => { const variantsByHandle = new Map(); for (const model of models) { diff --git a/src/websocket/listener/commands/model-catalog.ts b/src/websocket/listener/commands/model-catalog.ts index 6122ee0e50..8485aa5eaa 100644 --- a/src/websocket/listener/commands/model-catalog.ts +++ b/src/websocket/listener/commands/model-catalog.ts @@ -35,21 +35,38 @@ function availableModelUpdateArgs( }; } +function availableModelReasoningCapabilities( + model: AvailableModel, +): ListModelsResponseModelEntry["reasoningCapabilities"] { + const capabilities = model.reasoningCapabilities; + return capabilities + ? { + ...capabilities, + ...(Array.isArray(capabilities.supported_efforts) + ? { supported_efforts: [...capabilities.supported_efforts] } + : {}), + } + : undefined; +} + function withAvailableModelMetadata( entry: ListModelsResponseModelEntry, model: AvailableModel, ): ListModelsResponseModelEntry { const availableUpdateArgs = availableModelUpdateArgs(model); + const reasoningCapabilities = availableModelReasoningCapabilities(model); return { ...entry, handle: model.handle, ...(availableUpdateArgs ? { updateArgs: { ...(entry.updateArgs ?? {}), ...availableUpdateArgs } } : {}), + ...(reasoningCapabilities ? { reasoningCapabilities } : {}), }; } function buildNativeEntry(model: AvailableModel): ListModelsResponseModelEntry { + const reasoningCapabilities = availableModelReasoningCapabilities(model); return { id: model.handle, handle: model.handle, @@ -58,6 +75,7 @@ function buildNativeEntry(model: AvailableModel): ListModelsResponseModelEntry { ...(availableModelUpdateArgs(model) ? { updateArgs: availableModelUpdateArgs(model) } : {}), + ...(reasoningCapabilities ? { reasoningCapabilities } : {}), }; } diff --git a/src/websocket/listener/commands/model-toolset.ts b/src/websocket/listener/commands/model-toolset.ts index 02859a6bbf..69f5381275 100644 --- a/src/websocket/listener/commands/model-toolset.ts +++ b/src/websocket/listener/commands/model-toolset.ts @@ -28,6 +28,7 @@ import { type ToolsetPreference, } from "@/tools/toolset"; import { formatToolsetName } from "@/tools/toolset-labels"; +import type { ModelRuntimeStatus } from "@/types/model-reasoning"; import type { ListModelsResponseMessage, UpdateModelPayload, @@ -81,6 +82,7 @@ type ModelToolsetCommandContext = { type ModelScopeSnapshot = { modelHandle: string | null; + providerType?: string; llmConfig: { model?: string | null; model_endpoint_type?: string | null; @@ -88,11 +90,7 @@ type ModelScopeSnapshot = { } | null; }; -export type CurrentModelStatus = { - modelHandle: string | null; - modelLabel: string; - scope: "agent" | "conversation"; -}; +export type CurrentModelStatus = ModelRuntimeStatus; function inferProviderTypeFromRegistryHandle( modelHandle: string, @@ -184,10 +182,20 @@ async function getCurrentModelScopeSnapshot(params: { : typeof agent.llm_config?.context_window === "number" ? agent.llm_config.context_window : undefined; + const agentModelSettings = + agentRecord.model_settings && typeof agentRecord.model_settings === "object" + ? (agentRecord.model_settings as Record) + : null; + const agentProviderType = + providerTypeFromModelSettings(agentModelSettings) ?? + (typeof agent.llm_config?.model_endpoint_type === "string" + ? agent.llm_config.model_endpoint_type + : undefined); if (params.conversationId === "default") { return { modelHandle: agentModelHandle, + ...(agentProviderType ? { providerType: agentProviderType } : {}), llmConfig: withContextWindow( agent.llm_config as ModelScopeSnapshot["llmConfig"], agentContextWindow, @@ -207,9 +215,20 @@ async function getCurrentModelScopeSnapshot(params: { typeof conversationRecord.context_window_limit === "number" ? conversationRecord.context_window_limit : undefined; + const conversationModelSettings = + conversationRecord.model_settings && + typeof conversationRecord.model_settings === "object" + ? (conversationRecord.model_settings as Record) + : null; + const conversationProviderType = providerTypeFromModelSettings( + conversationModelSettings, + ); return { modelHandle: conversationModel ?? agentModelHandle, + ...((conversationProviderType ?? agentProviderType) + ? { providerType: conversationProviderType ?? agentProviderType } + : {}), llmConfig: withContextWindow( agent.llm_config as ModelScopeSnapshot["llmConfig"], conversationContextWindow ?? agentContextWindow, @@ -229,6 +248,10 @@ export async function getCurrentModelStatusForRuntime(params: { modelHandle: snapshot.modelHandle, modelLabel: modelInfo?.label ?? snapshot.modelHandle ?? "unknown", scope: params.conversationId === "default" ? "agent" : "conversation", + ...(typeof snapshot.llmConfig?.context_window === "number" + ? { contextWindow: snapshot.llmConfig.context_window } + : {}), + ...(snapshot.providerType ? { providerType: snapshot.providerType } : {}), }; } @@ -745,6 +768,30 @@ export async function buildListModelsResponse( }; } +export async function buildListModelsResponseWithStatus( + requestId: string, + options?: { + forceRefresh?: boolean; + runtime?: { agent_id: string; conversation_id: string }; + }, +): Promise { + const [response, currentModel] = await Promise.all([ + buildListModelsResponse(requestId, { + forceRefresh: options?.forceRefresh, + }), + options?.runtime + ? getCurrentModelStatusForRuntime({ + agentId: options.runtime.agent_id, + conversationId: options.runtime.conversation_id, + }) + : Promise.resolve(undefined), + ]); + return { + ...response, + ...(currentModel ? { current_model: currentModel } : {}), + }; +} + export function handleModelToolsetCommand( parsed: unknown, context: ModelToolsetCommandContext, @@ -760,9 +807,13 @@ export function handleModelToolsetCommand( if (isListModelsCommand(parsed)) { runDetachedListenerTask("list_models", async () => { try { - const response = await buildListModelsResponse(parsed.request_id, { - forceRefresh: parsed.force === true, - }); + const response = await buildListModelsResponseWithStatus( + parsed.request_id, + { + forceRefresh: parsed.force === true, + ...(parsed.runtime ? { runtime: parsed.runtime } : {}), + }, + ); safeSocketSend( socket, response, diff --git a/src/websocket/listener/protocol-inbound.test.ts b/src/websocket/listener/protocol-inbound.test.ts index 5ba7abea54..dfdc65022b 100644 --- a/src/websocket/listener/protocol-inbound.test.ts +++ b/src/websocket/listener/protocol-inbound.test.ts @@ -3,6 +3,7 @@ import { isChannelAccountCreateCommand, isChannelAccountUpdateCommand, isChannelSetConfigCommand, + isListModelsCommand, isUpdateModelCommand, parseServerMessage, } from "@/websocket/listener/protocol-inbound"; @@ -22,6 +23,23 @@ describe("app-server protocol hard cut", () => { }); describe("input protocol-inbound validators", () => { + test("accepts list_models with an optional runtime scope", () => { + expect( + isListModelsCommand({ + type: "list_models", + request_id: "models-1", + runtime: { agent_id: "agent-1", conversation_id: "conv-1" }, + }), + ).toBe(true); + expect( + isListModelsCommand({ + type: "list_models", + request_id: "models-2", + runtime: { agent_id: "agent-1" }, + }), + ).toBe(false); + }); + test("accepts create_message with interactive tools excluded", () => { const parsed = parseServerMessage( Buffer.from( diff --git a/src/websocket/listener/protocol-inbound.ts b/src/websocket/listener/protocol-inbound.ts index bebafd5162..c4ef376744 100644 --- a/src/websocket/listener/protocol-inbound.ts +++ b/src/websocket/listener/protocol-inbound.ts @@ -963,15 +963,12 @@ export function isListModelsCommand( value: unknown, ): value is ListModelsCommand { if (!value || typeof value !== "object") return false; - const c = value as { - type?: unknown; - request_id?: unknown; - force?: unknown; - }; + const c = value as Record; return ( c.type === "list_models" && typeof c.request_id === "string" && - (c.force === undefined || typeof c.force === "boolean") + (c.force === undefined || typeof c.force === "boolean") && + (c.runtime === undefined || isRuntimeScope(c.runtime)) ); }