Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { providerID: ProviderID; modelID: ModelID }> = {}

export function getRoutedModel(sessionID: string) {
return routedModels[sessionID]
}

const state = Instance.state(
() => {
const data: Record<
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
15 changes: 15 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,21 @@ export interface Hooks {
},
output: { message: UserMessage; parts: Part[] },
) => Promise<void>
/**
* 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<void>
/**
* Modify parameters sent to LLM
*/
Expand Down
Loading