Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 51 additions & 6 deletions crates/agent-gateway/web/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1544,15 +1544,36 @@ export const translations: Record<Locale, Record<string, string>> = {
"settings.deleteConfirm": "确认删除",
"settings.deleteConfirmDesc": "此操作不可撤销。",
"settings.deleteConfirmYes": "确定删除",
"settings.customSettings": "自定义设置",
"settings.openCustomSettings": "打开自定义设置",
"settings.closeCustomSettings": "关闭自定义设置",
"settings.customSettings": "高级设置",
"settings.openCustomSettings": "打开高级设置",
"settings.closeCustomSettings": "关闭高级设置",
"settings.conversationTitleGeneration": "对话标题生成",
"settings.conversationTitleModel": "标题生成模型",
"settings.conversationTitleModelFollowCurrent": "使用当前对话模型",
"settings.conversationTitleModelHint": "未选择时,标题生成会使用当前对话使用的模型。",
"settings.customSettingsModelEmpty": "当前 Provider 未配置模型。",

/* ── Settings Model Failover ── */
"settings.failoverTitle": "自动故障转移",
"settings.failoverEnabled": "已开启",
"settings.failoverToggleHint":
"开启后,{vendor} 请求失败时会按队列顺序自动把当前对话的模型切换到下一个 {vendor} 供应商继续请求(模型不变,只换供应商),并在成功后停留在该供应商。仅在同厂商供应商之间转移,不会跨厂商。",
"settings.failoverQueueTitle": "故障转移队列",
"settings.failoverQueueHint":
"按优先级排列的 {vendor} 备用供应商(P1 优先)。当前供应商请求失败时,用同一个模型依次尝试;未激活该模型的供应商会被跳过。",
"settings.failoverQueueAdd": "选择供应商加入队列",
"settings.failoverQueueEmpty": "队列为空。添加至少一个备用供应商后,自动故障转移才会生效。",
"settings.failoverQueueMoveUp": "上移",
"settings.failoverQueueMoveDown": "下移",
"settings.failoverQueueRemove": "移除",
"settings.failoverMaxSwitches": "单次请求最大切换次数",
"settings.failoverMaxSwitchesHint": "一次请求失败后最多切换的备用供应商个数(1-10)。",
"settings.failoverFailureThreshold": "失败阈值",
"settings.failoverFailureThresholdHint": "连续失败多少次后熔断该供应商(建议 3-10)。",
"settings.failoverCooldownSeconds": "熔断冷却时间(秒)",
"settings.failoverCooldownSecondsHint":
"熔断后跳过该供应商的时长,到期后放行探测请求(5-3600 秒)。",

/* ── Settings Prompt ── */
"settings.agentsTitle": "全局提示词",
"settings.agentsDesc": "管理可复用的全局提示词模板",
Expand Down Expand Up @@ -3754,9 +3775,9 @@ export const translations: Record<Locale, Record<string, string>> = {
"settings.deleteConfirm": "Confirm Delete",
"settings.deleteConfirmDesc": "This action cannot be undone.",
"settings.deleteConfirmYes": "Delete",
"settings.customSettings": "Custom Settings",
"settings.openCustomSettings": "Open custom settings",
"settings.closeCustomSettings": "Close custom settings",
"settings.customSettings": "Advanced Settings",
"settings.openCustomSettings": "Open advanced settings",
"settings.closeCustomSettings": "Close advanced settings",
"settings.conversationTitleGeneration": "Conversation title generation",
"settings.conversationTitleModel": "Title generation model",
"settings.conversationTitleModelFollowCurrent": "Use current chat model",
Expand All @@ -3765,6 +3786,30 @@ export const translations: Record<Locale, Record<string, string>> = {
"settings.customSettingsModelEmpty":
"No active models are configured for the current providers.",

/* ── Settings Model Failover ── */
"settings.failoverTitle": "Auto Failover",
"settings.failoverEnabled": "Enabled",
"settings.failoverToggleHint":
"When a {vendor} request fails, automatically retry it on the next {vendor} provider in the queue with the same model (provider changes, model does not) and stay on the provider that answered. Failover never crosses vendors.",
"settings.failoverQueueTitle": "Failover queue",
"settings.failoverQueueHint":
"Fallback {vendor} providers in priority order (P1 first), tried with the conversation's current model when the active provider fails. Providers without that model active are skipped.",
"settings.failoverQueueAdd": "Add a provider to the queue",
"settings.failoverQueueEmpty":
"The queue is empty. Auto failover only takes effect after at least one fallback provider is added.",
"settings.failoverQueueMoveUp": "Move up",
"settings.failoverQueueMoveDown": "Move down",
"settings.failoverQueueRemove": "Remove",
"settings.failoverMaxSwitches": "Max switches per request",
"settings.failoverMaxSwitchesHint":
"Maximum number of fallback providers tried after a failed request (1-10).",
"settings.failoverFailureThreshold": "Failure threshold",
"settings.failoverFailureThresholdHint":
"Consecutive failures before a provider's circuit breaker opens (suggested 3-10).",
"settings.failoverCooldownSeconds": "Cooldown (seconds)",
"settings.failoverCooldownSecondsHint":
"How long an open circuit skips the provider before allowing a probe request (5-3600 seconds).",

/* ── Settings Prompt ── */
"settings.agentsTitle": "Prompt",
"settings.agentsDesc": "Manage reusable global prompt templates",
Expand Down
180 changes: 180 additions & 0 deletions crates/agent-gateway/web/src/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,62 @@ export type CustomSettings = {
fontScale: FontScaleSettings;
};

/**
* cc-switch style automatic provider failover: an ordered fallback queue of
* same-vendor *providers* tried when the active model's request fails with a
* provider-fault-class error, plus circuit breaker knobs mirroring cc-switch's
* 失败阈值/冷却时间 settings.
*
* Failover switches providers, never models (matching cc-switch): the failed
* request is re-sent to the next provider in the queue with the *same model
* id* the conversation was using. Providers that don't have that model active
* are skipped at plan time.
*
* Failover is scoped per vendor type (mirroring cc-switch's Claude/Codex/
* Gemini app tabs): a Claude request only fails over to Claude providers, a
* Codex request only to Codex providers, never across vendors.
*/
export type ProviderFailoverSettings = {
enabled: boolean;
/** Ordered fallback provider ids (P1 → P2 → …), same vendor type only. */
queue: string[];
/** Max provider switches per request (attempts = switches + 1). */
maxSwitches: number;
/** Consecutive failures before a target's circuit breaker opens. */
failureThreshold: number;
/** Seconds an open breaker skips its target before a half-open probe. */
cooldownSeconds: number;
};

/** Per-vendor failover settings, keyed by the provider tab type. */
export type ModelFailoverSettings = Record<ProviderId, ProviderFailoverSettings>;

export const MODEL_FAILOVER_QUEUE_LIMIT = 8;

export const PROVIDER_FAILOVER_TYPES: readonly ProviderId[] = [
"claude_code",
"codex",
"gemini",
"xai",
];

export const DEFAULT_PROVIDER_FAILOVER_SETTINGS: ProviderFailoverSettings = {
enabled: false,
queue: [],
maxSwitches: 3,
failureThreshold: 4,
cooldownSeconds: 60,
};

export function getDefaultModelFailoverSettings(): ModelFailoverSettings {
return {
claude_code: { ...DEFAULT_PROVIDER_FAILOVER_SETTINGS },
codex: { ...DEFAULT_PROVIDER_FAILOVER_SETTINGS },
gemini: { ...DEFAULT_PROVIDER_FAILOVER_SETTINGS },
xai: { ...DEFAULT_PROVIDER_FAILOVER_SETTINGS },
};
}

export type SystemProxyType = "socks5" | "http";

// 系统级出站代理:注入本地 shell 命令 env,并供勾选了 useSystemProxy 的
Expand Down Expand Up @@ -393,6 +449,7 @@ export type AppSettings = {
remote: RemoteSettings;
memory: MemorySettings;
customSettings: CustomSettings;
modelFailover: ModelFailoverSettings;
skills: SkillsSettings;
chatRuntimeControls: ChatRuntimeControls;
selectedModel?: SelectedModel;
Expand Down Expand Up @@ -2217,6 +2274,107 @@ export function normalizeCustomSettings(
};
}

function clampFailoverInteger(input: unknown, min: number, max: number, fallback: number): number {
const value =
typeof input === "number" && Number.isFinite(input)
? Math.round(input)
: typeof input === "string" && input.trim() !== ""
? Math.round(Number(input))
: Number.NaN;
if (!Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, value));
}

/**
* Normalizes one vendor's failover config. Queue entries must reference an
* existing provider of `providerType` — cross-vendor entries (e.g. a Codex
* provider inside the Claude queue) are dropped so failover can never mix
* vendors.
*
* Legacy entry migration: the queue used to hold {customProviderId, model}
* objects. Those collapse to their provider id (deduped), because failover now
* always re-sends the conversation's own model to the fallback provider.
*/
export function normalizeProviderFailoverSettings(
input: unknown,
customProviders: CustomProvider[],
providerType: ProviderId,
): ProviderFailoverSettings {
const obj = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
const defaults = DEFAULT_PROVIDER_FAILOVER_SETTINGS;

const queue: string[] = [];
const seen = new Set<string>();
if (Array.isArray(obj.queue)) {
for (const raw of obj.queue) {
const providerId =
typeof raw === "string"
? raw
: raw &&
typeof raw === "object" &&
typeof (raw as SelectedModel).customProviderId === "string"
? (raw as SelectedModel).customProviderId
: "";
if (!providerId) continue;
const provider = customProviders.find((item) => item.id === providerId);
if (!provider || provider.type !== providerType) continue;
if (seen.has(providerId)) continue;
seen.add(providerId);
queue.push(providerId);
if (queue.length >= MODEL_FAILOVER_QUEUE_LIMIT) break;
}
}

return {
// An enabled toggle with an empty queue is a harmless no-op at runtime;
// keep the user's toggle state instead of silently flipping it off.
enabled: obj.enabled === true,
queue,
maxSwitches: clampFailoverInteger(obj.maxSwitches, 1, 10, defaults.maxSwitches),
failureThreshold: clampFailoverInteger(obj.failureThreshold, 1, 10, defaults.failureThreshold),
cooldownSeconds: clampFailoverInteger(obj.cooldownSeconds, 5, 3600, defaults.cooldownSeconds),
};
}

/** True for the pre-per-vendor persisted shape ({enabled, queue, ...}). */
function isLegacyFlatModelFailoverShape(obj: Record<string, unknown>): boolean {
return (
!PROVIDER_FAILOVER_TYPES.some((type) => type in obj) &&
("enabled" in obj || "queue" in obj || "maxSwitches" in obj)
);
}

export function normalizeModelFailoverSettings(
input: unknown,
customProviders: CustomProvider[],
): ModelFailoverSettings {
const obj = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;

// Legacy migration: the old single global config becomes each vendor's
// config. Cross-vendor queue entries are filtered per tab by the per-vendor
// normalizer, so a mixed legacy queue splits cleanly into its vendors.
if (isLegacyFlatModelFailoverShape(obj)) {
const result = getDefaultModelFailoverSettings();
for (const type of PROVIDER_FAILOVER_TYPES) {
const migrated = normalizeProviderFailoverSettings(obj, customProviders, type);
// Only vendors that actually kept queue entries stay enabled; an empty
// migrated queue with enabled=true would surface confusing "on but
// empty" warnings on tabs the user never configured.
result[type] = {
...migrated,
enabled: migrated.enabled && migrated.queue.length > 0,
};
}
return result;
}

const result = getDefaultModelFailoverSettings();
for (const type of PROVIDER_FAILOVER_TYPES) {
result[type] = normalizeProviderFailoverSettings(obj[type], customProviders, type);
}
return result;
}

export function getDefaultSettings(): AppSettings {
const customProviders = getBuiltinCustomProviders();
return {
Expand Down Expand Up @@ -2255,6 +2413,7 @@ export function getDefaultSettings(): AppSettings {
},
memory: normalizeMemorySettings({}, customProviders),
customSettings: normalizeCustomSettings({}, customProviders),
modelFailover: normalizeModelFailoverSettings({}, customProviders),
skills: {
enabled: true,
selected: mergeAlwaysEnabledSkillNames([]),
Expand Down Expand Up @@ -2289,6 +2448,10 @@ export function normalizeSettings(input?: Partial<AppSettings> | null): AppSetti
obj.customSettings ?? defaults.customSettings,
customProviders,
),
modelFailover: normalizeModelFailoverSettings(
obj.modelFailover ?? defaults.modelFailover,
customProviders,
),
skills: normalizeSkillsSettings(obj.skills ?? defaults.skills),
chatRuntimeControls: normalizeChatRuntimeControls(
obj.chatRuntimeControls ?? defaults.chatRuntimeControls,
Expand Down Expand Up @@ -2436,6 +2599,23 @@ export function updateCustomSettings(
});
}

export function updateModelFailover(
prev: AppSettings,
providerType: ProviderId,
patch: Partial<ProviderFailoverSettings>,
): AppSettings {
return normalizeSettings({
...prev,
modelFailover: {
...prev.modelFailover,
[providerType]: {
...prev.modelFailover[providerType],
...patch,
},
},
});
}

const RIGHT_DOCK_WRITER_ID_STORAGE_KEY = "liveagent.client-id";

let cachedRightDockWriterId = "";
Expand Down
Loading
Loading