Add per-family proxy provider priority system with TUI and 429 fallback

- model-registry: export PROXY_FAMILY_PRIORITY and GLOBAL_PROVIDER_FALLBACK
  constants; add getModelsForProxy() returning candidates ordered by family
  priority then global fallback (opencode → opencode-go → openrouter →
  ollama-cloud); add getModel() convenience wrapper
- proxy-server: add priorityOverrides option; handleChat iterates all
  candidates in priority order and falls through to the next on 429
- settings-manager: add ProxySettings type with providerPriority override
  map; add getProxyProviderPriority() / setProxyFamilyProvider() accessors
- settings-selector: add ProxyPrioritySubmenu — a two-level TUI submenu
  (family → provider) that dynamically generates entries from
  PROXY_FAMILY_PRIORITY; wired in interactive-mode with full callback

Family defaults: MiniMax→minimax, GLM→zai, Kimi→kimi-coding,
MiMo→global-fallback, Gemini/Gemma→google-gemini-cli, Claude→anthropic,
GPT/o-series→openai

https://claude.ai/code/session_013BwmqG3NuwwZY3vsUb4Y9Y

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
mikkihugo 2026-04-17 19:17:50 +02:00 committed by GitHub
parent f92ee8d64c
commit dc0db3868a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 260 additions and 20 deletions

View file

@ -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, string[]>): 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<string, string[]> = {}): Model<Api>[] {
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<string, string[]> = {}): Model<Api> | 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).

View file

@ -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<string, string[]>;
}
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<string, string[]> {
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).

View file

@ -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<string, string>;
}
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<string, string>,
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());

View file

@ -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<string, string> = {};
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();

View file

@ -15,6 +15,8 @@ export type ProxyServerOptions = {
port: number;
authStorage: AuthStorage;
modelRegistry: ModelRegistry;
/** Per-family provider priority overrides from settings.proxy.providerPriority */
priorityOverrides?: Record<string, string[]>;
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) {