From 16364c7dba0ba540845aecbb961d033f7ac0f3cb Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Sat, 14 Mar 2026 23:15:00 -0500 Subject: [PATCH] fix: prevent web_search tool injection for non-Anthropic providers serving Claude models (#444) (#446) GitHub Copilot users with Claude models got 400 errors because the native Anthropic web_search_20250305 tool was injected into requests to Copilot's API proxy, which doesn't support it. The root cause was that model_select never fires before the first API request on new sessions, so the fallback heuristic (model name starts with "claude-") couldn't distinguish direct Anthropic from proxied providers. Fix: pass the resolved Model object through to the before_provider_request event so extensions can check model.provider directly instead of relying on model name heuristics. --- .../src/core/extensions/runner.ts | 3 +- .../src/core/extensions/types.ts | 2 + packages/pi-coding-agent/src/core/sdk.ts | 4 +- .../search-the-web/native-search.ts | 25 +++++---- src/tests/native-search.test.ts | 51 +++++++++++++++++++ 5 files changed, 72 insertions(+), 13 deletions(-) diff --git a/packages/pi-coding-agent/src/core/extensions/runner.ts b/packages/pi-coding-agent/src/core/extensions/runner.ts index 42fd75e64..ccce69e99 100644 --- a/packages/pi-coding-agent/src/core/extensions/runner.ts +++ b/packages/pi-coding-agent/src/core/extensions/runner.ts @@ -712,7 +712,7 @@ export class ExtensionRunner { return currentMessages; } - async emitBeforeProviderRequest(payload: unknown): Promise { + async emitBeforeProviderRequest(payload: unknown, model?: { provider: string; id: string }): Promise { const ctx = this.createContext(); let currentPayload = payload; @@ -725,6 +725,7 @@ export class ExtensionRunner { const event: BeforeProviderRequestEvent = { type: "before_provider_request", payload: currentPayload, + model, }; const handlerResult = await handler(event, ctx); if (handlerResult !== undefined) { diff --git a/packages/pi-coding-agent/src/core/extensions/types.ts b/packages/pi-coding-agent/src/core/extensions/types.ts index 1efc7eda0..5e6d08421 100644 --- a/packages/pi-coding-agent/src/core/extensions/types.ts +++ b/packages/pi-coding-agent/src/core/extensions/types.ts @@ -506,6 +506,8 @@ export interface ContextEvent { export interface BeforeProviderRequestEvent { type: "before_provider_request"; payload: unknown; + /** The resolved model for this request (provider, id, etc.) */ + model?: { provider: string; id: string }; } /** Fired after user submits prompt but before agent loop. */ diff --git a/packages/pi-coding-agent/src/core/sdk.ts b/packages/pi-coding-agent/src/core/sdk.ts index 6ce5854cf..015644401 100644 --- a/packages/pi-coding-agent/src/core/sdk.ts +++ b/packages/pi-coding-agent/src/core/sdk.ts @@ -292,12 +292,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} tools: [], }, convertToLlm: convertToLlmWithBlockImages, - onPayload: async (payload, _model) => { + onPayload: async (payload, currentModel) => { const runner = extensionRunnerRef.current; if (!runner?.hasHandlers("before_provider_request")) { return payload; } - return runner.emitBeforeProviderRequest(payload); + return runner.emitBeforeProviderRequest(payload, currentModel); }, sessionId: sessionManager.getSessionId(), transformContext: async (messages) => { diff --git a/src/resources/extensions/search-the-web/native-search.ts b/src/resources/extensions/search-the-web/native-search.ts index e3e2cbafc..8221afce7 100644 --- a/src/resources/extensions/search-the-web/native-search.ts +++ b/src/resources/extensions/search-the-web/native-search.ts @@ -105,16 +105,21 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic: const payload = event.payload as Record; if (!payload) return; - // Detect Anthropic provider. Prefer the model_select flag when available, - // but fall back to checking the model name in the payload. model_select - // may not fire when the session restores with the same model already set - // (modelsAreEqual guard in the SDK suppresses the event). When model_select - // HAS fired and said "not Anthropic" (e.g. Copilot serving claude-*), - // respect that — don't override with model name heuristic. - const modelName = typeof payload.model === "string" ? payload.model : ""; - const isAnthropic = modelSelectFired - ? isAnthropicProvider - : modelName.startsWith("claude-"); + // Detect Anthropic provider. Use the model object from the event (most + // reliable — comes directly from the resolved Model), then fall back to + // the model_select flag, then to the model name heuristic (last resort). + // The model name heuristic is needed for session restores where + // modelsAreEqual suppresses model_select AND the SDK doesn't pass model. + const eventModel = event.model as { provider: string } | undefined; + let isAnthropic: boolean; + if (eventModel?.provider) { + isAnthropic = eventModel.provider === "anthropic"; + } else if (modelSelectFired) { + isAnthropic = isAnthropicProvider; + } else { + const modelName = typeof payload.model === "string" ? payload.model : ""; + isAnthropic = modelName.startsWith("claude-"); + } if (!isAnthropic) return; // Strip thinking blocks from history to avoid signature validation errors diff --git a/src/tests/native-search.test.ts b/src/tests/native-search.test.ts index 7e818260c..09fd7597e 100644 --- a/src/tests/native-search.test.ts +++ b/src/tests/native-search.test.ts @@ -177,6 +177,57 @@ test("before_provider_request does NOT inject for claude model on non-Anthropic ); }); +// ─── Issue #444 regression: Copilot claude-* model without model_select ────── + +test("before_provider_request does NOT inject when event.model indicates non-Anthropic provider (no model_select)", async () => { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + // NO model_select fired — simulates a new session where model was set before + // extensions were bound. The event.model field from the SDK reveals the true provider. + const payload: Record = { + model: "claude-sonnet-4-6-20250514", + tools: [{ name: "bash", type: "custom" }], + }; + + const result = await pi.fire("before_provider_request", { + type: "before_provider_request", + payload, + model: { provider: "github-copilot", id: "claude-sonnet-4-6" }, + }); + + assert.equal(result, undefined, "Should not modify payload when event.model says non-Anthropic"); + const tools = payload.tools as any[]; + assert.equal(tools.length, 1, "Should not inject web_search for Copilot provider"); + assert.ok( + !tools.some((t: any) => t.type === "web_search_20250305"), + "web_search_20250305 must NOT be present for Copilot" + ); +}); + +test("before_provider_request DOES inject when event.model indicates Anthropic provider (no model_select)", async () => { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + // NO model_select fired, but event.model confirms Anthropic provider + const payload: Record = { + model: "claude-sonnet-4-6-20250514", + tools: [{ name: "bash", type: "custom" }], + }; + + const result = await pi.fire("before_provider_request", { + type: "before_provider_request", + payload, + model: { provider: "anthropic", id: "claude-sonnet-4-6" }, + }); + + const tools = ((result as any)?.tools ?? payload.tools) as any[]; + assert.ok( + tools.some((t: any) => t.type === "web_search_20250305"), + "Should inject web_search when event.model confirms Anthropic" + ); +}); + test("before_provider_request does not double-inject", async () => { const pi = createMockPI(); registerNativeSearchHooks(pi);