Merge PR #2611: Add web search provider exclusions

This commit is contained in:
can1357
2026-06-15 19:46:13 +02:00
8 changed files with 145 additions and 5 deletions
+9
View File
@@ -277,6 +277,15 @@
- External editor (Ctrl-G) now shows full pasted content instead of `[paste #N ...]` placeholders ([#444](https://github.com/badlogic/pi-mono/pull/444) by [@aliou](https://github.com/aliou))
- Subagent example README referenced incorrect filename `subagent.ts` instead of `index.ts` ([#427](https://github.com/badlogic/pi-mono/pull/427) by [@Whamp](https://github.com/Whamp))
### Added
- Added web search provider exclusions so `web_search` can skip configured providers without disabling them for model use ([#2608](https://github.com/can1357/oh-my-pi/issues/2608)).
### Fixed
- Fixed `web_search` using stale or missing provider exclusions after `/move` or resuming a session from another project. Provider preferences (`providers.webSearchExclude`, `providers.webSearch`, `providers.image`) are now reapplied when project settings reload on cwd change ([#2611](https://github.com/can1357/oh-my-pi/pull/2611)).
## [15.13.1] - 2026-06-15
### Added
@@ -34,7 +34,7 @@ import {
TTS_LOCAL_VOICE_VALUES,
} from "../tts/models";
import { EDIT_MODES } from "../utils/edit-mode";
import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_PREFERENCES } from "../web/search/types";
import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_PREFERENCES, type SearchProviderId } from "../web/search/types";
/** Unified settings schema - single source of truth for all settings.
*
@@ -3846,6 +3846,16 @@ export const SETTINGS_SCHEMA = {
options: SEARCH_PROVIDER_OPTIONS,
},
},
"providers.webSearchExclude": {
type: "array",
default: [] as SearchProviderId[],
ui: {
tab: "providers",
group: "Services",
label: "Excluded Web Search Providers",
description: "Providers that web_search should never use, even as fallbacks",
},
},
"providers.image": {
type: "enum",
values: ["auto", "openai", "antigravity", "xai", "gemini", "openrouter"] as const,
@@ -40,7 +40,9 @@ import {
import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking";
import {
isImageProviderPreference,
isSearchProviderId,
isSearchProviderPreference,
setExcludedSearchProviders,
setPreferredImageProvider,
setPreferredSearchProvider,
} from "../../tools";
@@ -419,6 +421,11 @@ export class SelectorController {
setPreferredSearchProvider(value);
}
break;
case "providers.webSearchExclude":
if (Array.isArray(value)) {
setExcludedSearchProviders(value.filter(isSearchProviderId));
}
break;
case "providers.image":
if (isImageProviderPreference(value)) {
setPreferredImageProvider(value);
@@ -92,6 +92,7 @@ import { STTController, type SttState } from "../stt";
import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt";
import { formatTaskId } from "../task/render";
import type { LspStartupServerInfo } from "../tools";
import { isImageProviderPreference, setPreferredImageProvider } from "../tools/image-gen";
import { normalizeLocalScheme } from "../tools/path-utils";
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils";
import { setAutoQaConsentHandler } from "../tools/report-tool-issue";
@@ -103,6 +104,12 @@ import type { EventBus } from "../utils/event-bus";
import { getEditorCommand, openInEditor } from "../utils/external-editor";
import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
import {
isSearchProviderId,
isSearchProviderPreference,
setExcludedSearchProviders,
setPreferredSearchProvider,
} from "../web/search";
import type { AssistantMessageComponent } from "./components/assistant-message";
import type { BashExecutionComponent } from "./components/bash-execution";
import { ChatBlock, type ChatBlockHost } from "./components/chat-block";
@@ -904,6 +911,22 @@ export class InteractiveMode implements InteractiveModeContext {
// up the destination project's configuration.
if (isSettingsInitialized()) {
await settings.reloadForCwd(newCwd);
// Reapply provider preferences from the newly-loaded settings so the
// module-level search/image provider state reflects the destination
// project's configuration. Without this, the previous project's
// exclusions leak and newly-excluded providers are still used.
const excludedWebSearchProviders = settings.get("providers.webSearchExclude");
if (Array.isArray(excludedWebSearchProviders)) {
setExcludedSearchProviders(excludedWebSearchProviders.filter(isSearchProviderId));
}
const webSearchProvider = settings.get("providers.webSearch");
if (typeof webSearchProvider === "string" && isSearchProviderPreference(webSearchProvider)) {
setPreferredSearchProvider(webSearchProvider);
}
const imageProvider = settings.get("providers.image");
if (isImageProviderPreference(imageProvider)) {
setPreferredImageProvider(imageProvider);
}
}
// Re-warm plugin roots, capabilities, slash commands, and the ssh tool so
// the next prompt sees everything scoped to the new project directory.
+7
View File
@@ -177,6 +177,7 @@ import {
getSearchTools,
HIDDEN_TOOLS,
isImageProviderPreference,
isSearchProviderId,
isSearchProviderPreference,
type LspStartupServerInfo,
loadSshTool,
@@ -185,6 +186,7 @@ import {
renderSearchToolBm25Description,
SearchTool,
SearchToolBm25Tool,
setExcludedSearchProviders,
setPreferredImageProvider,
setPreferredSearchProvider,
type Tool,
@@ -1164,6 +1166,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
discoveredSkillsPromise?.catch(() => {});
// Initialize provider preferences from settings
const excludedWebSearchProviders = settings.get("providers.webSearchExclude");
if (Array.isArray(excludedWebSearchProviders)) {
setExcludedSearchProviders(excludedWebSearchProviders.filter(isSearchProviderId));
}
const webSearchProvider = settings.get("providers.webSearch");
if (typeof webSearchProvider === "string" && isSearchProviderPreference(webSearchProvider)) {
setPreferredSearchProvider(webSearchProvider);
@@ -300,6 +300,6 @@ export function getSearchTools(): CustomTool<any, any>[] {
return [webSearchCustomTool];
}
export { getSearchProvider, setPreferredSearchProvider } from "./provider";
export { getSearchProvider, setExcludedSearchProviders, setPreferredSearchProvider } from "./provider";
export type { SearchProviderId as SearchProvider, SearchResponse } from "./types";
export { isSearchProviderPreference } from "./types";
export { isSearchProviderId, isSearchProviderPreference } from "./types";
@@ -127,6 +127,18 @@ export function setPreferredSearchProvider(provider: SearchProviderId | "auto"):
preferredProvId = provider;
}
/** Providers excluded from web search resolution via settings. */
let excludedProvIds = new Set<SearchProviderId>();
/** Set providers that web search should never use, including fallbacks. */
export function setExcludedSearchProviders(providers: readonly SearchProviderId[]): void {
excludedProvIds = new Set(providers);
}
function isSearchProviderExcluded(id: SearchProviderId): boolean {
return excludedProvIds.has(id);
}
/**
* Determine which providers are configured and currently available.
* Each candidate is loaded (and its `isAvailable()` called) only as the chain
@@ -138,7 +150,7 @@ export async function resolveProviderChain(
): Promise<SearchProvider[]> {
const providers: SearchProvider[] = [];
if (preferredProvider !== "auto") {
if (preferredProvider !== "auto" && !isSearchProviderExcluded(preferredProvider)) {
const provider = await getSearchProvider(preferredProvider);
if (await provider.isExplicitlyAvailable(authStorage)) {
providers.push(provider);
@@ -146,7 +158,7 @@ export async function resolveProviderChain(
}
for (const id of SEARCH_PROVIDER_ORDER) {
if (id === preferredProvider) continue;
if (id === preferredProvider || isSearchProviderExcluded(id)) continue;
const provider = await getSearchProvider(id);
if (await provider.isAvailable(authStorage)) {
providers.push(provider);
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it } from "bun:test";
import type { AuthStorage } from "@oh-my-pi/pi-ai";
import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller";
import {
resolveProviderChain,
setExcludedSearchProviders,
setPreferredSearchProvider,
} from "@oh-my-pi/pi-coding-agent/web/search/provider";
import { SEARCH_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/web/search/types";
const authStorage = {} as AuthStorage;
const originalBraveApiKey = process.env.BRAVE_API_KEY;
const originalJinaApiKey = process.env.JINA_API_KEY;
function enableKeyBackedProviders(): void {
process.env.BRAVE_API_KEY = "test-brave-key";
process.env.JINA_API_KEY = "test-jina-key";
}
function restoreEnv(): void {
if (originalBraveApiKey === undefined) {
delete process.env.BRAVE_API_KEY;
} else {
process.env.BRAVE_API_KEY = originalBraveApiKey;
}
if (originalJinaApiKey === undefined) {
delete process.env.JINA_API_KEY;
} else {
process.env.JINA_API_KEY = originalJinaApiKey;
}
}
afterEach(() => {
setPreferredSearchProvider("auto");
setExcludedSearchProviders([]);
restoreEnv();
});
describe("resolveProviderChain", () => {
it("omits excluded providers from the fallback chain", async () => {
enableKeyBackedProviders();
setExcludedSearchProviders(SEARCH_PROVIDER_ORDER.filter(id => id !== "jina"));
const providers = await resolveProviderChain(authStorage, "auto");
expect(providers.map(provider => provider.id)).toEqual(["jina"]);
});
it("ignores the preferred provider when it is excluded", async () => {
enableKeyBackedProviders();
setExcludedSearchProviders(SEARCH_PROVIDER_ORDER.filter(id => id !== "jina"));
const providers = await resolveProviderChain(authStorage, "brave");
expect(providers.map(provider => provider.id)).toEqual(["jina"]);
});
it("applies live settings edits to the exclusion chain", async () => {
enableKeyBackedProviders();
const controller = new SelectorController({} as unknown as ConstructorParameters<typeof SelectorController>[0]);
controller.handleSettingChange(
"providers.webSearchExclude",
SEARCH_PROVIDER_ORDER.filter(id => id !== "jina"),
);
const providers = await resolveProviderChain(authStorage, "auto");
expect(providers.map(provider => provider.id)).toEqual(["jina"]);
});
});