mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-10-25 17:36:29 +08:00
Merge branch 'master' of https://github.com/zhayujie/CowAgent
This commit is contained in:
commit
bda0505302
14 files changed
+1694
-318
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user