From cf6ae71b738b7142f093deed8cd435d4466d3807 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 13 Jul 2026 16:16:16 +0900 Subject: [PATCH 1/8] fix(coding-agent): bound native web search fallbacks --- .../builtin/websearch/websearch/native.ts | 90 ++++++++----- .../websearch-native-route-dedup.test.ts | 121 ++++++++++++++++++ 2 files changed, 178 insertions(+), 33 deletions(-) create mode 100644 packages/coding-agent/test/suite/websearch-native-route-dedup.test.ts diff --git a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts index 1d8f877cb..387abbf04 100644 --- a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts +++ b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import { isAllowedProviderBaseUrl } from "./provider-endpoints.ts"; import type { SearchProvider, SearchProviderEntry } from "./types.ts"; @@ -65,14 +67,39 @@ function nativeMapping(model: NativeModelInfo): NativeProviderMapping | null { } function buildEndpointUrl(baseUrl: string, resource: string): string { - const trimmed = baseUrl.replace(/\/+$/, ""); + let configured: URL; + try { + configured = new URL(baseUrl); + } catch { + return baseUrl; + } + const trimmedPath = configured.pathname.replace(/\/+$/, ""); const resourceSlash = `/${resource}`; - if (trimmed.endsWith(resourceSlash)) return trimmed; - if (/\/v\d+$/.test(trimmed)) return `${trimmed}${resourceSlash}`; - return `${trimmed}/v1${resourceSlash}`; + if (trimmedPath.endsWith(resourceSlash)) { + configured.pathname = trimmedPath; + } else if (/\/v\d+$/.test(trimmedPath)) { + configured.pathname = `${trimmedPath}${resourceSlash}`; + } else { + configured.pathname = `${trimmedPath}/v1${resourceSlash}`; + } + configured.hash = ""; + return configured.href; } -export async function buildNativeEntry( +function nativeRouteKey(model: NativeModelInfo): string | null { + const mapping = nativeMapping(model); + if (!mapping) return null; + const baseUrl = buildEndpointUrl(model.baseUrl, mapping.resource); + if (!isAllowedProviderBaseUrl(baseUrl)) return null; + return `${mapping.provider}|${new URL(baseUrl).href}`; +} + +function discoveredNativeEntryId(provider: SearchProvider, routeKey: string): string { + const routeFingerprint = createHash("sha256").update(routeKey).digest("hex").slice(0, 16); + return `native-${provider}-${routeFingerprint}`; +} + +async function buildNativeEntryForModel( model: NativeModelInfo | undefined, modelRegistry: NativeModelRegistry | undefined, id = "native", @@ -96,24 +123,6 @@ export async function buildNativeEntry( priority: -1, }; } - -function nativeEntryKey(entry: SearchProviderEntry): string { - return `${entry.provider}:${entry.baseUrl ?? ""}:${entry.model ?? ""}`; -} - -function stableIdPart(value: string): string { - return ( - value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "model" - ); -} - -function discoveredNativeEntryId(entry: SearchProviderEntry): string { - return `native-${entry.provider}-${stableIdPart(entry.model ?? "model")}`; -} - export async function buildNativeEntries( model: NativeModelInfo | undefined, modelRegistry: NativeModelRegistry | undefined, @@ -121,18 +130,33 @@ export async function buildNativeEntries( if (!modelRegistry) return []; const entries: SearchProviderEntry[] = []; - const activeEntry = await buildNativeEntry(model, modelRegistry); - if (activeEntry) entries.push(activeEntry); + const seenRoutes = new Set(); - const seen = new Set(entries.map(nativeEntryKey)); - const availableModels = modelRegistry.getAvailable?.() ?? []; - for (const availableModel of availableModels) { - const entry = await buildNativeEntry(availableModel, modelRegistry, "native-discovered"); + const activeRouteKey = model ? nativeRouteKey(model) : null; + if (activeRouteKey) { + seenRoutes.add(activeRouteKey); + const activeEntry = await buildNativeEntryForModel(model, modelRegistry); + if (activeEntry) entries.push(activeEntry); + } + + if (!modelRegistry.getAvailable) return entries; + + for (const availableModel of modelRegistry.getAvailable()) { + const routeKey = nativeRouteKey(availableModel); + if (!routeKey || seenRoutes.has(routeKey)) continue; + seenRoutes.add(routeKey); + const entry = await buildNativeEntryForModel(availableModel, modelRegistry, "native-discovered"); if (!entry) continue; - const key = nativeEntryKey(entry); - if (seen.has(key)) continue; - seen.add(key); - entries.push({ ...entry, id: discoveredNativeEntryId(entry) }); + entries.push({ ...entry, id: discoveredNativeEntryId(entry.provider, routeKey) }); } + return entries; } + +export async function buildNativeEntry( + model: NativeModelInfo | undefined, + modelRegistry: NativeModelRegistry | undefined, + id = "native", +): Promise { + return buildNativeEntryForModel(model, modelRegistry, id); +} diff --git a/packages/coding-agent/test/suite/websearch-native-route-dedup.test.ts b/packages/coding-agent/test/suite/websearch-native-route-dedup.test.ts new file mode 100644 index 000000000..f60171ab4 --- /dev/null +++ b/packages/coding-agent/test/suite/websearch-native-route-dedup.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import { + buildNativeEntries, + type NativeModelInfo, + type NativeModelRegistry, +} from "../../src/core/extensions/builtin/websearch/websearch/native.ts"; + +type DiscoveryRegistry = NativeModelRegistry & { + getAvailable(): NativeModelInfo[]; +}; + +function anthropicAliases(baseUrl: string): NativeModelInfo[] { + return Array.from({ length: 8 }, (_value, index) => ({ + provider: "anthropic", + id: index === 0 ? "claude-opus-4" : `claude-opus-4-${index}`, + baseUrl, + })); +} + +function zAiAliases(baseUrl: string): NativeModelInfo[] { + return Array.from({ length: 6 }, (_value, index) => ({ + provider: "z-ai", + id: `glm-4.6-${index}`, + baseUrl, + })); +} + +describe("vendored websearch native route discovery", () => { + it("#given fourteen aliases across two endpoints #when discovering native entries #then emits one opaque entry per route", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: true, apiKey: "native-test" }; + }, + getAvailable() { + return [ + ...anthropicAliases("https://gateway.example.com/v1"), + ...zAiAliases("https://gateway.example.com/v1"), + ]; + }, + }; + + // when + const firstEntries = await buildNativeEntries(undefined, modelRegistry); + const secondEntries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(firstEntries).toHaveLength(2); + expect(firstEntries.map((entry) => entry.id)).toEqual(secondEntries.map((entry) => entry.id)); + expect(firstEntries[0]?.id).toMatch(/^native-anthropic-[0-9a-f]{16}$/); + expect(firstEntries[1]?.id).toMatch(/^native-z-ai-[0-9a-f]{16}$/); + expect(firstEntries.map((entry) => entry.id).join(" ")).not.toContain("gateway-example"); + expect(authModels).toEqual(["claude-opus-4", "glm-4.6-0", "claude-opus-4", "glm-4.6-0"]); + }); + + it("#given active route auth fails #when a discovered alias shares the route #then does not retry auth through the alias", async () => { + // given + const activeModel: NativeModelInfo = { + provider: "openai", + id: "gpt-5.5", + baseUrl: "https://gateway.example.com/v1", + }; + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return model.id === activeModel.id + ? { ok: false, error: "active unavailable" } + : { ok: true, apiKey: "alias-key" }; + }, + getAvailable() { + return [{ provider: "openai", id: "gpt-4.1", baseUrl: "https://gateway.example.com/v1" }]; + }, + }; + + // when + const entries = await buildNativeEntries(activeModel, modelRegistry); + + // then + expect(entries).toEqual([]); + expect(authModels).toEqual(["gpt-5.5"]); + }); + + it("#given query auth and fragment aliases #when building the endpoint #then preserves query and dedupes fragments", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: true, apiKey: "native-test" }; + }, + getAvailable() { + return [ + { + provider: "openai", + id: "gpt-5.5", + baseUrl: "https://gateway.example.com/v1?token=secret#first", + }, + { + provider: "openai", + id: "gpt-4.1", + baseUrl: "https://gateway.example.com/v1?token=secret#second", + }, + ]; + }, + }; + + // when + const entries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(entries).toHaveLength(1); + expect(entries[0]?.baseUrl).toBe("https://gateway.example.com/v1/responses?token=secret"); + expect(entries[0]?.id).toMatch(/^native-openai-[0-9a-f]{16}$/); + expect(entries[0]?.id).not.toContain("secret"); + expect(authModels).toEqual(["gpt-5.5"]); + }); +}); From efbe411478b9f4acf92929438f07f8e72b98b100 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 13 Jul 2026 16:16:22 +0900 Subject: [PATCH 2/8] docs(coding-agent): record web search fallback fix --- packages/coding-agent/CHANGELOG.md | 2 ++ .../src/core/extensions/builtin/websearch/changes.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index afd677b59..aad5edfbb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- Fixed built-in web search discovery so model aliases sharing a provider endpoint no longer multiply serial fallback attempts ([upstream #5](https://github.com/code-yeongyu/pi-websearch/pull/5)). + ### Removed ## [2026.7.11] - 2026-07-11 diff --git a/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md b/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md index dca993be9..2f837a79e 100644 --- a/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md @@ -1,6 +1,6 @@ # changes.md — websearch (vendored) -Vendored from [`code-yeongyu/pi-websearch`](https://github.com/code-yeongyu/pi-websearch) at `0b9b44e83eef46121758f22965aadf59544faccf`. +Vendored from [`code-yeongyu/pi-websearch`](https://github.com/code-yeongyu/pi-websearch) at `ff6db5ccbd73522d683aada9f0a365205fd1c2c6`. ## Senpi adaptations vs upstream From a7fc7bc01e4f48f777326f4d416a5279815266b5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 13 Jul 2026 16:55:06 +0900 Subject: [PATCH 3/8] fix(coding-agent): verify web search fallback ordering --- .../websearch-native-route-dedup.test.ts | 121 -------- .../test/websearch-native-route-dedup.test.ts | 276 ++++++++++++++++++ 2 files changed, 276 insertions(+), 121 deletions(-) delete mode 100644 packages/coding-agent/test/suite/websearch-native-route-dedup.test.ts create mode 100644 packages/coding-agent/test/websearch-native-route-dedup.test.ts diff --git a/packages/coding-agent/test/suite/websearch-native-route-dedup.test.ts b/packages/coding-agent/test/suite/websearch-native-route-dedup.test.ts deleted file mode 100644 index f60171ab4..000000000 --- a/packages/coding-agent/test/suite/websearch-native-route-dedup.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - buildNativeEntries, - type NativeModelInfo, - type NativeModelRegistry, -} from "../../src/core/extensions/builtin/websearch/websearch/native.ts"; - -type DiscoveryRegistry = NativeModelRegistry & { - getAvailable(): NativeModelInfo[]; -}; - -function anthropicAliases(baseUrl: string): NativeModelInfo[] { - return Array.from({ length: 8 }, (_value, index) => ({ - provider: "anthropic", - id: index === 0 ? "claude-opus-4" : `claude-opus-4-${index}`, - baseUrl, - })); -} - -function zAiAliases(baseUrl: string): NativeModelInfo[] { - return Array.from({ length: 6 }, (_value, index) => ({ - provider: "z-ai", - id: `glm-4.6-${index}`, - baseUrl, - })); -} - -describe("vendored websearch native route discovery", () => { - it("#given fourteen aliases across two endpoints #when discovering native entries #then emits one opaque entry per route", async () => { - // given - const authModels: string[] = []; - const modelRegistry: DiscoveryRegistry = { - async getApiKeyAndHeaders(model) { - authModels.push(model.id); - return { ok: true, apiKey: "native-test" }; - }, - getAvailable() { - return [ - ...anthropicAliases("https://gateway.example.com/v1"), - ...zAiAliases("https://gateway.example.com/v1"), - ]; - }, - }; - - // when - const firstEntries = await buildNativeEntries(undefined, modelRegistry); - const secondEntries = await buildNativeEntries(undefined, modelRegistry); - - // then - expect(firstEntries).toHaveLength(2); - expect(firstEntries.map((entry) => entry.id)).toEqual(secondEntries.map((entry) => entry.id)); - expect(firstEntries[0]?.id).toMatch(/^native-anthropic-[0-9a-f]{16}$/); - expect(firstEntries[1]?.id).toMatch(/^native-z-ai-[0-9a-f]{16}$/); - expect(firstEntries.map((entry) => entry.id).join(" ")).not.toContain("gateway-example"); - expect(authModels).toEqual(["claude-opus-4", "glm-4.6-0", "claude-opus-4", "glm-4.6-0"]); - }); - - it("#given active route auth fails #when a discovered alias shares the route #then does not retry auth through the alias", async () => { - // given - const activeModel: NativeModelInfo = { - provider: "openai", - id: "gpt-5.5", - baseUrl: "https://gateway.example.com/v1", - }; - const authModels: string[] = []; - const modelRegistry: DiscoveryRegistry = { - async getApiKeyAndHeaders(model) { - authModels.push(model.id); - return model.id === activeModel.id - ? { ok: false, error: "active unavailable" } - : { ok: true, apiKey: "alias-key" }; - }, - getAvailable() { - return [{ provider: "openai", id: "gpt-4.1", baseUrl: "https://gateway.example.com/v1" }]; - }, - }; - - // when - const entries = await buildNativeEntries(activeModel, modelRegistry); - - // then - expect(entries).toEqual([]); - expect(authModels).toEqual(["gpt-5.5"]); - }); - - it("#given query auth and fragment aliases #when building the endpoint #then preserves query and dedupes fragments", async () => { - // given - const authModels: string[] = []; - const modelRegistry: DiscoveryRegistry = { - async getApiKeyAndHeaders(model) { - authModels.push(model.id); - return { ok: true, apiKey: "native-test" }; - }, - getAvailable() { - return [ - { - provider: "openai", - id: "gpt-5.5", - baseUrl: "https://gateway.example.com/v1?token=secret#first", - }, - { - provider: "openai", - id: "gpt-4.1", - baseUrl: "https://gateway.example.com/v1?token=secret#second", - }, - ]; - }, - }; - - // when - const entries = await buildNativeEntries(undefined, modelRegistry); - - // then - expect(entries).toHaveLength(1); - expect(entries[0]?.baseUrl).toBe("https://gateway.example.com/v1/responses?token=secret"); - expect(entries[0]?.id).toMatch(/^native-openai-[0-9a-f]{16}$/); - expect(entries[0]?.id).not.toContain("secret"); - expect(authModels).toEqual(["gpt-5.5"]); - }); -}); diff --git a/packages/coding-agent/test/websearch-native-route-dedup.test.ts b/packages/coding-agent/test/websearch-native-route-dedup.test.ts new file mode 100644 index 000000000..0c9c66894 --- /dev/null +++ b/packages/coding-agent/test/websearch-native-route-dedup.test.ts @@ -0,0 +1,276 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { DEFAULT_COMPACTION_SETTINGS } from "../src/core/compaction/index.ts"; +import { + buildNativeEntries, + type NativeModelInfo, + type NativeModelRegistry, +} from "../src/core/extensions/builtin/websearch/websearch/native.ts"; +import { createWebSearchTool } from "../src/core/extensions/builtin/websearch/websearch/tool.ts"; +import type { + SearchProgressDetails, + WebsearchConfig, +} from "../src/core/extensions/builtin/websearch/websearch/types.ts"; +import type { ExtensionContext } from "../src/core/extensions/types.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; + +type DiscoveryRegistry = NativeModelRegistry & { + getAvailable(): NativeModelInfo[]; +}; + +function anthropicAliases(baseUrl: string): NativeModelInfo[] { + return Array.from({ length: 8 }, (_value, index) => ({ + provider: "anthropic", + id: index === 0 ? "claude-opus-4" : `claude-opus-4-${index}`, + baseUrl, + })); +} + +function zAiAliases(baseUrl: string): NativeModelInfo[] { + return Array.from({ length: 6 }, (_value, index) => ({ + provider: "z-ai", + id: `glm-4.6-${index}`, + baseUrl, + })); +} + +function successfulSearchResponse(): Response { + return new Response( + JSON.stringify({ + output: [ + { + type: "web_search_call", + action: { sources: [{ url: "https://results.example.com/native" }] }, + }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); +} + +function nativeModel(provider: string, id: string, api: Api, baseUrl: string): Model { + return { + provider, + id, + name: id, + api, + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }; +} + +function toolContext(model: Model | undefined, modelRegistry: ModelRegistry): ExtensionContext { + return { + ui: Object.create(null) as ExtensionContext["ui"], + mode: "print", + hasUI: false, + cwd: process.cwd(), + sessionManager: Object.create(null) as ExtensionContext["sessionManager"], + modelRegistry, + model, + serviceTier: undefined, + isIdle: () => true, + isProjectTrusted: () => true, + signal: undefined, + abort: vi.fn(), + hasPendingMessages: () => false, + shutdown: vi.fn(), + getContextUsage: () => undefined, + getCompactionSettings: () => DEFAULT_COMPACTION_SETTINGS, + compact: vi.fn(), + getMessageRevision: () => 0, + applyCompaction: async () => ({ applied: false, reason: "rejected" }), + getSystemPrompt: () => "", + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("vendored websearch native route discovery", () => { + it("#given fourteen aliases across two endpoints #when discovering native entries #then emits one opaque entry per route", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: true, apiKey: "native-test" }; + }, + getAvailable() { + return [ + ...anthropicAliases("https://gateway.example.com/v1"), + ...zAiAliases("https://gateway.example.com/v1"), + ]; + }, + }; + + // when + const firstEntries = await buildNativeEntries(undefined, modelRegistry); + const secondEntries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(firstEntries).toHaveLength(2); + expect(firstEntries.map((entry) => entry.id)).toEqual(secondEntries.map((entry) => entry.id)); + expect(firstEntries[0]?.id).toMatch(/^native-anthropic-[0-9a-f]{16}$/); + expect(firstEntries[1]?.id).toMatch(/^native-z-ai-[0-9a-f]{16}$/); + expect(firstEntries.map((entry) => entry.id).join(" ")).not.toContain("gateway-example"); + expect(authModels).toEqual(["claude-opus-4", "glm-4.6-0", "claude-opus-4", "glm-4.6-0"]); + }); + + it("#given active route auth fails #when a discovered alias shares the route #then does not retry auth through the alias", async () => { + // given + const activeModel: NativeModelInfo = { + provider: "openai", + id: "gpt-5.5", + baseUrl: "https://gateway.example.com/v1", + }; + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return model.id === activeModel.id + ? { ok: false, error: "active unavailable" } + : { ok: true, apiKey: "alias-key" }; + }, + getAvailable() { + return [{ provider: "openai", id: "gpt-4.1", baseUrl: "https://gateway.example.com/v1" }]; + }, + }; + + // when + const entries = await buildNativeEntries(activeModel, modelRegistry); + + // then + expect(entries).toEqual([]); + expect(authModels).toEqual(["gpt-5.5"]); + }); + + it("#given query auth and fragment aliases #when building the endpoint #then preserves query and dedupes fragments", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: true, apiKey: "native-test" }; + }, + getAvailable() { + return [ + { + provider: "openai", + id: "gpt-5.5", + baseUrl: "https://gateway.example.com/v1?token=secret#first", + }, + { + provider: "openai", + id: "gpt-4.1", + baseUrl: "https://gateway.example.com/v1?token=secret#second", + }, + ]; + }, + }; + + // when + const entries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(entries).toHaveLength(1); + expect(entries[0]?.baseUrl).toBe("https://gateway.example.com/v1/responses?token=secret"); + expect(entries[0]?.id).toMatch(/^native-openai-[0-9a-f]{16}$/); + expect(entries[0]?.id).not.toContain("secret"); + expect(authModels).toEqual(["gpt-5.5"]); + }); + + it("#given an active native model and same-route aliases #when the real tool is executed #then orders native routes before configured providers without duplication", async () => { + // given + const activeModel = nativeModel("openai", "gpt-5.5", "openai-responses", "https://gateway.example.com/v1"); + const authModels: string[] = []; + const modelRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); + vi.spyOn(modelRegistry, "getApiKeyAndHeaders").mockImplementation(async (model) => { + authModels.push(model.id); + return { ok: true, apiKey: `${model.provider}-native-key` }; + }); + vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([ + nativeModel("openai", "gpt-4o-2025-06-01", "openai-responses", "https://gateway.example.com/v1"), + nativeModel("openai", "gpt-4.1", "openai-responses", "https://gateway.example.com/v1"), + nativeModel("anthropic", "claude-sonnet-4-20250514", "anthropic-messages", "https://anthropic.example.com"), + ]); + const config: WebsearchConfig = { + strategy: "priority", + fallback: true, + auto: true, + providers: [ + { id: "configured-first", provider: "duckduckgo-html" }, + { id: "configured-second", provider: "z-ai", apiKey: "configured-key" }, + ], + }; + const progress: SearchProgressDetails[] = []; + const fetchMock = vi.fn(async () => successfulSearchResponse()); + vi.stubGlobal("fetch", fetchMock); + const tool = createWebSearchTool(() => ({ ok: true, config, source: "test" })); + + // when + await tool.execute( + "native-route-order", + { query: "native route order" }, + undefined, + (update) => { + if (update.details && "phase" in update.details && update.details.phase === "searching") { + progress.push(update.details); + } + }, + toolContext(activeModel, modelRegistry), + ); + + // then + expect(progress).toHaveLength(1); + const providerLabels = progress[0]?.providerLabels ?? []; + expect(providerLabels).toHaveLength(4); + expect(providerLabels[0]).toBe("native/openai"); + expect(providerLabels[1]).toMatch(/^native-anthropic-[0-9a-f]{16}\/anthropic$/); + expect(providerLabels[2]).toBe("configured-first/duckduckgo-html"); + expect(providerLabels[3]).toBe("configured-second/z-ai"); + expect(providerLabels.filter((label) => label.endsWith("/openai"))).toEqual(["native/openai"]); + expect(authModels).toEqual(["gpt-5.5", "claude-sonnet-4-20250514"]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("#given a pre-aborted signal #when the real tool is executed #then propagates the reason before fetch or fallback", async () => { + // given + const config: WebsearchConfig = { + strategy: "priority", + fallback: true, + auto: false, + providers: [ + { id: "first", provider: "duckduckgo-html" }, + { id: "second", provider: "z-ai", apiKey: "configured-key" }, + ], + }; + const abortReason = new DOMException("cancelled by test", "AbortError"); + const controller = new AbortController(); + controller.abort(abortReason); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const tool = createWebSearchTool(() => ({ ok: true, config, source: "test" })); + const modelRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); + + // when + const execution = tool.execute( + "pre-aborted", + { query: "should not search" }, + controller.signal, + undefined, + toolContext(undefined, modelRegistry), + ); + + // then + await expect(execution).rejects.toBe(abortReason); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); From 8736353607350dbf3db1fc6f6d96ab70c9cd7c83 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 13 Jul 2026 17:23:24 +0900 Subject: [PATCH 4/8] fix(coding-agent): cover native route edge cases --- .../test/websearch-native-route-dedup.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/coding-agent/test/websearch-native-route-dedup.test.ts b/packages/coding-agent/test/websearch-native-route-dedup.test.ts index 0c9c66894..5070d7ddf 100644 --- a/packages/coding-agent/test/websearch-native-route-dedup.test.ts +++ b/packages/coding-agent/test/websearch-native-route-dedup.test.ts @@ -152,6 +152,56 @@ describe("vendored websearch native route discovery", () => { expect(authModels).toEqual(["gpt-5.5"]); }); + it("#given unavailable aliases on one route #when discovering native entries #then resolves auth for only the first alias", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: false, error: "unavailable" }; + }, + getAvailable() { + return anthropicAliases("https://gateway.example.com/v1"); + }, + }; + + // when + const entries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(entries).toEqual([]); + expect(authModels).toEqual(["claude-opus-4"]); + }); + + it("#given one provider on distinct endpoints #when discovering native entries #then preserves both route candidates", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: true, apiKey: "native-test" }; + }, + getAvailable() { + return [ + { provider: "openai", id: "gpt-4.1", baseUrl: "https://openai-a.example.com/v1" }, + { provider: "openai", id: "gpt-5.5", baseUrl: "https://openai-b.example.com/v1" }, + ]; + }, + }; + + // when + const entries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(entries).toHaveLength(2); + expect(entries.map((entry) => entry.baseUrl)).toEqual([ + "https://openai-a.example.com/v1/responses", + "https://openai-b.example.com/v1/responses", + ]); + expect(new Set(entries.map((entry) => entry.id)).size).toBe(2); + expect(authModels).toEqual(["gpt-4.1", "gpt-5.5"]); + }); + it("#given query auth and fragment aliases #when building the endpoint #then preserves query and dedupes fragments", async () => { // given const authModels: string[] = []; From 529d6e08b6d2d5f12718f286f8cd2c8b0d09eb71 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 13 Jul 2026 18:44:34 +0900 Subject: [PATCH 5/8] fix(coding-agent): abort native web search discovery --- packages/coding-agent/CHANGELOG.md | 2 +- .../builtin/websearch/websearch/native.ts | 39 +++- .../builtin/websearch/websearch/tool.ts | 10 +- .../test/websearch-native-route-dedup.test.ts | 168 +--------------- .../test/websearch-native-tool.test.ts | 186 ++++++++++++++++++ 5 files changed, 235 insertions(+), 170 deletions(-) create mode 100644 packages/coding-agent/test/websearch-native-tool.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index aad5edfbb..9004ce05c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixed -- Fixed built-in web search discovery so model aliases sharing a provider endpoint no longer multiply serial fallback attempts ([upstream #5](https://github.com/code-yeongyu/pi-websearch/pull/5)). +- Fixed built-in web search discovery so model aliases sharing a provider endpoint no longer multiply serial fallback attempts, and aborting a search stops pending native authentication discovery ([upstream #5](https://github.com/code-yeongyu/pi-websearch/pull/5)). ### Removed diff --git a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts index 387abbf04..f26946ba2 100644 --- a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts +++ b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts @@ -23,6 +23,11 @@ interface NativeProviderMapping { resource: string; } +interface NativeEntryOptions { + id?: string; + signal?: AbortSignal; +} + function nativeMapping(model: NativeModelInfo): NativeProviderMapping | null { if ( model.provider === "openai" && @@ -102,16 +107,35 @@ function discoveredNativeEntryId(provider: SearchProvider, routeKey: string): st async function buildNativeEntryForModel( model: NativeModelInfo | undefined, modelRegistry: NativeModelRegistry | undefined, - id = "native", + options: NativeEntryOptions = {}, ): Promise { if (!model || !modelRegistry) return null; + const { id = "native", signal } = options; const mapping = nativeMapping(model); if (!mapping) return null; const baseUrl = buildEndpointUrl(model.baseUrl, mapping.resource); if (!isAllowedProviderBaseUrl(baseUrl)) return null; - const auth = await modelRegistry.getApiKeyAndHeaders(model); + signal?.throwIfAborted(); + const authPromise = modelRegistry.getApiKeyAndHeaders(model); + let auth: NativeAuthResult; + if (!signal) { + auth = await authPromise; + } else { + signal.throwIfAborted(); + let removeAbortListener = (): void => {}; + const abortPromise = new Promise((_resolve, reject) => { + const onAbort = (): void => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => signal.removeEventListener("abort", onAbort); + }); + try { + auth = await Promise.race([authPromise, abortPromise]); + } finally { + removeAbortListener(); + } + } if (!auth.ok || !auth.apiKey) return null; return { @@ -126,7 +150,9 @@ async function buildNativeEntryForModel( export async function buildNativeEntries( model: NativeModelInfo | undefined, modelRegistry: NativeModelRegistry | undefined, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); if (!modelRegistry) return []; const entries: SearchProviderEntry[] = []; @@ -135,7 +161,7 @@ export async function buildNativeEntries( const activeRouteKey = model ? nativeRouteKey(model) : null; if (activeRouteKey) { seenRoutes.add(activeRouteKey); - const activeEntry = await buildNativeEntryForModel(model, modelRegistry); + const activeEntry = await buildNativeEntryForModel(model, modelRegistry, { signal }); if (activeEntry) entries.push(activeEntry); } @@ -145,7 +171,10 @@ export async function buildNativeEntries( const routeKey = nativeRouteKey(availableModel); if (!routeKey || seenRoutes.has(routeKey)) continue; seenRoutes.add(routeKey); - const entry = await buildNativeEntryForModel(availableModel, modelRegistry, "native-discovered"); + const entry = await buildNativeEntryForModel(availableModel, modelRegistry, { + id: "native-discovered", + signal, + }); if (!entry) continue; entries.push({ ...entry, id: discoveredNativeEntryId(entry.provider, routeKey) }); } @@ -158,5 +187,5 @@ export async function buildNativeEntry( modelRegistry: NativeModelRegistry | undefined, id = "native", ): Promise { - return buildNativeEntryForModel(model, modelRegistry, id); + return buildNativeEntryForModel(model, modelRegistry, { id }); } diff --git a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/tool.ts b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/tool.ts index 0e9873089..fb686a312 100644 --- a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/tool.ts +++ b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/tool.ts @@ -34,9 +34,13 @@ interface WebSearchToolContext { modelRegistry: NativeModelRegistry; } -async function configWithNativeRoute(config: WebsearchConfig, ctx?: WebSearchToolContext): Promise { +async function configWithNativeRoute( + config: WebsearchConfig, + ctx: WebSearchToolContext | undefined, + signal: AbortSignal | undefined, +): Promise { if (!config.auto) return config; - const nativeEntries = await buildNativeEntries(ctx?.model, ctx?.modelRegistry); + const nativeEntries = await buildNativeEntries(ctx?.model, ctx?.modelRegistry, signal); return nativeEntries.length > 0 ? { ...config, providers: [...nativeEntries, ...config.providers] } : config; } @@ -78,7 +82,7 @@ export function createWebSearchTool(getConfig: ConfigProvider): WebSearchTool { } const maxResults = loaded.config.providers[0]?.maxResults ?? 10; - const config = await configWithNativeRoute(loaded.config, ctx); + const config = await configWithNativeRoute(loaded.config, ctx, signal); const progressDetails: SearchProgressDetails = { phase: "searching", query: params.query, diff --git a/packages/coding-agent/test/websearch-native-route-dedup.test.ts b/packages/coding-agent/test/websearch-native-route-dedup.test.ts index 5070d7ddf..389a559da 100644 --- a/packages/coding-agent/test/websearch-native-route-dedup.test.ts +++ b/packages/coding-agent/test/websearch-native-route-dedup.test.ts @@ -1,20 +1,10 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -import { AuthStorage } from "../src/core/auth-storage.ts"; -import { DEFAULT_COMPACTION_SETTINGS } from "../src/core/compaction/index.ts"; import { buildNativeEntries, type NativeModelInfo, type NativeModelRegistry, } from "../src/core/extensions/builtin/websearch/websearch/native.ts"; -import { createWebSearchTool } from "../src/core/extensions/builtin/websearch/websearch/tool.ts"; -import type { - SearchProgressDetails, - WebsearchConfig, -} from "../src/core/extensions/builtin/websearch/websearch/types.ts"; -import type { ExtensionContext } from "../src/core/extensions/types.ts"; -import { ModelRegistry } from "../src/core/model-registry.ts"; type DiscoveryRegistry = NativeModelRegistry & { getAvailable(): NativeModelInfo[]; @@ -36,64 +26,6 @@ function zAiAliases(baseUrl: string): NativeModelInfo[] { })); } -function successfulSearchResponse(): Response { - return new Response( - JSON.stringify({ - output: [ - { - type: "web_search_call", - action: { sources: [{ url: "https://results.example.com/native" }] }, - }, - ], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); -} - -function nativeModel(provider: string, id: string, api: Api, baseUrl: string): Model { - return { - provider, - id, - name: id, - api, - baseUrl, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128_000, - maxTokens: 16_384, - }; -} - -function toolContext(model: Model | undefined, modelRegistry: ModelRegistry): ExtensionContext { - return { - ui: Object.create(null) as ExtensionContext["ui"], - mode: "print", - hasUI: false, - cwd: process.cwd(), - sessionManager: Object.create(null) as ExtensionContext["sessionManager"], - modelRegistry, - model, - serviceTier: undefined, - isIdle: () => true, - isProjectTrusted: () => true, - signal: undefined, - abort: vi.fn(), - hasPendingMessages: () => false, - shutdown: vi.fn(), - getContextUsage: () => undefined, - getCompactionSettings: () => DEFAULT_COMPACTION_SETTINGS, - compact: vi.fn(), - getMessageRevision: () => 0, - applyCompaction: async () => ({ applied: false, reason: "rejected" }), - getSystemPrompt: () => "", - }; -} - -afterEach(() => { - vi.unstubAllGlobals(); -}); - describe("vendored websearch native route discovery", () => { it("#given fourteen aliases across two endpoints #when discovering native entries #then emits one opaque entry per route", async () => { // given @@ -117,10 +49,11 @@ describe("vendored websearch native route discovery", () => { // then expect(firstEntries).toHaveLength(2); - expect(firstEntries.map((entry) => entry.id)).toEqual(secondEntries.map((entry) => entry.id)); - expect(firstEntries[0]?.id).toMatch(/^native-anthropic-[0-9a-f]{16}$/); - expect(firstEntries[1]?.id).toMatch(/^native-z-ai-[0-9a-f]{16}$/); - expect(firstEntries.map((entry) => entry.id).join(" ")).not.toContain("gateway-example"); + const firstIds = firstEntries.map((entry) => entry.id); + expect(firstIds).toEqual(secondEntries.map((entry) => entry.id)); + expect(new Set(firstIds).size).toBe(2); + expect(firstIds.every((id) => id?.startsWith("native-"))).toBe(true); + expect(firstIds.join(" ")).not.toMatch(/gateway|claude|glm|native-test/); expect(authModels).toEqual(["claude-opus-4", "glm-4.6-0", "claude-opus-4", "glm-4.6-0"]); }); @@ -232,95 +165,8 @@ describe("vendored websearch native route discovery", () => { // then expect(entries).toHaveLength(1); expect(entries[0]?.baseUrl).toBe("https://gateway.example.com/v1/responses?token=secret"); - expect(entries[0]?.id).toMatch(/^native-openai-[0-9a-f]{16}$/); + expect(entries[0]?.id).toBeTruthy(); expect(entries[0]?.id).not.toContain("secret"); expect(authModels).toEqual(["gpt-5.5"]); }); - - it("#given an active native model and same-route aliases #when the real tool is executed #then orders native routes before configured providers without duplication", async () => { - // given - const activeModel = nativeModel("openai", "gpt-5.5", "openai-responses", "https://gateway.example.com/v1"); - const authModels: string[] = []; - const modelRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); - vi.spyOn(modelRegistry, "getApiKeyAndHeaders").mockImplementation(async (model) => { - authModels.push(model.id); - return { ok: true, apiKey: `${model.provider}-native-key` }; - }); - vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([ - nativeModel("openai", "gpt-4o-2025-06-01", "openai-responses", "https://gateway.example.com/v1"), - nativeModel("openai", "gpt-4.1", "openai-responses", "https://gateway.example.com/v1"), - nativeModel("anthropic", "claude-sonnet-4-20250514", "anthropic-messages", "https://anthropic.example.com"), - ]); - const config: WebsearchConfig = { - strategy: "priority", - fallback: true, - auto: true, - providers: [ - { id: "configured-first", provider: "duckduckgo-html" }, - { id: "configured-second", provider: "z-ai", apiKey: "configured-key" }, - ], - }; - const progress: SearchProgressDetails[] = []; - const fetchMock = vi.fn(async () => successfulSearchResponse()); - vi.stubGlobal("fetch", fetchMock); - const tool = createWebSearchTool(() => ({ ok: true, config, source: "test" })); - - // when - await tool.execute( - "native-route-order", - { query: "native route order" }, - undefined, - (update) => { - if (update.details && "phase" in update.details && update.details.phase === "searching") { - progress.push(update.details); - } - }, - toolContext(activeModel, modelRegistry), - ); - - // then - expect(progress).toHaveLength(1); - const providerLabels = progress[0]?.providerLabels ?? []; - expect(providerLabels).toHaveLength(4); - expect(providerLabels[0]).toBe("native/openai"); - expect(providerLabels[1]).toMatch(/^native-anthropic-[0-9a-f]{16}\/anthropic$/); - expect(providerLabels[2]).toBe("configured-first/duckduckgo-html"); - expect(providerLabels[3]).toBe("configured-second/z-ai"); - expect(providerLabels.filter((label) => label.endsWith("/openai"))).toEqual(["native/openai"]); - expect(authModels).toEqual(["gpt-5.5", "claude-sonnet-4-20250514"]); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("#given a pre-aborted signal #when the real tool is executed #then propagates the reason before fetch or fallback", async () => { - // given - const config: WebsearchConfig = { - strategy: "priority", - fallback: true, - auto: false, - providers: [ - { id: "first", provider: "duckduckgo-html" }, - { id: "second", provider: "z-ai", apiKey: "configured-key" }, - ], - }; - const abortReason = new DOMException("cancelled by test", "AbortError"); - const controller = new AbortController(); - controller.abort(abortReason); - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - const tool = createWebSearchTool(() => ({ ok: true, config, source: "test" })); - const modelRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); - - // when - const execution = tool.execute( - "pre-aborted", - { query: "should not search" }, - controller.signal, - undefined, - toolContext(undefined, modelRegistry), - ); - - // then - await expect(execution).rejects.toBe(abortReason); - expect(fetchMock).not.toHaveBeenCalled(); - }); }); diff --git a/packages/coding-agent/test/websearch-native-tool.test.ts b/packages/coding-agent/test/websearch-native-tool.test.ts new file mode 100644 index 000000000..9691428e2 --- /dev/null +++ b/packages/coding-agent/test/websearch-native-tool.test.ts @@ -0,0 +1,186 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { DEFAULT_COMPACTION_SETTINGS } from "../src/core/compaction/index.ts"; +import { createWebSearchTool } from "../src/core/extensions/builtin/websearch/websearch/tool.ts"; +import type { + SearchProgressDetails, + WebsearchConfig, +} from "../src/core/extensions/builtin/websearch/websearch/types.ts"; +import type { ExtensionContext } from "../src/core/extensions/types.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; + +function successfulSearchResponse(): Response { + return new Response( + JSON.stringify({ + output: [{ type: "web_search_call", action: { sources: [{ url: "https://results.example.com/native" }] } }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); +} + +function nativeModel(provider: string, id: string, api: Api, baseUrl: string): Model { + return { + provider, + id, + name: id, + api, + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }; +} + +function toolContext(model: Model | undefined, modelRegistry: ModelRegistry): ExtensionContext { + return { + ui: Object.create(null) as ExtensionContext["ui"], + mode: "print", + hasUI: false, + cwd: process.cwd(), + sessionManager: Object.create(null) as ExtensionContext["sessionManager"], + modelRegistry, + model, + serviceTier: undefined, + isIdle: () => true, + isProjectTrusted: () => true, + signal: undefined, + abort: vi.fn(), + hasPendingMessages: () => false, + shutdown: vi.fn(), + getContextUsage: () => undefined, + getCompactionSettings: () => DEFAULT_COMPACTION_SETTINGS, + compact: vi.fn(), + getMessageRevision: () => 0, + applyCompaction: async () => ({ applied: false, reason: "rejected" }), + getSystemPrompt: () => "", + }; +} + +function autoConfig(): WebsearchConfig { + return { + strategy: "priority", + fallback: true, + auto: true, + providers: [ + { id: "configured-first", provider: "duckduckgo-html" }, + { id: "configured-second", provider: "z-ai", apiKey: "configured-key" }, + ], + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("vendored websearch native tool", () => { + it("#given an active native model and same-route aliases #when the real tool is executed #then orders native routes before configured providers without duplication", async () => { + // given + const activeModel = nativeModel("openai", "gpt-5.5", "openai-responses", "https://gateway.example.com/v1"); + const authModels: string[] = []; + const modelRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); + vi.spyOn(modelRegistry, "getApiKeyAndHeaders").mockImplementation(async (model) => { + authModels.push(model.id); + return { ok: true, apiKey: `${model.provider}-native-key` }; + }); + vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([ + nativeModel("openai", "gpt-4o-2025-06-01", "openai-responses", "https://gateway.example.com/v1"), + nativeModel("openai", "gpt-4.1", "openai-responses", "https://gateway.example.com/v1"), + nativeModel("anthropic", "claude-sonnet-4-20250514", "anthropic-messages", "https://anthropic.example.com"), + ]); + const progress: SearchProgressDetails[] = []; + const fetchMock = vi.fn(async () => successfulSearchResponse()); + vi.stubGlobal("fetch", fetchMock); + const tool = createWebSearchTool(() => ({ ok: true, config: autoConfig(), source: "test" })); + + // when + await tool.execute( + "native-route-order", + { query: "native route order" }, + undefined, + (update) => { + if (update.details && "phase" in update.details && update.details.phase === "searching") { + progress.push(update.details); + } + }, + toolContext(activeModel, modelRegistry), + ); + + // then + expect(progress).toHaveLength(1); + const providerLabels = progress[0]?.providerLabels ?? []; + expect(providerLabels).toHaveLength(4); + expect(providerLabels[0]).toBe("native/openai"); + expect(providerLabels[1]?.endsWith("/anthropic")).toBe(true); + expect(providerLabels[1]).not.toContain("claude"); + expect(providerLabels[2]).toBe("configured-first/duckduckgo-html"); + expect(providerLabels[3]).toBe("configured-second/z-ai"); + expect(providerLabels.filter((label) => label.endsWith("/openai"))).toEqual(["native/openai"]); + expect(authModels).toEqual(["gpt-5.5", "claude-sonnet-4-20250514"]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("#given auto discovery and a pre-aborted signal #when the real tool is executed #then skips native auth and fetch", async () => { + // given + const abortReason = new DOMException("cancelled by test", "AbortError"); + const controller = new AbortController(); + controller.abort(abortReason); + const modelRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); + const authMock = vi.spyOn(modelRegistry, "getApiKeyAndHeaders"); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const tool = createWebSearchTool(() => ({ ok: true, config: autoConfig(), source: "test" })); + const activeModel = nativeModel("openai", "gpt-5.5", "openai-responses", "https://gateway.example.com/v1"); + + // when + const execution = tool.execute( + "pre-aborted", + { query: "should not search" }, + controller.signal, + undefined, + toolContext(activeModel, modelRegistry), + ); + + // then + await expect(execution).rejects.toBe(abortReason); + expect(authMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("#given native auth is pending #when the search is aborted #then rejects without waiting for auth", async () => { + // given + let markAuthStarted: (() => void) | undefined; + const authStarted = new Promise((resolve) => { + markAuthStarted = resolve; + }); + const modelRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); + vi.spyOn(modelRegistry, "getApiKeyAndHeaders").mockImplementation(async () => { + markAuthStarted?.(); + return new Promise(() => {}); + }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const tool = createWebSearchTool(() => ({ ok: true, config: autoConfig(), source: "test" })); + const activeModel = nativeModel("openai", "gpt-5.5", "openai-responses", "https://gateway.example.com/v1"); + const controller = new AbortController(); + const abortReason = new DOMException("cancelled during auth", "AbortError"); + + // when + const execution = tool.execute( + "abort-during-auth", + { query: "should stop" }, + controller.signal, + undefined, + toolContext(activeModel, modelRegistry), + ); + await authStarted; + controller.abort(abortReason); + + // then + await expect(execution).rejects.toBe(abortReason); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); From 5a0ffe4f4a821dca2944bee7953b4141c4f0ede6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 13 Jul 2026 20:57:27 +0900 Subject: [PATCH 6/8] docs(coding-agent): clarify web search divergence --- .../src/core/extensions/builtin/websearch/changes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md b/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md index 2f837a79e..bc0dc8134 100644 --- a/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md @@ -8,7 +8,7 @@ Vendored from [`code-yeongyu/pi-websearch`](https://github.com/code-yeongyu/pi-w - `@mariozechner/pi-tui` -> `@earendil-works/pi-tui` - `@mariozechner/pi-coding-agent` type/tool imports -> senpi local `../../types.ts` / `../../../types.ts` - relative `.js` import suffixes -> `.ts` -- No behavior changes versus upstream. The `web_search` tool is registered unconditionally; native provider search bypass remains upstream's provider-based check (`openai` / `anthropic`) so Anthropic-protocol third-party providers such as `kimi-coding` can still use the provider-backed tool. +- Senpi forwards the tool `AbortSignal` into native route discovery so cancellation stops waiting for pending authentication before any provider request begins. Otherwise behavior matches upstream: the `web_search` tool is registered unconditionally, and native provider search bypass remains upstream's provider-based check (`openai` / `anthropic`) so Anthropic-protocol third-party providers such as `kimi-coding` can still use the provider-backed tool. ## Conflict zones From 413954492b179adc2e1b6b744d15fe10bf8fe583 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 13 Jul 2026 21:24:04 +0900 Subject: [PATCH 7/8] fix(coding-agent): reject dotted private web search hosts --- packages/coding-agent/CHANGELOG.md | 2 +- .../extensions/builtin/websearch/changes.md | 2 +- .../websearch/websearch/provider-endpoints.ts | 3 +- .../test/websearch-native-route-dedup.test.ts | 24 ++++++++++ .../test/websearch-provider-endpoints.test.ts | 48 +++++++++++++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/test/websearch-provider-endpoints.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9004ce05c..c790404fa 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,7 +8,7 @@ ### Fixed -- Fixed built-in web search discovery so model aliases sharing a provider endpoint no longer multiply serial fallback attempts, and aborting a search stops pending native authentication discovery ([upstream #5](https://github.com/code-yeongyu/pi-websearch/pull/5)). +- Fixed built-in web search discovery so model aliases sharing a provider endpoint no longer multiply serial fallback attempts, aborting a search stops pending native authentication discovery, and dotted private host spellings are rejected before auth or fetch ([upstream #5](https://github.com/code-yeongyu/pi-websearch/pull/5), [upstream #6](https://github.com/code-yeongyu/pi-websearch/pull/6)). ### Removed diff --git a/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md b/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md index bc0dc8134..0dc7f0ccf 100644 --- a/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md @@ -1,6 +1,6 @@ # changes.md — websearch (vendored) -Vendored from [`code-yeongyu/pi-websearch`](https://github.com/code-yeongyu/pi-websearch) at `ff6db5ccbd73522d683aada9f0a365205fd1c2c6`. +Vendored from [`code-yeongyu/pi-websearch`](https://github.com/code-yeongyu/pi-websearch) at `06e3ec457e86d299c20808954e18f20b23cc7a64`. ## Senpi adaptations vs upstream diff --git a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/provider-endpoints.ts b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/provider-endpoints.ts index 6fda53c3c..ef868f3a7 100644 --- a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/provider-endpoints.ts +++ b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/provider-endpoints.ts @@ -31,7 +31,7 @@ function isPrivateIpv4(hostname: string): boolean { } function isPrivateHostname(hostname: string): boolean { - const normalized = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""); + const normalized = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, ""); return ( normalized === "localhost" || normalized.endsWith(".localhost") || @@ -55,6 +55,7 @@ export function isAllowedProviderBaseUrl(baseUrl: string): boolean { configured.protocol === "https:" && configured.username === "" && configured.password === "" && + !configured.hostname.endsWith("..") && !isPrivateHostname(configured.hostname) ); } diff --git a/packages/coding-agent/test/websearch-native-route-dedup.test.ts b/packages/coding-agent/test/websearch-native-route-dedup.test.ts index 389a559da..1ac4603af 100644 --- a/packages/coding-agent/test/websearch-native-route-dedup.test.ts +++ b/packages/coding-agent/test/websearch-native-route-dedup.test.ts @@ -106,6 +106,30 @@ describe("vendored websearch native route discovery", () => { expect(authModels).toEqual(["claude-opus-4"]); }); + it("#given dotted private route spellings #when discovering native entries #then rejects them before auth", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: true, apiKey: "native-test" }; + }, + getAvailable() { + return [ + { provider: "openai", id: "gpt-5.5", baseUrl: "https://localhost./v1" }, + { provider: "openai", id: "gpt-4.1", baseUrl: "https://127.1../v1" }, + ]; + }, + }; + + // when + const entries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(entries).toEqual([]); + expect(authModels).toEqual([]); + }); + it("#given one provider on distinct endpoints #when discovering native entries #then preserves both route candidates", async () => { // given const authModels: string[] = []; diff --git a/packages/coding-agent/test/websearch-provider-endpoints.test.ts b/packages/coding-agent/test/websearch-provider-endpoints.test.ts new file mode 100644 index 000000000..08a5cf949 --- /dev/null +++ b/packages/coding-agent/test/websearch-provider-endpoints.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { isAllowedProviderBaseUrl } from "../src/core/extensions/builtin/websearch/websearch/provider-endpoints.ts"; + +describe("vendored websearch provider endpoint safety", () => { + it("#given private hosts with terminal DNS dots #when validating provider URLs #then rejects them", () => { + // given + const privateBaseUrls = [ + "https://localhost./search", + "https://sub.localhost./search", + "https://127.1../search", + "https://0177.0.0.1../search", + "https://2130706433../search", + "https://0x7f000001../search", + "https://10.1../search", + ]; + + for (const baseUrl of privateBaseUrls) { + // when + const allowed = isAllowedProviderBaseUrl(baseUrl); + + // then + expect(allowed).toBe(false); + } + }); + + it("#given a public FQDN with one terminal DNS dot #when validating the provider URL #then allows it", () => { + // given + const baseUrl = "https://search-gateway.example.com./search"; + + // when + const allowed = isAllowedProviderBaseUrl(baseUrl); + + // then + expect(allowed).toBe(true); + }); + + it("#given a public hostname with repeated terminal dots #when validating the provider URL #then rejects it", () => { + // given + const baseUrl = "https://search-gateway.example.com../search"; + + // when + const allowed = isAllowedProviderBaseUrl(baseUrl); + + // then + expect(allowed).toBe(false); + }); +}); From d44e567c788d2ab7c46bd8637efc1ecbc5c1984e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 13 Jul 2026 21:52:47 +0900 Subject: [PATCH 8/8] fix(coding-agent): canonicalize native route hosts --- .../extensions/builtin/websearch/changes.md | 2 +- .../builtin/websearch/websearch/native.ts | 4 ++- .../test/websearch-native-route-dedup.test.ts | 25 +++++++++++++++++++ .../test/websearch-native-tool.test.ts | 2 +- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md b/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md index 0dc7f0ccf..ad8182973 100644 --- a/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md @@ -8,7 +8,7 @@ Vendored from [`code-yeongyu/pi-websearch`](https://github.com/code-yeongyu/pi-w - `@mariozechner/pi-tui` -> `@earendil-works/pi-tui` - `@mariozechner/pi-coding-agent` type/tool imports -> senpi local `../../types.ts` / `../../../types.ts` - relative `.js` import suffixes -> `.ts` -- Senpi forwards the tool `AbortSignal` into native route discovery so cancellation stops waiting for pending authentication before any provider request begins. Otherwise behavior matches upstream: the `web_search` tool is registered unconditionally, and native provider search bypass remains upstream's provider-based check (`openai` / `anthropic`) so Anthropic-protocol third-party providers such as `kimi-coding` can still use the provider-backed tool. +- Senpi forwards the tool `AbortSignal` into native route discovery so cancellation stops waiting for pending authentication before any provider request begins, and canonicalizes one permitted terminal DNS dot in route identity so dotted and undotted aliases share one candidate. Otherwise behavior matches upstream: the `web_search` tool is registered unconditionally, and native provider search bypass remains upstream's provider-based check (`openai` / `anthropic`) so Anthropic-protocol third-party providers such as `kimi-coding` can still use the provider-backed tool. ## Conflict zones diff --git a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts index f26946ba2..48603228d 100644 --- a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts +++ b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts @@ -96,7 +96,9 @@ function nativeRouteKey(model: NativeModelInfo): string | null { if (!mapping) return null; const baseUrl = buildEndpointUrl(model.baseUrl, mapping.resource); if (!isAllowedProviderBaseUrl(baseUrl)) return null; - return `${mapping.provider}|${new URL(baseUrl).href}`; + const routeUrl = new URL(baseUrl); + routeUrl.hostname = routeUrl.hostname.replace(/\.$/, ""); + return `${mapping.provider}|${routeUrl.href}`; } function discoveredNativeEntryId(provider: SearchProvider, routeKey: string): string { diff --git a/packages/coding-agent/test/websearch-native-route-dedup.test.ts b/packages/coding-agent/test/websearch-native-route-dedup.test.ts index 1ac4603af..1bb1d8e46 100644 --- a/packages/coding-agent/test/websearch-native-route-dedup.test.ts +++ b/packages/coding-agent/test/websearch-native-route-dedup.test.ts @@ -130,6 +130,31 @@ describe("vendored websearch native route discovery", () => { expect(authModels).toEqual([]); }); + it("#given dotted and undotted public aliases #when discovering native entries #then emits one route", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: true, apiKey: "native-test" }; + }, + getAvailable() { + return [ + { provider: "openai", id: "gpt-5.5", baseUrl: "https://gateway.example.com./v1" }, + { provider: "openai", id: "gpt-4.1", baseUrl: "https://gateway.example.com/v1" }, + ]; + }, + }; + + // when + const entries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(entries).toHaveLength(1); + expect(entries[0]?.baseUrl).toBe("https://gateway.example.com./v1/responses"); + expect(authModels).toEqual(["gpt-5.5"]); + }); + it("#given one provider on distinct endpoints #when discovering native entries #then preserves both route candidates", async () => { // given const authModels: string[] = []; diff --git a/packages/coding-agent/test/websearch-native-tool.test.ts b/packages/coding-agent/test/websearch-native-tool.test.ts index 9691428e2..f3a1e3889 100644 --- a/packages/coding-agent/test/websearch-native-tool.test.ts +++ b/packages/coding-agent/test/websearch-native-tool.test.ts @@ -87,7 +87,7 @@ describe("vendored websearch native tool", () => { return { ok: true, apiKey: `${model.provider}-native-key` }; }); vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([ - nativeModel("openai", "gpt-4o-2025-06-01", "openai-responses", "https://gateway.example.com/v1"), + nativeModel("openai", "gpt-4o-2025-06-01", "openai-responses", "https://gateway.example.com./v1"), nativeModel("openai", "gpt-4.1", "openai-responses", "https://gateway.example.com/v1"), nativeModel("anthropic", "claude-sonnet-4-20250514", "anthropic-messages", "https://anthropic.example.com"), ]);