Repository navigation
[FEATURE]: Plugin hook that fires before provider/inference API calls #21240
Description
Feature hasn't been suggested before.
- I have verified this feature I'm about to request hasn't been suggested before.
Describe the enhancement you want to request
OpenCode's plugin system has tool.execute.before for intercepting tool calls, but no hook that fires before the model inference call. Plugins that need to validate something before the provider API is called (credential freshness, rate limits, cost budgets) have no way to do it.
Use case: Enterprise credential rotation
When using AWS Bedrock with temporary credentials (via SAML/SSO federation), credentials expire periodically. A plugin needs to check credential validity and attempt refresh before the Bedrock API call. tool.execute.before doesn't cover inference, so expired credentials produce a raw provider error instead of a helpful message.
Claude Code solves this with UserPromptSubmit (fires before the model is called). pi-mono solves it with before_provider_request (fires right before the HTTP request).
The same hook would also cover rate limiting, request logging, cost controls, or any other pre-flight check.
Proposed hook: chat.request.before
A trigger-style hook that fires after chat.params and chat.headers resolve but before streamText() is called. It would follow the existing Plugin.trigger() pattern.
The existing trigger chain in llm.ts already has two hooks right before the inference call:
opencode/packages/opencode/src/session/llm.ts
Lines 168 to 200 in 40e4cd2
| const params = await Plugin.trigger( | |
| "chat.params", | |
| { | |
| sessionID: input.sessionID, | |
| agent: input.agent.name, | |
| model: input.model, | |
| provider, | |
| message: input.user, | |
| }, | |
| { | |
| temperature: input.model.capabilities.temperature | |
| ? (input.agent.temperature ?? ProviderTransform.temperature(input.model)) | |
| : undefined, | |
| topP: input.agent.topP ?? ProviderTransform.topP(input.model), | |
| topK: ProviderTransform.topK(input.model), | |
| maxOutputTokens, | |
| options, | |
| }, | |
| ) | |
| const { headers } = await Plugin.trigger( | |
| "chat.headers", | |
| { | |
| sessionID: input.sessionID, | |
| agent: input.agent.name, | |
| model: input.model, | |
| provider, | |
| message: input.user, | |
| }, | |
| { | |
| headers: {}, | |
| }, | |
| ) |
The streamText() call (the actual provider API request) happens after tool resolution:
opencode/packages/opencode/src/session/llm.ts
Line 261 in 40e4cd2
| return streamText({ |
A new Plugin.trigger("chat.request.before", ...) between tool resolution and streamText() (around line 260) would let plugins inspect the assembled request and throw to block it, same as throwing from tool.execute.before blocks a tool call. The input would mirror the existing chat.params/chat.headers shape (sessionID, agent, model, provider).
The type definition would sit alongside the existing chat.headers definition:
opencode/packages/plugin/src/index.ts
Lines 213 to 226 in 40e4cd2
| "chat.params"?: ( | |
| input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage }, | |
| output: { | |
| temperature: number | |
| topP: number | |
| topK: number | |
| maxOutputTokens: number | undefined | |
| options: Record<string, any> | |
| }, | |
| ) => Promise<void> | |
| "chat.headers"?: ( | |
| input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage }, | |
| output: { headers: Record<string, string> }, | |
| ) => Promise<void> |
Proposed interface
For reference, here's what the equivalent hooks expose in other systems:
- Claude Code's
UserPromptSubmitreceives the user's prompt text, session ID, and working directory. It can block the prompt (with a reason shown to the user) or inject additional context. It fires once per user prompt, before any processing. - pi-mono's
before_provider_requestreceives the fully serialized provider payload (messages, temperature, tools, etc.). It can inspect the payload for debugging or replace it entirely. It fires once per LLM call, including retries.
A useful middle ground for OpenCode, following the existing Plugin.trigger() pattern:
"chat.request.before"?: (
input: {
sessionID: string
agent: string
model: Model
provider: ProviderContext
messages: ModelMessage[]
tools: string[]
},
output: {},
) => Promise<void>input gives the plugin enough to make decisions: which provider and model are being called, what messages are being sent, and which tools are active. messages and tools are read-only context (not mutable through this hook, since experimental.chat.messages.transform and chat.params already cover mutation). output is empty since this is a validation hook.
To block a request, a plugin throws. To allow it, it returns normally:
export const CredentialCheck: Plugin = async () => {
return {
"chat.request.before": async (input) => {
if (input.provider.info.id === "amazon-bedrock") {
const remaining = getCredentialTTL()
if (remaining <= 0) {
throw new Error("AWS credentials have expired. Refresh credentials and try again.")
}
}
},
}
}
Activity
dru89 commentedon Apr 6, 2026
I'm happy to tackle this myself with a pull request, but I didn't want to necessarily just open one out of nowhere since it's a feature request and not just a bug fix.
Seemed better to have the discussion on whether or not this was worthwhile before making the change.
github-actions commentedon Apr 6, 2026
This issue might be a duplicate of existing issues. Please check:
chat.request.beforehook overlaps significantly with theapi.request.beforeconcept described there.dru89 commentedon Apr 6, 2026
I definitely missed this in my search! It does seem related, and if that existed it would probably replace my request for it here. I'll close this issue and follow that one instead.