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:
parent
f92ee8d64c
commit
dc0db3868a
5 changed files with 260 additions and 20 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue