diff --git a/packages/pi-coding-agent/src/core/model-registry.ts b/packages/pi-coding-agent/src/core/model-registry.ts index b83607eab..b59e82097 100644 --- a/packages/pi-coding-agent/src/core/model-registry.ts +++ b/packages/pi-coding-agent/src/core/model-registry.ts @@ -36,7 +36,44 @@ import { isLocalModel } from "./local-model-check.js"; const Ajv = (AjvModule as any).default || AjvModule; const ajv = new Ajv(); -// Schema for OpenRouter routing preferences +// ── Proxy provider priority ────────────────────────────────────────────────── + +/** Global fallback chain appended after every family's direct providers. */ +export const GLOBAL_PROVIDER_FALLBACK: readonly string[] = [ + "opencode", + "opencode-go", + "openrouter", + "ollama-cloud", +]; + +/** + * Per-family direct-provider priority. Each entry lists only the preferred + * direct providers for that family. GLOBAL_PROVIDER_FALLBACK is always + * appended after these when building the effective resolution order. + */ +export const PROXY_FAMILY_PRIORITY: ReadonlyArray<{ + match: RegExp; + /** Canonical key used when matching settings.proxy.providerPriority overrides */ + prefix: string; + providers: string[]; +}> = [ + // minimax direct (api.minimax.io) → CN endpoint + { match: /^MiniMax-/i, prefix: "MiniMax-", providers: ["minimax", "minimax-cn"] }, + // ZAI direct API for GLM + { match: /^glm-/i, prefix: "glm-", providers: ["zai"] }, + // Kimi Code direct API + { match: /^kimi-/i, prefix: "kimi-", providers: ["kimi-coding"] }, + // MiMo/Xiaomi — no direct provider; resolved entirely via global fallback + { match: /^mimo-|^XiaomiMiMo\//i, prefix: "mimo-", providers: [] }, + // Gemini/Gemma: free CLI (OAuth) first, then paid API tiers + { match: /^gemini-|^gemma-/i, prefix: "gemini-", providers: ["google-gemini-cli", "google", "google-vertex", "github-copilot"] }, + // Claude: Anthropic direct, then OAuth/delegated + { match: /^claude-/i, prefix: "claude-", providers: ["anthropic", "google-antigravity", "github-copilot"] }, + // GPT / o-series / codex + { match: /^gpt-|^o\d|^codex-/i, prefix: "gpt-", providers: ["openai", "azure-openai-responses", "github-copilot"] }, +]; + +// ── Schema for OpenRouter routing preferences const OpenRouterRoutingSchema = Type.Object({ only: Type.Optional(Type.Array(Type.String())), order: Type.Optional(Type.Array(Type.String())), @@ -783,6 +820,46 @@ export class ModelRegistry { } } + private buildCandidateOrder(modelId: string, overrides: Record): string[] { + const overrideEntry = Object.entries(overrides).find(([k]) => modelId.startsWith(k)); + const familyProviders = + overrideEntry?.[1] ?? + PROXY_FAMILY_PRIORITY.find((r) => r.match.test(modelId))?.providers ?? + []; + const seen = new Set(familyProviders); + const globalFallback = GLOBAL_PROVIDER_FALLBACK.filter((p) => !seen.has(p)); + return [...familyProviders, ...globalFallback]; + } + + /** + * Return all registered candidates for a bare model ID, ordered by family + global priority. + * Candidates with auth configured are placed first within the same priority tier. + * The proxy server iterates this list and falls through to the next entry on 429. + */ + getModelsForProxy(modelId: string, overrides: Record = {}): Model[] { + const candidates = this.models.filter((m) => m.id === modelId); + if (candidates.length === 0) return []; + + const order = this.buildCandidateOrder(modelId, overrides); + const sorted = [...candidates].sort((a, b) => { + const pa = order.indexOf(a.provider); + const pb = order.indexOf(b.provider); + return (pa === -1 ? Infinity : pa) - (pb === -1 ? Infinity : pb); + }); + + const withAuth = sorted.filter((m) => this.isProviderRequestReady(m.provider)); + const withoutAuth = sorted.filter((m) => !this.isProviderRequestReady(m.provider)); + return [...withAuth, ...withoutAuth]; + } + + /** + * Resolve a bare model ID to the single highest-priority candidate. + * Convenience wrapper over getModelsForProxy for callers that don't need retry. + */ + getModel(modelId: string, overrides: Record = {}): Model | undefined { + return this.getModelsForProxy(modelId, overrides)[0]; + } + /** * Discover models from all providers that support discovery. * Results are cached and merged into the registry (never overrides existing models). diff --git a/packages/pi-coding-agent/src/core/settings-manager.ts b/packages/pi-coding-agent/src/core/settings-manager.ts index d1b131ff9..3e55d4716 100644 --- a/packages/pi-coding-agent/src/core/settings-manager.ts +++ b/packages/pi-coding-agent/src/core/settings-manager.ts @@ -92,6 +92,14 @@ export interface ModelDiscoverySettings { autoRefreshOnModelSelect?: boolean; // default: false - refresh discovery when opening model selector } +export interface ProxySettings { + /** Per-family provider priority overrides for proxy model resolution. + * Key: model-ID prefix (e.g. "gemini-", "glm-"). + * Value: ordered provider list — first = highest priority. + * Replaces the built-in family list for that prefix; global fallback is always appended. */ + providerPriority?: Record; +} + export type TransportSetting = Transport; /** @@ -154,6 +162,7 @@ export interface Settings { timestampFormat?: "date-time-iso" | "date-time-us"; // Timestamp display format for messages. Default: "date-time-iso" allowedCommandPrefixes?: string[]; // Override built-in SAFE_COMMAND_PREFIXES for !command resolution (global-only — ignored in project settings) fetchAllowedUrls?: string[]; // Hostnames exempted from SSRF blocklist in fetch_page (global-only — ignored in project settings) + proxy?: ProxySettings; } /** Settings keys that are only respected from global config — project settings cannot override these. */ @@ -1114,6 +1123,18 @@ export class SettingsManager { this.setGlobalSetting("timestampFormat", format); } + getProxyProviderPriority(): Record { + return this.settings.proxy?.providerPriority ?? {}; + } + + setProxyFamilyProvider(familyPrefix: string, orderedProviders: string[]): void { + const current = this.settings.proxy?.providerPriority ?? {}; + this.setGlobalSetting("proxy", { + ...this.settings.proxy, + providerPriority: { ...current, [familyPrefix]: orderedProviders }, + }); + } + /** * Get the allowed command prefixes from global settings only. * Returns undefined if not configured (caller should use built-in defaults). diff --git a/packages/pi-coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/pi-coding-agent/src/modes/interactive/components/settings-selector.ts index 2b816435b..8a204f6d3 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/settings-selector.ts @@ -1,5 +1,6 @@ import type { ThinkingLevel } from "@singularity-forge/pi-agent-core"; import type { Transport } from "@singularity-forge/pi-ai"; +import { PROXY_FAMILY_PRIORITY } from "../../../core/model-registry.js"; import { Container, getCapabilities, @@ -46,6 +47,9 @@ export interface SettingsConfig { quietStartup: boolean; clearOnShrink: boolean; timestampFormat: "date-time-iso" | "date-time-us"; + /** Top-preferred provider per model family for proxy resolution. + * Keyed by family prefix (e.g. "gemini-", "claude-"). */ + proxyFamilyProviders: Record; } export interface SettingsCallbacks { @@ -71,6 +75,7 @@ export interface SettingsCallbacks { onQuietStartupChange: (enabled: boolean) => void; onClearOnShrinkChange: (enabled: boolean) => void; onTimestampFormatChange: (format: "date-time-iso" | "date-time-us") => void; + onProxyFamilyProviderChange: (familyPrefix: string, preferredProvider: string) => void; onCancel: () => void; } @@ -136,6 +141,92 @@ export class SelectSubmenu extends Container { } } +/** + * Two-level submenu for proxy provider priority: + * Level 1 — select a model family + * Level 2 — select preferred provider for that family + * + * Selecting a provider calls onChange(familyPrefix, provider) and closes via onDone. + * Pressing Escape on level 2 returns to level 1; Escape on level 1 calls onCancel. + */ +export class ProxyPrioritySubmenu extends Container { + private selectList!: SelectList; + + constructor( + private proxyFamilyProviders: Record, + private onChange: (familyPrefix: string, provider: string) => void, + private onDone: () => void, + private onCancel: () => void, + ) { + super(); + this.showFamilyList(); + } + + private clearAndRebuild(title: string, description: string, items: SelectItem[], onSelect: (item: SelectItem) => void, onEscape: () => void): void { + this.clear(); + this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); + if (description) { + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", description), 0, 0)); + } + this.addChild(new Spacer(1)); + this.selectList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme()); + this.selectList.onSelect = onSelect; + this.selectList.onCancel = onEscape; + this.addChild(this.selectList); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); + } + + private showFamilyList(): void { + const families = PROXY_FAMILY_PRIORITY.filter((r) => r.providers.length > 0); + const familyItems: SelectItem[] = families.map((r) => { + const current = this.proxyFamilyProviders[r.prefix] ?? r.providers[0]; + return { + value: r.prefix, + label: r.prefix.replace(/-+$/i, ""), + description: `Current: ${current}`, + }; + }); + this.clearAndRebuild( + "Proxy Priorities", + "Select a model family to configure its preferred provider", + familyItems, + (item) => this.showProviderList(item.value), + this.onCancel, + ); + } + + private showProviderList(familyPrefix: string): void { + const rule = PROXY_FAMILY_PRIORITY.find((r) => r.prefix === familyPrefix); + if (!rule) return; + const current = this.proxyFamilyProviders[familyPrefix] ?? rule.providers[0]; + const providerItems: SelectItem[] = rule.providers.map((p) => ({ + value: p, + label: p, + description: p === current ? "(current)" : "", + })); + this.clearAndRebuild( + `${familyPrefix.replace(/-+$/i, "")} — choose provider`, + `Preferred provider for ${familyPrefix}* proxy requests`, + providerItems, + (item) => { + this.proxyFamilyProviders = { ...this.proxyFamilyProviders, [familyPrefix]: item.value }; + this.onChange(familyPrefix, item.value); + this.showFamilyList(); // return to family list after selection + }, + () => this.showFamilyList(), // Esc → back to family list + ); + // Pre-select current provider + const currentIndex = providerItems.findIndex((p) => p.value === current); + if (currentIndex !== -1) this.selectList.setSelectedIndex(currentIndex); + } + + handleInput(data: string): void { + this.selectList.handleInput(data); + } +} + /** * Main settings selector component. */ @@ -367,6 +458,25 @@ export class SettingsSelectorComponent extends Container { values: ["date-time-iso", "date-time-us"], }); + // Single entry that opens a two-level submenu: family → provider + const timestampIndex = items.findIndex((item) => item.id === "timestamp-format"); + items.splice(timestampIndex + 1, 0, { + id: "proxy-priorities", + label: "Proxy priorities", + description: "Configure preferred provider per model family for proxy requests", + currentValue: "", + values: [""], + submenu: (_currentValue, done) => + new ProxyPrioritySubmenu( + config.proxyFamilyProviders, + (familyPrefix, provider) => { + callbacks.onProxyFamilyProviderChange(familyPrefix, provider); + }, + () => done(), + () => done(), + ), + }); + // Add borders this.addChild(new DynamicBorder()); diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 5e3506714..540543144 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -57,6 +57,7 @@ import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/ import { type AppAction, KeybindingsManager } from "../../core/keybindings.js"; import { createCompactionSummaryMessage } from "../../core/messages.js"; import { resolveModelScope } from "../../core/model-resolver.js"; +import { PROXY_FAMILY_PRIORITY } from "../../core/model-registry.js"; import type { ResourceDiagnostic } from "../../core/resource-loader.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js"; import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js"; @@ -2963,6 +2964,15 @@ export class InteractiveMode { quietStartup: this.settingsManager.getQuietStartup(), clearOnShrink: this.settingsManager.getClearOnShrink(), timestampFormat: this.settingsManager.getTimestampFormat(), + proxyFamilyProviders: (() => { + const overrides = this.settingsManager.getProxyProviderPriority(); + const result: Record = {}; + for (const rule of PROXY_FAMILY_PRIORITY) { + const top = overrides[rule.prefix]?.[0] ?? rule.providers[0]; + if (top) result[rule.prefix] = top; + } + return result; + })(), }, { onAutoCompactChange: (enabled) => { @@ -3069,6 +3079,13 @@ export class InteractiveMode { onTimestampFormatChange: (format) => { this.settingsManager.setTimestampFormat(format); }, + onProxyFamilyProviderChange: (familyPrefix, preferredProvider) => { + const rule = PROXY_FAMILY_PRIORITY.find((r) => r.prefix === familyPrefix); + if (!rule) return; + // Move selected provider to front, keep others in original order + const rest = rule.providers.filter((p) => p !== preferredProvider); + this.settingsManager.setProxyFamilyProvider(familyPrefix, [preferredProvider, ...rest]); + }, onCancel: () => { done(); this.ui.requestRender(); diff --git a/packages/pi-coding-agent/src/utils/proxy-server.ts b/packages/pi-coding-agent/src/utils/proxy-server.ts index 449d4eef4..cf6cfc521 100644 --- a/packages/pi-coding-agent/src/utils/proxy-server.ts +++ b/packages/pi-coding-agent/src/utils/proxy-server.ts @@ -15,6 +15,8 @@ export type ProxyServerOptions = { port: number; authStorage: AuthStorage; modelRegistry: ModelRegistry; + /** Per-family provider priority overrides from settings.proxy.providerPriority */ + priorityOverrides?: Record; onLog?: (msg: string) => void; }; @@ -30,6 +32,7 @@ export class ProxyServer { app.use(express.json()); const { authStorage, modelRegistry, onLog } = this.options; + const priorityOverrides = this.options.priorityOverrides ?? {}; const log = (msg: string) => onLog?.(msg); @@ -59,43 +62,55 @@ export class ProxyServer { const body = req.body; const isOpenAi = req.path.includes("/v1/chat/completions"); const modelId = isOpenAi ? body.model : req.params.modelId?.replace(/:streamGenerateContent$/, ""); - + if (!modelId) { return res.status(400).json({ error: "Model ID is required" }); } try { - // Resolve model and provider - const resolvedModel = modelRegistry.getModel(modelId); - if (!resolvedModel) { + const candidates = modelRegistry.getModelsForProxy(modelId, priorityOverrides); + if (candidates.length === 0) { return res.status(404).json({ error: `Model ${modelId} not found` }); } - // Resolve API key - const apiKey = await authStorage.getApiKey(resolvedModel.provider); - if (!apiKey) { - return res.status(401).json({ error: `No API key for provider ${resolvedModel.provider}. Use /login first.` }); - } - - // Normalize messages - const context: Context = isOpenAi + // Normalize messages once — shared across retry attempts + const context: Context = isOpenAi ? this.normalizeOpenAi(body) : this.normalizeGoogle(body); const streamOptions: StreamOptions = { - apiKey, temperature: body.temperature, maxTokens: isOpenAi ? body.max_tokens : body.generationConfig?.maxOutputTokens, }; - const eventStream = stream(resolvedModel as any, context, streamOptions); + for (const resolvedModel of candidates) { + const apiKey = await authStorage.getApiKey(resolvedModel.provider); + if (!apiKey) continue; // no credentials — try next - if (body.stream) { - this.handleStreamingResponse(eventStream, res, isOpenAi, modelId); - } else { - await this.handleStaticResponse(eventStream, res, isOpenAi, modelId); + const streamOptionsWithKey: StreamOptions = { ...streamOptions, apiKey }; + + try { + const eventStream = stream(resolvedModel as any, context, streamOptionsWithKey); + + if (body.stream) { + this.handleStreamingResponse(eventStream, res, isOpenAi, modelId); + } else { + await this.handleStaticResponse(eventStream, res, isOpenAi, modelId); + } + return; // success + } catch (err: any) { + const status = err?.status ?? err?.statusCode; + if (status === 429) { + log(`Provider ${resolvedModel.provider} rate-limited (429), trying next candidate`); + continue; + } + throw err; + } } + // All candidates exhausted + res.status(429).json({ error: `All providers rate-limited for model ${modelId}` }); + } catch (err: any) { log(`Proxy error: ${err.message}`); res.status(500).json({ error: err.message }); @@ -142,7 +157,7 @@ export class ProxyServer { private handleStreamingResponse(eventStream: any, res: express.Response, isOpenAi: boolean, modelId: string) { res.setHeader("Content-Type", isOpenAi ? "text/event-stream" : "application/json"); - + eventStream.on("data", (ev: any) => { if (ev.type === "text_delta") { if (isOpenAi) {