feat(runner): support structured fallback models

Bind fallback model chains to the active model configuration so defaults and presets do not inherit or merge fallback behavior implicitly. Require explicit fallback providers while preserving per-fallback generation overrides and context-window safety.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren
2026-05-13 13:57:30 +00:00
co-authored by Cursor
parent eaa8ebd5d3
commit 02b059a616
5 changed files with 325 additions and 42 deletions
+55 -6
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.config.schema import Config, ModelFallbackConfig, ModelPresetConfig
from nanobot.providers.base import LLMProvider
from nanobot.providers.fallback_provider import FallbackProvider
from nanobot.providers.registry import find_by_name
@@ -104,6 +104,28 @@ def _make_provider_core(
return provider
def _fallback_preset(primary: ModelPresetConfig, fallback: ModelFallbackConfig) -> ModelPresetConfig:
"""Build the effective provider/generation config for one fallback model."""
return ModelPresetConfig(
model=fallback.model,
provider=fallback.provider,
max_tokens=fallback.max_tokens if fallback.max_tokens is not None else primary.max_tokens,
context_window_tokens=(
fallback.context_window_tokens
if fallback.context_window_tokens is not None
else primary.context_window_tokens
),
temperature=(
fallback.temperature if fallback.temperature is not None else primary.temperature
),
reasoning_effort=(
fallback.reasoning_effort
if fallback.reasoning_effort is not None
else primary.reasoning_effort
),
)
def make_provider(
config: Config,
*,
@@ -120,12 +142,11 @@ def make_provider(
provider = _make_provider_core(config, preset_name=preset_name, preset=preset, model=model)
if resolved.fallback_models:
fb_preset = resolved.model_copy(update={"provider": "auto", "fallback_models": []})
provider = FallbackProvider(
primary=provider,
fallback_models=resolved.fallback_models,
provider_factory=lambda m: _make_provider_core(
config, preset_name=preset_name, preset=fb_preset, model=m
provider_factory=lambda fb: _make_provider_core(
config, preset_name=preset_name, preset=_fallback_preset(resolved, fb)
),
)
@@ -138,9 +159,32 @@ def provider_signature(
preset_name: str | None = None,
preset: ModelPresetConfig | None = None,
) -> tuple[object, ...]:
"""Return the config fields that affect the primary LLM provider."""
"""Return the config fields that affect the active provider chain."""
resolved = _resolve_model_preset(config, preset_name=preset_name, preset=preset)
p = config.get_provider(resolved.model, preset=resolved)
def _fallback_signature(fallback: ModelFallbackConfig) -> tuple[object, ...]:
fallback_preset = _fallback_preset(resolved, fallback)
fp = config.get_provider(fallback.model, preset=fallback_preset)
return (
fallback.model,
fallback.provider,
fallback_preset.max_tokens,
fallback_preset.temperature,
fallback_preset.reasoning_effort,
fallback_preset.context_window_tokens,
config.get_provider_name(fallback.model, preset=fallback_preset),
config.get_api_key(fallback.model, preset=fallback_preset),
config.get_api_base(fallback.model, preset=fallback_preset),
fp.extra_headers if fp else None,
fp.extra_body if fp else None,
getattr(fp, "region", None) if fp else None,
getattr(fp, "profile", None) if fp else None,
)
fallback_signatures = tuple(
_fallback_signature(fallback) for fallback in resolved.fallback_models
)
return (
resolved.model,
resolved.provider,
@@ -155,6 +199,7 @@ def provider_signature(
resolved.temperature,
resolved.reasoning_effort,
resolved.context_window_tokens,
fallback_signatures,
)
@@ -165,10 +210,14 @@ def build_provider_snapshot(
preset: ModelPresetConfig | None = None,
) -> ProviderSnapshot:
resolved = _resolve_model_preset(config, preset_name=preset_name, preset=preset)
fallback_windows = [
_fallback_preset(resolved, fallback).context_window_tokens
for fallback in resolved.fallback_models
]
return ProviderSnapshot(
provider=make_provider(config, preset=resolved),
model=resolved.model,
context_window_tokens=resolved.context_window_tokens,
context_window_tokens=min([resolved.context_window_tokens, *fallback_windows]),
signature=provider_signature(config, preset=resolved),
)