diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dca8085c5b2e..2dd1e0fcf61a 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,34 @@ 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 + // 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: { providerID: proposedModel.providerID as string, modelID: proposedModel.modelID as string }, + }, + { model: undefined as { providerID: string; modelID: string } | undefined }, + ) + + // 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) { + 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, + }) + } 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 */