This commit is contained in:
zhayujie committed 2026-09-12 14:56:36 +08:00
commit bda0505302
14 files changed
+1694 -318

No files matched your search

+89 -9
View File
@@ -1304,7 +1304,7 @@ class AgentStreamExecutor:
logger.debug(f"[Agent] fallback reset skipped: {e}")
def _switch_to_fallback(self, fallback_reason: str = "") -> bool:
"""Try to reroute the rest of this turn onto the configured fallback.
"""Advance this turn to the next link in the configured fallback chain.
Returns True only when the switch actually happened, so the caller can
retry the turn on the new model. Every guard lives in
@@ -1312,6 +1312,10 @@ class AgentStreamExecutor:
where there is no such model to ask (tests pass doubles, and the
fallback is opt-in so a plain LLMModel simply has no support for it).
Calling it again after a link has failed advances one more step along
the chain, which is what turns a single backup into an ordered list of
them; it returns False once the chain is spent.
Context-overflow and message-format errors are deliberately NOT
candidates: they are caused by the conversation, not the provider, and
the recovery paths above already rewrite the history for them.
@@ -1327,7 +1331,9 @@ class AgentStreamExecutor:
return False
def _call_llm_stream(self, retry_on_empty=True, retry_count=0, max_retries=3,
_overflow_stage: int = 0) -> Tuple[str, List[Dict], Optional[str]]:
_overflow_stage: int = 0,
_on_fallback: bool = False,
_exhausted: Optional[List[str]] = None) -> Tuple[str, List[Dict], Optional[str]]:
"""
Call LLM with streaming and automatic retry on errors
@@ -1337,6 +1343,15 @@ class AgentStreamExecutor:
max_retries: Maximum number of retries for API errors
_overflow_stage: Context-overflow recovery escalation level (internal):
0 = first hit, 1 = after aggressive trim, 2 = after hard compaction.
_on_fallback: Whether this attempt is already running on a fallback
link from the chain. A link gets one attempt, not the primary's
full retry budget: walking a long chain with full backoff each
would outlast the web channel's SSE idle timeout before the
last link is even reached.
_exhausted: Errors already collected from models this turn has
given up on. Reported together when the whole chain is spent,
so the failure reads as "every backup is down" rather than
blaming whichever link happened to be last.
Returns:
(response_text, tool_calls, stop_reason), where stop_reason is the
@@ -1594,6 +1609,8 @@ class AgentStreamExecutor:
retry_count=retry_count,
max_retries=max_retries,
_overflow_stage=1,
_on_fallback=_on_fallback,
_exhausted=_exhausted,
)
# Trimming exhausted, or this is a message format error.
@@ -1623,7 +1640,42 @@ class AgentStreamExecutor:
'429', '500', '502', '503', '504', '512'
])
if is_retryable and retry_count < max_retries:
# A fallback link gets a single attempt. Giving every link the
# primary's full retry budget would multiply the wait by the chain
# length — three rate-limited links alone would sleep past the web
# channel's SSE idle timeout (see RATE_LIMIT_MAX_WAIT above) and
# the user would see a dropped connection instead of a reply.
# Links are tried in order precisely because the whole point is to
# move on to a different provider, not to wait out this one.
link_retries = 0 if _on_fallback else max_retries
# A rate limit is the one error where waiting is the wrong move
# *when a backup exists*: the backup is a different provider with
# its own quota, so switching can answer now instead of after a
# 30-60s sleep. With no chain configured there is nothing to switch
# to, so keep the old behaviour and wait it out rather than
# failing the turn outright.
switch_now = False
if is_rate_limit and not _on_fallback:
available = getattr(self.model, "fallback_available", None)
if callable(available):
try:
switch_now = bool(available())
except Exception:
switch_now = False
if switch_now:
logger.warning(
"⚠️ Rate limited (429) and a fallback is configured: "
"switching now instead of waiting it out"
)
# Retrying is only worth it when we are going to wait: either a
# plain retryable error, or a rate limit with no backup to move to.
# (A rate limit WITH a backup skips straight to the chain below.)
should_retry = (is_retryable and not switch_now
and retry_count < link_retries)
if should_retry:
# Rate limit needs longer wait time, but capped so the cumulative
# backoff stays within the web stream's idle timeout (see the
# RATE_LIMIT_MAX_WAIT note above).
@@ -1632,19 +1684,26 @@ class AgentStreamExecutor:
else:
wait_time = (retry_count + 1) * 2 # 2s, 4s, 6s for other errors
logger.warning(f"⚠️ LLM API error (attempt {retry_count + 1}/{max_retries}): {e}")
logger.warning(f"⚠️ LLM API error (attempt {retry_count + 1}/{link_retries}): {e}")
logger.info(f"Retrying in {wait_time}s...")
time.sleep(wait_time)
return self._call_llm_stream(
retry_on_empty=retry_on_empty,
retry_count=retry_count + 1,
max_retries=max_retries
max_retries=max_retries,
_overflow_stage=_overflow_stage,
_on_fallback=_on_fallback,
_exhausted=_exhausted,
)
# Retries are exhausted (or the error was never retryable). Before
# surfacing the failure, try the configured fallback model once —
# a provider-wide outage is exactly what retrying the same endpoint
# can never fix.
# surfacing the failure, advance to the next link in the configured
# fallback chain — a provider-wide outage is exactly what retrying
# the same endpoint can never fix, and a backup that is also down
# earns the link after it.
model_label = getattr(self.model, "model", "") or "primary"
spent = list(_exhausted or [])
spent.append(f"{model_label}: {error_str}")
if self._switch_to_fallback(fallback_reason=error_str):
self._emit_event("model_fallback", {
"reason": error_str,
@@ -1654,8 +1713,26 @@ class AgentStreamExecutor:
retry_on_empty=retry_on_empty,
retry_count=0, # fresh attempt on the new model
max_retries=max_retries,
_on_fallback=True, # one attempt, then the next link
_exhausted=spent,
)
# Every link is spent (or none was configured). Report the whole
# chain rather than the last error: naming only the final link
# reads as "that one model is broken" when in fact each one was
# tried and each one failed.
if spent and len(spent) > 1:
detail = " | ".join(spent)
logger.error(
f"❌ LLM call failed across {len(spent)} models "
f"(primary + fallback chain): {detail}"
)
raise Exception(_t(
"抱歉,主模型与全部兜底模型均调用失败,请检查模型配置或供应商状态。",
"Sorry, the main model and every fallback model failed. "
"Please check your model configuration or provider status.",
) + f"\n{detail}")
if retry_count >= max_retries:
logger.error(f"❌ LLM API error after {max_retries} retries: {e}", exc_info=True)
else:
@@ -1759,7 +1836,10 @@ class AgentStreamExecutor:
return self._call_llm_stream(
retry_on_empty=False,
retry_count=retry_count,
max_retries=max_retries
max_retries=max_retries,
_overflow_stage=_overflow_stage,
_on_fallback=_on_fallback,
_exhausted=_exhausted,
)
# Filter full_content one more time (in case tags were split across chunks)
+91 -37
View File
@@ -109,6 +109,10 @@ class AgentLLMModel(LLMModel):
# failed a turn for good. Declared here (not in __init__) for the same
# reason as the fields above: `model` is read on every call, including on
# instances built with __new__.
#
# The fallback is a chain: `_fallback_depth` is the index of the link the
# run currently sits on, so a run that has burned through link 0 and is on
# link 1 can still advance to link 2 when that one fails too.
_fallback_model = None
_fallback_provider = None
_fallback_depth = 0
@@ -139,56 +143,105 @@ class AgentLLMModel(LLMModel):
pass
def fallback_config(self) -> dict:
"""Return the configured chat fallback, normalized.
"""Return the configured fallback chain, normalized.
A non-dict or disabled entry yields empty provider/model so callers can
treat "not usable" as a single check.
A non-dict or disabled entry yields an empty chain so callers can treat
"not usable" as a single check. Links missing a provider or a model are
dropped — half a link could route the turn nowhere — as are duplicates
of the primary model or of an earlier link, which would only re-probe a
model the run has already proven is down.
The chain is unbounded: however many links the user configured is how
many switches a turn gets. There is no separate cap.
"""
raw = conf().get("chat_fallback")
empty = {"chain": []}
if not isinstance(raw, dict) or not raw.get("enabled"):
return {"provider": "", "model": "", "max_switches": 0}
try:
max_switches = int(raw.get("max_switches") or 0)
except (TypeError, ValueError):
max_switches = 0
return {
"provider": (raw.get("provider") or "").strip(),
"model": (raw.get("model") or "").strip(),
"max_switches": max(0, max_switches),
}
return empty
raw_chain = raw.get("chain")
if not isinstance(raw_chain, list):
# Pre-chain shape ({provider, model}): config._migrate_chat_fallback
# normally upgrades it at load time, so reaching here means a
# caller handed us the raw dict. Honor it rather than dropping the
# user's backup model.
raw_chain = [raw]
primary = (self._session_model or self._agent_model
or conf().get("model") or const.DEFAULT_MODEL)
primary_provider = (self._session_provider or self._agent_provider or "")
chain = []
seen = {(primary_provider, (primary or "").strip())}
# The global model carries no provider (session/agent overrides do), so
# a provider+model comparison alone would miss the most common
# misconfiguration: listing the primary model as its own backup. Match
# on the model name too when no provider was pinned.
primary_model_only = (primary or "").strip() if not primary_provider else None
for item in raw_chain:
if not isinstance(item, dict):
continue
provider = (item.get("provider") or "").strip()
model = (item.get("model") or "").strip()
if not provider or not model:
continue
key = (provider, model)
if key in seen or (primary_model_only and model == primary_model_only):
continue
seen.add(key)
chain.append({"provider": provider, "model": model})
return {"chain": chain}
# How many times one turn may walk the whole chain before the failure is
# reported. Two passes rather than one because a pass takes real time: by
# the time the walk comes back around to a link that was rate limited, the
# window may well have cleared. A third pass would mostly re-probe an
# outage that is not going to clear inside a single turn.
_FALLBACK_MAX_PASSES = 2
def fallback_available(self) -> bool:
"""Whether this run can still switch to the fallback model."""
if self._fallback_model:
return False # already on it; the fallback is sticky for the run
cfg = self.fallback_config()
if not cfg["provider"] or not cfg["model"]:
return False # half-configured means "off"
return self._fallback_depth < max(1, cfg["max_switches"])
"""Whether this run can still advance along the fallback chain.
True while link attempts remain inside the pass budget. Unlike the old
single-model fallback, sitting on a link is not the end: a backup that
fails for good earns the next one, which is the point of having a
chain — and reaching the last link wraps back to the first instead of
ending the turn.
"""
chain = self.fallback_config()["chain"]
return self._fallback_depth < len(chain) * self._FALLBACK_MAX_PASSES
def use_fallback(self) -> bool:
"""Switch the rest of this run onto the configured fallback model.
"""Advance the rest of this run onto the next fallback model.
Returns True when the switch happened. Called after the primary model
has failed a turn for good (retries exhausted), never mid-retry. The
switch is sticky: once engaged, every remaining step of the run runs on
the backup (``model`` returns ``_fallback_model``), so a sustained
outage isn't re-probed on the primary once per step. reset_fallback()
clears it at the start of the next run.
Returns True when the switch happened. Called after the *current*
model has failed a turn for good (retries exhausted), never mid-retry —
for the primary that is the end of its own retries, and for a fallback
link the single attempt it is granted.
The switch is sticky in the sense that the run stays on whichever link
answered, so a sustained outage isn't re-probed once per step; but a
link that fails advances to the next one rather than giving up.
reset_fallback() returns the run to the primary at the start of the
next one.
"""
if not self.fallback_available():
return False
cfg = self.fallback_config()
self._fallback_provider = cfg["provider"]
self._fallback_model = cfg["model"]
chain = self.fallback_config()["chain"]
# Wrap around: `_fallback_depth` counts attempts, not links, so the
# second pass re-tries link 0. A turn that reaches the end of the chain
# has not run out of options — the walk took long enough that a rate
# limit hit on the first pass may have cleared by now.
link = chain[self._fallback_depth % len(chain)]
self._fallback_provider = link["provider"]
self._fallback_model = link["model"]
self._fallback_depth += 1
# Drop the cached primary bot; `bot` rebuilds it for the new routing.
self._bot = None
self._bot_model = None
self._bot_type = None
total = len(chain) * self._FALLBACK_MAX_PASSES
logger.warning(
"[AgentLLMModel] primary model failed; falling back to "
f"{cfg['provider']}/{cfg['model']} (switch {self._fallback_depth})"
"[AgentLLMModel] current model failed; falling back to "
f"{link['provider']}/{link['model']} "
f"(link {self._fallback_depth}/{total})"
)
return True
@@ -196,16 +249,17 @@ class AgentLLMModel(LLMModel):
"""Return to the primary model — call once at the start of a run.
A new user message always starts fresh on the primary; within a run the
fallback stays engaged (see use_fallback). Clearing the switch counter
here — not mid-run — is what lets the *next* run fall back again, while
bounding the current run to ``max_switches`` switches total.
fallback stays engaged on whichever link answered (see use_fallback).
Rewinding the chain index here — not mid-run — is what lets the *next*
run walk the chain again from the front.
"""
if self._fallback_model is None:
return
self._fallback_model = None
self._fallback_provider = None
# Back to zero: the next run starts fresh on the primary model, and if
# it fails again it is a new failure that earns a new switch.
# Back to the front of the chain: the next run starts on the primary
# model, and if it fails again it is a new failure that earns a new
# walk through the links.
self._fallback_depth = 0
self._bot = None
self._bot_model = None
+367 -9
View File
@@ -126,8 +126,17 @@ const I18N = {
models_fallback_config: '兜底模型',
models_fallback_config_tip: '配置主模型兜底:主模型彻底失败后接管',
models_fallback_modal_title: '主模型兜底',
models_fallback_modal_desc: '当主模型重试次数用尽仍然失败时,自动切换到兜底模型完成本轮回复',
models_fallback_modal_desc: '当主模型重试次数用尽仍然失败时,按顺序尝试兜底链上的下一个模型,直到某个成功或全部失败',
models_fallback_badge_on: '兜底已启用',
models_fallback_chain_title: '兜底链(按顺序尝试)',
models_fallback_chain_desc: '第 1 个失败就试第 2 个,依次推进;链越长,一轮可用的备选越多',
models_fallback_chain_add: '添加兜底模型',
models_fallback_chain_empty: '还没有兜底模型,点下方按钮添加一个',
models_fallback_chain_link: '兜底 {{n}}',
models_fallback_chain_move_up: '上移',
models_fallback_chain_move_down: '下移',
models_fallback_chain_remove: '移除',
models_fallback_chain_incomplete: '启用兜底至少需要一条完整的「厂商 + 模型」',
models_capability_vision: '图像理解',
models_capability_vision_desc: '识别图片内容,用于图像识别工具',
models_capability_image: '图像生成',
@@ -624,8 +633,17 @@ const I18N = {
models_fallback_config: '兜底模型',
models_fallback_config_tip: '設定主模型兜底:主模型徹底失敗後接管',
models_fallback_modal_title: '主模型兜底',
models_fallback_modal_desc: '當主模型重試次數用盡仍然失敗時,自動切換到兜底模型完成本輪回覆',
models_fallback_modal_desc: '當主模型重試次數用盡仍然失敗時,依序嘗試兜底鏈上的下一個模型,直到某個成功或全部失敗',
models_fallback_badge_on: '兜底已啟用',
models_fallback_chain_title: '兜底鏈(依序嘗試)',
models_fallback_chain_desc: '第 1 個失敗就試第 2 個,依序推進;鏈越長,一輪可用的備選越多',
models_fallback_chain_add: '新增兜底模型',
models_fallback_chain_empty: '還沒有兜底模型,點下方按鈕新增一個',
models_fallback_chain_link: '兜底 {{n}}',
models_fallback_chain_move_up: '上移',
models_fallback_chain_move_down: '下移',
models_fallback_chain_remove: '移除',
models_fallback_chain_incomplete: '啟用兜底至少需要一條完整的「廠商 + 模型」',
models_capability_vision: '影像理解',
models_capability_vision_desc: '識別圖片內容,用於影像識別工具',
models_capability_image: '影像生成',
@@ -1117,8 +1135,17 @@ const I18N = {
models_fallback_config: 'Fallback',
models_fallback_config_tip: 'Configure the main-model fallback (takes over after the main model fails)',
models_fallback_modal_title: 'Main Model Fallback',
models_fallback_modal_desc: 'When the main model still fails after exhausting its retries, automatically switch to the fallback model to finish the reply.',
models_fallback_modal_desc: 'When the main model still fails after exhausting its retries, the next model in the fallback chain is tried in order until one succeeds or the chain runs out.',
models_fallback_badge_on: 'Fallback on',
models_fallback_chain_title: 'Fallback chain (tried in order)',
models_fallback_chain_desc: 'If the first one fails, the second is tried, and so on — the longer the chain, the more backups a turn has',
models_fallback_chain_add: 'Add a fallback model',
models_fallback_chain_empty: 'No fallback models yet — add one below',
models_fallback_chain_link: 'Fallback {{n}}',
models_fallback_chain_move_up: 'Move up',
models_fallback_chain_move_down: 'Move down',
models_fallback_chain_remove: 'Remove',
models_fallback_chain_incomplete: 'Enabling the fallback needs at least one complete provider + model entry',
models_capability_vision: 'Image Understanding',
models_capability_vision_desc: 'Recognizes image content, used by image recognition tools',
models_capability_image: 'Image Generation',
@@ -10847,6 +10874,251 @@ const CHAT_FALLBACK_DEF = {
titleKey: 'models_fallback_modal_title', descKey: 'models_capability_chat_fallback_desc',
};
// ---------- Fallback chain editor -------------------------------------
//
// The fallback is an ordered list of provider+model links rather than a single
// backup model, so the modal renders one row per link with up/down/remove
// controls instead of a single provider + model pickers. Each row reuses the
// same initDropdown machinery the capability cards use, keyed off
// `fb-chain-<i>-*` so nothing collides with the shared capability ids.
// Working copy of the chain while the modal is open. Persisting is a separate
// act (Save), so a user can reorder freely and cancel without writing.
let fallbackChainDraft = [];
function _fallbackChainFromCapability() {
const cap = modelsState.capabilities.chat_fallback || {};
if (Array.isArray(cap.chain) && cap.chain.length) {
return cap.chain.map(l => ({ provider: l.provider || '', model: l.model || '' }));
}
// Older backend (or a pre-chain config): a single backup model.
if (cap.current_provider || cap.current_model) {
return [{ provider: cap.current_provider || '', model: cap.current_model || '' }];
}
return [];
}
function _fallbackProviderOptions() {
const cap = modelsState.capabilities.chat_fallback || {};
const ids = (cap.providers && cap.providers.length)
? cap.providers.slice()
: modelsState.providers.map(p => p.id);
const byId = {};
modelsState.providers.forEach(p => { byId[p.id] = p; });
return ids.map(pid => ({
value: pid,
label: (byId[pid] && localizedLabel(byId[pid].label)) || pid,
}));
}
// Model list for one row, mirroring rebuildCapabilityModelDropdown's
// provider_models -> provider.models fallback.
function _fallbackModelOptions(providerId) {
const cap = modelsState.capabilities.chat_fallback || {};
const map = cap.provider_models || {};
let raw = map[providerId];
if (!raw && providerId.startsWith('custom:') && map['custom']) raw = map['custom'];
if (!raw) {
const p = modelsState.providers.find(x => x.id === providerId);
raw = (p && p.models) ? p.models : [];
}
return raw.map(e => {
const v = (typeof e === 'string') ? e : e.value;
return { value: v, label: (typeof e === 'string') ? e : (e.label || v) };
});
}
function _readFallbackChainRow(i) {
const provDd = document.getElementById(`fb-chain-${i}-provider`);
const modelDd = document.getElementById(`fb-chain-${i}-model`);
const customInput = document.getElementById(`fb-chain-${i}-model-custom`);
let model = modelDd ? getDropdownValue(modelDd) : '';
if (model === '__custom__') model = customInput ? customInput.value.trim() : '';
return {
provider: provDd ? getDropdownValue(provDd) : '',
model: model,
};
}
function _syncFallbackChainDraft() {
// Pull every visible row into the draft so a reorder keeps the values the
// user has typed but not yet committed to it.
fallbackChainDraft = fallbackChainDraft.map((_, i) => {
if (!document.getElementById(`fb-chain-${i}-provider`)) return fallbackChainDraft[i];
return _readFallbackChainRow(i);
});
}
function addFallbackChainLink() {
_syncFallbackChainDraft();
// Seed a new row from the first provider with a model, so it is one click
// from usable instead of two.
const opts = _fallbackProviderOptions();
const first = opts[0];
const models = first && first.value ? _fallbackModelOptions(first.value) : [];
fallbackChainDraft.push({
provider: first ? first.value : '',
model: models.length ? models[0].value : '',
});
renderFallbackChainEditor();
}
function removeFallbackChainLink(i) {
_syncFallbackChainDraft();
fallbackChainDraft.splice(i, 1);
renderFallbackChainEditor();
}
function moveFallbackChainLink(i, delta) {
const j = i + delta;
if (j < 0 || j >= fallbackChainDraft.length) return;
_syncFallbackChainDraft();
const tmp = fallbackChainDraft[i];
fallbackChainDraft[i] = fallbackChainDraft[j];
fallbackChainDraft[j] = tmp;
renderFallbackChainEditor();
}
function onFallbackChainProviderChange(i, providerId) {
// Keep the draft in sync, then rebuild just this row's model picker.
const modelDd = document.getElementById(`fb-chain-${i}-model`);
const customWrap = document.getElementById(`fb-chain-${i}-model-custom-wrap`);
const models = _fallbackModelOptions(providerId);
const opts = models.concat([{
value: '__custom__',
label: currentLang === 'zh' ? '自定义' : 'Custom',
}]);
if (modelDd) {
initDropdown(modelDd, opts, models.length ? models[0].value : '', (value) => {
if (!customWrap) return;
if (value === '__custom__') customWrap.classList.remove('hidden');
else customWrap.classList.add('hidden');
});
}
if (customWrap) customWrap.classList.add('hidden');
// The row just became usable (provider + a seeded model), so let the save
// button re-evaluate.
refreshFallbackChainSaveState();
}
function renderFallbackChainEditor() {
const host = document.getElementById('fb-chain-list');
if (!host) return;
const rows = fallbackChainDraft;
if (!rows.length) {
host.innerHTML = `<p class="text-xs text-slate-400 dark:text-slate-500">${escapeHtml(t('models_fallback_chain_empty'))}</p>`;
// No rows left means nothing usable to save. Refresh before returning
// — the save button's state is derived from the draft, and removing the
// last row is exactly when it has to flip back to disabled.
refreshFallbackChainSaveState();
return;
}
host.innerHTML = rows.map((link, i) => `
<div class="rounded-xl border border-slate-200 dark:border-white/10 p-3 space-y-2.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs font-medium text-slate-500 dark:text-slate-400">
${escapeHtml(t('models_fallback_chain_link').replace('{{n}}', String(i + 1)))}
</span>
<div class="flex items-center gap-1">
<button type="button" title="${escapeHtml(t('models_fallback_chain_move_up'))}"
onclick="moveFallbackChainLink(${i}, -1)"
${i === 0 ? 'disabled' : ''}
class="px-1.5 py-1 rounded text-slate-400 hover:text-slate-700 dark:hover:text-slate-200
hover:bg-slate-100 dark:hover:bg-white/5 cursor-pointer transition-colors
disabled:opacity-30 disabled:cursor-not-allowed">
<i class="fas fa-arrow-up text-[11px]"></i>
</button>
<button type="button" title="${escapeHtml(t('models_fallback_chain_move_down'))}"
onclick="moveFallbackChainLink(${i}, 1)"
${i === rows.length - 1 ? 'disabled' : ''}
class="px-1.5 py-1 rounded text-slate-400 hover:text-slate-700 dark:hover:text-slate-200
hover:bg-slate-100 dark:hover:bg-white/5 cursor-pointer transition-colors
disabled:opacity-30 disabled:cursor-not-allowed">
<i class="fas fa-arrow-down text-[11px]"></i>
</button>
<button type="button" title="${escapeHtml(t('models_fallback_chain_remove'))}"
onclick="removeFallbackChainLink(${i})"
class="px-1.5 py-1 rounded text-slate-400 hover:text-danger
hover:bg-danger/10 cursor-pointer transition-colors">
<i class="fas fa-trash-can text-[11px]"></i>
</button>
</div>
</div>
<div id="fb-chain-${i}-provider" class="cfg-dropdown" tabindex="0">
<div class="cfg-dropdown-selected">
<span class="cfg-dropdown-text">--</span>
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
</div>
<div class="cfg-dropdown-menu"></div>
</div>
<div id="fb-chain-${i}-model" class="cfg-dropdown" tabindex="0">
<div class="cfg-dropdown-selected">
<span class="cfg-dropdown-text">--</span>
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
</div>
<div class="cfg-dropdown-menu"></div>
</div>
<div id="fb-chain-${i}-model-custom-wrap" class="hidden">
<input id="fb-chain-${i}-model-custom" type="text"
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
focus:outline-none focus:border-primary-500 font-mono transition-colors"
placeholder="custom model name">
</div>
</div>`).join('');
rows.forEach((link, i) => {
const provDd = document.getElementById(`fb-chain-${i}-provider`);
if (provDd) {
initDropdown(provDd, _fallbackProviderOptions(), link.provider || '',
(value) => onFallbackChainProviderChange(i, value));
}
const models = _fallbackModelOptions(link.provider || '');
const inCatalog = models.some(o => o.value === link.model);
const modelDd = document.getElementById(`fb-chain-${i}-model`);
const customWrap = document.getElementById(`fb-chain-${i}-model-custom-wrap`);
const customInput = document.getElementById(`fb-chain-${i}-model-custom`);
if (modelDd) {
initDropdown(modelDd, models.concat([{
value: '__custom__',
label: currentLang === 'zh' ? '自定义' : 'Custom',
}]), inCatalog ? link.model : (link.model ? '__custom__' : ''), (value) => {
if (!customWrap) return;
if (value === '__custom__') customWrap.classList.remove('hidden');
else customWrap.classList.add('hidden');
});
}
if (!inCatalog && link.model) {
if (customWrap) customWrap.classList.remove('hidden');
if (customInput) customInput.value = link.model;
}
});
// Every add / remove / reorder re-renders the rows, so refresh the save
// button here rather than at each call site: a row added after the toggle
// was switched on would otherwise leave Save stuck on its previous state.
refreshFallbackChainSaveState();
}
// Whether at least one row is usable — the backend rejects an empty chain
// when the fallback is being enabled, so block Save here too.
function fallbackChainIsComplete() {
_syncFallbackChainDraft();
return fallbackChainDraft.some(l => l.provider && l.model);
}
function refreshFallbackChainSaveState() {
const cap = modelsState.capabilities.chat_fallback || {};
const btn = document.getElementById('fb-chain-save');
const hint = document.getElementById('fb-chain-incomplete-hint');
if (!btn) return;
const needsChain = !!cap.enabled;
btn.disabled = needsChain && !fallbackChainIsComplete();
if (hint) {
hint.classList.toggle('hidden', !(needsChain && btn.disabled));
}
}
// Resolve a capability def by id. The chat fallback is intentionally absent
// from MODELS_CAPABILITY_DEFS (it renders in a modal, not as a card), so the
// shared save/toggle handlers look it up here too.
@@ -10858,6 +11130,11 @@ function capabilityDefById(capId) {
function openChatFallbackModal() {
closeChatFallbackModal(); // never stack two
// Read the capability *before* building the markup: the template below
// renders the toggle and the chain from it, so a `cap` declared after the
// innerHTML would still be in its temporal dead zone and throw.
const cap = modelsState.capabilities.chat_fallback || {};
const overlay = document.createElement('div');
overlay.id = 'chat-fallback-modal-overlay';
overlay.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4';
@@ -10876,7 +11153,42 @@ function openChatFallbackModal() {
<i class="fas fa-xmark"></i>
</button>
</div>
<div class="px-6 py-5 space-y-4" data-cap-body="chat_fallback"></div>
<div class="px-6 py-5 space-y-4 max-h-[70vh] overflow-y-auto" data-cap-body="chat_fallback">
<div id="cap-chat_fallback-toggle-wrap" class="flex items-center justify-between gap-3">
<label class="text-sm font-medium text-slate-600 dark:text-slate-400">${escapeHtml(t('models_fallback_enable'))}</label>
<button type="button" id="cap-chat_fallback-toggle" role="switch"
aria-checked="${cap.enabled ? 'true' : 'false'}"
onclick="toggleChatFallbackEnabled()"
class="relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors cursor-pointer ${cap.enabled ? 'bg-primary-500' : 'bg-slate-200 dark:bg-slate-700'}">
<span class="inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${cap.enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}"></span>
</button>
</div>
<div id="fb-chain-wrap" class="space-y-3 ${cap.enabled ? '' : 'hidden'}">
<div>
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">${escapeHtml(t('models_fallback_chain_title'))}</label>
<p class="text-xs text-slate-400 dark:text-slate-500 leading-relaxed">${escapeHtml(t('models_fallback_chain_desc'))}</p>
</div>
<div id="fb-chain-list" class="space-y-2.5"></div>
<button type="button" onclick="addFallbackChainLink()"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs
text-primary-600 dark:text-primary-300 bg-primary-50 dark:bg-primary-900/30
hover:bg-primary-100 dark:hover:bg-primary-900/50 cursor-pointer transition-colors">
<i class="fas fa-plus text-[11px]"></i>${escapeHtml(t('models_fallback_chain_add'))}
</button>
<p id="fb-chain-incomplete-hint" class="text-xs text-danger hidden">${escapeHtml(t('models_fallback_chain_incomplete'))}</p>
</div>
<div class="flex items-center justify-between gap-3 pt-1">
<div class="flex-1 min-w-0"></div>
<div class="flex items-center gap-3 flex-shrink-0">
<span id="cap-chat_fallback-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
<button id="fb-chain-save" onclick="saveCapability('chat_fallback')"
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed">
${escapeHtml(t('save'))}
</button>
</div>
</div>
</div>
</div>`;
// Close on backdrop click (but not when clicking inside the dialog).
@@ -10884,9 +11196,36 @@ function openChatFallbackModal() {
document.body.appendChild(overlay);
// Reset the draft each time the modal opens so a cancelled edit never
// leaks into the next open.
fallbackChainDraft = _fallbackChainFromCapability();
renderFallbackChainEditor();
refreshFallbackChainSaveState();
}
// The chain modal owns its own toggle (the shared capability body renders the
// picker rows instead), so it needs its own flip handler: toggle the local
// switch, show/hide the chain, and re-check whether Save is allowed.
function toggleChatFallbackEnabled() {
const cap = modelsState.capabilities.chat_fallback || {};
const body = overlay.querySelector('[data-cap-body="chat_fallback"]');
renderCapabilityBody(CHAT_FALLBACK_DEF, cap, body);
cap.enabled = !cap.enabled;
modelsState.capabilities.chat_fallback = cap;
const btn = document.getElementById('cap-chat_fallback-toggle');
if (btn) {
btn.setAttribute('aria-checked', cap.enabled ? 'true' : 'false');
btn.classList.toggle('bg-primary-500', cap.enabled);
btn.classList.toggle('bg-slate-200', !cap.enabled);
btn.classList.toggle('dark:bg-slate-700', !cap.enabled);
const knob = btn.querySelector('span');
if (knob) {
knob.classList.toggle('translate-x-[18px]', cap.enabled);
knob.classList.toggle('translate-x-[3px]', !cap.enabled);
}
}
const wrap = document.getElementById('fb-chain-wrap');
if (wrap) wrap.classList.toggle('hidden', !cap.enabled);
refreshFallbackChainSaveState();
}
function closeChatFallbackModal() {
@@ -12051,14 +12390,16 @@ function saveCapability(capId) {
// Search has its own form (strategy + provider, no model picker).
if (capId === 'search') { saveSearchCapability(); return; }
const provDd = document.getElementById(`cap-${capId}-provider`);
const provider = provDd ? getDropdownValue(provDd) : '';
let provider = provDd ? getDropdownValue(provDd) : '';
// When the user is in auto mode (provider == ""), the model picker is
// hidden and any value left in it is stale; persist an empty model so
// the backend treats this as "fall back to the runtime chain".
const isAuto = provider === '' && capabilitySupportsAuto(capId);
// Embedding without a provider similarly means "cleared" — don't leak
// a stale model value into config.
const model = (isAuto || (capId === 'embedding' && !provider)) ? '' : getCapabilityModelValue(def);
// Declared with `let` because the chat fallback branch clears both values
// below: it posts an ordered chain instead of a single provider/model pair.
let model = (isAuto || (capId === 'embedding' && !provider)) ? '' : getCapabilityModelValue(def);
// TTS carries an extra voice timbre (supports free-text custom ids).
let voice = '';
if (capId === 'tts' && !isAuto) {
@@ -12122,7 +12463,21 @@ function saveCapability(capId) {
// lands so the user drops straight back to the models page (already
// reloaded by _persistCapability, which refreshes the main-card badge).
const onAfterSuccess = capId === 'chat_fallback' ? closeChatFallbackModal : undefined;
_persistCapability(capId, provider, model, onAfterSuccess, { voice, enabled });
// The fallback is an ordered chain rather than one provider/model pair,
// so it posts the rows the modal is showing instead of the pickers.
//
// Sync first: the draft only records a row when it is added, removed or
// moved, so a provider/model picked in a dropdown afterwards still lives
// in the DOM alone. Saving without this would persist the row's seed
// value — silently discarding whatever the user actually chose.
let chain = undefined;
if (capId === 'chat_fallback') {
_syncFallbackChainDraft();
chain = fallbackChainDraft.map(l => ({ provider: l.provider, model: l.model }));
provider = '';
model = '';
}
_persistCapability(capId, provider, model, onAfterSuccess, { voice, enabled, chain });
}
function _persistCapability(capId, provider, model, onAfterSuccess, extras) {
@@ -12130,6 +12485,9 @@ function _persistCapability(capId, provider, model, onAfterSuccess, extras) {
if (extras && extras.voice !== undefined) payload.voice = extras.voice;
// Opt-in capabilities (the chat fallback) carry their on/off switch.
if (extras && extras.enabled !== undefined) payload.enabled = extras.enabled;
// Only the chat fallback carries an ordered chain; every other capability
// keeps sending the single provider/model pair above.
if (extras && extras.chain !== undefined) payload.chain = extras.chain;
fetch('/api/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+107 -44
View File
@@ -3825,13 +3825,14 @@ class ModelsHandler:
}
@staticmethod
def _chat_provider_models() -> dict:
def _chat_preset_models() -> dict:
"""{provider_id: [model, ...]} for every chat-capable vendor.
``ConfigHandler.PROVIDER_MODELS`` carries per-vendor metadata
(label, api_key_field, ...) around the model list; the console's model
picker wants only the lists. Kept as its own helper so the two chat
cards can never drift apart.
picker wants only the lists. Used as the base for the fallback card's
model lists, so a vendor without a catalog still offers its presets
while a catalogued one offers its catalog instead.
"""
out = {}
for pid, meta in ConfigHandler.PROVIDER_MODELS.items():
@@ -3841,34 +3842,61 @@ class ModelsHandler:
@classmethod
def _chat_fallback_capability(cls, local_config: dict) -> dict:
"""The backup chat model, tried only after the primary one fails.
"""The fallback chain, tried in order after the primary model fails.
Deliberately separate from ``_chat_capability``: the primary model is
the one that answers, while this is a safety net that stays idle until
an outage. It is opt-in (``enabled`` defaults to false) and needs both
a provider and a model — a half-filled entry is treated as "off" so a
partially configured fallback can never hijack a healthy setup.
an outage. It is opt-in (``enabled`` defaults to false) and every link
needs both a provider and a model — a half-filled link is dropped so a
partially configured chain can never hijack a healthy setup.
The chain is ordered and unbounded: the console renders one editable
row per link and the runtime walks them front to back, so the number of
links the user saves *is* the number of backups a turn gets.
"""
cfg = local_config.get("chat_fallback") or {}
if not isinstance(cfg, dict):
cfg = {}
provider_id = (cfg.get("provider") or "").strip()
model = (cfg.get("model") or "").strip()
# Same provider list as the primary chat card, so the two dropdowns
# always offer identical choices (including expanded custom:<id>).
raw_chain = cfg.get("chain")
chain = []
if isinstance(raw_chain, list):
for item in raw_chain:
if not isinstance(item, dict):
continue
chain.append({
"provider": (item.get("provider") or "").strip(),
"model": (item.get("model") or "").strip(),
})
elif isinstance(cfg, dict) and (cfg.get("provider") or cfg.get("model")):
# Pre-chain config: surface it as a one-link chain so the console
# shows what is configured instead of an empty list.
chain = [{
"provider": (cfg.get("provider") or "").strip(),
"model": (cfg.get("model") or "").strip(),
}]
# Same provider list as the primary chat card, so the dropdowns always
# offer identical choices (including expanded custom:<id>).
primary = cls._chat_capability(local_config)
# Same model lists too: start from the vendors' presets and let a
# catalog override them, which is what the primary card does. Building
# this from the presets alone would leave the fallback on a free-form
# model field for a vendor whose models the primary card can list.
custom_cards = cls._custom_provider_cards(local_config)
return {
"editable": True,
"enabled": bool(cfg.get("enabled", False)),
"current_provider": provider_id,
"current_model": model,
"providers": primary.get("providers", []),
# The model picker expects {provider_id: [models]}. PROVIDER_MODELS
# is richer ({provider_id: {label, models, ...}}), so reduce it to
# just the lists — handing over the raw dict makes the web console
# call .slice() on a mapping and throw.
"provider_models": cls._chat_provider_models(),
"max_switches": cfg.get("max_switches", 1),
"provider_models": cls._apply_catalog(
cls._chat_preset_models(), "text", custom_cards),
"chain": chain,
# Kept for older clients that still read a single backup model:
# link 1 of the chain, so a downgraded console does not show blank.
"current_provider": chain[0]["provider"] if chain else "",
"current_model": chain[0]["model"] if chain else "",
# Shown in the UI so it's obvious the fallback is inactive.
"primary_provider": primary.get("current_provider", ""),
"primary_model": primary.get("current_model", ""),
@@ -4873,7 +4901,7 @@ class ModelsHandler:
provider_id,
model,
bool(data.get("enabled")),
data.get("max_switches"),
chain=data.get("chain"),
)
if capability == "vision":
return self._set_vision(provider_id, model)
@@ -5018,52 +5046,87 @@ class ModelsHandler:
self._reset_bridge()
return json.dumps({"status": "success", "applied": applied})
def _set_chat_fallback(self, provider_id: str, model: str, enabled: bool,
max_switches=None) -> str:
"""Persist the backup chat model under ``chat_fallback``.
def _normalized_custom_provider(self, provider_id: str):
"""Resolve a ``custom:<id>`` provider id, or None for a builtin one.
Validation mirrors ``_set_chat`` (custom:<id> ids included), with two
differences: the entry is opt-in via ``enabled``, and turning it on
requires both a provider and a model so a half-configured fallback can
never hijack a healthy primary model.
Returns ``(provider_entry, error_json)``; exactly one is set. Kept
separate so a chain can reuse it per link instead of duplicating the
lookup.
"""
custom_provider = None
if not provider_id:
return None, None
if provider_id.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type
_, custom_id = parse_custom_bot_type(provider_id)
providers = self._normalize_custom_providers(conf().get("custom_providers"))
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
if custom_provider is None:
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
elif provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
return None, json.dumps({"status": "error",
"message": f"unknown custom provider id: {custom_id}"})
return custom_provider, None
if provider_id not in ConfigHandler.PROVIDER_MODELS:
return None, json.dumps({"status": "error",
"message": f"unknown provider: {provider_id}"})
return None, None
# Fall back to the custom provider's default model when none is given.
if not model and custom_provider:
model = custom_provider.get("model") or ""
def _set_chat_fallback(self, provider_id: str, model: str, enabled: bool,
chain=None) -> str:
"""Persist the fallback chain under ``chat_fallback``.
# Enabling is all-or-nothing; disabling is always allowed (it is the
# safe direction, and lets a user clear a broken entry).
if enabled and (not provider_id or not model):
``chain`` is an ordered list of ``{"provider", "model"}`` links, tried
front to back after the primary model fails a turn for good. It is
unbounded: however many links are saved is how many backups a turn
gets, so there is no cap to configure.
For callers still sending the single-model shape (``provider_id`` /
``model``), the pair is folded into a one-link chain, so an older
client keeps working against the new config.
Validation mirrors ``_set_chat`` (custom:<id> ids included), with two
differences: the chain is opt-in via ``enabled``, and turning it on
requires at least one fully specified link so a half-configured
fallback can never hijack a healthy primary model. Individual links
that are incomplete or repeat the primary model are dropped rather
than rejected — one bad row should not block saving the good ones.
"""
links = []
if chain is not None:
if not isinstance(chain, list):
return json.dumps({"status": "error", "message": "chain must be a list"})
raw_links = chain
else:
raw_links = [{"provider": provider_id or "", "model": model or ""}]
for item in raw_links:
if not isinstance(item, dict):
continue
link_provider = (item.get("provider") or "").strip()
link_model = (item.get("model") or "").strip()
if not link_provider and not link_model:
continue # an empty row the user never filled in
custom_provider, err = self._normalized_custom_provider(link_provider)
if err:
return err
# Fall back to the custom provider's default model when none given.
if not link_model and custom_provider:
link_model = custom_provider.get("model") or ""
if not link_provider or not link_model:
continue # half a link routes nowhere
links.append({"provider": link_provider, "model": link_model})
# Enabling needs at least one usable link; disabling is always allowed
# (it is the safe direction, and lets a user clear a broken entry).
if enabled and not links:
return json.dumps({
"status": "error",
"message": "both a provider and a model are required to enable the fallback",
"message": "at least one provider/model pair is required to enable the fallback",
})
try:
switches = int(max_switches) if max_switches is not None else 1
except (TypeError, ValueError):
switches = 1
# At least one switch, or the fallback could never engage at all.
switches = max(1, min(switches, 5))
local_config = conf()
file_cfg = self._read_file_config()
payload = {
"enabled": bool(enabled),
"provider": provider_id or "",
"model": model or "",
"max_switches": switches,
"chain": links,
}
# Written as a whole so a stale key from an older shape can't survive.
local_config["chat_fallback"] = dict(payload)
+52 -7
View File
@@ -36,15 +36,23 @@ available_setting = {
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
"model": "deepseek-flash", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list
"bot_type": "", # optional; for OpenAI-compatible third-party services set "openai" or "custom" (in custom mode switching model won't auto-switch bot_type). See common/const.py for bot names; inferred from model name if left empty
# Fallback chat model, used only after the primary one has failed
# permanently for a turn (all retries exhausted). Opt-in: an empty
# provider/model disables the switch and the error surfaces as before.
# {"enabled": bool, "provider": str, "model": str, "max_switches": int}
# Fallback chat models, tried in order after the primary one has failed
# permanently for a turn (all retries exhausted). Opt-in: an empty `chain`
# disables the switch and the error surfaces as before.
#
# The chain is ordered and unbounded: a turn walks it from the front, and
# the number of models the user lists *is* the number of switches on offer
# — there is no separate cap to raise. Each entry needs both a provider and
# a model; an entry missing either is skipped, and an entry equal to the
# primary model (or to an earlier link) is skipped too, so a misconfigured
# chain can never bounce a turn back onto the model that just failed.
# {"enabled": bool, "chain": [{"provider": str, "model": str}, ...]}
"chat_fallback": {
"enabled": False,
"provider": "", # a provider id as used by `bot_type` (e.g. "openai", "qianfan", "custom:<id>")
"model": "", # e.g. "gpt-4o-mini"
"max_switches": 1, # how many times one turn may fall back, guarding against ping-pong
# Each item: {"provider": "openai", "model": "gpt-4o-mini"}.
# `provider` is a provider id as used by `bot_type`
# (e.g. "openai", "qianfan", "custom:<id>").
"chain": [],
},
"use_azure_chatgpt": False, # whether to use Azure chatgpt
"azure_deployment_id": "", # azure model deployment name
@@ -533,6 +541,9 @@ def load_config():
# only missing namespaces are filled in from the legacy section.
_merge_legacy_namespace(config, legacy="tool", canonical="tools")
_merge_legacy_namespace(config, legacy="skill", canonical="skills")
# A backup model configured before the fallback chain must survive the
# upgrade, so normalize it into the current shape before anything reads it.
_migrate_chat_fallback(config)
# Fresh desktop installs default to the stricter "workspace-write"; every
# other case keeps the template's "full-access". A packaged client only
@@ -721,6 +732,40 @@ def _merge_duplicate_keys(pairs):
return out
def _migrate_chat_fallback(cfg) -> None:
"""Upgrade the single-model ``chat_fallback`` to the chain shape.
Before the fallback chain, ``chat_fallback`` held one ``provider`` and one
``model`` (plus a ``max_switches`` cap). An existing config with a backup
model configured would otherwise load as an empty chain — silently
disabling a safety net the user had turned on — so fold those fields into
a one-entry chain here. ``max_switches`` is dropped rather than carried
over: the chain length is the new bound, and the old counter never
actually bounded anything (the fallback was already sticky for a run).
"""
raw = cfg.get("chat_fallback")
if not isinstance(raw, dict):
return
chain = raw.get("chain")
if isinstance(chain, list):
raw.pop("max_switches", None)
return
provider = (raw.get("provider") or "").strip()
model = (raw.get("model") or "").strip()
entry = []
if provider and model:
entry = [{"provider": provider, "model": model}]
raw.pop("provider", None)
raw.pop("model", None)
raw.pop("max_switches", None)
raw["chain"] = entry
if entry:
logger.warning(
"[INIT] chat_fallback.provider/model is deprecated; migrated into "
"chat_fallback.chain. Please rewrite it as a list in config.json."
)
def _merge_legacy_namespace(cfg, legacy: str, canonical: str) -> None:
"""Fold deprecated singular keys (``tool`` / ``skill``) into their plural
canonical counterparts at load time. Canonical entries always win."""
+20 -4
View File
@@ -435,12 +435,20 @@ const translations: Record<string, Record<string, string>> = {
models_cap_chat: '主模型',
models_cap_chat_sub: '用于基础对话和 Agent 推理',
models_cap_chat_fallback: '主模型兜底',
models_cap_chat_fallback_sub: '当主模型重试次数用尽仍然失败时,自动切换到兜底模型完成本轮回复',
models_cap_chat_fallback_sub: '当主模型重试次数用尽仍然失败时,按顺序尝试兜底链上的下一个模型,直到某个成功或全部失败',
models_chat_fallback_enable: '启用兜底模型',
models_chat_fallback_incomplete: '启用兜底需要同时选择厂商和模型',
models_chat_fallback_incomplete: '启用兜底至少需要一条完整的「厂商 + 模型」',
models_chat_fallback_button: '兜底模型',
models_chat_fallback_button_tip: '配置主模型兜底:主模型彻底失败后接管',
models_chat_fallback_badge_on: '兜底已启用',
models_chat_fallback_chain_title: '兜底链(按顺序尝试)',
models_chat_fallback_chain_desc: '第 1 个失败就试第 2 个,依次推进;链越长,一轮可用的备选越多',
models_chat_fallback_chain_add: '添加兜底模型',
models_chat_fallback_chain_empty: '还没有兜底模型,点下方按钮添加一个',
models_chat_fallback_link: '兜底',
models_chat_fallback_move_up: '上移',
models_chat_fallback_move_down: '下移',
models_chat_fallback_remove: '移除',
models_cap_vision: '图像理解',
models_cap_vision_sub: '识别图片内容,用于图像识别工具',
models_cap_image: '图像生成',
@@ -1159,12 +1167,20 @@ const translations: Record<string, Record<string, string>> = {
models_cap_chat: 'Main Model',
models_cap_chat_sub: 'Used for basic chat and agent reasoning',
models_cap_chat_fallback: 'Main Model Fallback',
models_cap_chat_fallback_sub: 'When the main model still fails after exhausting its retries, automatically switch to the fallback model to finish the reply',
models_cap_chat_fallback_sub: 'When the main model still fails after exhausting its retries, the next model in the fallback chain is tried in order until one succeeds or the chain runs out',
models_chat_fallback_enable: 'Enable the fallback model',
models_chat_fallback_incomplete: 'Pick both a provider and a model to enable the fallback',
models_chat_fallback_incomplete: 'Enabling the fallback needs at least one complete provider + model entry',
models_chat_fallback_button: 'Fallback',
models_chat_fallback_button_tip: 'Configure the main-model fallback (takes over after the main model fails)',
models_chat_fallback_badge_on: 'Fallback on',
models_chat_fallback_chain_title: 'Fallback chain (tried in order)',
models_chat_fallback_chain_desc: 'If the first one fails, the second is tried, and so on — the longer the chain, the more backups a turn has',
models_chat_fallback_chain_add: 'Add a fallback model',
models_chat_fallback_chain_empty: 'No fallback models yet — add one below',
models_chat_fallback_link: 'Fallback',
models_chat_fallback_move_up: 'Move up',
models_chat_fallback_move_down: 'Move down',
models_chat_fallback_remove: 'Remove',
models_cap_vision: 'Image Understanding',
models_cap_vision_sub: 'Recognizes image content, used by image recognition tools',
models_cap_image: 'Image Generation',
@@ -1,23 +1,24 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Loader2, ShieldAlert } from 'lucide-react'
import { ChevronDown, ChevronUp, Loader2, Plus, ShieldAlert, Trash2 } from 'lucide-react'
import { t } from '../../i18n'
import type { ChatFallbackCapabilityState, ModelsData } from '../../types'
import type { ChatFallbackCapabilityState, ChatFallbackLink, ModelsData } from '../../types'
import { Field, Dropdown, TextInput, Toggle, Modal, Btn, type DropdownOption } from './primitives'
import { resolveModels, providerLabel } from './modelsHelpers'
// Backup chat model. Unlike the other capabilities this one is opt-in and
// Backup chat models. Unlike the other capabilities this one is opt-in and
// rarely touched, so it does NOT get its own top-level card: it lives behind a
// small button on the main model card (see ChatFallbackButton) and is edited
// in a modal. It sits idle until the primary model fails a turn for good, so
// the whole form is gated behind an "enabled" toggle and validates provider +
// model together — an incomplete entry is never allowed to hijack a healthy
// setup.
// in a modal. It sits idle until the primary model fails a turn for good.
//
// The backups form an ordered chain rather than a single model: link 1 is
// tried first and, if it also fails, link 2 takes over, and so on. The chain
// is unbounded — however many links the user adds is how many backups a turn
// gets — so the modal renders one editable row per link with up/down/remove
// controls instead of a single provider + model picker.
export interface ChatFallbackSavePayload {
providerId: string
model: string
enabled: boolean
maxSwitches: number
chain: ChatFallbackLink[]
}
export interface ChatFallbackButtonProps {
@@ -71,6 +72,137 @@ interface ChatFallbackModalProps extends ChatFallbackButtonProps {
onClose: () => void
}
// One editable chain link. Split out so each row keeps its own custom-model
// input state without the parent tracking a map of them.
interface LinkEditorProps {
index: number
link: ChatFallbackLink
total: number
data: ModelsData | null
state: ChatFallbackCapabilityState | undefined
onChange: (next: ChatFallbackLink) => void
onMove: (delta: number) => void
onRemove: () => void
}
const LinkEditor: React.FC<LinkEditorProps> = ({
index, link, total, data, state, onChange, onMove, onRemove,
}) => {
const isCustomProvider = link.provider.startsWith('custom:')
const [showCustom, setShowCustom] = useState(false)
const isConfigured = (id: string): boolean => {
const p = data?.providers?.find((x) => x.id === id)
if (!p) return true
return p.configured || (p.is_custom && !!p.custom_name)
}
const providerOptions: DropdownOption[] = useMemo(() => {
const opts = (state?.providers || [])
.filter((id) => isConfigured(id) || id === link.provider)
.map((id) => ({ value: id, label: providerLabel(data, id) }))
return [{ value: '', label: t('models_select_provider') }, ...opts]
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state?.providers, data, link.provider])
const modelOptions: DropdownOption[] = useMemo(() => {
const list = resolveModels(data, link.provider, state?.provider_models).map((o) => ({
value: o.value,
label: o.value,
hint: o.hint,
}))
if (link.model && !showCustom && !list.some((o) => o.value === link.model)) {
list.unshift({ value: link.model, label: link.model, hint: undefined })
}
return list
}, [data, state?.provider_models, link.provider, link.model, showCustom])
const handleProvider = (id: string) => {
setShowCustom(false)
if (id.startsWith('custom:')) {
onChange({ provider: id, model: '' })
return
}
onChange({ provider: id, model: resolveModels(data, id, state?.provider_models)[0]?.value || '' })
}
return (
<div className="rounded-lg border border-border p-2.5 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-content-tertiary">
{`${t('models_chat_fallback_link')} ${index + 1}`}
</span>
<div className="flex items-center gap-0.5">
<button
type="button"
title={t('models_chat_fallback_move_up')}
onClick={() => onMove(-1)}
disabled={index === 0}
className="p-1 rounded text-content-tertiary hover:text-content-primary hover:bg-hover
cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronUp size={13} />
</button>
<button
type="button"
title={t('models_chat_fallback_move_down')}
onClick={() => onMove(1)}
disabled={index === total - 1}
className="p-1 rounded text-content-tertiary hover:text-content-primary hover:bg-hover
cursor-pointer transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronDown size={13} />
</button>
<button
type="button"
title={t('models_chat_fallback_remove')}
onClick={onRemove}
className="p-1 rounded text-content-tertiary hover:text-danger hover:bg-danger/10
cursor-pointer transition-colors"
>
<Trash2 size={13} />
</button>
</div>
</div>
<Dropdown
value={link.provider}
options={providerOptions}
placeholder={t('models_select_provider')}
onChange={handleProvider}
/>
{!!link.provider && !isConfigured(link.provider) && (
<p className="text-xs text-danger">{t('config_provider_unconfigured_hint')}</p>
)}
{isCustomProvider ? (
<TextInput
className="font-mono"
value={link.model}
onChange={(e) => onChange({ ...link, model: e.target.value })}
placeholder={t('config_custom_model_hint')}
/>
) : (
<Dropdown
value={link.model}
options={modelOptions}
placeholder={t('models_select_model')}
onChange={(m) => onChange({ ...link, model: m })}
disabled={!link.provider}
/>
)}
{!!showCustom && !isCustomProvider && (
<TextInput
className="font-mono"
value={link.model}
onChange={(e) => onChange({ ...link, model: e.target.value })}
placeholder={t('config_custom_model_hint')}
/>
)}
</div>
)
}
const ChatFallbackModal: React.FC<ChatFallbackModalProps> = ({
open,
state,
@@ -81,67 +213,54 @@ const ChatFallbackModal: React.FC<ChatFallbackModalProps> = ({
onSave,
}) => {
const [enabled, setEnabled] = useState(!!state?.enabled)
const [provider, setProvider] = useState(state?.current_provider || '')
const [model, setModel] = useState(state?.current_model || '')
const [customModel, setCustomModel] = useState(
(state?.current_provider || '').startsWith('custom:') ? state?.current_model || '' : ''
)
const [showCustom, setShowCustom] = useState(false)
const [chain, setChain] = useState<ChatFallbackLink[]>([])
// Reset the form to the persisted state each time the modal is (re)opened so
// a cancelled edit never leaks into the next open.
useEffect(() => {
if (!open) return
setEnabled(!!state?.enabled)
setProvider(state?.current_provider || '')
setModel(state?.current_model || '')
setCustomModel((state?.current_provider || '').startsWith('custom:') ? state?.current_model || '' : '')
setShowCustom(false)
const persisted = (state?.chain || []).map((l) => ({
provider: l.provider || '',
model: l.model || '',
}))
// Older backends report a single backup model instead of a chain.
if (persisted.length === 0 && (state?.current_provider || state?.current_model)) {
persisted.push({ provider: state?.current_provider || '', model: state?.current_model || '' })
}
setChain(persisted)
}, [open, state])
const isCustomProvider = provider.startsWith('custom:')
const isConfigured = (id: string): boolean => {
const p = data?.providers?.find((x) => x.id === id)
if (!p) return true
return p.configured || (p.is_custom && !!p.custom_name)
const updateLink = (i: number, next: ChatFallbackLink) => {
setChain((prev) => prev.map((l, idx) => (idx === i ? next : l)))
}
const providerOptions: DropdownOption[] = useMemo(() => {
const opts = (state?.providers || [])
.filter((id) => isConfigured(id) || id === provider)
.map((id) => ({ value: id, label: providerLabel(data, id) }))
return [{ value: '', label: t('models_select_provider') }, ...opts]
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state?.providers, data, provider])
const modelOptions: DropdownOption[] = useMemo(() => {
const list = resolveModels(data, provider, state?.provider_models).map((o) => ({
value: o.value,
label: o.value,
hint: o.hint,
}))
if (model && !showCustom && !list.some((o) => o.value === model)) {
list.unshift({ value: model, label: model, hint: undefined })
}
return list
}, [data, state?.provider_models, provider, model, showCustom])
const handleProvider = (id: string) => {
setProvider(id)
setShowCustom(false)
if (id.startsWith('custom:')) {
setCustomModel(id === state?.current_provider ? state?.current_model || '' : '')
setModel('')
return
}
setCustomModel('')
setModel(resolveModels(data, id, state?.provider_models)[0]?.value || '')
const addLink = () => {
const firstProvider = (state?.providers || [])[0] || ''
const firstModel = resolveModels(data, firstProvider, state?.provider_models)[0]?.value || ''
setChain((prev) => [...prev, { provider: firstProvider, model: firstModel }])
}
const finalModel = showCustom || isCustomProvider ? customModel.trim() : model
// Enabling is all-or-nothing; the backend rejects a half-filled entry too.
const incomplete = enabled && (!provider || !finalModel)
const removeLink = (i: number) => {
setChain((prev) => prev.filter((_, idx) => idx !== i))
}
const moveLink = (i: number, delta: number) => {
const j = i + delta
setChain((prev) => {
if (j < 0 || j >= prev.length) return prev
const next = prev.slice()
const tmp = next[i]
next[i] = next[j]
next[j] = tmp
return next
})
}
// Enabling needs at least one complete link; the backend rejects an empty
// chain for the same reason (a fallback with nothing to fall back to).
const hasCompleteLink = chain.some((l) => !!l.provider && !!l.model)
const incomplete = enabled && !hasCompleteLink
return (
<Modal
@@ -159,16 +278,7 @@ const ChatFallbackModal: React.FC<ChatFallbackModalProps> = ({
<Btn
variant="primary"
disabled={busy || incomplete}
onClick={() =>
onSave({
providerId: provider,
model: finalModel,
enabled,
// The fallback is sticky for the whole run, so a single switch
// is all it takes; no user-facing knob needed.
maxSwitches: 1,
})
}
onClick={() => onSave({ enabled, chain })}
>
{busy ? <Loader2 size={14} className="animate-spin" /> : t('config_save')}
</Btn>
@@ -184,40 +294,45 @@ const ChatFallbackModal: React.FC<ChatFallbackModalProps> = ({
</div>
{enabled && (
<>
<Field label={t('models_provider')}>
<Dropdown
value={provider}
options={providerOptions}
placeholder={t('models_select_provider')}
onChange={handleProvider}
/>
{!!provider && !isConfigured(provider) && (
<p className="text-xs text-danger mt-1.5">{t('config_provider_unconfigured_hint')}</p>
)}
<div className="space-y-2">
<Field label={t('models_chat_fallback_chain_title')}>
<p className="text-xs text-content-tertiary leading-relaxed">
{t('models_chat_fallback_chain_desc')}
</p>
</Field>
<Field label={t('models_model')}>
{isCustomProvider ? (
<TextInput
className="font-mono"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
placeholder={t('config_custom_model_hint')}
/>
) : (
<Dropdown
value={model}
options={modelOptions}
placeholder={t('models_select_model')}
onChange={setModel}
disabled={!provider}
/>
)}
</Field>
{chain.length === 0 ? (
<p className="text-xs text-content-tertiary">{t('models_chat_fallback_chain_empty')}</p>
) : (
<div className="space-y-2">
{chain.map((link, i) => (
<LinkEditor
key={`${i}-${link.provider}`}
index={i}
link={link}
total={chain.length}
data={data}
state={state}
onChange={(next) => updateLink(i, next)}
onMove={(delta) => moveLink(i, delta)}
onRemove={() => removeLink(i)}
/>
))}
</div>
)}
<button
type="button"
onClick={addLink}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs
text-accent bg-accent-soft hover:bg-accent-soft/70 cursor-pointer transition-colors"
>
<Plus size={12} />
{t('models_chat_fallback_chain_add')}
</button>
{incomplete && <p className="text-xs text-danger">{t('models_chat_fallback_incomplete')}</p>}
</>
</div>
)}
</Modal>
)
@@ -141,14 +141,12 @@ const ModelsTab: React.FC<ModelsTabProps> = ({ baseUrl }) => {
data={data}
busy={busy === 'chat_fallback'}
status={statusMap.chat_fallback}
onSave={({ providerId, model, enabled, maxSwitches }) =>
onSave={({ enabled, chain }) =>
run('chat_fallback', {
action: 'set_capability',
capability: 'chat_fallback',
provider_id: providerId,
model,
enabled,
max_switches: maxSwitches,
chain,
})
}
/>
+10 -4
View File
@@ -637,17 +637,23 @@ export interface CapabilityState {
[k: string]: unknown
}
/** Backup chat model, tried only after the primary one fails a turn. */
/** One link in the fallback chain: tried after the one before it fails. */
export interface ChatFallbackLink {
provider: string
model: string
}
/** Backup chat models, tried in order after the primary one fails a turn. */
export interface ChatFallbackCapabilityState {
editable?: boolean
/** Opt-in: when false the fallback never engages. */
enabled?: boolean
/** Ordered links; index 0 is tried first. Unbounded by design. */
chain?: ChatFallbackLink[]
current_provider?: string
current_model?: string
providers?: string[]
provider_models?: Record<string, ModelEntry[]>
/** How many times a single turn may switch; guards against ping-pong. */
max_switches?: number
/** The primary model, shown so the user sees what is being backed up. */
primary_provider?: string
primary_model?: string
@@ -688,7 +694,7 @@ export type ModelsAction =
// `chat_fallback` is not a first-class CapabilityKey (it has no top-level
// card), but it is persisted through the same set_capability action, so it
// is accepted here alongside its opt-in fields.
| { action: 'set_capability'; capability: CapabilityKey | 'chat_fallback'; provider_id?: string; model?: string; voice?: string; strategy?: string; provider?: string; enabled?: boolean; max_switches?: number }
| { action: 'set_capability'; capability: CapabilityKey | 'chat_fallback'; provider_id?: string; model?: string; voice?: string; strategy?: string; provider?: string; enabled?: boolean; chain?: ChatFallbackLink[] }
| { action: 'set_voice_reply_mode'; mode: 'off' | 'voice_if_voice' | 'always' }
// Dedicated search-provider credentials (bocha / anysearch / serply / tavily
// use api_key; searxng uses url). The provider field defaults to bocha
@@ -0,0 +1,81 @@
"""The pre-chain ``chat_fallback`` must survive an upgrade.
``chat_fallback`` used to hold a single backup: ``{enabled, provider, model,
max_switches}``. It now holds an ordered chain. A user who had already
configured a backup model — and who is exactly the person relying on the
fallback — would otherwise load into an empty chain and silently lose their
safety net on the first launch after upgrading.
``max_switches`` is intentionally not carried over: the chain length is the new
bound, and the old counter never bounded anything, since the fallback was
already sticky for a whole run.
"""
from config import _migrate_chat_fallback
class TestMigratingTheLegacyShape:
def test_a_legacy_backup_becomes_a_one_link_chain(self):
cfg = {"chat_fallback": {
"enabled": True,
"provider": "openai",
"model": "backup-model",
"max_switches": 3,
}}
_migrate_chat_fallback(cfg)
assert cfg["chat_fallback"] == {
"enabled": True,
"chain": [{"provider": "openai", "model": "backup-model"}],
}
def test_a_half_configured_legacy_entry_yields_an_empty_chain(self):
"""No provider or no model means there was never a usable backup; an
empty chain keeps the fallback off rather than inventing one."""
cfg = {"chat_fallback": {"enabled": True, "provider": "openai", "model": ""}}
_migrate_chat_fallback(cfg)
assert cfg["chat_fallback"]["chain"] == []
def test_a_disabled_legacy_entry_stays_disabled(self):
cfg = {"chat_fallback": {
"enabled": False, "provider": "openai", "model": "backup-model",
}}
_migrate_chat_fallback(cfg)
assert cfg["chat_fallback"]["enabled"] is False
assert cfg["chat_fallback"]["chain"] == [
{"provider": "openai", "model": "backup-model"}
]
def test_the_new_shape_is_left_alone(self):
"""Idempotent: re-running must not wrap an existing chain in another."""
cfg = {"chat_fallback": {
"enabled": True,
"chain": [{"provider": "openai", "model": "backup-1"},
{"provider": "qianfan", "model": "backup-2"}],
}}
_migrate_chat_fallback(cfg)
assert cfg["chat_fallback"]["chain"] == [
{"provider": "openai", "model": "backup-1"},
{"provider": "qianfan", "model": "backup-2"},
]
def test_a_stale_max_switches_is_dropped_from_the_new_shape(self):
cfg = {"chat_fallback": {
"enabled": True,
"chain": [{"provider": "openai", "model": "backup-1"}],
"max_switches": 5,
}}
_migrate_chat_fallback(cfg)
assert "max_switches" not in cfg["chat_fallback"]
assert cfg["chat_fallback"]["chain"] == [
{"provider": "openai", "model": "backup-1"}
]
def test_a_missing_entry_is_tolerated(self):
cfg = {}
_migrate_chat_fallback(cfg)
assert "chat_fallback" not in cfg
def test_a_non_dict_entry_is_tolerated(self):
cfg = {"chat_fallback": "openai/backup-model"}
_migrate_chat_fallback(cfg)
assert cfg["chat_fallback"] == "openai/backup-model"
+180
View File
@@ -0,0 +1,180 @@
"""The executor must walk the whole chain, one attempt per link.
These drive ``_call_llm_stream`` for real (with a stubbed transport) rather
than the model's ``use_fallback``, because the two behaviors that matter live
in the executor: that a failing link advances to the next one instead of
ending the turn, and that a link gets *one* attempt rather than the primary's
full retry budget.
The single-attempt rule is what keeps a chain usable: with the primary's
backoff (30s+ per rate-limited retry) a three-link chain would sleep past the
web channel's SSE idle timeout before the last link was even tried, and the
user would see a dropped connection instead of a reply.
"""
import pytest
from agent.protocol.agent_stream import AgentStreamExecutor
class _FailingModel:
"""A model that reports failure through use_fallback, like the real one."""
def __init__(self, chain):
self.chain = list(chain)
self.calls = [] # model name per call attempt
self._model = "primary-model"
self._depth = 0
@property
def model(self):
return self._model
def use_fallback(self):
if self._depth >= len(self.chain):
return False
self._model = self.chain[self._depth]["model"]
self._depth += 1
return True
def call_stream(self, request): # replaced per-test via monkeypatch
self.calls.append(self._model)
raise Exception("provider is down (Status: 503)")
def _executor(model, monkeypatch):
"""An executor reduced to the retry/fallback path alone."""
executor = AgentStreamExecutor.__new__(AgentStreamExecutor)
executor.model = model
executor.agent = None
executor.messages = []
executor.tools = {}
executor.system_prompt = ""
monkeypatch.setattr(executor, "_validate_and_fix_messages", lambda: None)
monkeypatch.setattr(executor, "_prepare_messages", lambda: [])
monkeypatch.setattr(executor, "_identify_complete_turns", lambda: [])
monkeypatch.setattr(executor, "_emit_event", lambda *a, **k: None)
monkeypatch.setattr(executor, "_is_thinking_enabled", lambda: False)
# Never actually sleep — the point of these tests is the call sequence.
monkeypatch.setattr("agent.protocol.agent_stream.time.sleep", lambda s: None)
return executor
def _always_down(executor, error="provider is down (Status: 503)"):
def _call(request):
executor.model.calls.append(executor.model.model)
raise Exception(error)
return _call
# The primary gets its own full retry budget before the chain is touched:
# 1 attempt + max_retries (3) retries.
PRIMARY_ATTEMPTS = 4
class TestWalkingTheChain:
def test_a_failed_link_advances_to_the_next(self, monkeypatch):
model = _FailingModel([
{"provider": "openai", "model": "backup-1"},
{"provider": "qianfan", "model": "backup-2"},
{"provider": "zhipu", "model": "backup-3"},
])
executor = _executor(model, monkeypatch)
monkeypatch.setattr(executor.model, "call_stream", _always_down(executor))
with pytest.raises(Exception):
executor._call_llm_stream(retry_on_empty=False)
# Every model was tried, in chain order, right after the primary.
assert executor.model.calls == (
["primary-model"] * PRIMARY_ATTEMPTS
+ ["backup-1", "backup-2", "backup-3"]
)
def test_the_whole_chain_is_reported_when_it_runs_out(self, monkeypatch):
"""Blaming only the last link reads as 'that one model is broken'
when in fact each one was tried and each one failed."""
model = _FailingModel([
{"provider": "openai", "model": "backup-1"},
{"provider": "qianfan", "model": "backup-2"},
])
executor = _executor(model, monkeypatch)
monkeypatch.setattr(executor.model, "call_stream", _always_down(executor))
with pytest.raises(Exception) as exc:
executor._call_llm_stream(retry_on_empty=False)
message = str(exc.value)
for name in ("primary-model", "backup-1", "backup-2"):
assert name in message, f"the failure should name {name}"
def test_a_link_gets_one_attempt_not_the_primarys_budget(self, monkeypatch):
"""The primary retries; a link is tried once and then left behind."""
model = _FailingModel([{"provider": "openai", "model": "backup-1"}])
executor = _executor(model, monkeypatch)
monkeypatch.setattr(executor.model, "call_stream", _always_down(executor))
with pytest.raises(Exception):
executor._call_llm_stream(retry_on_empty=False, max_retries=3)
assert executor.model.calls == ["primary-model"] * PRIMARY_ATTEMPTS + ["backup-1"]
def test_a_long_chain_is_walked_in_full(self, monkeypatch):
"""No cap: five links means five tries, not one."""
model = _FailingModel([
{"provider": "openai", "model": f"backup-{i}"} for i in range(5)
])
executor = _executor(model, monkeypatch)
monkeypatch.setattr(executor.model, "call_stream", _always_down(executor))
with pytest.raises(Exception):
executor._call_llm_stream(retry_on_empty=False)
assert executor.model.calls == (
["primary-model"] * PRIMARY_ATTEMPTS
+ [f"backup-{i}" for i in range(5)]
)
def test_the_turn_stops_at_the_link_that_answers(self, monkeypatch):
model = _FailingModel([
{"provider": "openai", "model": "backup-1"},
{"provider": "qianfan", "model": "backup-2"},
])
executor = _executor(model, monkeypatch)
attempts = {"n": 0}
def _call(request):
model.calls.append(model.model)
attempts["n"] += 1
if attempts["n"] <= PRIMARY_ATTEMPTS:
raise Exception("provider is down (Status: 503)")
return iter([]) # backup-1 answers (with an empty stream)
monkeypatch.setattr(executor.model, "call_stream", _call)
result = executor._call_llm_stream(retry_on_empty=False)
assert result is not None
assert executor.model.calls == ["primary-model"] * PRIMARY_ATTEMPTS + ["backup-1"]
# The second link was never needed.
assert "backup-2" not in executor.model.calls
class TestNoChainConfigured:
"""Without a fallback the turn must still fail the way it always did."""
def test_the_original_error_is_raised_untouched(self, monkeypatch):
model = _FailingModel([])
executor = _executor(model, monkeypatch)
monkeypatch.setattr(
executor.model, "call_stream",
_always_down(executor, "boom (Status: 503)"),
)
with pytest.raises(Exception) as exc:
executor._call_llm_stream(retry_on_empty=False)
# Not wrapped in the "chain exhausted" message — there is no chain.
assert "boom" in str(exc.value)
assert "fallback" not in str(exc.value).lower()
assert executor.model.calls == ["primary-model"] * PRIMARY_ATTEMPTS
+180
View File
@@ -0,0 +1,180 @@
"""A rate limit should skip the wait and switch — but only when a backup exists.
Before this change a 429 always slept 30-60s per retry and only reached the
fallback after the whole budget was spent. With a chain configured that wait is
pure latency: the backup is a different provider with its own quota, so it can
answer immediately.
Without a chain there is nothing to switch to, so the old wait-it-out behaviour
must stay: failing a turn outright over a transient rate limit would be worse.
"""
import pytest
from agent.protocol.agent_stream import AgentStreamExecutor
RATE_LIMITED = "Rate limit reached (Status: 429)"
OTHER_ERROR = "server is busy (Status: 503)"
PRIMARY_ATTEMPTS = 4 # 1 attempt + max_retries (3)
class _Model:
"""Fails every call; reports chain availability like the real model."""
def __init__(self, chain):
self.chain = list(chain)
self.calls = []
self._model = "primary-model"
self._depth = 0
@property
def model(self):
return self._model
def fallback_available(self):
return self._depth < len(self.chain)
def use_fallback(self):
if self._depth >= len(self.chain):
return False
self._model = self.chain[self._depth]["model"]
self._depth += 1
return True
def call_stream(self, request): # replaced per-test via monkeypatch
self.calls.append(self._model)
raise Exception(RATE_LIMITED)
def _executor(model, monkeypatch):
executor = AgentStreamExecutor.__new__(AgentStreamExecutor)
executor.model = model
executor.agent = None
executor.messages = []
executor.tools = {}
executor.system_prompt = ""
monkeypatch.setattr(executor, "_validate_and_fix_messages", lambda: None)
monkeypatch.setattr(executor, "_prepare_messages", lambda: [])
monkeypatch.setattr(executor, "_identify_complete_turns", lambda: [])
monkeypatch.setattr(executor, "_emit_event", lambda *a, **k: None)
monkeypatch.setattr(executor, "_is_thinking_enabled", lambda: False)
monkeypatch.setattr("agent.protocol.agent_stream.time.sleep", lambda s: None)
return executor
def _always_down(model, error):
def _call(request):
model.calls.append(model.model)
raise Exception(error)
return _call
class TestRateLimitWithABackup:
"""429 + chain => switch immediately, no retries, no sleeping."""
def test_it_switches_on_the_first_attempt(self, monkeypatch):
model = _Model([{"provider": "openai", "model": "backup-1"}])
executor = _executor(model, monkeypatch)
monkeypatch.setattr(executor.model, "call_stream", _always_down(model, RATE_LIMITED))
with pytest.raises(Exception):
executor._call_llm_stream(retry_on_empty=False)
# Primary tried once, then straight to the backup — no 30s sleeps.
assert executor.model.calls == ["primary-model", "backup-1"]
def test_it_walks_the_whole_chain_without_retrying(self, monkeypatch):
model = _Model([
{"provider": "openai", "model": "backup-1"},
{"provider": "qianfan", "model": "backup-2"},
{"provider": "zhipu", "model": "backup-3"},
])
executor = _executor(model, monkeypatch)
monkeypatch.setattr(executor.model, "call_stream", _always_down(model, RATE_LIMITED))
with pytest.raises(Exception):
executor._call_llm_stream(retry_on_empty=False)
assert executor.model.calls == [
"primary-model", "backup-1", "backup-2", "backup-3",
]
def test_it_never_sleeps(self, monkeypatch):
"""The point of the change: no waiting before switching."""
model = _Model([{"provider": "openai", "model": "backup-1"}])
executor = _executor(model, monkeypatch)
monkeypatch.setattr(executor.model, "call_stream", _always_down(model, RATE_LIMITED))
slept = []
monkeypatch.setattr("agent.protocol.agent_stream.time.sleep",
lambda s: slept.append(s))
with pytest.raises(Exception):
executor._call_llm_stream(retry_on_empty=False)
assert slept == [], f"should not have slept, slept {slept}"
class TestRateLimitWithoutABackup:
"""429 + no chain => the old wait-it-out behaviour must be preserved."""
def test_it_still_retries_the_full_budget(self, monkeypatch):
model = _Model([])
executor = _executor(model, monkeypatch)
monkeypatch.setattr(executor.model, "call_stream", _always_down(model, RATE_LIMITED))
with pytest.raises(Exception):
executor._call_llm_stream(retry_on_empty=False)
assert executor.model.calls == ["primary-model"] * PRIMARY_ATTEMPTS
def test_it_still_sleeps_between_retries(self, monkeypatch):
model = _Model([])
executor = _executor(model, monkeypatch)
monkeypatch.setattr(executor.model, "call_stream", _always_down(model, RATE_LIMITED))
slept = []
monkeypatch.setattr("agent.protocol.agent_stream.time.sleep",
lambda s: slept.append(s))
with pytest.raises(Exception):
executor._call_llm_stream(retry_on_empty=False)
# 30s, 45s, 60s — the pre-existing backoff, untouched.
assert slept == [30, 45, 60]
class TestOtherErrorsAreUnaffected:
"""Only 429 gets the fast path; everything else keeps retrying."""
def test_a_503_still_retries_even_with_a_chain(self, monkeypatch):
model = _Model([{"provider": "openai", "model": "backup-1"}])
executor = _executor(model, monkeypatch)
monkeypatch.setattr(executor.model, "call_stream", _always_down(model, OTHER_ERROR))
with pytest.raises(Exception):
executor._call_llm_stream(retry_on_empty=False)
# 503 is not a rate limit: full retry budget, then the backup.
assert executor.model.calls == ["primary-model"] * PRIMARY_ATTEMPTS + ["backup-1"]
class TestNoFallbackSupport:
"""Plain models / doubles have no fallback_available — must not break."""
def test_a_plain_model_keeps_retrying_on_429(self, monkeypatch):
class Plain:
def __init__(self):
self.calls = []
self.model = "primary-model"
def call_stream(self, request):
self.calls.append(self.model)
raise Exception(RATE_LIMITED)
model = Plain()
executor = _executor(model, monkeypatch)
monkeypatch.setattr(executor.model, "call_stream", model.call_stream)
with pytest.raises(Exception):
executor._call_llm_stream(retry_on_empty=False)
assert model.calls == ["primary-model"] * PRIMARY_ATTEMPTS
+263 -88
View File
@@ -1,10 +1,11 @@
"""Fallback chat model: when it engages, and when it must stay out of the way.
"""Fallback chat chain: when it engages, and when it must stay out of the way.
The fallback is opt-in and only takes over once a turn has failed for good, so
these tests pin down both directions — that a dead provider hands over to the
backup, and that a healthy one is never disturbed. The failure modes worth
guarding are the quiet ones: a half-configured entry silently answering for a
working model, or a turn ping-ponging between two broken ones.
first backup, that a backup which also fails hands over to the one after it,
and that a healthy primary is never disturbed. The failure modes worth guarding
are the quiet ones: a half-configured entry silently answering for a working
model, or a chain that loops back onto a model already proven to be down.
"""
import pytest
@@ -20,19 +21,22 @@ def _model(monkeypatch, chat_fallback, **extra_conf):
return AgentLLMModel.__new__(AgentLLMModel)
FALLBACK = {
"enabled": True,
"provider": "openai",
"model": "backup-model",
"max_switches": 1,
}
def _chain(*links, enabled=True):
"""A chain config from (provider, model) pairs."""
return {
"enabled": enabled,
"chain": [{"provider": p, "model": m} for p, m in links],
}
FALLBACK = _chain(("openai", "backup-model"))
class TestFallbackConfig:
"""A malformed or disabled entry must never be usable."""
def test_a_disabled_entry_is_not_available(self, monkeypatch):
model = _model(monkeypatch, dict(FALLBACK, enabled=False))
model = _model(monkeypatch, _chain(("openai", "backup-model"), enabled=False))
assert model.fallback_available() is False
def test_a_missing_entry_is_not_available(self, monkeypatch):
@@ -43,23 +47,68 @@ class TestFallbackConfig:
model = _model(monkeypatch, "openai/backup-model")
assert model.fallback_available() is False
def test_an_empty_chain_is_not_available(self, monkeypatch):
"""Enabled with nothing in it: there is no backup to fall back to."""
model = _model(monkeypatch, {"enabled": True, "chain": []})
assert model.fallback_available() is False
@pytest.mark.parametrize(
"field",
["provider", "model"],
)
def test_a_half_configured_entry_is_not_available(self, monkeypatch, field):
"""Half an entry is 'off': a fallback without a provider or a model
could hijack a perfectly healthy primary."""
model = _model(monkeypatch, dict(FALLBACK, **{field: ""}))
def test_a_half_configured_link_is_dropped(self, monkeypatch, field):
"""Half a link routes nowhere, and must not make the chain look usable."""
link = {"provider": "openai", "model": "backup-model"}
link[field] = ""
model = _model(monkeypatch, {"enabled": True, "chain": [link]})
assert model.fallback_config()["chain"] == []
assert model.fallback_available() is False
def test_a_garbage_max_switches_does_not_raise(self, monkeypatch):
model = _model(monkeypatch, dict(FALLBACK, max_switches="soon"))
assert model.fallback_config()["max_switches"] == 0
def test_a_non_dict_link_is_ignored(self, monkeypatch):
model = _model(monkeypatch, {"enabled": True, "chain": ["openai/backup", 42, None]})
assert model.fallback_config()["chain"] == []
def test_a_garbage_chain_does_not_raise(self, monkeypatch):
model = _model(monkeypatch, {"enabled": True, "chain": "openai/backup"})
assert model.fallback_available() is False
def test_the_primary_model_is_stripped_from_the_chain(self, monkeypatch):
"""Listing the primary as its own backup would bounce the turn straight
back onto the model that just failed."""
model = _model(
monkeypatch,
_chain(("openai", "primary-model"), ("openai", "backup-model")),
)
assert model.fallback_config()["chain"] == [
{"provider": "openai", "model": "backup-model"}
]
def test_duplicate_links_are_collapsed(self, monkeypatch):
"""The same backup twice would spend a switch to re-probe a dead model."""
model = _model(
monkeypatch,
_chain(("openai", "backup-model"), ("openai", "backup-model")),
)
assert model.fallback_config()["chain"] == [
{"provider": "openai", "model": "backup-model"}
]
def test_the_legacy_single_model_shape_is_honored(self, monkeypatch):
"""A pre-chain config keeps working: config.py normally upgrades it at
load time, but a caller handing over the raw dict must not lose it."""
model = _model(monkeypatch, {
"enabled": True,
"provider": "openai",
"model": "backup-model",
"max_switches": 1,
})
assert model.fallback_config()["chain"] == [
{"provider": "openai", "model": "backup-model"}
]
class TestEngagingTheFallback:
def test_the_answer_comes_from_the_fallback_once_engaged(self, monkeypatch):
def test_the_answer_comes_from_the_first_link(self, monkeypatch):
model = _model(monkeypatch, FALLBACK)
assert model.model == "primary-model"
@@ -69,7 +118,7 @@ class TestEngagingTheFallback:
def test_routing_follows_the_fallback_provider(self, monkeypatch):
"""The bot type has to change too, or the request would go back to the
provider that just failed."""
model = _model(monkeypatch, dict(FALLBACK, provider="qianfan"))
model = _model(monkeypatch, _chain(("qianfan", "backup-model")))
model.use_fallback()
assert model._resolve_bot_type("backup-model") == "qianfan"
@@ -87,18 +136,79 @@ class TestEngagingTheFallback:
model.use_fallback()
assert model.model == "backup-model"
def test_it_engages_only_once_per_turn(self, monkeypatch):
"""Already on the fallback, so a second failure must not loop back."""
model = _model(monkeypatch, FALLBACK)
assert model.use_fallback() is True
assert model.use_fallback() is False
def test_disabled_fallback_returns_false(self, monkeypatch):
model = _model(monkeypatch, dict(FALLBACK, enabled=False))
model = _model(monkeypatch, _chain(("openai", "backup-model"), enabled=False))
assert model.use_fallback() is False
assert model.model == "primary-model"
class TestWalkingTheChain:
"""A backup that fails must earn the next one, not end the turn."""
def test_a_failed_link_advances_to_the_next(self, monkeypatch):
model = _model(monkeypatch, _chain(
("openai", "backup-1"),
("qianfan", "backup-2"),
("zhipu", "backup-3"),
))
assert model.use_fallback() is True
assert model.model == "backup-1"
assert model.use_fallback() is True
assert model.model == "backup-2"
assert model.use_fallback() is True
assert model.model == "backup-3"
def test_the_walk_wraps_around_after_the_last_link(self, monkeypatch):
"""Reaching the last link is not the end of the turn.
The walk comes back to the first link: a whole pass takes real time,
so a rate limit that blocked link 1 on the way through may well have
cleared by the time the walk returns to it.
"""
model = _model(monkeypatch, _chain(("openai", "backup-1"), ("qianfan", "backup-2")))
assert model.use_fallback() is True
assert model.model == "backup-1"
assert model.use_fallback() is True
assert model.model == "backup-2"
# Wraps around to the front again rather than giving up.
assert model.use_fallback() is True
assert model.model == "backup-1"
def test_the_walk_stops_after_two_passes(self, monkeypatch):
"""Two passes, then the turn reports failure.
Unbounded would hang the turn on a chain whose providers are all
genuinely down, and the user would sit through every extra pass.
"""
model = _model(monkeypatch, _chain(("openai", "backup-1"), ("qianfan", "backup-2")))
# 2 links x 2 passes = 4 advances, then the chain is spent.
assert [model.use_fallback() for _ in range(4)] == [True] * 4
assert model.fallback_available() is False
assert model.use_fallback() is False
def test_routing_follows_each_link_in_turn(self, monkeypatch):
"""Every link carries its own provider, so a chain can leave a whole
vendor behind rather than one model."""
model = _model(monkeypatch, _chain(("openai", "backup-1"), ("qianfan", "backup-2")))
model.use_fallback()
assert model._resolve_bot_type("backup-1") == "chatGPT"
model.use_fallback()
assert model._resolve_bot_type("backup-2") == "qianfan"
def test_a_long_chain_is_not_capped(self, monkeypatch):
"""There is no separate switch limit to hit: five links, five tries
per pass."""
links = [(f"provider-{i}", f"backup-{i}") for i in range(5)]
model = _model(monkeypatch, _chain(*links))
assert [model.use_fallback() for _ in range(5)] == [True] * 5
# A second pass is still available — five links is not a hard cap on
# the number of switches, the pass budget is.
assert model.fallback_available() is True
assert [model.use_fallback() for _ in range(5)] == [True] * 5
assert model.fallback_available() is False
assert model.model == "backup-4"
class TestResettingBetweenTurns:
def test_a_new_turn_starts_on_the_primary_again(self, monkeypatch):
"""Scoping the switch to one turn keeps a transient outage from
@@ -108,12 +218,21 @@ class TestResettingBetweenTurns:
model.reset_fallback()
assert model.model == "primary-model"
def test_reset_lets_the_fallback_engage_again_later(self, monkeypatch):
model = _model(monkeypatch, FALLBACK)
def test_reset_rewinds_the_whole_chain(self, monkeypatch):
"""A run that had walked three links starts the next one from link 1,
not from wherever the previous turn gave up."""
model = _model(monkeypatch, _chain(
("openai", "backup-1"), ("qianfan", "backup-2"), ("zhipu", "backup-3"),
))
model.use_fallback()
model.use_fallback()
model.use_fallback()
assert model.model == "backup-3"
model.reset_fallback()
assert model.fallback_available() is True
assert model.model == "primary-model"
assert model.use_fallback() is True
assert model.model == "backup-1"
def test_reset_on_a_healthy_model_is_a_no_op(self, monkeypatch):
model = _model(monkeypatch, FALLBACK)
@@ -168,53 +287,14 @@ class TestStreamRecovery:
class TestConsoleValidation:
"""The models console must not persist an entry that can't do its job."""
"""The models console must not persist a chain that can't do its job."""
def _handler(self):
from channel.web.web_channel import ModelsHandler
return ModelsHandler.__new__(ModelsHandler)
def test_enabling_without_a_model_is_rejected(self, monkeypatch):
monkeypatch.setattr(
"channel.web.web_channel.conf", lambda: {"model": "primary-model"}
)
result = self._handler()._set_chat_fallback("openai", "", True)
assert "error" in result
def test_enabling_without_a_provider_is_rejected(self, monkeypatch):
monkeypatch.setattr(
"channel.web.web_channel.conf", lambda: {"model": "primary-model"}
)
result = self._handler()._set_chat_fallback("", "backup-model", True)
assert "error" in result
def test_disabling_a_broken_entry_is_always_allowed(self, monkeypatch):
"""Turning it off is the safe direction — never block the user."""
written = {}
monkeypatch.setattr(
"channel.web.web_channel.conf", lambda: {"model": "primary-model"}
)
monkeypatch.setattr(
self._handler().__class__, "_read_file_config", lambda self: {}
)
handler = self._handler()
monkeypatch.setattr(
handler, "_write_file_config", lambda cfg: written.update(cfg)
)
result = handler._set_chat_fallback("", "", False)
assert '"status": "success"' in result
assert written["chat_fallback"]["enabled"] is False
def test_an_unknown_provider_is_rejected(self, monkeypatch):
monkeypatch.setattr(
"channel.web.web_channel.conf", lambda: {"model": "primary-model"}
)
result = self._handler()._set_chat_fallback("not-a-vendor", "m", True)
assert "error" in result
def test_max_switches_is_clamped(self, monkeypatch):
"""0 would mean 'never engage'; 99 would risk unbounded ping-pong."""
def _saved(self, monkeypatch, *args, **kwargs):
written = {}
monkeypatch.setattr(
"channel.web.web_channel.conf", lambda: {"model": "primary-model"}
@@ -224,10 +304,93 @@ class TestConsoleValidation:
monkeypatch.setattr(
handler, "_write_file_config", lambda cfg: written.update(cfg)
)
handler._set_chat_fallback("openai", "backup-model", True, max_switches=0)
assert written["chat_fallback"]["max_switches"] == 1
handler._set_chat_fallback("openai", "backup-model", True, max_switches=99)
assert written["chat_fallback"]["max_switches"] == 5
result = handler._set_chat_fallback(*args, **kwargs)
return result, written
def test_enabling_without_a_model_is_rejected(self, monkeypatch):
result, _ = self._saved(monkeypatch, "openai", "", True)
assert "error" in result
def test_enabling_without_a_provider_is_rejected(self, monkeypatch):
result, _ = self._saved(monkeypatch, "", "backup-model", True)
assert "error" in result
def test_enabling_with_an_empty_chain_is_rejected(self, monkeypatch):
result, _ = self._saved(monkeypatch, "", "", True, chain=[])
assert "error" in result
def test_disabling_a_broken_entry_is_always_allowed(self, monkeypatch):
"""Turning it off is the safe direction — never block the user."""
result, written = self._saved(monkeypatch, "", "", False)
assert '"status": "success"' in result
assert written["chat_fallback"]["enabled"] is False
def test_an_unknown_provider_is_rejected(self, monkeypatch):
result, _ = self._saved(monkeypatch, "not-a-vendor", "m", True)
assert "error" in result
def test_an_unknown_provider_inside_the_chain_is_rejected(self, monkeypatch):
result, _ = self._saved(
monkeypatch, "", "", True,
chain=[{"provider": "openai", "model": "ok"},
{"provider": "not-a-vendor", "model": "m"}],
)
assert "error" in result
def test_the_chain_is_persisted_in_order(self, monkeypatch):
"""Order is the whole point: link 1 is tried before link 2."""
result, written = self._saved(
monkeypatch, "", "", True,
chain=[{"provider": "openai", "model": "backup-1"},
{"provider": "qianfan", "model": "backup-2"}],
)
assert '"status": "success"' in result
assert written["chat_fallback"]["chain"] == [
{"provider": "openai", "model": "backup-1"},
{"provider": "qianfan", "model": "backup-2"},
]
def test_a_long_chain_is_persisted_whole(self, monkeypatch):
"""No cap: five links in, five links out."""
chain = [{"provider": "openai", "model": f"backup-{i}"} for i in range(5)]
_, written = self._saved(monkeypatch, "", "", True, chain=chain)
assert len(written["chat_fallback"]["chain"]) == 5
def test_incomplete_links_are_dropped_but_the_rest_survive(self, monkeypatch):
"""One half-filled row shouldn't block saving the good ones."""
_, written = self._saved(
monkeypatch, "", "", True,
chain=[{"provider": "openai", "model": "backup-1"},
{"provider": "qianfan", "model": ""},
{"provider": "", "model": "orphan"}],
)
assert written["chat_fallback"]["chain"] == [
{"provider": "openai", "model": "backup-1"}
]
def test_an_untouched_row_is_dropped_rather_than_rejected(self, monkeypatch):
"""A row the user added and never filled in is not an error."""
_, written = self._saved(
monkeypatch, "", "", True,
chain=[{"provider": "", "model": ""},
{"provider": "openai", "model": "backup-1"}],
)
assert written["chat_fallback"]["chain"] == [
{"provider": "openai", "model": "backup-1"}
]
def test_a_non_list_chain_is_rejected(self, monkeypatch):
result, _ = self._saved(monkeypatch, "", "", True, chain="openai/backup")
assert "error" in result
def test_the_legacy_two_argument_call_still_works(self, monkeypatch):
"""An older client sending one provider/model pair gets a one-link
chain, so it keeps working against the new config."""
_, written = self._saved(monkeypatch, "openai", "backup-model", True)
assert written["chat_fallback"] == {
"enabled": True,
"chain": [{"provider": "openai", "model": "backup-model"}],
}
def test_provider_models_is_a_list_per_provider(self, monkeypatch):
"""The console's model picker calls .slice() on provider_models[id],
@@ -251,20 +414,32 @@ class TestConsoleValidation:
), "PROVIDER_MODELS entries are dicts — hence the reduction"
assert "gpt-4o" in provider_models["openai"]
def test_a_valid_entry_is_persisted(self, monkeypatch):
written = {}
def test_the_capability_exposes_the_saved_chain(self, monkeypatch):
monkeypatch.setattr(
"channel.web.web_channel.conf", lambda: {"model": "primary-model"}
"channel.web.web_channel.conf", lambda: {"model": "deepseek-v4-flash"}
)
handler = self._handler()
monkeypatch.setattr(handler, "_read_file_config", lambda: {})
from channel.web.web_channel import ModelsHandler
cap = ModelsHandler._chat_fallback_capability({
"chat_fallback": {
"enabled": True,
"chain": [{"provider": "openai", "model": "backup-1"},
{"provider": "qianfan", "model": "backup-2"}],
}
})
assert cap["chain"] == [
{"provider": "openai", "model": "backup-1"},
{"provider": "qianfan", "model": "backup-2"},
]
def test_a_legacy_config_is_reported_as_a_one_link_chain(self, monkeypatch):
"""An old config.json must still show its backup model in the UI."""
monkeypatch.setattr(
handler, "_write_file_config", lambda cfg: written.update(cfg)
"channel.web.web_channel.conf", lambda: {"model": "deepseek-v4-flash"}
)
handler._set_chat_fallback("openai", "backup-model", True)
assert written["chat_fallback"] == {
"enabled": True,
"provider": "openai",
"model": "backup-model",
"max_switches": 1,
}
from channel.web.web_channel import ModelsHandler
cap = ModelsHandler._chat_fallback_capability({
"chat_fallback": {"enabled": True, "provider": "openai", "model": "backup-1"}
})
assert cap["chain"] == [{"provider": "openai", "model": "backup-1"}]
+38 -13
View File
@@ -1,8 +1,9 @@
"""A sub agent must not reset the parent run's engaged fallback.
The fallback is sticky for a whole run: once the primary model has failed a turn
for good, the remaining steps stay on the backup instead of re-probing a
provider we already know is down. It is cleared once, at the top of a run.
The fallback is an ordered chain, and it is sticky for a whole run: once the
primary model has failed a turn for good, the remaining steps stay on whichever
link answered instead of re-probing a provider we already know is down. It is
cleared once, at the top of a run.
That breaks when the parent delegates. A sub agent is built with
``model=parent.model`` — the *same* ``AgentLLMModel`` object, not a copy — and
@@ -10,7 +11,9 @@ its ``run_stream`` runs that same reset at its top. The child therefore clears
the fallback the parent is currently relying on: when the child returns, the
parent goes back to the primary model that just failed and burns the full retry
budget again on every remaining step. The child loses the backup too, since it
is running inside the same outage and starts on the dead primary itself.
is running inside the same outage and starts on the dead primary itself — and
with a chain it would also rewind the parent to link 1, throwing away the
progress of every link already proven to be down.
The reset has to be scoped to a *top-level* run: an outer scope that already set
a run id means this run is nested and must leave the parent's routing alone.
@@ -22,9 +25,10 @@ from bridge.agent_bridge import AgentLLMModel
FALLBACK = {
"enabled": True,
"provider": "openai",
"model": "backup-model",
"max_switches": 1,
"chain": [
{"provider": "openai", "model": "backup-model"},
{"provider": "qianfan", "model": "backup-model-2"},
],
}
@@ -111,8 +115,8 @@ class TestSubAgentDoesNotClearParentFallback:
assert model.model == "primary-model"
def test_the_reset_still_bounds_switches_per_run(self, monkeypatch, executor_cls):
"""After a top-level reset the run earns a fresh switch."""
def test_the_reset_still_lets_the_next_turn_walk_the_chain(self, monkeypatch, executor_cls):
"""After a top-level reset the run starts from link 1 again."""
model = _model(monkeypatch, FALLBACK)
model.use_fallback()
_reset_like_a_run(executor_cls, model)
@@ -122,15 +126,36 @@ class TestSubAgentDoesNotClearParentFallback:
assert model.use_fallback() is True
assert model.model == "backup-model"
def test_a_nested_run_leaves_the_switch_budget_alone(
def test_a_nested_run_leaves_the_chain_position_alone(
self, monkeypatch, executor_cls
):
"""A nested run must not hand the parent extra switches either."""
"""A nested run must not hand the parent extra links, nor rewind it."""
model = _model(monkeypatch, FALLBACK)
model.use_fallback()
_reset_like_a_run(executor_cls, model, ambient_run_id="parent-run-123")
# Still on the backup and out of switches, so it cannot ping-pong.
# Still on the backup and still on link 1, so the parent's next failure
# advances to link 2 rather than repeating the one that just failed.
assert model.model == "backup-model"
assert model.fallback_available() is False
assert model.fallback_available() is True
def test_a_parent_that_walked_two_links_keeps_its_position(
self, monkeypatch, executor_cls
):
"""The regression a chain adds: the child must not rewind the parent to
link 1 after it already proved link 1 was down."""
model = _model(monkeypatch, FALLBACK)
assert model.use_fallback() is True # link 1
assert model.use_fallback() is True # link 2
assert model.model == "backup-model-2"
_reset_like_a_run(executor_cls, model, ambient_run_id="parent-run-123")
assert model.model == "backup-model-2"
# The point of the test: the child's run did NOT rewind the parent to
# link 1. (Two passes are available now, so more advances remain —
# position, not exhaustion, is what this pins down.)
assert model.fallback_available() is True
assert model.use_fallback() is True
assert model.model == "backup-model" # wrapped around: pass 2, link 1