feat(coding-agent): introduced ordered provider priority lists for search and images

- Replaced single-provider preferences with ordered priority lists for web search and image generation.
- Added a `MultiSelectSubmenu` component supporting toggle and reordering interactions in settings.
- Implemented migration logic to convert legacy single-provider preferences into ordered priority lists.
- Updated setup wizard scenes, image generation fallback logic, and search provider chains to use priority lists.
This commit is contained in:
can1357
2026-07-23 20:43:36 +02:00
parent 04a57c958b
commit 5b3275c7ae
25 changed files with 613 additions and 230 deletions
+3 -5
View File
@@ -647,9 +647,8 @@ For a custom status line, set `statusLine.preset: custom` and configure `statusL
```yaml
providers:
webSearch: auto
webSearchOrder: [perplexity, exa, gemini]
image: auto
imageOrder: [openai, xai]
fetch: auto
webSearchGeminiModel: gemini-2.5-flash
tinyModel: online
@@ -675,10 +674,9 @@ searxng:
| Key | Type | Default | Values / notes |
|---|---|---|---|
| `providers.webSearch` | enum | `auto` | `auto` plus the configured search providers (`perplexity`, `gemini`, `anthropic`, `codex`, `zai`, `exa`, `jina`, `kagi`, `tavily`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng`). |
| `providers.webSearchOrder` | array | `[]` | Provider IDs to prioritize after the preferred provider. Duplicates and unknown IDs are ignored; unlisted providers retain their built-in relative order afterward. |
| `providers.webSearchOrder` | array | `[]` | Provider IDs in priority order for `web_search` (`perplexity`, `gemini`, `anthropic`, `codex`, `zai`, `exa`, `jina`, `kagi`, `tavily`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng`, …). Duplicates and unknown IDs are ignored; unlisted providers retain their built-in relative order afterward. Empty = built-in order. Replaces the removed `providers.webSearch` enum (a legacy value migrates to the head of this list). |
| `providers.webSearchGeminiModel` | string | _(unset)_ | Gemini model ID for Google Search grounding when `web_search` uses Gemini; defaults to `gemini-2.5-flash`, overridden by `GEMINI_SEARCH_MODEL`. |
| `providers.image` | enum | `auto` | `auto`, `openai`, `antigravity`, `xai`, `gemini`, `openrouter`. |
| `providers.imageOrder` | array | `[]` | Image-generation provider IDs in priority order (`openai`, `openai-codex`, `antigravity`, `xai`, `gemini`, `openrouter`). Unlisted providers follow the active session provider and the built-in order. Replaces the removed `providers.image` enum (a legacy value migrates to the head of this list). |
| `providers.fetch` | enum | `auto` | `auto`, `native`, `trafilatura`, `lynx`, `parallel`, `jina`. |
| `providers.tinyModel` | enum | `online` | `online` or a local model (`lfm2-350m`, `qwen3-0.6b`, `gemma-270m`, `qwen2.5-0.5b`, `lfm2-700m`). |
| `providers.tinyModelDevice` | enum | `default` | ONNX execution provider for local tiny models. Overridden by `PI_TINY_DEVICE`. |
+4 -5
View File
@@ -101,10 +101,9 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
## Modes / Variants
- **Provider selection**
- **Forced provider**: internal callers may pass `provider`; a non-`auto` value is the only attempted provider, while `auto` bypasses the configured preference and uses the ordered fallback chain. This field is not in the model-facing schema.
- **Preferred provider**: `setPreferredSearchProvider()` sets a module-global first choice used by `resolveProviderCandidates()`. Failure continues through the configured fallback order. `packages/coding-agent/src/sdk.ts` and `packages/coding-agent/src/modes/controllers/selector-controller.ts` wire this from settings.
- **Configured order**: `setSearchProviderOrder()` prioritizes the valid, first-occurrence provider IDs in `providers.webSearchOrder`; providers omitted from the setting follow in their built-in relative order. `providers.webSearch` remains the first-choice override.
- **Excluded providers**: `setExcludedSearchProviders()` records providers `resolveProviderCandidates()` must skip, including as fallbacks. Wired from the `providers.webSearchExclude` setting (`providers.webSearch` drives the preferred provider) in `packages/coding-agent/src/sdk.ts`, `packages/coding-agent/src/modes/interactive-mode.ts`, and `packages/coding-agent/src/modes/controllers/selector-controller.ts`.
- **Forced provider**: internal callers may pass `provider`; a non-`auto` value is the only attempted provider, while `auto` (or omitting it) walks the configured chain. This field is not in the model-facing schema.
- **Configured order**: `setSearchProviderOrder()` prioritizes the valid, first-occurrence provider IDs in `providers.webSearchOrder`; providers omitted from the setting follow in their built-in relative order. Listed providers are explicit selections — they resolve through `isExplicitlyAvailable()`, so e.g. a hand-listed Perplexity may fall back to anonymous search. Wired from settings in `packages/coding-agent/src/config/provider-globals.ts` (SDK startup, cwd reloads, live settings changes).
- **Excluded providers**: `setExcludedSearchProviders()` records providers `resolveProviderCandidates()` must skip, including as fallbacks. Wired from the `providers.webSearchExclude` setting via the same `provider-globals.ts` paths.
- **Default auto chain order** (25 providers): `perplexity`, `gemini`, `anthropic`, `codex`, `xai`, `zai`, `exa`, `tinyfish`, `jina`, `kagi`, `tavily`, `firecrawl`, `brave`, `kimi`, `parallel`, `synthetic`, `searxng`, `duckduckgo`, `bing`, `yahoo`, `startpage`, `google`, `ecosia`, `mojeek`, `public` (`SEARCH_PROVIDER_ORDER` in `packages/coding-agent/src/web/search/types.ts`). `public` is explicit-only: its `isAvailable()` returns `false` so the auto chain never fans out implicitly.
- **Provider adapters**
- **Perplexity** — `packages/coding-agent/src/web/search/providers/perplexity.ts`
@@ -148,7 +147,7 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
- `limit` and `num_search_results` are collapsed together before dispatch.
- Output may include parsed free-text `answer`, `sources`, `requestId`.
- **Exa** — `packages/coding-agent/src/web/search/providers/exa.ts`
- Availability: env or `agent.db` credential for `exa` admits Exa to the auto chain; settings must not explicitly disable `exa.enabled` or `exa.enableSearch`. Explicit selection (`providers.webSearch: exa`) reaches Exa even without a credential and falls back to public MCP.
- Availability: env or `agent.db` credential for `exa` admits Exa to the auto chain; settings must not explicitly disable `exa.enabled` or `exa.enableSearch`. Explicit selection (listing `exa` in `providers.webSearchOrder`, or a forced `provider: exa`) reaches Exa even without a credential and falls back to public MCP.
- Querying: POST `https://api.exa.ai/search` with the resolved Exa API key, otherwise JSON-RPC `tools/call` against `https://mcp.exa.ai/mcp` for remote MCP tool `web_search_exa`.
- `limit` and `num_search_results` are collapsed together before dispatch.
- Output: synthesized `answer` from up to 3 result summaries, `sources`, `requestId`.
+5
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Breaking Changes
- Replaced the `providers.webSearch` and `providers.image` single-preference enums with the `providers.webSearchOrder` and `providers.imageOrder` priority lists. Existing config values migrate automatically: a concrete legacy choice becomes the head of the new list with the remaining providers appended in their built-in order, and `auto` simply resets to the default chain.
### Added
- Added `error.notify` so failed model turns can emit distinct terminal/desktop notifications without changing completion notifications ([#2691](https://github.com/can1357/oh-my-pi/issues/2691)).
@@ -12,6 +16,7 @@
- Added `friendlyName` support for hidden secrets so model-visible placeholders can carry sanitized semantic labels, content-derived hashes, and case hints while preserving exact deobfuscation ([#2465](https://github.com/can1357/oh-my-pi/issues/2465)).
- Made the statusline `git` segment jj-aware: in a Jujutsu repo it shows the nearest bookmark (falling back to the short change-id) instead of git's `detached` label or nothing, and working-copy change counts come from jj where there is no `.git` to read ([#3582](https://github.com/can1357/oh-my-pi/issues/3582))
- Added `block`/`unblock` todo operations and a `blocked` status for tasks waiting on external input; blocked tasks stay visible in the todo HUD and summary but are excluded from the incomplete-todo stop reminder, and an optional blocker note records what the task is waiting for.
- Added a toggle-list editor in `/settings` for array-of-enum settings: `providers.webSearchOrder` and `providers.imageOrder` (ordered — Enter/Space toggles, ←/→ nudges, 1-9 splices the hovered provider into that position) and `providers.webSearchExclude` now appear under Providers → Services instead of being config-file only.
### Changed
@@ -1,11 +1,10 @@
import * as imageGen from "../tools/image-gen";
import { setImageProviderOrder } from "../tools/image-gen";
import * as webSearch from "../web/search";
interface ProviderGlobalSettings {
get(path: "providers.webSearchOrder"): unknown;
get(path: "providers.webSearchExclude"): unknown;
get(path: "providers.webSearch"): unknown;
get(path: "providers.image"): unknown;
get(path: "providers.imageOrder"): unknown;
}
export function applyProviderGlobalsFromSettings(settings: ProviderGlobalSettings): void {
@@ -19,13 +18,8 @@ export function applyProviderGlobalsFromSettings(settings: ProviderGlobalSetting
webSearch.setSearchProviderOrder(orderedWebSearchProviders.filter(webSearch.isSearchProviderId));
}
const webSearchProvider = settings.get("providers.webSearch");
if (typeof webSearchProvider === "string" && webSearch.isSearchProviderPreference(webSearchProvider)) {
webSearch.setPreferredSearchProvider(webSearchProvider);
}
const imageProvider = settings.get("providers.image");
if (imageGen.isImageProviderPreference(imageProvider)) {
imageGen.setPreferredImageProvider(imageProvider);
const orderedImageProviders = settings.get("providers.imageOrder");
if (Array.isArray(orderedImageProviders)) {
setImageProviderOrder(orderedImageProviders.filter((entry): entry is string => typeof entry === "string"));
}
}
@@ -26,6 +26,7 @@ import {
TINY_TITLE_MODEL_OPTIONS,
TINY_TITLE_MODEL_VALUES,
} from "../tiny/models";
import { IMAGE_PROVIDER_CHOICES, type ImageProvider } from "../tools/image-providers";
import {
DEFAULT_TTS_LOCAL_MODEL_KEY,
DEFAULT_TTS_VOICE,
@@ -35,7 +36,7 @@ import {
TTS_LOCAL_VOICE_VALUES,
} from "../tts/models";
import { EDIT_MODES } from "../utils/edit-mode";
import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_PREFERENCES, type SearchProviderId } from "../web/search/types";
import { SEARCH_PROVIDER_CHOICES, type SearchProviderId } from "../web/search/types";
import {
SERVICE_TIER_ANTHROPIC_OPTIONS,
SERVICE_TIER_ANTHROPIC_VALUES,
@@ -218,10 +219,18 @@ interface UiString extends UiBase {
options?: ReadonlyArray<SubmenuOption> | "runtime";
}
interface UiArray extends UiBase {
/** Membership choices. Without options, an array setting has no UI representation (config-file only). */
options?: ReadonlyArray<SubmenuOption>;
/** Selection order is meaningful; the editor renders positions and supports reordering. */
ordered?: boolean;
}
/** Wide ui shape exposed to consumers that walk the schema generically. */
export type AnyUiMetadata = UiBase & {
options?: ReadonlyArray<SubmenuOption> | "runtime";
secret?: boolean;
ordered?: boolean;
};
interface BooleanDef {
@@ -252,7 +261,7 @@ interface EnumDef<T extends readonly string[]> {
interface ArrayDef<T> {
type: "array";
default: T[];
ui?: UiBase;
ui?: UiArray;
}
interface RecordDef<T> {
@@ -4555,18 +4564,6 @@ export const SETTINGS_SCHEMA = {
"Maximum concurrent Ollama Cloud subagent runs per process; 0 disables the provider-specific limit",
},
},
"providers.webSearch": {
type: "enum",
values: SEARCH_PROVIDER_PREFERENCES,
default: "auto",
ui: {
tab: "providers",
group: "Services",
label: "Web Search Provider",
description: "Preferred provider for the web_search tool",
options: SEARCH_PROVIDER_OPTIONS,
},
},
"providers.webSearchOrder": {
type: "array",
default: [] as SearchProviderId[],
@@ -4574,7 +4571,10 @@ export const SETTINGS_SCHEMA = {
tab: "providers",
group: "Services",
label: "Web Search Provider Order",
description: "Prioritized providers; unlisted providers retain their default order afterward",
description:
"Prioritized providers for the web_search tool; unlisted providers retain their default order afterward",
options: SEARCH_PROVIDER_CHOICES,
ordered: true,
},
},
"providers.webSearchExclude": {
@@ -4585,6 +4585,7 @@ export const SETTINGS_SCHEMA = {
group: "Services",
label: "Excluded Web Search Providers",
description: "Providers that web_search should never use, even as fallbacks",
options: SEARCH_PROVIDER_CHOICES,
},
},
"providers.webSearchGeminiModel": {
@@ -4625,46 +4626,17 @@ export const SETTINGS_SCHEMA = {
],
},
},
"providers.image": {
type: "enum",
values: ["auto", "openai", "openai-codex", "antigravity", "xai", "gemini", "openrouter"] as const,
default: "auto",
"providers.imageOrder": {
type: "array",
default: [] as ImageProvider[],
ui: {
tab: "providers",
group: "Services",
label: "Image Provider",
description: "Preferred provider for image generation",
options: [
{
value: "auto",
label: "Auto",
description:
"Priority: per-request provider > configured provider > active session provider > GPT model image tool > Codex subscription > Antigravity > xAI > OpenRouter > Gemini",
},
{
value: "openai",
label: "OpenAI",
description:
"OPENAI_API_KEY (gpt-image-2) or active GPT model; falls back to a connected Codex subscription",
},
{
value: "openai-codex",
label: "OpenAI Codex (ChatGPT)",
description: "Uses a connected Codex / ChatGPT subscription — no OPENAI_API_KEY needed",
},
{
value: "antigravity",
label: "Antigravity",
description: "Requires google-antigravity OAuth",
},
{
value: "xai",
label: "xAI Grok Imagine",
description: "Requires xAI Grok OAuth or XAI_API_KEY",
},
{ value: "gemini", label: "Gemini", description: "Requires GEMINI_API_KEY" },
{ value: "openrouter", label: "OpenRouter", description: "Requires OPENROUTER_API_KEY" },
],
label: "Image Provider Order",
description:
"Prioritized providers for image generation; unlisted providers follow the active session provider and the built-in order",
options: IMAGE_PROVIDER_CHOICES,
ordered: true,
},
},
"providers.fireworksTier": {
@@ -34,7 +34,9 @@ import type { ModelRole } from "../config/model-roles";
import { loadCapability } from "../discovery";
import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme";
import { AgentStorage } from "../session/agent-storage";
import { AUTO_IMAGE_PROVIDER_ORDER, isImageProviderId } from "../tools/image-providers";
import { type EditMode, normalizeEditMode } from "../utils/edit-mode";
import { isSearchProviderId, SEARCH_PROVIDER_ORDER } from "../web/search/types";
import { withFileLock } from "./file-lock";
import {
type BashInterceptorRule,
@@ -1596,6 +1598,45 @@ export class Settings {
delete raw["mcp.discoveryMode"];
delete raw["mcp.discoveryDefaultServers"];
// providers.webSearch / providers.image (single preferred provider) →
// providers.webSearchOrder / providers.imageOrder (priority lists). A
// concrete legacy choice becomes the head of the new list with every
// remaining provider appended in its built-in order, so the old
// preference stays #1 and the fallback chain is written out explicitly.
// "auto" (or an unknown id) just drops the key — the default chain.
const providerPrefsObj = raw.providers as Record<string, unknown> | undefined;
const migrateProviderPreference = (
legacyKey: string,
orderKey: string,
expand: (value: string) => string[] | undefined,
): void => {
const flatLegacyKey = `providers.${legacyKey}`;
const legacy = providerPrefsObj?.[legacyKey] ?? raw[flatLegacyKey];
if (legacy === undefined) return;
const existingOrder = providerPrefsObj?.[orderKey] ?? raw[`providers.${orderKey}`];
const orderAlreadySet = Array.isArray(existingOrder) && existingOrder.length > 0;
if (!orderAlreadySet && typeof legacy === "string") {
const expanded = expand(legacy);
if (expanded) {
const root = providerPrefsObj ?? {};
root[orderKey] = expanded;
raw.providers = root;
}
}
if (providerPrefsObj) delete providerPrefsObj[legacyKey];
delete raw[flatLegacyKey];
};
migrateProviderPreference("webSearch", "webSearchOrder", value =>
value !== "auto" && isSearchProviderId(value)
? [value, ...SEARCH_PROVIDER_ORDER.filter(id => id !== value)]
: undefined,
);
migrateProviderPreference("image", "imageOrder", value =>
value !== "auto" && isImageProviderId(value)
? [value, ...AUTO_IMAGE_PROVIDER_ORDER.filter(id => id !== value)]
: undefined,
);
return raw;
}
@@ -73,12 +73,20 @@ export interface ProviderLimitsSettingDef extends BaseSettingDef {
type: "providerLimits";
}
/** Array-of-enum setting edited as a toggle list; `ordered` lists render positions and support reordering. */
export interface MultiSelectSettingDef extends BaseSettingDef {
type: "multiselect";
options: OptionList;
ordered: boolean;
}
export type SettingDef =
| BooleanSettingDef
| EnumSettingDef
| SubmenuSettingDef
| TextInputSettingDef
| ProviderLimitsSettingDef;
| ProviderLimitsSettingDef
| MultiSelectSettingDef;
// ═══════════════════════════════════════════════════════════════════════════
// Condition Functions
@@ -180,6 +188,13 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
return { ...base, type: "text", secret: ui.secret === true };
}
if (schemaType === "array") {
// Arrays without declared options stay config-file only (free-form lists
// like extension paths have no finite choice set to toggle).
if (!options || options === "runtime") return null;
return { ...base, type: "multiselect", options, ordered: ui.ordered === true };
}
if (schemaType === "record") {
return path === "providers.maxInFlightRequests"
? { ...base, type: "providerLimits" }
@@ -221,6 +221,131 @@ class SelectSubmenu extends Container {
}
}
/**
* Submenu for array-of-enum settings: every option is a toggle row. Enter or
* Space flips membership; ordered lists render 1-based positions and reorder
* the highlighted member with ←/→. Changes apply live; Esc goes back.
*/
class MultiSelectSubmenu extends Container {
#selectList!: SelectList;
#value: string[];
#cursor = 0;
#selectListLineOffset = 0;
constructor(
private readonly title: string,
private readonly description: string,
private readonly options: ReadonlyArray<SelectItem>,
initial: readonly string[],
private readonly ordered: boolean,
private readonly onApply: (value: string[]) => void,
private readonly onClose: () => void,
) {
super();
// Drop stale ids (renamed/removed providers) so positions stay contiguous.
this.#value = initial.filter(id => options.some(option => option.value === id));
this.#rebuild();
}
#rebuild(): void {
this.clear();
this.addChild(new Text(theme.bold(theme.fg("accent", this.title)), 0, 0));
if (this.description) {
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("muted", this.description), 0, 0));
}
this.addChild(new Spacer(1));
const items = this.options.map((option): SelectItem => {
const position = this.#value.indexOf(option.value);
const mark =
position === -1
? theme.fg("dim", this.ordered ? " · " : " ○ ")
: this.ordered
? theme.fg("accent", `${String(position + 1).padStart(2)}.`)
: theme.fg("accent", " ● ");
return { value: option.value, label: `${mark} ${option.label}`, description: option.description };
});
this.#selectList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme());
this.#selectList.setSelectedIndex(this.#cursor);
this.#selectList.onSelect = item => this.#toggle(item.value);
this.#selectList.onSelectionChange = item => {
this.#cursor = this.options.findIndex(option => option.value === item.value);
};
this.#selectList.onCancel = this.onClose;
this.addChild(this.#selectList);
this.addChild(new Spacer(1));
const hint = this.ordered
? " Enter/Space to toggle · ←/→ move · 1-9 place at position · Esc to go back"
: " Enter/Space to toggle · Esc to go back";
this.addChild(new Text(theme.fg("dim", hint), 0, 0));
}
#apply(next: string[]): void {
this.#value = next;
this.onApply([...next]);
this.#rebuild();
}
#toggle(id: string): void {
const next = this.#value.includes(id) ? this.#value.filter(v => v !== id) : [...this.#value, id];
this.#apply(next);
}
#move(id: string, delta: -1 | 1): void {
const from = this.#value.indexOf(id);
if (from === -1) return;
const to = from + delta;
if (to < 0 || to >= this.#value.length) return;
const next = [...this.#value];
next[from] = next[to]!;
next[to] = id;
this.#apply(next);
}
/** Splice the option into the 1-based `position` of the selection (adding it if unselected). */
#placeAt(id: string, position: number): void {
const next = this.#value.filter(v => v !== id);
next.splice(Math.min(position - 1, next.length), 0, id);
this.#apply(next);
}
/** Concatenate children, recording the select list's line offset for mouse routing. */
override render(width: number): readonly string[] {
const lines: string[] = [];
for (const child of this.children) {
const childLines = child.render(Math.max(1, width));
if (child === this.#selectList) {
this.#selectListLineOffset = lines.length;
}
lines.push(...childLines);
}
return lines;
}
routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
routeSelectListMouse(this.#selectList, event, line - this.#selectListLineOffset);
}
handleInput(data: string): void {
const current = this.options[this.#cursor]?.value;
if (data === " " && current !== undefined) {
this.#toggle(current);
return;
}
if (this.ordered && current !== undefined && (data === "\x1b[D" || data === "\x1b[C")) {
this.#move(current, data === "\x1b[D" ? -1 : 1);
return;
}
if (this.ordered && current !== undefined && data.length === 1 && data >= "1" && data <= "9") {
this.#placeAt(current, Number(data));
return;
}
this.#selectList.handleInput(data);
}
}
class ProviderLimitsSubmenu extends Container {
#selectList: SelectList | undefined;
@@ -854,6 +979,16 @@ export class SettingsSelectorComponent implements Component {
submenu: (_cv, done) => this.#createProviderLimitsInput(done),
changed,
};
case "multiselect":
return {
id: def.path,
label: def.label,
description: def.description,
currentValue: this.#formatMultiSelectValue(def, currentValue),
submenu: (_cv, done) => this.#createMultiSelect(def, done),
changed,
};
}
}
@@ -865,7 +1000,14 @@ export class SettingsSelectorComponent implements Component {
}
#isChanged(def: SettingDef, currentValue: unknown): boolean {
return !Object.is(currentValue, getDefault(def.path));
const defaultValue: unknown = getDefault(def.path);
if (Array.isArray(currentValue) && Array.isArray(defaultValue)) {
return (
currentValue.length !== defaultValue.length ||
currentValue.some((entry, index) => entry !== defaultValue[index])
);
}
return !Object.is(currentValue, defaultValue);
}
#getSubmenuCurrentValue(path: SettingPath, value: unknown): string {
@@ -1026,6 +1168,32 @@ export class SettingsSelectorComponent implements Component {
return entries.map(([provider, limit]) => `${provider}: ${limit}`).join(", ");
}
#createMultiSelect(def: SettingDef & { type: "multiselect" }, done: (value?: string) => void): Container {
const current: unknown = settings.get(def.path);
const initial = Array.isArray(current)
? current.filter((entry): entry is string => typeof entry === "string")
: [];
return new MultiSelectSubmenu(
def.label,
def.description,
def.options,
initial,
def.ordered,
value => {
settings.set(def.path, value as never);
this.callbacks.onChange(def.path, value);
},
() => done(this.#formatMultiSelectValue(def, settings.get(def.path))),
);
}
#formatMultiSelectValue(def: SettingDef & { type: "multiselect" }, value: unknown): string {
const ids = Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
if (ids.length === 0) return def.ordered ? "default" : "none";
const labels = ids.map(id => def.options.find(option => option.value === id)?.label ?? id);
return def.ordered ? labels.join(" → ") : labels.join(", ");
}
#formatTextInputValue(def: SettingDef & { type: "text" }, value: unknown): string {
if (def.secret) return value ? "••••••••" : "";
return this.#formatTextInputEditValue(def.path, value);
@@ -57,12 +57,9 @@ import {
parseConfiguredThinkingLevel,
} from "../../thinking";
import {
isImageProviderPreference,
isSearchProviderId,
isSearchProviderPreference,
setExcludedSearchProviders,
setPreferredImageProvider,
setPreferredSearchProvider,
setImageProviderOrder,
setSearchProviderOrder,
type ToolSession,
} from "../../tools";
@@ -610,11 +607,6 @@ export class SelectorController {
}
// Provider settings - update runtime preferences
case "providers.webSearch":
if (typeof value === "string" && isSearchProviderPreference(value)) {
setPreferredSearchProvider(value);
}
break;
case "providers.webSearchOrder":
if (Array.isArray(value)) {
setSearchProviderOrder(value.filter(isSearchProviderId));
@@ -625,9 +617,9 @@ export class SelectorController {
setExcludedSearchProviders(value.filter(isSearchProviderId));
}
break;
case "providers.image":
if (isImageProviderPreference(value)) {
setPreferredImageProvider(value);
case "providers.imageOrder":
if (Array.isArray(value)) {
setImageProviderOrder(value.filter((entry): entry is string => typeof entry === "string"));
}
break;
@@ -5,16 +5,20 @@ import {
type SgrMouseEvent,
truncateToWidth,
} from "@oh-my-pi/pi-tui";
import { SETTINGS_SCHEMA } from "../../../config/settings-schema";
import { getSearchProvider, setPreferredSearchProvider } from "../../../web/search/provider";
import { isSearchProviderPreference, type SearchProviderId } from "../../../web/search/types";
import { getSearchProvider, setSearchProviderOrder } from "../../../web/search/provider";
import {
isSearchProviderId,
SEARCH_PROVIDER_OPTIONS,
SEARCH_PROVIDER_ORDER,
type SearchProviderId,
} from "../../../web/search/types";
import { getSelectListTheme, theme } from "../../theme/theme";
import type { SetupSceneHost, SetupTab } from "./types";
const MAX_VISIBLE = 8;
/** Reuse the settings schema as the single source of truth for labels/descriptions. */
const WEB_SEARCH_ITEMS: readonly SelectItem[] = SETTINGS_SCHEMA["providers.webSearch"].ui.options.map(option => ({
/** Reuse the shared provider options as the single source of truth for labels/descriptions. */
const WEB_SEARCH_ITEMS: readonly SelectItem[] = SEARCH_PROVIDER_OPTIONS.map(option => ({
value: option.value,
label: option.label,
description: option.description,
@@ -42,7 +46,8 @@ export class WebSearchTab implements SetupTab {
constructor(private readonly host: SetupSceneHost) {
this.#list = new SelectList(WEB_SEARCH_ITEMS, MAX_VISIBLE, getSelectListTheme());
const current = host.ctx.settings.get("providers.webSearch");
const order = host.ctx.settings.get("providers.webSearchOrder");
const current = Array.isArray(order) && typeof order[0] === "string" ? order[0] : "auto";
const index = WEB_SEARCH_ITEMS.findIndex(item => item.value === current);
if (index >= 0) this.#list.setSelectedIndex(index);
this.#list.onSelectionChange = item => this.#onHighlight(item.value);
@@ -119,9 +124,12 @@ export class WebSearchTab implements SetupTab {
}
#apply(value: string): void {
if (!isSearchProviderPreference(value)) return;
this.host.ctx.settings.set("providers.webSearch", value);
setPreferredSearchProvider(value);
if (value !== "auto" && !isSearchProviderId(value)) return;
// The wizard picks one favorite; persist it as the head of the priority
// list with the remaining providers in their built-in order (auto = reset).
const order = value === "auto" ? [] : [value, ...SEARCH_PROVIDER_ORDER.filter(id => id !== value)];
this.host.ctx.settings.set("providers.webSearchOrder", order);
setSearchProviderOrder(order);
const label = WEB_SEARCH_ITEMS.find(item => item.value === value)?.label ?? value;
this.#status = [theme.fg("success", `${theme.status.success} Web search set to ${label}`)];
if (value !== "auto" && this.#availability.get(value as SearchProviderId) === false) {
@@ -2,5 +2,5 @@ Your yield was recorded, but {{count}} background job{{#if multiple}}s{{/if}} yo
This run completes only after these jobs settle AND you submit a fresh `yield` that accounts for their results. Job results arrive as follow-up messages; a result that arrives after your yield supersedes it — your current yield will NOT be accepted as the final report. Decide now:
- Need the results? Wait for them (`hub` op:"wait"), then submit a fresh `yield` that incorporates them.
- Job no longer needed? Cancel it (`hub` op:"cancel", ids:[...]) and re-yield.
- Job no longer needed? Cancel it (`hub` op:"cancel", ids:[…]) and re-yield.
- Otherwise stand by; when each result arrives, submit a fresh `yield` (repeat your report unchanged if the result does not affect it).
+2 -26
View File
@@ -55,6 +55,7 @@ import {
resolveModelRoleValue,
} from "./config/model-resolver";
import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates";
import { applyProviderGlobalsFromSettings } from "./config/provider-globals";
import { buildServiceTierByFamily } from "./config/service-tier";
import { Settings, type SkillsSettings } from "./config/settings";
import { CursorExecHandlers } from "./cursor";
@@ -175,16 +176,9 @@ import {
GrepTool,
getSearchTools,
HIDDEN_TOOLS,
isImageProviderPreference,
isMountableUnderXdev,
isSearchProviderId,
isSearchProviderPreference,
type LspStartupServerInfo,
ReadTool,
setExcludedSearchProviders,
setPreferredImageProvider,
setPreferredSearchProvider,
setSearchProviderOrder,
type Tool,
type ToolSession,
WebSearchTool,
@@ -1256,25 +1250,7 @@ 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 orderedWebSearchProviders = settings.get("providers.webSearchOrder");
if (Array.isArray(orderedWebSearchProviders)) {
setSearchProviderOrder(orderedWebSearchProviders.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);
}
applyProviderGlobalsFromSettings(settings);
const sessionManager =
options.sessionManager ??
+20 -28
View File
@@ -27,6 +27,7 @@ import { settings } from "../config/settings";
import type { CustomTool } from "../extensibility/custom-tools/types";
import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http";
import imageGenDescription from "../prompts/tools/image-gen.md" with { type: "text" };
import { AUTO_IMAGE_PROVIDER_ORDER, type ImageProvider, isImageProviderId } from "./image-providers";
import { resolveReadPath } from "./path-utils";
const DEFAULT_MODEL = "gemini-3-pro-image-preview";
@@ -44,7 +45,7 @@ const DEFAULT_ANTIGRAVITY_ENDPOINT_SANDBOX = "https://daily-cloudcode-pa.sandbox
const IMAGE_SYSTEM_INSTRUCTION =
"You are an AI image generator. Generate images based on user descriptions. Focus on creating high-quality, visually appealing images that match the user's request.";
export type ImageProvider = "antigravity" | "gemini" | "openai" | "openai-codex" | "openrouter" | "xai";
export type { ImageProvider } from "./image-providers";
export type ImageProviderPreference = ImageProvider | "auto";
interface ImageApiKey {
@@ -57,17 +58,8 @@ interface ImageApiKey {
const COMMON_IMAGE_ASPECT_RATIOS = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const;
const XAI_IMAGE_ASPECT_RATIOS = [...COMMON_IMAGE_ASPECT_RATIOS, "3:2", "2:3"] as const;
const COMMON_IMAGE_ASPECT_RATIO_SET = new Set<string>(COMMON_IMAGE_ASPECT_RATIOS);
const IMAGE_PROVIDER_CHOICES = [
"auto",
"antigravity",
"gemini",
"openai",
"openai-codex",
"openrouter",
"xai",
] as const;
const IMAGE_PROVIDER_PREFERENCES = new Set<string>(IMAGE_PROVIDER_CHOICES);
const AUTO_IMAGE_PROVIDER_ORDER = ["openai", "openai-codex", "antigravity", "xai", "openrouter", "gemini"] as const;
const IMAGE_PROVIDER_REQUEST_CHOICES = ["auto", ...AUTO_IMAGE_PROVIDER_ORDER] as const;
const IMAGE_PROVIDER_PREFERENCES = new Set<string>(IMAGE_PROVIDER_REQUEST_CHOICES);
const responseModalitySchema = type('"IMAGE" | "TEXT"');
@@ -81,8 +73,8 @@ const inputImageSchema = type({
});
const imageProviderSchema = type
.enumerated(...IMAGE_PROVIDER_CHOICES)
.describe("image provider for this request; overrides the providers.image setting (default: use the setting)");
.enumerated(...IMAGE_PROVIDER_REQUEST_CHOICES)
.describe("image provider for this request; overrides the providers.imageOrder setting (default: use the setting)");
export const imageGenSchema = type({
subject: type("string").describe("main subject"),
@@ -447,16 +439,16 @@ function extractOpenRouterImageUrls(message: OpenRouterMessage | undefined): str
return urls;
}
/** Preferred provider set via settings (default: auto) */
let preferredImageProvider: ImageProviderPreference = "auto";
/** Configured provider priority set via `providers.imageOrder` (default: none). */
let configuredImageProviderOrder: readonly ImageProvider[] = [];
export function isImageProviderPreference(value: unknown): value is ImageProviderPreference {
return typeof value === "string" && IMAGE_PROVIDER_PREFERENCES.has(value);
}
/** Set the preferred image provider from settings */
export function setPreferredImageProvider(provider: ImageProviderPreference): void {
preferredImageProvider = provider;
/** Set the configured image-provider priority from settings; invalid IDs are dropped. */
export function setImageProviderOrder(providers: readonly string[]): void {
configuredImageProviderOrder = providers.filter(isImageProviderId);
}
function assertImageAspectRatioSupported(provider: ImageProvider, aspectRatio: ImageGenParams["aspect_ratio"]): void {
if (!aspectRatio || provider === "xai" || COMMON_IMAGE_ASPECT_RATIO_SET.has(aspectRatio)) {
@@ -624,19 +616,19 @@ function activeImageProvider(model: Model | undefined): Exclude<ImageProviderPre
}
}
function imageProviderOrder(
activeModel: Model | undefined,
preference: ImageProviderPreference = preferredImageProvider,
): Array<Exclude<ImageProviderPreference, "auto">> {
const providers: Array<Exclude<ImageProviderPreference, "auto">> = [];
const added = new Set<Exclude<ImageProviderPreference, "auto">>();
const add = (provider: Exclude<ImageProviderPreference, "auto"> | null): void => {
function imageProviderOrder(activeModel: Model | undefined, requested?: ImageProviderPreference): ImageProvider[] {
const providers: ImageProvider[] = [];
const added = new Set<ImageProvider>();
const add = (provider: ImageProvider | null): void => {
if (!provider || added.has(provider)) return;
added.add(provider);
providers.push(provider);
};
if (preference !== "auto") add(preference);
// Per-request provider wins, then the configured priority list, then the
// active session's provider, then the built-in auto order.
if (requested !== undefined && requested !== "auto") add(requested);
for (const provider of configuredImageProviderOrder) add(provider);
add(activeImageProvider(activeModel));
for (const provider of AUTO_IMAGE_PROVIDER_ORDER) add(provider);
return providers;
@@ -1109,7 +1101,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
async execute(_toolCallId, params, _onUpdate, ctx, signal) {
return untilAborted(signal, async () => {
const sessionId = ctx.sessionManager.getSessionId();
const providerOrder = imageProviderOrder(ctx.model, params.provider ?? preferredImageProvider);
const providerOrder = imageProviderOrder(ctx.model, params.provider);
const cwd = ctx.sessionManager.getCwd();
const requestSignal = ptree.combineSignals(signal, IMAGE_TIMEOUT);
const fetchImpl = ctx.fetch ?? fetch;
@@ -0,0 +1,50 @@
/**
* Image Generation Providers
*
* Leaf module (no runtime deps) shared by the image_gen tool, the settings
* schema, and settings migrations — mirrors `web/search/types.ts` so the
* provider list, auto order, and settings choices never drift apart.
*/
/** Image generation backends, in settings/tool vocabulary. */
export type ImageProvider = "antigravity" | "gemini" | "openai" | "openai-codex" | "openrouter" | "xai";
/** Auto-resolution fallback order when no configured entry or session provider matches. */
export const AUTO_IMAGE_PROVIDER_ORDER: readonly ImageProvider[] = [
"openai",
"openai-codex",
"antigravity",
"xai",
"openrouter",
"gemini",
];
/** Settings choices for `providers.imageOrder` (labels shared with the retired single-preference enum). */
export const IMAGE_PROVIDER_CHOICES = [
{
value: "openai",
label: "OpenAI",
description: "OPENAI_API_KEY (gpt-image-2) or active GPT model; falls back to a connected Codex subscription",
},
{
value: "openai-codex",
label: "OpenAI Codex (ChatGPT)",
description: "Uses a connected Codex / ChatGPT subscription — no OPENAI_API_KEY needed",
},
{
value: "antigravity",
label: "Antigravity",
description: "Requires google-antigravity OAuth",
},
{
value: "xai",
label: "xAI Grok Imagine",
description: "Requires xAI Grok OAuth or XAI_API_KEY",
},
{ value: "gemini", label: "Gemini", description: "Requires GEMINI_API_KEY" },
{ value: "openrouter", label: "OpenRouter", description: "Requires OPENROUTER_API_KEY" },
] as const satisfies ReadonlyArray<{ value: ImageProvider; label: string; description: string }>;
export function isImageProviderId(value: unknown): value is ImageProvider {
return typeof value === "string" && AUTO_IMAGE_PROVIDER_ORDER.includes(value as ImageProvider);
}
+3 -10
View File
@@ -135,11 +135,9 @@ async function executeSearch(
let candidates: SearchProviderCandidate[];
if (explicitProvider && explicitProvider !== "auto") {
candidates = [{ id: explicitProvider, explicit: true }];
} else if (explicitProvider === "auto") {
// Explicit `--provider auto` bypasses the configured preferred provider
// for this invocation; exclusions still apply.
candidates = resolveProviderCandidates("auto");
} else {
// `--provider auto` and the default both walk the configured chain;
// exclusions still apply.
candidates = resolveProviderCandidates();
}
@@ -350,11 +348,6 @@ export function getSearchTools(): CustomTool<any, any>[] {
return [webSearchCustomTool];
}
export {
getSearchProvider,
setExcludedSearchProviders,
setPreferredSearchProvider,
setSearchProviderOrder,
} from "./provider";
export { getSearchProvider, setExcludedSearchProviders, setSearchProviderOrder } from "./provider";
export type { SearchProviderId as SearchProvider, SearchResponse } from "./types";
export { isSearchProviderId, isSearchProviderPreference } from "./types";
@@ -189,23 +189,21 @@ export async function getSearchProvider(id: SearchProviderId): Promise<SearchPro
return provider;
}
/** Preferred provider set via settings (default: auto) */
let preferredProvId: SearchProviderId | "auto" = "auto";
/** Set the preferred web search provider from settings */
export function setPreferredSearchProvider(provider: SearchProviderId | "auto"): void {
preferredProvId = provider;
}
/** Provider fallback order set via settings (default: built-in order). */
let orderedProvIds: readonly SearchProviderId[] = SEARCH_PROVIDER_ORDER;
/** Providers the user explicitly listed in `providers.webSearchOrder`. */
let explicitProvIds = new Set<SearchProviderId>();
/**
* Prioritize configured providers while retaining every unlisted provider in
* its built-in relative order. Invalid IDs are ignored defensively.
* its built-in relative order. Invalid IDs are ignored defensively. Listed
* providers are treated as explicit selections: they resolve through
* `isExplicitlyAvailable`, so e.g. a hand-listed Perplexity may fall back to
* anonymous search exactly like the retired single-preference setting did.
*/
export function setSearchProviderOrder(providers: readonly SearchProviderId[]): void {
const prioritized = new Set(providers.filter(id => SEARCH_PROVIDER_ORDER.includes(id)));
explicitProvIds = prioritized;
orderedProvIds =
prioritized.size === 0
? SEARCH_PROVIDER_ORDER
@@ -230,19 +228,21 @@ export interface SearchProviderCandidate {
explicit: boolean;
}
/** Return provider candidates in fallback order without loading their modules. */
export function resolveProviderCandidates(
preferredProvider: SearchProviderId | "auto" = preferredProvId,
): SearchProviderCandidate[] {
/**
* Return provider candidates in fallback order without loading their modules.
* `forcedProvider` (a per-request `provider` argument) is terminal-first and
* bypasses exclusion; configured-order entries carry `explicit: true`.
*/
export function resolveProviderCandidates(forcedProvider?: SearchProviderId): SearchProviderCandidate[] {
const candidates: SearchProviderCandidate[] = [];
if (preferredProvider !== "auto" && !isSearchProviderExcluded(preferredProvider)) {
candidates.push({ id: preferredProvider, explicit: true });
if (forcedProvider !== undefined && !isSearchProviderExcluded(forcedProvider)) {
candidates.push({ id: forcedProvider, explicit: true });
}
for (const id of orderedProvIds) {
if (id === preferredProvider || isSearchProviderExcluded(id)) continue;
candidates.push({ id, explicit: false });
if (id === forcedProvider || isSearchProviderExcluded(id)) continue;
candidates.push({ id, explicit: explicitProvIds.has(id) });
}
return candidates;
@@ -256,11 +256,11 @@ export function resolveProviderCandidates(
*/
export async function resolveProviderChain(
authStorage: AuthStorage,
preferredProvider: SearchProviderId | "auto" = preferredProvId,
forcedProvider?: SearchProviderId,
): Promise<SearchProvider[]> {
const providers: SearchProvider[] = [];
for (const candidate of resolveProviderCandidates(preferredProvider)) {
for (const candidate of resolveProviderCandidates(forcedProvider)) {
const provider = await getSearchProvider(candidate.id);
const available = candidate.explicit
? await provider.isExplicitlyAvailable(authStorage)
@@ -96,6 +96,9 @@ export const SEARCH_PROVIDER_ORDER: readonly SearchProviderId[] = SEARCH_PROVIDE
option.value === "auto" ? [] : [option.value],
);
/** Concrete provider choices (no `auto` sentinel) — for list-valued settings like order/exclude. */
export const SEARCH_PROVIDER_CHOICES = SEARCH_PROVIDER_OPTIONS.filter(option => option.value !== "auto");
export const SEARCH_PROVIDER_PREFERENCES = ["auto", ...SEARCH_PROVIDER_ORDER] as const;
/** Display labels, derived from {@link SEARCH_PROVIDER_OPTIONS}. */
@@ -11,18 +11,14 @@ describe("applyProviderGlobalsFromSettings", () => {
it("reapplies valid web and image provider globals from cwd-scoped settings", () => {
const excludeSpy = vi.spyOn(webSearch, "setExcludedSearchProviders").mockImplementation(() => {});
const orderSpy = vi.spyOn(webSearch, "setSearchProviderOrder").mockImplementation(() => {});
const webSpy = vi.spyOn(webSearch, "setPreferredSearchProvider").mockImplementation(() => {});
const imageSpy = vi.spyOn(imageGen, "setPreferredImageProvider").mockImplementation(() => {});
const imageOrderSpy = vi.spyOn(imageGen, "setImageProviderOrder").mockImplementation(() => {});
applyProviderGlobalsFromSettings({
get(
path: "providers.webSearchOrder" | "providers.webSearchExclude" | "providers.webSearch" | "providers.image",
): unknown {
get(path: "providers.webSearchOrder" | "providers.webSearchExclude" | "providers.imageOrder"): unknown {
const values: Record<string, unknown> = {
"providers.webSearchOrder": ["perplexity", "not-a-provider", "exa"],
"providers.webSearchExclude": ["exa", "not-a-provider", "gemini"],
"providers.webSearch": "perplexity",
"providers.image": "xai",
"providers.imageOrder": ["xai", 42, "gemini"],
};
return values[path];
},
@@ -30,7 +26,6 @@ describe("applyProviderGlobalsFromSettings", () => {
expect(orderSpy).toHaveBeenCalledWith(["perplexity", "exa"]);
expect(excludeSpy).toHaveBeenCalledWith(["exa", "gemini"]);
expect(webSpy).toHaveBeenCalledWith("perplexity");
expect(imageSpy).toHaveBeenCalledWith("xai");
expect(imageOrderSpy).toHaveBeenCalledWith(["xai", "gemini"]);
});
});
@@ -0,0 +1,131 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { SettingsSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/settings-selector";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { SEARCH_PROVIDER_CHOICES } from "@oh-my-pi/pi-coding-agent/web/search/types";
beforeAll(async () => {
await initTheme();
});
let geometryStub: { restore(): void } | undefined;
beforeEach(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true });
geometryStub = stubStdoutGeometry(120);
});
afterEach(() => {
resetSettingsForTest();
geometryStub?.restore();
geometryStub = undefined;
});
function stubStdoutGeometry(cols: number): { restore(): void } {
const rowsDesc = Object.getOwnPropertyDescriptor(process.stdout, "rows");
const colsDesc = Object.getOwnPropertyDescriptor(process.stdout, "columns");
const rows = 40;
Object.defineProperty(process.stdout, "rows", { configurable: true, get: () => rows, set: () => {} });
Object.defineProperty(process.stdout, "columns", { configurable: true, get: () => cols, set: () => {} });
const restoreOne = (key: "rows" | "columns", desc: PropertyDescriptor | undefined) => {
if (desc) Object.defineProperty(process.stdout, key, desc);
};
return {
restore() {
restoreOne("rows", rowsDesc);
restoreOne("columns", colsDesc);
},
};
}
function createSelector(): SettingsSelectorComponent {
return new SettingsSelectorComponent(
{
availableThinkingLevels: [],
thinkingLevel: undefined,
availableThemes: ["dark"],
providers: [],
cwd: process.cwd(),
},
{
onChange: () => {},
onCancel: () => {},
},
);
}
const [firstChoice, secondChoice] = SEARCH_PROVIDER_CHOICES;
describe("multiselect settings (array-of-enum)", () => {
it("edits providers.webSearchOrder via the ordered toggle list", () => {
const comp = createSelector();
for (const ch of "web search provider order") comp.handleInput(ch);
const row = comp.render(120).join("\n");
expect(row).toContain("Web Search Provider Order");
expect(row).toContain("default");
// Open the editor; Space toggles the first provider, Enter the second.
comp.handleInput("\n");
comp.handleInput(" ");
comp.handleInput("\x1b[B");
comp.handleInput("\n");
expect(settings.get("providers.webSearchOrder")).toEqual([firstChoice!.value, secondChoice!.value]);
// ← promotes the highlighted member one slot earlier in priority.
comp.handleInput("\x1b[D");
expect(settings.get("providers.webSearchOrder")).toEqual([secondChoice!.value, firstChoice!.value]);
// Toggling a member off removes it and renumbers the rest.
comp.handleInput(" ");
expect(settings.get("providers.webSearchOrder")).toEqual([firstChoice!.value]);
// Esc returns to the list; the row summary reflects the saved order.
comp.handleInput("\x1b");
expect(comp.render(120).join("\n")).toContain(firstChoice!.label);
});
it("splices the hovered option into the pressed digit's position", () => {
const [a, b, c] = SEARCH_PROVIDER_CHOICES;
const comp = createSelector();
for (const ch of "web search provider order") comp.handleInput(ch);
comp.handleInput("\n");
// Select rows 1 and 3 → [a, c].
comp.handleInput(" ");
comp.handleInput("\x1b[B");
comp.handleInput("\x1b[B");
comp.handleInput(" ");
expect(settings.get("providers.webSearchOrder")).toEqual([a!.value, c!.value]);
// Hover row 2 (unselected) and press "2" → spliced between them.
comp.handleInput("\x1b[A");
comp.handleInput("2");
expect(settings.get("providers.webSearchOrder")).toEqual([a!.value, b!.value, c!.value]);
// Press "9" (past the end) → clamps to the tail.
comp.handleInput("9");
expect(settings.get("providers.webSearchOrder")).toEqual([a!.value, c!.value, b!.value]);
// Press "1" → promotes to the head.
comp.handleInput("1");
expect(settings.get("providers.webSearchOrder")).toEqual([b!.value, a!.value, c!.value]);
});
it("edits providers.webSearchExclude as an unordered toggle set", () => {
const comp = createSelector();
for (const ch of "excluded web search providers") comp.handleInput(ch);
expect(comp.render(120).join("\n")).toContain("none");
comp.handleInput("\n");
comp.handleInput(" ");
expect(settings.get("providers.webSearchExclude")).toEqual([firstChoice!.value]);
// Unordered lists ignore reorder keys.
comp.handleInput("\x1b[C");
expect(settings.get("providers.webSearchExclude")).toEqual([firstChoice!.value]);
comp.handleInput(" ");
expect(settings.get("providers.webSearchExclude")).toEqual([]);
});
});
@@ -370,7 +370,7 @@ describe("createAgentSession defaultInactive tool activation", () => {
const normalDir = makeTempDir();
const configuredSettings = () =>
Settings.isolated({
"providers.image": "openai",
"providers.imageOrder": ["openai"],
"generate_image.enabled": true,
"speechgen.enabled": true,
"memory.backend": "hindsight",
@@ -16,6 +16,8 @@ import {
Settings,
} from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage";
import { AUTO_IMAGE_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/tools/image-providers";
import { SEARCH_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/web/search/types";
import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils";
import { YAML } from "bun";
import * as fileLock from "../src/config/file-lock";
@@ -599,6 +601,46 @@ describe("Settings", () => {
});
});
describe("provider preference migration", () => {
it("expands a legacy providers.webSearch choice into the head of webSearchOrder", async () => {
await writeSettings({ providers: { webSearch: "exa" } });
const settings = await Settings.init({ cwd: projectDir, agentDir });
expect(settings.get("providers.webSearchOrder")).toEqual([
"exa",
...SEARCH_PROVIDER_ORDER.filter(id => id !== "exa"),
]);
});
it("drops legacy providers.webSearch auto without seeding an order", async () => {
await writeSettings({ providers: { webSearch: "auto" } });
const settings = await Settings.init({ cwd: projectDir, agentDir });
expect(settings.get("providers.webSearchOrder")).toEqual([]);
});
it("keeps an explicit webSearchOrder over the legacy webSearch preference", async () => {
await writeSettings({ providers: { webSearch: "exa", webSearchOrder: ["gemini"] } });
const settings = await Settings.init({ cwd: projectDir, agentDir });
expect(settings.get("providers.webSearchOrder")).toEqual(["gemini"]);
});
it("expands a legacy providers.image choice into the head of imageOrder", async () => {
await writeSettings({ providers: { image: "xai" } });
const settings = await Settings.init({ cwd: projectDir, agentDir });
expect(settings.get("providers.imageOrder")).toEqual([
"xai",
...AUTO_IMAGE_PROVIDER_ORDER.filter(id => id !== "xai"),
]);
});
});
describe("migrations", () => {
it("maps removed atom edit mode settings to hashline", async () => {
await writeSettings({
+16 -10
View File
@@ -3,7 +3,6 @@ import type { Model } from "@oh-my-pi/pi-ai";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { runOnboardingSetup } from "@oh-my-pi/pi-coding-agent/commands/setup";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { SETTINGS_SCHEMA } from "@oh-my-pi/pi-coding-agent/config/settings-schema";
import {
ALL_SCENES,
CURRENT_SETUP_VERSION,
@@ -19,7 +18,7 @@ import { WebSearchTab } from "@oh-my-pi/pi-coding-agent/modes/setup-wizard/scene
import { SetupWizardComponent } from "@oh-my-pi/pi-coding-agent/modes/setup-wizard/wizard-overlay";
import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_PREFERENCES } from "@oh-my-pi/pi-coding-agent/web/search/types";
import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/web/search/types";
function fakeContextWithConfiguredModel(): InteractiveModeContext {
return {
@@ -487,13 +486,12 @@ describe("setup wizard glyph scene", () => {
});
describe("setup wizard web search tab", () => {
it("exposes every web-search provider preference in the schema-backed TUI list", () => {
const schema = SETTINGS_SCHEMA["providers.webSearch"];
expect(schema.values).toEqual(SEARCH_PROVIDER_PREFERENCES);
expect(schema.ui.options).toEqual(SEARCH_PROVIDER_OPTIONS);
it("exposes every web-search provider preference in the shared TUI list", () => {
expect(SEARCH_PROVIDER_OPTIONS[0]?.value).toBe("auto");
expect(SEARCH_PROVIDER_OPTIONS.slice(1).map(option => option.value)).toEqual([...SEARCH_PROVIDER_ORDER]);
});
it("persists the highlighted provider as the web search preference", async () => {
it("persists the highlighted provider as the head of the web search order", async () => {
const settings = Settings.isolated();
const host = {
ctx: {
@@ -511,9 +509,12 @@ describe("setup wizard web search tab", () => {
tab.handleInput("\n"); // confirm the highlighted provider
await Bun.sleep(20);
const expected = SETTINGS_SCHEMA["providers.webSearch"].ui.options[1].value;
const expected = SEARCH_PROVIDER_OPTIONS[1]!.value;
expect(expected).not.toBe("auto");
expect(settings.get("providers.webSearch")).toBe(expected);
expect(settings.get("providers.webSearchOrder")).toEqual([
expected,
...SEARCH_PROVIDER_ORDER.filter(id => id !== expected),
]);
});
it("can select the last provider in the setup TUI list", async () => {
@@ -537,7 +538,12 @@ describe("setup wizard web search tab", () => {
await Bun.sleep(20);
const lastOption = SEARCH_PROVIDER_OPTIONS[SEARCH_PROVIDER_OPTIONS.length - 1]!;
expect(settings.get("providers.webSearch")).toBe(lastOption.value);
const lastValue = lastOption.value;
if (lastValue === "auto") throw new Error("last option must be a concrete provider");
expect(settings.get("providers.webSearchOrder")).toEqual([
lastValue,
...SEARCH_PROVIDER_ORDER.filter(id => id !== lastValue),
]);
});
});
@@ -7,7 +7,7 @@ import {
getImageGenTools,
getImageGenToolsWithRegistry,
imageGenTool,
setPreferredImageProvider,
setImageProviderOrder,
} from "@oh-my-pi/pi-coding-agent/tools/image-gen";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
@@ -21,7 +21,7 @@ afterEach(async () => {
} else {
Bun.env.OPENROUTER_API_KEY = originalOpenRouterKey;
}
setPreferredImageProvider("auto");
setImageProviderOrder([]);
});
function createAntigravityXAIContext(model: Model | undefined, fetchMock: typeof fetch): CustomToolContext {
@@ -71,7 +71,7 @@ describe("imageGenTool", () => {
});
it("resolves image provider credentials on execution", async () => {
setPreferredImageProvider("antigravity");
setImageProviderOrder(["antigravity"]);
const ctx: CustomToolContext = {
fetch: async () => new Response(null),
sessionManager: {
@@ -165,7 +165,7 @@ describe("imageGenTool", () => {
});
it("routes OpenAI Images edits through the Responses image tool", async () => {
setPreferredImageProvider("openai");
setImageProviderOrder(["openai"]);
let requestUrl: string | undefined;
let requestBody: Record<string, unknown> | undefined;
@@ -236,7 +236,7 @@ describe("imageGenTool", () => {
});
it("routes image generation through a connected Codex (ChatGPT) subscription when the active model is not OpenAI", async () => {
setPreferredImageProvider("openai-codex");
setImageProviderOrder(["openai-codex"]);
let requestUrl: string | undefined;
let accountHeader: string | null | undefined;
let requestBody: Record<string, unknown> | undefined;
@@ -387,9 +387,9 @@ describe("imageGenTool", () => {
expect(result.details?.imageCount).toBe(1);
});
it("honors a per-request provider override over the providers.image setting", async () => {
it("honors a per-request provider override over the providers.imageOrder setting", async () => {
// Setting selects Codex and a Codex subscription IS connected...
setPreferredImageProvider("openai-codex");
setImageProviderOrder(["openai-codex"]);
let requestUrl: string | undefined;
const captured: { authorization: string | null } = { authorization: null };
@@ -569,7 +569,7 @@ describe("imageGenTool", () => {
expect(result.details?.imageCount).toBe(1);
});
it("routes xAI image generation with xAI-only aspect ratios", async () => {
setPreferredImageProvider("xai");
setImageProviderOrder(["xai"]);
let requestUrl: string | undefined;
let requestBody: Record<string, unknown> | undefined;
const captured: { authorization: string | null; userAgent: string | null } = {
@@ -4,7 +4,7 @@ import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config
import {
SEARCH_PROVIDER_ORDER,
setExcludedSearchProviders,
setPreferredSearchProvider,
setSearchProviderOrder,
} from "@oh-my-pi/pi-coding-agent/web/search/provider";
import { __resetDirsFromEnvForTests, setAgentDir, TempDir } from "@oh-my-pi/pi-utils";
import { runSearchCommand } from "../../../src/cli/web-search-cli";
@@ -81,7 +81,7 @@ beforeEach(async () => {
process.exitCode = undefined;
resetSettingsForTest();
setPreferredSearchProvider("auto");
setSearchProviderOrder([]);
setExcludedSearchProviders([]);
tempAgentDir = TempDir.createSync("@omp-search-cli-");
setAgentDir(tempAgentDir.path());
@@ -89,7 +89,7 @@ beforeEach(async () => {
inMemory: true,
cwd: tempAgentDir.path(),
overrides: {
"providers.webSearch": "tavily",
"providers.webSearchOrder": ["tavily"],
"providers.webSearchExclude": ["jina"],
},
});
@@ -98,7 +98,7 @@ beforeEach(async () => {
afterEach(async () => {
vi.restoreAllMocks();
resetSettingsForTest();
setPreferredSearchProvider("auto");
setSearchProviderOrder([]);
setExcludedSearchProviders([]);
process.exitCode = originalExitCode;
for (const key of WEB_SEARCH_ENV_KEYS) {
@@ -115,7 +115,7 @@ afterEach(async () => {
});
describe("runSearchCommand provider settings", () => {
it("applies configured web-search preference and exclusions before resolving the implicit chain", async () => {
it("applies the configured web-search order and exclusions before resolving the implicit chain", async () => {
vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock());
let stdout = "";
@@ -131,23 +131,19 @@ describe("runSearchCommand provider settings", () => {
expect(plain).not.toContain("Provider: Jina");
});
it("treats explicit --provider auto as a one-shot override of the configured preferred provider", async () => {
// Tavily is the configured preference, but `--provider auto` overrides it and walks the
// chain. Restrict eligibility to Jina + Tavily so an ambient broker/OAuth provider
// (gemini, anthropic, codex, perplexity…) can't win on a dev machine; the chain order
// (Jina before Tavily) still decides between the two.
it("treats an explicit --provider as a one-shot override of the configured order", async () => {
// Tavily heads the configured order, but an explicit `--provider jina`
// forces Jina for this invocation without touching the configured chain.
const currentTempDir = tempAgentDir;
if (!currentTempDir) throw new Error("tempAgentDir missing");
// Drive the exclusion through settings too — Settings.init re-applies
// `providers.webSearchExclude`, overwriting a bare setExcludedSearchProviders() call.
const onlyJinaTavily = SEARCH_PROVIDER_ORDER.filter(id => id !== "jina" && id !== "tavily");
resetSettingsForTest();
setPreferredSearchProvider("auto");
setSearchProviderOrder([]);
setExcludedSearchProviders(onlyJinaTavily);
await Settings.init({
inMemory: true,
cwd: currentTempDir.path(),
overrides: { "providers.webSearch": "tavily", "providers.webSearchExclude": onlyJinaTavily },
overrides: { "providers.webSearchOrder": ["tavily"], "providers.webSearchExclude": onlyJinaTavily },
});
vi.spyOn(globalThis, "fetch").mockImplementation(makeFetchMock());
@@ -158,7 +154,7 @@ describe("runSearchCommand provider settings", () => {
return true;
});
await runSearchCommand({ query: "explicit auto chain", provider: "auto", limit: 1, expanded: false });
await runSearchCommand({ query: "explicit provider override", provider: "jina", limit: 1, expanded: false });
const plain = stripVTControlCharacters(stdout);
expect(plain).toContain("Provider: Jina");
@@ -5,7 +5,6 @@ import {
resolveProviderCandidates,
resolveProviderChain,
setExcludedSearchProviders,
setPreferredSearchProvider,
setSearchProviderOrder,
} from "@oh-my-pi/pi-coding-agent/web/search/provider";
import { SEARCH_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/web/search/types";
@@ -34,14 +33,13 @@ function restoreEnv(): void {
}
afterEach(() => {
setPreferredSearchProvider("auto");
setExcludedSearchProviders([]);
setSearchProviderOrder([]);
restoreEnv();
});
describe("resolveProviderCandidates", () => {
it("orders the preferred provider before configured and built-in fallbacks", () => {
it("orders the forced provider before configured and built-in fallbacks", () => {
setSearchProviderOrder(["gemini", "exa"]);
const candidates = resolveProviderCandidates("perplexity");
@@ -54,6 +52,15 @@ describe("resolveProviderCandidates", () => {
]);
});
it("marks configured-order entries explicit so hand-listed providers keep explicit-selection semantics", () => {
setSearchProviderOrder(["perplexity"]);
const candidates = resolveProviderCandidates();
expect(candidates[0]).toEqual({ id: "perplexity", explicit: true });
expect(candidates[1]?.explicit).toBe(false);
});
it("omits excluded providers without resolving them", () => {
setExcludedSearchProviders(["duckduckgo", "google"]);
@@ -68,7 +75,7 @@ describe("resolveProviderCandidates", () => {
controller.handleSettingChange("providers.webSearchOrder", ["exa", "not-a-provider", "exa", "gemini"]);
const candidates = resolveProviderCandidates("auto");
const candidates = resolveProviderCandidates();
expect(candidates.slice(0, 2).map(candidate => candidate.id)).toEqual(["exa", "gemini"]);
expect(candidates).toHaveLength(SEARCH_PROVIDER_ORDER.length);
});
@@ -79,12 +86,12 @@ describe("resolveProviderChain", () => {
enableKeyBackedProviders();
setExcludedSearchProviders(SEARCH_PROVIDER_ORDER.filter(id => id !== "jina"));
const providers = await resolveProviderChain(authStorage, "auto");
const providers = await resolveProviderChain(authStorage);
expect(providers.map(provider => provider.id)).toEqual(["jina"]);
});
it("ignores the preferred provider when it is excluded", async () => {
it("ignores the forced provider when it is excluded", async () => {
enableKeyBackedProviders();
setExcludedSearchProviders(SEARCH_PROVIDER_ORDER.filter(id => id !== "jina"));
@@ -102,7 +109,7 @@ describe("resolveProviderChain", () => {
SEARCH_PROVIDER_ORDER.filter(id => id !== "jina"),
);
const providers = await resolveProviderChain(authStorage, "auto");
const providers = await resolveProviderChain(authStorage);
expect(providers.map(provider => provider.id)).toEqual(["jina"]);
});