fix(extensions): scoped provider hooks to request model

Passed the per-request model through SDK payload callbacks and ExtensionRunner context creation.

Added regression coverage for Codex-primary and Anthropic-request contexts.

Fixes #6006
This commit is contained in:
roboomp
2026-07-18 16:52:15 +00:00
parent 3fdd85ab6c
commit bf232ca062
4 changed files with 85 additions and 6 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `before_provider_request` extension contexts exposing the primary session model for cross-provider Advisor requests instead of the request model ([#6006](https://github.com/can1357/oh-my-pi/issues/6006)).
## [17.0.4] - 2026-07-18
### Fixed
@@ -534,8 +534,9 @@ export class ExtensionRunner {
return undefined;
}
createContext(): ExtensionContext {
const getModel = this.#getModel;
/** Creates an extension context, optionally scoped to a provider request model. */
createContext(model?: Model): ExtensionContext {
const getModel = model ? () => model : this.#getModel;
return {
ui: this.#uiContext,
getContextUsage: () => this.#getContextUsageFn(),
@@ -965,8 +966,9 @@ export class ExtensionRunner {
return currentMessages;
}
async emitBeforeProviderRequest(payload: unknown): Promise<BeforeProviderRequestEventResult> {
const ctx = this.createContext();
/** Runs request payload hooks with the model used for that provider request. */
async emitBeforeProviderRequest(payload: unknown, model?: Model): Promise<BeforeProviderRequestEventResult> {
const ctx = this.createContext(model);
let currentPayload = payload;
for (const ext of this.extensions) {
+2 -2
View File
@@ -2698,8 +2698,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (snapcompactInline) transformed = await snapcompactInline.transform(transformed, transformModel);
return clampProviderContextImages(transformed, transformModel);
};
const onPayload = async (payload: unknown, _model?: Model) => {
return await extensionRunner.emitBeforeProviderRequest(payload);
const onPayload = async (payload: unknown, model?: Model) => {
return await extensionRunner.emitBeforeProviderRequest(payload, model);
};
const onResponse: SimpleStreamOptions["onResponse"] = async (response, model) => {
await extensionRunner.emitAfterProviderResponse(response, model);
@@ -7,6 +7,7 @@ import * as fs from "node:fs";
import * as path from "node:path";
import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { discoverAndLoadExtensions, ExtensionRuntime } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
import {
@@ -481,6 +482,78 @@ describe("ExtensionRunner", () => {
});
describe("before_provider_request chaining", () => {
it("exposes the request model instead of the primary session model", async () => {
const primaryModel = getBundledModel("openai-codex", "gpt-5.6-sol");
const requestModel = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!primaryModel || !requestModel) throw new Error("Expected bundled cross-provider models to exist");
const extCode = `
export default function(pi) {
pi.on("before_provider_request", async (_event, ctx) => {
const current = ctx.models.current();
return {
model: ctx.model && {
provider: ctx.model.provider,
id: ctx.model.id,
api: ctx.model.api,
},
current: current && {
provider: current.provider,
id: current.id,
api: current.api,
},
};
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "request-model.ts"), extCode);
const result = await loadTestExtensions();
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
runner.initialize(
{
sendMessage: () => {},
sendUserMessage: () => {},
appendEntry: () => {},
setLabel: () => {},
getActiveTools: () => [],
getAllTools: () => [],
setActiveTools: async () => {},
getCommands: () => [],
setModel: async () => false,
getThinkingLevel: () => undefined,
setThinkingLevel: () => {},
getSessionName: () => undefined,
setSessionName: async () => {},
},
{
getModel: () => primaryModel,
isIdle: () => true,
abort: () => {},
hasPendingMessages: () => false,
shutdown: () => {},
getContextUsage: () => undefined,
compact: async () => {},
getSystemPrompt: () => [],
},
);
const payload = await runner.emitBeforeProviderRequest({}, requestModel);
const expected = {
provider: requestModel.provider,
id: requestModel.id,
api: requestModel.api,
};
expect(payload).toEqual({ model: expected, current: expected });
});
it("chains payload replacements across handlers in load order", async () => {
const extCode1 = `
export default function(pi) {