From f7b1cf814c39f19da62ca40dc839d212f5fd390d Mon Sep 17 00:00:00 2001 From: rmoralesuscs <33236635+rmoralesuscs@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:31:33 -0400 Subject: [PATCH 1/2] feat(plugin): add chat.model hook for dynamic model routing for token savings Adds a new 'chat.model' plugin hook that allows plugins to dynamically route messages to different models based on content/complexity. When a plugin sets output.model, the routed model is persisted in session state so future turns and subagents (via Task tool) inherit it. Changes: - packages/plugin/src/index.ts: Add chat.model hook type definition - packages/opencode/src/session/prompt.ts: Fire chat.model hook before model resolution, persist routed model for subagent inheritance - packages/opencode/src/tool/task.ts: Check for routed model when resolving subagent model (fixes subagent model inheritance) Related: #18644, #17870, #6928 --- packages/opencode/src/session/prompt.ts | 31 ++++++++++++++++++++++++- packages/opencode/src/tool/task.ts | 3 ++- packages/plugin/src/index.ts | 15 ++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dca8085c5b2e..e66e8750507e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -66,6 +66,13 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) + // Tracks plugin-routed models per session for subagent inheritance + const routedModels: Record = {} + + export function getRoutedModel(sessionID: string) { + return routedModels[sessionID] + } + const state = Instance.state( () => { const data: Record< @@ -966,7 +973,29 @@ export namespace SessionPrompt { async function createUserMessage(input: PromptInput) { const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) - const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) + // Resolve the proposed model before plugin can override + const proposedModel = input.model ?? agent.model ?? (await lastModel(input.sessionID)) + + // Fire chat.model hook to allow plugins to dynamically route to different models + const modelOverride = await Plugin.trigger( + "chat.model", + { + sessionID: input.sessionID, + agent: agent.name, + proposedModel, + }, + { model: undefined as { providerID: string; modelID: string } | undefined }, + ) + + // If plugin set a model, persist it for subagent inheritance + const model = modelOverride.model ?? proposedModel + if (modelOverride.model) { + routedModels[input.sessionID] = modelOverride.model + log.info("plugin routed model", { + sessionID: input.sessionID, + model: modelOverride.model, + }) + } const full = !input.variant && agent.variant ? await Provider.getModel(model.providerID, model.modelID).catch(() => undefined) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index e3781126d0c1..d3adc0622028 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -105,7 +105,8 @@ export const TaskTool = Tool.define("task", async (ctx) => { const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }) if (msg.info.role !== "assistant") throw new Error("Not an assistant message") - const model = agent.model ?? { + const routedModel = SessionPrompt.getRoutedModel(ctx.sessionID) + const model = agent.model ?? routedModel ?? { modelID: msg.info.modelID, providerID: msg.info.providerID, } diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 7e5ae7a6ec56..bfba267610c3 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -179,6 +179,21 @@ export interface Hooks { }, output: { message: UserMessage; parts: Part[] }, ) => Promise + /** + * Called to dynamically route messages to different models. + * When a plugin sets output.model, OpenCode updates the session's + * active model so future turns and subagents inherit the routed model. + */ + "chat.model"?: ( + input: { + sessionID: string + agent: string + proposedModel: { providerID: string; modelID: string } + }, + output: { + model?: { providerID: string; modelID: string } + }, + ) => Promise /** * Modify parameters sent to LLM */ From a30553c81cff032eecec3bc8da0db7370b5f96f7 Mon Sep 17 00:00:00 2001 From: rmoralesuscs <33236635+rmoralesuscs@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:51:40 -0400 Subject: [PATCH 2/2] fix: use branded ProviderID/ModelID types for routed model storage Convert plain strings from plugin hook to branded types using ProviderID.make() and ModelID.make(). Cast branded types to plain strings when sending to the hook interface. Fixes TS2345/TS2322 typecheck errors. --- packages/opencode/src/session/prompt.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e66e8750507e..2dd1e0fcf61a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -67,7 +67,7 @@ export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) // Tracks plugin-routed models per session for subagent inheritance - const routedModels: Record = {} + const routedModels: Record = {} export function getRoutedModel(sessionID: string) { return routedModels[sessionID] @@ -977,23 +977,28 @@ export namespace SessionPrompt { const proposedModel = input.model ?? agent.model ?? (await lastModel(input.sessionID)) // Fire chat.model hook to allow plugins to dynamically route to different models + // Plugin types use plain strings; cast branded types for the hook interface const modelOverride = await Plugin.trigger( "chat.model", { sessionID: input.sessionID, agent: agent.name, - proposedModel, + proposedModel: { providerID: proposedModel.providerID as string, modelID: proposedModel.modelID as string }, }, { model: undefined as { providerID: string; modelID: string } | undefined }, ) - // If plugin set a model, persist it for subagent inheritance - const model = modelOverride.model ?? proposedModel + // If plugin set a model, convert plain strings back to branded types and persist + let model = proposedModel as { providerID: ProviderID; modelID: ModelID } if (modelOverride.model) { - routedModels[input.sessionID] = modelOverride.model + model = { + providerID: ProviderID.make(modelOverride.model.providerID), + modelID: ModelID.make(modelOverride.model.modelID), + } + routedModels[input.sessionID] = model log.info("plugin routed model", { sessionID: input.sessionID, - model: modelOverride.model, + model, }) } const full =