feat(config): add image-aware model presets

This commit is contained in:
chengyongru 2026-07-29 00:02:21 +08:00
parent 9070d7489a
commit f239b45900
49 changed files with 1558 additions and 492 deletions

View File

@ -15,10 +15,13 @@ from nanobot.apps.cli import utils as cli_app_utils
from nanobot.bus.events import InboundMessage
from nanobot.runtime_context import (
RUNTIME_CONTEXT_END,
RUNTIME_CONTEXT_HISTORY_META,
RUNTIME_CONTEXT_MESSAGE_META,
RUNTIME_CONTEXT_TAG,
RuntimeContextBlock,
append_runtime_context,
detach_runtime_context,
reattach_runtime_context,
)
from nanobot.utils.helpers import (
detect_image_mime,
@ -60,6 +63,9 @@ class ContextBuilder:
_MAX_RECENT_HISTORY = 50
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
_RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END
_MISSING_IMAGE_TEXT = (
"[Image attachment unavailable — do not describe or reference it]"
)
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
self.workspace = workspace
@ -224,7 +230,7 @@ class ContextBuilder:
unified_session=unified_session,
),
},
*history,
*self._hydrate_history_media(history),
]
if messages[-1].get("role") == current_role:
last = dict(messages[-1])
@ -254,6 +260,9 @@ class ContextBuilder:
for path in image_paths:
p = Path(path)
if not p.is_file():
image_blocks.append(
{"type": "text", "text": self._MISSING_IMAGE_TEXT}
)
continue
raw = p.read_bytes()
# Re-detect from the bytes used for the request: the file may have
@ -271,3 +280,45 @@ class ContextBuilder:
if not image_blocks:
return text
return image_blocks + [{"type": "text", "text": text}]
def _hydrate_history_media(
self,
history: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Rebuild persisted user media into the same blocks used on first send."""
hydrated: list[dict[str, Any]] = []
for message in history:
clean = dict(message)
media_paths = clean.pop("_media_paths", None)
runtime_context = clean.pop(RUNTIME_CONTEXT_HISTORY_META, None)
if (
clean.get("role") == "user"
and isinstance(clean.get("content"), str)
and isinstance(media_paths, list)
and media_paths
):
visible_content = clean["content"]
detached = (
detach_runtime_context(visible_content, runtime_context)
if isinstance(runtime_context, Mapping)
else None
)
if detached is not None:
visible_content, sources, context_blocks = detached
hydrated_content = self.build_user_content(
visible_content,
image_paths=[
path
for path in media_paths
if isinstance(path, str) and path
],
)
if detached is not None:
hydrated_content, _ = reattach_runtime_context(
hydrated_content,
sources,
context_blocks,
)
clean["content"] = hydrated_content
hydrated.append(clean)
return hydrated

View File

@ -299,7 +299,7 @@ class AgentLoop:
initial_context_window = (
context_window_tokens
if context_window_tokens is not None
else defaults.context_window_tokens
else ModelPresetConfig(model=initial_model).context_window_tokens
)
configured_presets = model_presets or {}
self.runtime_resolver = ModelRuntimeResolver(
@ -445,15 +445,18 @@ class AgentLoop:
if bus is None:
bus = MessageBus()
defaults = config.agents.defaults
provider = extra.pop("provider", None) or make_provider(config)
explicit_provider = extra.pop("provider", None)
provider = explicit_provider or make_provider(config)
resolved = config.resolve_preset()
model = extra.pop("model", None) or resolved.model
context_window_tokens = extra.pop("context_window_tokens", None) or resolved.context_window_tokens
provider_snapshot_loader = extra.pop("provider_snapshot_loader", None)
preset_snapshot_loader = extra.pop("preset_snapshot_loader", None) or preset_helpers.make_preset_snapshot_loader(
config,
provider_snapshot_loader,
)
preset_snapshot_loader = extra.pop("preset_snapshot_loader", None)
if preset_snapshot_loader is None and explicit_provider is None:
preset_snapshot_loader = preset_helpers.make_preset_snapshot_loader(
config,
provider_snapshot_loader,
)
return cls(
bus=bus,
provider=provider,
@ -1616,6 +1619,7 @@ class AgentLoop:
"max_messages": replay_max_messages,
"max_tokens": self._replay_token_budget(runtime),
"extend_to_user": is_subagent,
"include_media": True,
}
ctx.history = ctx.session.get_history(**_hist_kwargs)
if is_subagent:

View File

@ -19,10 +19,12 @@ from nanobot.runtime_context import public_history_messages
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.gitstore import GitStore
from nanobot.utils.helpers import (
content_with_media_breadcrumbs,
ensure_dir,
estimate_message_tokens,
estimate_prompt_tokens_chain,
find_legal_message_start,
image_placeholder_text,
recent_message_start_index,
strip_think,
truncate_text,
@ -695,14 +697,58 @@ class MemoryStore:
def _format_messages(messages: list[dict]) -> str:
lines = []
for message in messages:
if not message.get("content"):
content = message.get("content") or ""
media = message.get("media")
media_paths = (
[
path.replace("\r", " ").replace("\n", " ")
for path in media[:16]
if isinstance(path, str) and path
]
if isinstance(media, list)
else []
)
content = content_with_media_breadcrumbs(
message.get("role"),
content,
media_paths,
)
if not content:
continue
tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else ""
lines.append(
f"[{message.get('timestamp', '?')[:16]}] {message['role'].upper()}{tools}: {message['content']}"
f"[{message.get('timestamp', '?')[:16]}] "
f"{message['role'].upper()}{tools}: {content}"
)
return "\n".join(lines)
@staticmethod
def _media_manifest(messages: list[dict]) -> str:
paths: list[str] = []
seen: set[str] = set()
for message in messages:
media = message.get("media")
if not isinstance(media, list):
continue
for raw_path in media:
if not isinstance(raw_path, str) or not raw_path:
continue
path = raw_path.replace("\r", " ").replace("\n", " ")
if path in seen:
continue
seen.add(path)
paths.append(path)
if len(paths) >= 64:
break
if len(paths) >= 64:
break
if not paths:
return ""
return "Archived attachments:\n" + "\n".join(
f"- {image_placeholder_text(path)}"
for path in paths
)
def raw_archive(
self,
messages: list[dict],
@ -712,10 +758,11 @@ class MemoryStore:
) -> None:
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
formatted = truncate_text(
self._format_messages(public_history_messages(messages)),
limit,
)
formatted = self._format_messages(public_history_messages(messages))
manifest = self._media_manifest(messages)
if manifest:
formatted = f"{manifest}\n\n{formatted}"
formatted = truncate_text(formatted, limit)
self.append_history(
f"[RAW] {len(messages)} messages\n"
f"{formatted}",
@ -1020,6 +1067,11 @@ class Consolidator:
self.store.raw_archive(messages, session_key=session_key)
return None
summary = response.content or "[no summary]"
manifest = MemoryStore._media_manifest(messages)
if manifest:
# Keep the deterministic manifest before generated prose so normal
# archive truncation preserves attachment references first.
summary = f"{manifest}\n\n{summary}"
self.store.append_history(
summary,
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,

View File

@ -23,7 +23,7 @@ def default_selection_signature(
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]:
return {**config.model_presets, "default": config.resolve_default_preset()}
return dict(config.model_presets)
def load_model_preset_catalog(
@ -61,6 +61,7 @@ def build_static_preset_snapshot(
signature=("model_preset", name, preset.model_dump_json()),
generation=preset.to_generation_settings(),
model_preset=name,
supports_image_input=preset.supports_image_input,
)

View File

@ -788,6 +788,7 @@ class AgentRunner:
kwargs["temperature"] = generation.temperature
kwargs["max_tokens"] = generation.max_tokens
kwargs["reasoning_effort"] = generation.reasoning_effort
kwargs["supports_image_input"] = spec.runtime.supports_image_input
return kwargs
async def _request_model(

View File

@ -26,7 +26,7 @@ from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import AgentDefaults, ToolsConfig
from nanobot.config.schema import AgentDefaults, ModelPresetConfig, ToolsConfig
from nanobot.providers.base import LLMProvider
from nanobot.security.workspace_access import (
WorkspaceScope,
@ -121,7 +121,9 @@ class SubagentManager:
self._compat_runtime = LLMRuntime.capture(
provider,
model or provider.get_default_model(),
context_window_tokens=defaults.context_window_tokens,
context_window_tokens=ModelPresetConfig(
model=model or provider.get_default_model()
).context_window_tokens,
)
self.workspace = workspace
self.bus = bus
@ -161,7 +163,7 @@ class SubagentManager:
context_window_tokens = (
self._compat_runtime.context_window_tokens
if self._compat_runtime is not None
else AgentDefaults().context_window_tokens
else ModelPresetConfig(model=model).context_window_tokens
)
self._compat_runtime = LLMRuntime.capture(
provider,

View File

@ -2462,7 +2462,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
port = 29891
config_path = tmp_path / "config.json"
config = Config()
config.agents.defaults.model = "openai/gpt-4o"
config.resolve_default_preset().model = "openai/gpt-4o"
config.providers.openai.api_key = "secret-key"
config.model_presets["deep"] = ModelPresetConfig(
model="anthropic/claude-opus-4-5",
@ -2795,8 +2795,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert bad_image.status_code == 400
saved = load_config(config_path)
assert saved.agents.defaults.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "atomic_chat"
assert saved.resolve_default_preset().model == "atomic_chat/test"
assert saved.resolve_default_preset().provider == "atomic_chat"
assert saved.agents.defaults.model_preset == "fast-writing"
assert saved.agents.defaults.fallback_models == ["deep"]
assert saved.model_presets["fast-writing"].label == "Codex"
@ -3001,7 +3001,7 @@ def test_settings_payload_normalizes_camel_case_provider(
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.agents.defaults.provider = "minimaxAnthropic"
config.resolve_default_preset().provider = "minimaxAnthropic"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)

View File

@ -794,7 +794,7 @@ def _model_display(config: Config) -> tuple[str, str]:
"""Return (resolved_model_name, preset_tag) for display strings."""
resolved = config.resolve_preset()
name = config.agents.defaults.model_preset
tag = f" (preset: {name})" if name else ""
tag = f" (preset: {name})" if name != "default" else ""
return resolved.model, tag
@ -2969,11 +2969,13 @@ def _set_oauth_provider_as_main(
config = load_config(resolved_config_path)
selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
config.agents.defaults.model_preset = None
config.agents.defaults.provider = provider_name
config.agents.defaults.model = selected_model
default_preset = config.resolve_default_preset().model_copy(
update={"provider": provider_name, "model": selected_model}
)
if provider_name == "xai_grok" and selected_model == "xai-grok/grok-4.5":
config.agents.defaults.context_window_tokens = 500_000
default_preset.context_window_tokens = 500_000
config.model_presets["default"] = default_preset
config.agents.defaults.model_preset = "default"
save_config(config, resolved_config_path)
saved_path = resolved_config_path or get_config_path()

View File

@ -755,15 +755,13 @@ def _handle_model_preset_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the 'model_preset' field with a list of existing presets."""
preset_names = sorted(_MODEL_PRESET_CACHE)
choices = [_CLEAR_CHOICE] + preset_names
default_choice = str(current_value) if current_value else _CLEAR_CHOICE
preset_names = sorted(_MODEL_PRESET_CACHE) or ["default"]
choices = preset_names
default_choice = str(current_value) if current_value else "default"
new_value = _select_with_back(field_display, choices, default=default_choice)
if new_value is _BACK_PRESSED:
return
if new_value == _CLEAR_CHOICE:
setattr(working_model, field_name, None)
elif new_value is not None:
if new_value is not None:
setattr(working_model, field_name, new_value)
@ -792,8 +790,6 @@ def _handle_fallback_models_field(
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
) -> None:
"""Handle the 'fallback_models' field with preset-aware list management."""
from nanobot.config.schema import InlineFallbackConfig
items: list[Any] = list(current_value) if isinstance(current_value, list) else []
preset_names = sorted(_MODEL_PRESET_CACHE)
@ -802,10 +798,7 @@ def _handle_fallback_models_field(
console.print(f"[bold]{field_display}[/bold]")
if items:
for idx, item in enumerate(items, 1):
if isinstance(item, InlineFallbackConfig):
console.print(f" {idx}. {item.model} - {item.provider} inline")
else:
console.print(f" {idx}. {item}")
console.print(f" {idx}. {item}")
else:
console.print(" [dim]empty[/dim]")
console.print()

View File

@ -110,7 +110,7 @@ def load_config(config_path: Path | None = None) -> Config:
),
)
data = _migrate_config(data)
data, migrated = _migrate_config(data)
try:
config = Config.model_validate(data)
except ValidationError as exc:
@ -122,6 +122,9 @@ def load_config(config_path: Path | None = None) -> Config:
issues=issues,
) from exc
if migrated:
_write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False))
_apply_ssrf_whitelist(config)
return config
@ -310,12 +313,191 @@ def _env_replace(match: re.Match[str]) -> str:
return value
def _migrate_config(data: dict) -> dict:
_LEGACY_DEFAULT_PRESET = {
"label": "Default",
"model": "anthropic/claude-opus-4-5",
"provider": "auto",
"maxTokens": 8192,
"contextWindowTokens": 200_000,
"temperature": 0.1,
"reasoningEffort": None,
}
_LEGACY_MODEL_FIELD_ALIASES = {
"model": ("model",),
"provider": ("provider",),
"maxTokens": ("maxTokens", "max_tokens"),
"contextWindowTokens": ("contextWindowTokens", "context_window_tokens"),
"temperature": ("temperature",),
"reasoningEffort": ("reasoningEffort", "reasoning_effort"),
}
def _pop_alias(mapping: dict[str, Any], aliases: tuple[str, ...]) -> tuple[bool, Any]:
found = False
value: Any = None
for alias in aliases:
if alias in mapping:
if not found:
value = mapping[alias]
found = True
mapping.pop(alias, None)
return found, value
def _preset_value(preset: dict[str, Any], camel: str, snake: str) -> Any:
return preset.get(camel, preset.get(snake))
def _first_not_none(*values: Any) -> Any:
return next((value for value in values if value is not None), None)
def _unique_legacy_fallback_name(presets: dict[str, Any], model: Any) -> str:
tail = str(model or "fallback").rsplit("/", 1)[-1].strip().lower()
base = re.sub(r"[^a-z0-9]+", "-", tail).strip("-") or "fallback"
name = base
suffix = 2
while name in presets:
name = f"{base}-{suffix}"
suffix += 1
return name
def _needs_legacy_model_migration(data: dict[str, Any]) -> bool:
agents = data.get("agents")
defaults = agents.get("defaults") if isinstance(agents, dict) else None
if isinstance(defaults, dict):
if any(
alias in defaults
for aliases in _LEGACY_MODEL_FIELD_ALIASES.values()
for alias in aliases
):
return True
if "model_preset" in defaults:
return True
active = defaults.get("modelPreset")
if "modelPreset" in defaults and (
not isinstance(active, str) or not active.strip()
):
return True
fallbacks = defaults.get(
"fallbackModels",
defaults.get("fallback_models"),
)
if isinstance(fallbacks, list) and any(
isinstance(fallback, dict) for fallback in fallbacks
):
return True
presets = data.get("modelPresets", data.get("model_presets"))
return isinstance(presets, dict) and "default" not in presets
def _migrate_legacy_model_config(data: dict[str, Any]) -> bool:
"""Move concrete model settings into named presets before schema validation."""
if not _needs_legacy_model_migration(data):
return False
changed = False
agents = data.setdefault("agents", {})
if not isinstance(agents, dict):
return False
defaults = agents.setdefault("defaults", {})
if not isinstance(defaults, dict):
return False
presets_key = "modelPresets" if "modelPresets" in data else "model_presets"
if presets_key not in data:
presets_key = "modelPresets"
data[presets_key] = {}
changed = True
presets = data[presets_key]
if not isinstance(presets, dict):
return changed
migrated_default = dict(_LEGACY_DEFAULT_PRESET)
legacy_values_found = False
for destination, aliases in _LEGACY_MODEL_FIELD_ALIASES.items():
found, value = _pop_alias(defaults, aliases)
if found:
migrated_default[destination] = value
legacy_values_found = True
changed = True
if "default" not in presets:
presets["default"] = migrated_default
changed = True
had_canonical_active = "modelPreset" in defaults
active_found, active = _pop_alias(defaults, ("modelPreset", "model_preset"))
normalized_active = active.strip() if isinstance(active, str) else ""
normalized_active = normalized_active or "default"
if not active_found or active != normalized_active or not had_canonical_active:
changed = True
defaults["modelPreset"] = normalized_active
fallback_key = (
"fallbackModels"
if "fallbackModels" in defaults
else "fallback_models"
if "fallback_models" in defaults
else None
)
if fallback_key is not None and isinstance(defaults[fallback_key], list):
primary = presets.get(normalized_active)
if not isinstance(primary, dict):
primary = presets["default"]
migrated_fallbacks: list[Any] = []
for fallback in defaults[fallback_key]:
if isinstance(fallback, str):
migrated_fallbacks.append(fallback)
continue
if not isinstance(fallback, dict):
migrated_fallbacks.append(fallback)
continue
name = _unique_legacy_fallback_name(presets, fallback.get("model"))
presets[name] = {
"label": str(fallback.get("model") or name),
"model": fallback.get("model"),
"provider": fallback.get("provider"),
"maxTokens": _first_not_none(
_preset_value(fallback, "maxTokens", "max_tokens"),
_preset_value(primary, "maxTokens", "max_tokens"),
_LEGACY_DEFAULT_PRESET["maxTokens"],
),
"contextWindowTokens": _first_not_none(
_preset_value(fallback, "contextWindowTokens", "context_window_tokens"),
_preset_value(primary, "contextWindowTokens", "context_window_tokens"),
_LEGACY_DEFAULT_PRESET["contextWindowTokens"],
),
"temperature": (
fallback["temperature"]
if fallback.get("temperature") is not None
else primary.get("temperature", _LEGACY_DEFAULT_PRESET["temperature"])
),
"reasoningEffort": _preset_value(
fallback,
"reasoningEffort",
"reasoning_effort",
),
}
migrated_fallbacks.append(name)
changed = True
if fallback_key != "fallbackModels":
defaults.pop(fallback_key, None)
changed = True
defaults["fallbackModels"] = migrated_fallbacks
return changed or legacy_values_found
def _migrate_config(data: dict) -> tuple[dict, bool]:
"""Migrate old config formats to current."""
changed = _migrate_legacy_model_config(data)
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
tools = data.get("tools", {})
if not isinstance(tools, dict):
return data
return data, changed
exec_cfg = tools.get("exec", {})
if (
isinstance(exec_cfg, dict)
@ -323,6 +505,7 @@ def _migrate_config(data: dict) -> dict:
and "restrictToWorkspace" not in tools
):
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
changed = True
# Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}.
# The old flat keys shipped in the initial MyTool landing; wrapping them in a
@ -332,18 +515,21 @@ def _migrate_config(data: dict) -> dict:
if my_cfg is None:
my_cfg = {}
tools["my"] = my_cfg
changed = True
if not isinstance(my_cfg, dict):
return data
return data, changed
if "myEnabled" in tools and "enable" not in my_cfg:
my_cfg["enable"] = tools.pop("myEnabled")
changed = True
else:
tools.pop("myEnabled", None)
changed = tools.pop("myEnabled", None) is not None or changed
if "mySet" in tools and "allowSet" not in my_cfg:
my_cfg["allowSet"] = tools.pop("mySet")
changed = True
else:
tools.pop("mySet", None)
changed = tools.pop("mySet", None) is not None or changed
return data
return data, changed
def _sentence(message: str) -> str:

View File

@ -79,20 +79,6 @@ class DreamConfig(Base):
return f"every {hours}h"
class InlineFallbackConfig(Base):
"""One inline fallback model configuration."""
model: str
provider: str
max_tokens: int | None = None
context_window_tokens: int | None = None
temperature: float | None = None
reasoning_effort: str | None = None
FallbackCandidate = str | InlineFallbackConfig
class ModelPresetConfig(Base):
"""A named set of model + generation parameters for quick switching."""
@ -103,6 +89,7 @@ class ModelPresetConfig(Base):
context_window_tokens: int = 200_000
temperature: float = 0.1
reasoning_effort: str | None = None
supports_image_input: bool | None = None
def to_generation_settings(self) -> Any:
from nanobot.providers.base import GenerationSettings
@ -117,16 +104,9 @@ class AgentDefaults(Base):
"""Default agent configuration."""
workspace: str = "~/.nanobot/workspace"
model_preset: str | None = None # Active preset name — takes precedence over fields below
model: str = "anthropic/claude-opus-4-5"
provider: str = (
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
)
max_tokens: int = 8192
context_window_tokens: int = 200_000
model_preset: str = "default"
context_block_limit: int | None = None
temperature: float = 0.1
fallback_models: list[FallbackCandidate] = Field(default_factory=list)
fallback_models: list[str] = Field(default_factory=list)
max_tool_iterations: int = 200
max_concurrent_subagents: int = Field(default=1, ge=1)
fail_on_tool_error: bool = True
@ -139,7 +119,6 @@ class AgentDefaults(Base):
validation_alias=AliasChoices("toolHintMaxLength"),
serialization_alias="toolHintMaxLength",
) # Max characters for tool hint display (e.g. "$ cd …/project && npm test")
reasoning_effort: str | None = None # low / medium / high / adaptive / none — LLM thinking effort; None preserves the provider default
timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
bot_name: str = "nanobot" # Display name shown in CLI prompts (e.g. "{name} is thinking...")
bot_icon: str = "🐈" # Short icon (emoji or text) shown next to the bot name in CLI; "" to omit
@ -419,7 +398,12 @@ class Config(BaseSettings):
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
tools: ToolsConfig = Field(default_factory=ToolsConfig)
model_presets: dict[str, ModelPresetConfig] = Field(
default_factory=dict,
default_factory=lambda: {
"default": ModelPresetConfig(
label="Default",
model="anthropic/claude-opus-4-5",
)
},
validation_alias=AliasChoices("modelPresets", "model_presets"),
serialization_alias="modelPresets",
)
@ -431,33 +415,26 @@ class Config(BaseSettings):
@model_validator(mode="after")
def _validate_model_preset(self) -> "Config":
if "default" in self.model_presets:
raise ValueError("model_preset name 'default' is reserved for agents.defaults")
if "default" not in self.model_presets:
raise ValueError("model_presets must define a 'default' preset")
name = self.agents.defaults.model_preset
if name and name != "default" and name not in self.model_presets:
if name not in self.model_presets:
raise ValueError(f"model_preset {name!r} not found in model_presets")
dream_name = self.agents.defaults.dream.model_override
if dream_name and dream_name != "default" and dream_name not in self.model_presets:
if dream_name and dream_name not in self.model_presets:
raise ValueError(f"Dream model preset {dream_name!r} not found in model_presets")
for fallback in self.agents.defaults.fallback_models:
if isinstance(fallback, str) and fallback not in self.model_presets:
if fallback not in self.model_presets:
raise ValueError(f"fallback_models entry {fallback!r} not found in model_presets")
return self
def resolve_default_preset(self) -> ModelPresetConfig:
"""Return the implicit `default` preset from agents.defaults fields."""
d = self.agents.defaults
return ModelPresetConfig(
model=d.model, provider=d.provider, max_tokens=d.max_tokens,
context_window_tokens=d.context_window_tokens,
temperature=d.temperature, reasoning_effort=d.reasoning_effort,
)
"""Return the concrete ``default`` model preset."""
return self.model_presets["default"]
def resolve_preset(self, name: str | None = None) -> ModelPresetConfig:
"""Return effective model params from a named preset or the implicit default."""
name = self.agents.defaults.model_preset if name is None else name
if not name or name == "default":
return self.resolve_default_preset()
"""Return effective model params from a named preset."""
name = self.agents.defaults.model_preset if name is None else (name or "default")
if name not in self.model_presets:
raise KeyError(f"model_preset {name!r} not found in model_presets")
return self.model_presets[name]

View File

@ -114,9 +114,10 @@ class Nanobot:
Path(workspace).expanduser().resolve()
)
if model is not None:
config.agents.defaults.model_preset = None
config.agents.defaults.model = model
config.agents.defaults.provider = "auto"
config.model_presets["default"] = config.resolve_preset().model_copy(
update={"model": model, "provider": "auto"}
)
config.agents.defaults.model_preset = "default"
elif model_preset is not None:
config.agents.defaults.model_preset = model_preset

View File

@ -218,6 +218,16 @@ class LLMProvider(ABC):
"速率限制",
"访问量过大",
)
_IMAGE_UNSUPPORTED_MARKERS = (
"does not support image",
"doesn't support image",
"images are not supported",
"image input is not supported",
"image input not supported",
"image_url is not supported",
"unsupported image input",
"vision is not supported",
)
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429})
_TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
_NON_RETRYABLE_429_ERROR_TOKENS = frozenset({
@ -272,6 +282,7 @@ class LLMProvider(ABC):
self.api_key = api_key
self.api_base = api_base
self.generation: GenerationSettings = GenerationSettings()
self.supports_image_input: bool | None = None
@staticmethod
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
@ -602,6 +613,51 @@ class LLMProvider(ABC):
result.append(msg)
return result if found else None
def _messages_for_image_capability(
self,
messages: list[dict[str, Any]],
*,
supports_image_input: bool | None | object = _SENTINEL,
) -> list[dict[str, Any]]:
"""Apply an explicit text-only preset before making a provider request."""
capability = (
self.supports_image_input
if supports_image_input is self._SENTINEL
else supports_image_input
)
if capability is not False:
return messages
return self._strip_image_content(messages) or messages
def _outer_image_capability(
self,
supports_image_input: bool | None,
) -> bool | None:
"""Return the image policy applied by this provider's retry wrapper."""
return supports_image_input
def _image_policy_request_kwargs(
self,
supports_image_input: bool | None,
) -> dict[str, Any]:
"""Return provider-internal kwargs needed for candidate image policy."""
return {}
@classmethod
def _is_image_unsupported_response(cls, response: LLMResponse) -> bool:
if response.finish_reason != "error":
return False
text = " ".join(
str(value or "")
for value in (
response.content,
response.error_kind,
response.error_type,
response.error_code,
)
).lower()
return any(marker in text for marker in cls._IMAGE_UNSUPPORTED_MARKERS)
@staticmethod
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
"""Replace image_url blocks with text placeholder *in-place*.
@ -692,6 +748,7 @@ class LLMProvider(ABC):
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
retry_mode: str = "standard",
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
supports_image_input: bool | None | object = _SENTINEL,
) -> LLMResponse:
"""Call chat_stream() with retry on transient provider failures."""
if max_tokens is self._SENTINEL or max_tokens is None:
@ -700,6 +757,14 @@ class LLMProvider(ABC):
temperature = self.generation.temperature
if reasoning_effort is self._SENTINEL:
reasoning_effort = self.generation.reasoning_effort
candidate_image_capability = (
self.supports_image_input
if supports_image_input is self._SENTINEL
else supports_image_input
)
outer_image_capability = self._outer_image_capability(
candidate_image_capability
)
has_streamed_content = False
@ -717,13 +782,19 @@ class LLMProvider(ABC):
has_streamed_content = False
kw: dict[str, Any] = dict(
messages=messages, tools=tools, model=model,
messages=self._messages_for_image_capability(
messages,
supports_image_input=outer_image_capability,
),
tools=tools,
model=model,
max_tokens=max_tokens, temperature=temperature,
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
on_content_delta=_tracking_delta if on_content_delta is not None else None,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
)
kw.update(self._image_policy_request_kwargs(candidate_image_capability))
if on_stream_recover and getattr(self, "supports_stream_recover_callback", False):
kw["on_stream_recover"] = _recover_stream
return await self._run_with_retry(
@ -734,6 +805,7 @@ class LLMProvider(ABC):
on_retry_wait=on_retry_wait,
should_retry_guard=lambda: not has_streamed_content,
on_stream_recover=_recover_stream if on_stream_recover else None,
supports_image_input=outer_image_capability,
)
async def chat_with_retry(
@ -747,6 +819,7 @@ class LLMProvider(ABC):
tool_choice: str | dict[str, Any] | None = None,
retry_mode: str = "standard",
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
supports_image_input: bool | None | object = _SENTINEL,
) -> LLMResponse:
"""Call chat() with retry on transient provider failures.
@ -763,18 +836,33 @@ class LLMProvider(ABC):
temperature = self.generation.temperature
if reasoning_effort is self._SENTINEL:
reasoning_effort = self.generation.reasoning_effort
candidate_image_capability = (
self.supports_image_input
if supports_image_input is self._SENTINEL
else supports_image_input
)
outer_image_capability = self._outer_image_capability(
candidate_image_capability
)
kw: dict[str, Any] = dict(
messages=messages, tools=tools, model=model,
messages=self._messages_for_image_capability(
messages,
supports_image_input=outer_image_capability,
),
tools=tools,
model=model,
max_tokens=max_tokens, temperature=temperature,
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
)
kw.update(self._image_policy_request_kwargs(candidate_image_capability))
return await self._run_with_retry(
self._safe_chat,
kw,
messages,
retry_mode=retry_mode,
on_retry_wait=on_retry_wait,
supports_image_input=outer_image_capability,
)
@classmethod
@ -882,6 +970,7 @@ class LLMProvider(ABC):
on_retry_wait: Callable[[str], Awaitable[None]] | None,
should_retry_guard: Callable[[], bool] | None = None,
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
supports_image_input: bool | None | object = _SENTINEL,
) -> LLMResponse:
attempt = 0
delays = list(self._CHAT_RETRY_DELAYS)
@ -928,9 +1017,19 @@ class LLMProvider(ABC):
if not self._is_transient_response(response):
stripped = self._strip_image_content(original_messages)
if stripped is not None and stripped != kw["messages"]:
if (
(
self.supports_image_input
if supports_image_input is self._SENTINEL
else supports_image_input
)
is None
and self._is_image_unsupported_response(response)
and stripped is not None
and stripped != kw["messages"]
):
logger.warning(
"Non-transient LLM error with image content, retrying without images"
"Model rejected image input, retrying without images"
)
retry_kw = dict(kw)
retry_kw["messages"] = stripped

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig, ProviderConfig
from nanobot.config.schema import Config, ModelPresetConfig, ProviderConfig
from nanobot.providers.base import GenerationSettings, LLMProvider
from nanobot.providers.fallback_provider import FallbackProvider
from nanobot.providers.registry import ProviderSpec, create_dynamic_spec, find_by_name
@ -19,6 +19,7 @@ class ProviderSnapshot:
signature: tuple[object, ...]
generation: GenerationSettings | None = None
model_preset: str | None = None
supports_image_input: bool | None = None
@dataclass(frozen=True)
@ -205,37 +206,15 @@ def _make_provider_core(
)
provider.generation = preset.to_generation_settings()
provider.supports_image_input = preset.supports_image_input
return provider
def _inline_fallback_preset(
primary: ModelPresetConfig,
fallback: InlineFallbackConfig,
) -> ModelPresetConfig:
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,
)
def _resolve_fallback_presets(config: Config, primary: ModelPresetConfig) -> list[ModelPresetConfig]:
presets: list[ModelPresetConfig] = []
for fallback in config.agents.defaults.fallback_models:
if isinstance(fallback, str):
presets.append(config.model_presets[fallback])
else:
presets.append(_inline_fallback_preset(primary, fallback))
return presets
def _resolve_fallback_presets(config: Config, _primary: ModelPresetConfig) -> list[ModelPresetConfig]:
return [
config.model_presets[name]
for name in config.agents.defaults.fallback_models
]
def make_provider(
@ -277,6 +256,7 @@ def build_unconfigured_provider_snapshot(config: Config, setup_error: str) -> Pr
context_window_tokens=preset.context_window_tokens,
signature=("unconfigured", setup_error, preset.model),
generation=provider.generation,
supports_image_input=preset.supports_image_input,
)
@ -310,6 +290,7 @@ def provider_signature(
fallback.temperature,
fallback.reasoning_effort,
fallback.context_window_tokens,
fallback.supports_image_input,
getattr(fp, "proxy", None) if fp else None,
fp.thinking_style if fp else None,
)
@ -331,6 +312,7 @@ def provider_signature(
resolved.temperature,
resolved.reasoning_effort,
resolved.context_window_tokens,
resolved.supports_image_input,
getattr(p, "proxy", None) if p else None,
p.thinking_style if p else None,
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
@ -360,6 +342,7 @@ def build_provider_snapshot(
signature=provider_signature(config, preset=resolved),
generation=resolved.to_generation_settings(),
model_preset=selected_preset,
supports_image_input=resolved.supports_image_input,
)

View File

@ -117,6 +117,9 @@ class FallbackProvider(LLMProvider):
self._provider_factory = provider_factory
self._fallback_model_observer = fallback_model_observer
self._has_fallbacks = bool(fallback_presets)
# Candidate-specific image policy is applied inside _try_with_fallback;
# the outer retry wrapper preserves canonical images for the chain.
self.supports_image_input = getattr(primary, "supports_image_input", None)
self._primary_failures = 0
self._primary_tripped_at: float | None = None
@ -139,6 +142,19 @@ class FallbackProvider(LLMProvider):
def supports_progress_deltas(self) -> bool:
return bool(getattr(self._primary, "supports_progress_deltas", False))
def _outer_image_capability(
self,
supports_image_input: bool | None,
) -> bool | None:
"""Keep canonical images intact until each candidate applies its policy."""
return True
def _image_policy_request_kwargs(
self,
supports_image_input: bool | None,
) -> dict[str, Any]:
return {"_primary_supports_image_input": supports_image_input}
def _primary_available(self) -> bool:
"""Return True if the primary provider is not currently tripped."""
if self._primary_tripped_at is None:
@ -149,16 +165,39 @@ class FallbackProvider(LLMProvider):
return False
async def chat(self, **kwargs: Any) -> LLMResponse:
primary_supports_image_input = kwargs.pop(
"_primary_supports_image_input",
getattr(self._primary, "supports_image_input", None),
)
if not self._has_fallbacks:
return await self._primary.chat(**kwargs)
return await self._call_with_image_policy(
lambda p, kw: p.chat(**kw),
self._primary,
kwargs,
has_streamed=None,
supports_image_input=primary_supports_image_input,
)
return await self._try_with_fallback(
lambda p, kw: p.chat(**kw), kwargs, has_streamed=None
lambda p, kw: p.chat(**kw),
kwargs,
has_streamed=None,
primary_supports_image_input=primary_supports_image_input,
)
async def chat_stream(self, **kwargs: Any) -> LLMResponse:
on_stream_recover = kwargs.pop("on_stream_recover", None)
primary_supports_image_input = kwargs.pop(
"_primary_supports_image_input",
getattr(self._primary, "supports_image_input", None),
)
if not self._has_fallbacks:
return await self._primary.chat_stream(**kwargs)
return await self._call_with_image_policy(
lambda p, kw: p.chat_stream(**kw),
self._primary,
kwargs,
has_streamed=None,
supports_image_input=primary_supports_image_input,
)
has_streamed: list[bool] = [False]
original_delta = kwargs.get("on_content_delta")
@ -175,6 +214,7 @@ class FallbackProvider(LLMProvider):
kwargs,
has_streamed=has_streamed,
on_stream_recover=on_stream_recover,
primary_supports_image_input=primary_supports_image_input,
)
async def _try_with_fallback(
@ -183,6 +223,7 @@ class FallbackProvider(LLMProvider):
kwargs: dict[str, Any],
has_streamed: list[bool] | None,
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
primary_supports_image_input: bool | None | object = LLMProvider._SENTINEL,
) -> LLMResponse:
primary_model = kwargs.get("model") or self._primary.get_default_model()
primary_was_attempted = False
@ -190,7 +231,13 @@ class FallbackProvider(LLMProvider):
if self._primary_available():
primary_was_attempted = True
response = await call(self._primary, kwargs)
response = await self._call_with_image_policy(
call,
self._primary,
kwargs,
has_streamed=has_streamed,
supports_image_input=primary_supports_image_input,
)
if response.finish_reason != "error":
self._primary_failures = 0
self._primary_tripped_at = None
@ -216,7 +263,8 @@ class FallbackProvider(LLMProvider):
)
return response
if not self._should_fallback(response):
image_rejected = self._primary._is_image_unsupported_response(response)
if not image_rejected and not self._should_fallback(response):
logger.warning(
"Primary model '{}' returned non-fallbackable error: {}",
primary_model,
@ -224,13 +272,14 @@ class FallbackProvider(LLMProvider):
)
return response
self._primary_failures += 1
if self._primary_failures >= _PRIMARY_FAILURE_THRESHOLD:
self._primary_tripped_at = time.monotonic()
logger.warning(
"Primary model '{}' circuit open after {} consecutive failures",
primary_model, self._primary_failures,
)
if not image_rejected:
self._primary_failures += 1
if self._primary_failures >= _PRIMARY_FAILURE_THRESHOLD:
self._primary_tripped_at = time.monotonic()
logger.warning(
"Primary model '{}' circuit open after {} consecutive failures",
primary_model, self._primary_failures,
)
else:
logger.debug("Primary model '{}' circuit open; skipping", primary_model)
@ -270,6 +319,7 @@ class FallbackProvider(LLMProvider):
)
try:
fallback_provider = self._provider_factory(fallback)
fallback_provider.supports_image_input = fallback.supports_image_input
except Exception as exc:
logger.warning(
"Failed to create provider for fallback '{}': {}", fallback_model, exc
@ -288,7 +338,13 @@ class FallbackProvider(LLMProvider):
fallback_kwargs.pop("reasoning_effort", None)
else:
fallback_kwargs["reasoning_effort"] = fallback.reasoning_effort
fallback_response = await call(fallback_provider, fallback_kwargs)
fallback_response = await self._call_with_image_policy(
call,
fallback_provider,
fallback_kwargs,
has_streamed=has_streamed,
supports_image_input=fallback.supports_image_input,
)
if fallback_response.finish_reason != "error":
logger.info(
@ -317,6 +373,49 @@ class FallbackProvider(LLMProvider):
finish_reason="error",
)
@staticmethod
async def _call_with_image_policy(
call: Callable[[LLMProvider, dict[str, Any]], Awaitable[LLMResponse]],
provider: LLMProvider,
kwargs: dict[str, Any],
*,
has_streamed: list[bool] | None,
supports_image_input: bool | None | object = LLMProvider._SENTINEL,
) -> LLMResponse:
original_messages = kwargs.get("messages")
if not isinstance(original_messages, list):
return await call(provider, kwargs)
prepared_kwargs = dict(kwargs)
prepared_kwargs["messages"] = provider._messages_for_image_capability(
original_messages,
supports_image_input=supports_image_input,
)
response = await call(provider, prepared_kwargs)
capability = (
provider.supports_image_input
if supports_image_input is LLMProvider._SENTINEL
else supports_image_input
)
if (
capability is None
and provider._is_image_unsupported_response(response)
and (has_streamed is None or not has_streamed[0])
):
stripped = provider._strip_image_content(original_messages)
if stripped is not None and stripped != prepared_kwargs["messages"]:
logger.warning(
"Fallback candidate '{}' rejected image input, retrying without images",
prepared_kwargs.get("model") or provider.get_default_model(),
)
retry_kwargs = dict(prepared_kwargs)
retry_kwargs["messages"] = stripped
retry_response = await call(provider, retry_kwargs)
if retry_response.finish_reason != "error":
provider._strip_image_content_inplace(original_messages)
return retry_response
return response
async def _notify_fallback_model(self, model: str) -> None:
if self._fallback_model_observer is None:
return

View File

@ -22,10 +22,10 @@ from nanobot.runtime_context import (
public_history_message,
)
from nanobot.utils.helpers import (
content_with_media_breadcrumbs,
ensure_dir,
estimate_message_tokens,
find_legal_message_start,
image_placeholder_text,
recent_message_start_index,
safe_filename,
strip_think,
@ -165,6 +165,7 @@ class Session:
max_tokens: int = 0,
extend_to_user: bool = False,
include_runtime_context: bool = True,
include_media: bool = False,
) -> list[dict[str, Any]]:
"""Return unconsolidated messages for LLM input.
@ -209,17 +210,17 @@ class Session:
role = message.get("role")
if role == "assistant" and isinstance(content, str):
content = _sanitize_assistant_replay_text(content)
# Synthesize an ``[image: path]`` breadcrumb from the persisted
# ``media`` kwarg so LLM replay still sees *something* where the
# image used to be. Without this, an image-only user turn
# replays as an empty user message — the assistant's reply then
# looks like it's responding to nothing.
media = message.get("media")
if role == "user" and isinstance(media, list) and media and isinstance(content, str):
breadcrumbs = "\n".join(
image_placeholder_text(p) for p in media if isinstance(p, str) and p
)
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
media_paths = (
[path for path in media if isinstance(path, str) and path]
if role == "user" and isinstance(media, list)
else []
)
# General history consumers retain a compact breadcrumb. The agent
# loop asks for internal media refs and deterministically rebuilds
# image blocks at the request boundary.
if media_paths and not include_media:
content = content_with_media_breadcrumbs(role, content, media_paths)
cli_apps = message.get("cli_apps")
if (
include_runtime_context
@ -248,6 +249,11 @@ class Session:
if not any(key in message for key in ("tool_calls", "reasoning_content", "thinking_blocks")):
continue
entry: dict[str, Any] = {"role": message["role"], "content": content}
if media_paths and include_media:
entry["_media_paths"] = media_paths
runtime_context = message.get(RUNTIME_CONTEXT_HISTORY_META)
if isinstance(runtime_context, dict):
entry[RUNTIME_CONTEXT_HISTORY_META] = deepcopy(runtime_context)
for key in ("tool_calls", "tool_call_id", "name", "reasoning_content", "thinking_blocks"):
if key in message:
entry[key] = message[key]

View File

@ -367,6 +367,24 @@ def image_placeholder_text(path: str | None, *, empty: str = "[image]") -> str:
return f"[image: {path}]" if path else empty
def content_with_media_breadcrumbs(
role: object,
content: object,
media: object,
) -> object:
"""Append persisted media paths to user text using the canonical breadcrumb."""
if role != "user" or not isinstance(content, str) or not isinstance(media, list):
return content
breadcrumbs = "\n".join(
image_placeholder_text(path)
for path in media
if isinstance(path, str) and path
)
if not breadcrumbs:
return content
return f"{content}\n{breadcrumbs}" if content else breadcrumbs
def truncate_text(text: str, max_chars: int) -> str:
"""Truncate text with a stable suffix."""
if max_chars <= 0 or len(text) <= max_chars:

View File

@ -10,6 +10,8 @@ from nanobot.providers.base import GenerationSettings, LLMProvider
if TYPE_CHECKING:
from nanobot.providers.factory import ProviderSnapshot
_IMAGE_CAPABILITY_UNSET = object()
@dataclass(frozen=True, slots=True)
class LLMRuntime:
@ -26,6 +28,7 @@ class LLMRuntime:
context_window_tokens: int
model_preset: str | None = None
snapshot_signature: tuple[object, ...] | None = None
supports_image_input: bool | None = None
@classmethod
def capture(
@ -36,10 +39,18 @@ class LLMRuntime:
context_window_tokens: int,
model_preset: str | None = None,
snapshot_signature: tuple[object, ...] | None = None,
supports_image_input: bool | None | object = _IMAGE_CAPABILITY_UNSET,
) -> LLMRuntime:
"""Capture provider defaults without retaining mutable generation state."""
defaults = GenerationSettings()
generation = getattr(provider, "generation", defaults)
provider_image_capability = getattr(provider, "supports_image_input", None)
if not (
provider_image_capability is True
or provider_image_capability is False
or provider_image_capability is None
):
provider_image_capability = None
return cls(
provider=provider,
model=model,
@ -55,6 +66,11 @@ class LLMRuntime:
context_window_tokens=context_window_tokens,
model_preset=model_preset,
snapshot_signature=snapshot_signature,
supports_image_input=(
provider_image_capability
if supports_image_input is _IMAGE_CAPABILITY_UNSET
else supports_image_input
),
)
def with_generation_overrides(
@ -94,6 +110,7 @@ def runtime_from_provider_snapshot(
context_window_tokens=snapshot.context_window_tokens,
model_preset=snapshot.model_preset,
snapshot_signature=snapshot.signature,
supports_image_input=snapshot.supports_image_input,
)
return LLMRuntime.capture(
snapshot.provider,
@ -101,4 +118,5 @@ def runtime_from_provider_snapshot(
context_window_tokens=snapshot.context_window_tokens,
model_preset=snapshot.model_preset,
snapshot_signature=snapshot.signature,
supports_image_input=snapshot.supports_image_input,
)

View File

@ -857,6 +857,13 @@ def _parse_bool(value: str, field: str) -> bool:
return normalized in {"1", "true", "yes"}
def _parse_image_input_support(value: str | None) -> bool | None:
normalized = (value or "").strip().lower()
if normalized in {"", "auto"}:
return None
return _parse_bool(normalized, "supports_image_input")
def _parse_context_window_tokens(value: str | None) -> int | None:
if value is None:
return None
@ -945,28 +952,10 @@ def _provider_display_name_exists(
return False
def _unique_model_configuration_name(config: Any, label: str) -> str:
"""Return a stable, unused preset name for a migrated model configuration."""
try:
base = _model_configuration_slug(label)
except WebUISettingsError:
base = "model"
candidate = base
suffix = 2
while candidate in config.model_presets:
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
def _model_configuration_label(model: str) -> str:
return model.rsplit("/", 1)[-1] or model
def _model_call_order_state(config: Any) -> tuple[list[str], bool]:
defaults = config.agents.defaults
primary = defaults.model_preset
if not primary or primary == "default" or primary not in config.model_presets:
if primary not in config.model_presets:
return [], False
order = [primary]
for fallback in defaults.fallback_models:
@ -1088,7 +1077,7 @@ def settings_payload(
) -> dict[str, Any]:
config = load_config()
defaults = config.agents.defaults
active_preset_name = defaults.model_preset or "default"
active_preset_name = defaults.model_preset
effective_preset = config.resolve_preset()
provider_name = (
@ -1132,32 +1121,7 @@ def settings_payload(
),
None,
)
model_presets = [
{
"name": "default",
"label": "Default",
"active": active_preset_name == "default",
"is_default": True,
"model": defaults.model,
"provider": defaults.provider,
"resolved_provider": config.get_provider_name(
defaults.model,
preset=config.resolve_default_preset(),
),
"max_tokens": defaults.max_tokens,
"context_window_tokens": defaults.context_window_tokens,
"temperature": defaults.temperature,
"reasoning_effort": defaults.reasoning_effort,
"reasoning_effort_values": _reasoning_effort_values_for(
config.get_provider_name(
defaults.model,
preset=config.resolve_default_preset(),
)
or defaults.provider,
defaults.model,
),
}
]
model_presets = []
for name, preset in config.model_presets.items():
resolved_preset_provider = (
config.get_provider_name(
@ -1171,7 +1135,7 @@ def settings_payload(
"name": name,
"label": preset.label or name,
"active": active_preset_name == name,
"is_default": False,
"is_default": name == "default",
"model": preset.model,
"provider": preset.provider,
"resolved_provider": resolved_preset_provider,
@ -1179,6 +1143,7 @@ def settings_payload(
"context_window_tokens": preset.context_window_tokens,
"temperature": preset.temperature,
"reasoning_effort": preset.reasoning_effort,
"supports_image_input": preset.supports_image_input,
"reasoning_effort_values": _reasoning_effort_values_for(
resolved_preset_provider, preset.model
),
@ -1320,13 +1285,14 @@ def settings_usage_payload() -> dict[str, Any]:
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
config = load_config()
defaults = config.agents.defaults
default_preset = config.resolve_default_preset()
changed = False
restart_required = False
if "model_preset" in query or "modelPreset" in query:
preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip()
preset_value = None if not preset or preset == "default" else preset
if preset_value is not None and preset_value not in config.model_presets:
preset_value = preset or "default"
if preset_value not in config.model_presets:
raise WebUISettingsError("unknown model preset")
if defaults.model_preset != preset_value:
defaults.model_preset = preset_value
@ -1337,8 +1303,8 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
model = model.strip()
if not model:
raise WebUISettingsError("model is required")
if defaults.model != model:
defaults.model = model
if default_preset.model != model:
default_preset.model = model
changed = True
provider = _query_first(query, "provider")
@ -1347,8 +1313,8 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
if not provider:
raise WebUISettingsError("provider is required")
_validate_configured_provider(config, provider)
if defaults.provider != provider:
defaults.provider = provider
if default_preset.provider != provider:
default_preset.provider = provider
changed = True
context_window_tokens = _parse_context_window_tokens(
@ -1356,9 +1322,9 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
)
if (
context_window_tokens is not None
and defaults.context_window_tokens != context_window_tokens
and default_preset.context_window_tokens != context_window_tokens
):
defaults.context_window_tokens = context_window_tokens
default_preset.context_window_tokens = context_window_tokens
changed = True
timezone = _query_first(query, "timezone")
@ -1449,6 +1415,9 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
reasoning_effort = (
_query_first_alias(query, "reasoning_effort", "reasoningEffort") or ""
).strip() or None
supports_image_input = _parse_image_input_support(
_query_first_alias(query, "supports_image_input", "supportsImageInput")
)
config.model_presets[name] = ModelPresetConfig(
label=label,
model=model,
@ -1461,6 +1430,7 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
),
temperature=temperature if temperature is not None else base.temperature,
reasoning_effort=reasoning_effort,
supports_image_input=supports_image_input,
)
save_config(config)
payload = settings_payload()
@ -1470,7 +1440,7 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
def update_model_configuration(query: QueryParams) -> dict[str, Any]:
name = (_query_first(query, "name") or "").strip()
if not name or name == "default":
if not name:
raise WebUISettingsError("model configuration is required")
config = load_config()
@ -1539,6 +1509,14 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
preset.reasoning_effort = reasoning_effort
changed = True
if "supports_image_input" in query or "supportsImageInput" in query:
supports_image_input = _parse_image_input_support(
_query_first_alias(query, "supports_image_input", "supportsImageInput")
)
if preset.supports_image_input is not supports_image_input:
preset.supports_image_input = supports_image_input
changed = True
if changed:
save_config(config)
return settings_payload()
@ -1584,68 +1562,16 @@ def update_model_call_order(query: QueryParams) -> dict[str, Any]:
def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, Any]:
"""Materialize legacy primary/inline model settings as named presets."""
config = load_config()
defaults = config.agents.defaults
primary = config.resolve_preset()
created: list[str] = []
if not defaults.model_preset or defaults.model_preset == "default":
label = _model_configuration_label(primary.model)
name = _unique_model_configuration_name(config, label)
config.model_presets[name] = ModelPresetConfig(
label=label,
model=primary.model,
provider=primary.provider,
max_tokens=primary.max_tokens,
context_window_tokens=primary.context_window_tokens,
temperature=primary.temperature,
reasoning_effort=primary.reasoning_effort,
)
defaults.model_preset = name
created.append(name)
fallback_models: list[str] = []
for fallback in defaults.fallback_models:
if isinstance(fallback, str):
fallback_models.append(fallback)
continue
label = _model_configuration_label(fallback.model)
name = _unique_model_configuration_name(config, label)
config.model_presets[name] = ModelPresetConfig(
label=label,
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,
)
fallback_models.append(name)
created.append(name)
if created:
defaults.fallback_models = fallback_models
save_config(config)
"""Compatibility endpoint; loading config now performs this migration."""
return settings_payload()
def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
name = (_query_first(query, "name") or "").strip()
if not name or name == "default":
if not name:
raise WebUISettingsError("model configuration is required")
if name == "default":
raise WebUISettingsError("default model configuration cannot be deleted", status=409)
config = load_config()
if name not in config.model_presets:

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Any
from nanobot.agent.runner import AgentRunSpec
from nanobot.config.schema import AgentDefaults
from nanobot.config.schema import ModelPresetConfig
from nanobot.providers.base import GenerationSettings, LLMProvider
from nanobot.utils.llm_runtime import LLMRuntime
@ -21,7 +21,7 @@ def make_run_spec(provider: LLMProvider, **kwargs: Any) -> AgentRunSpec:
model = kwargs.pop("model")
context_window_tokens = kwargs.pop(
"context_window_tokens",
AgentDefaults().context_window_tokens,
ModelPresetConfig(model=model).context_window_tokens,
)
provider_generation = getattr(provider, "generation", None)
defaults = GenerationSettings()

View File

@ -272,7 +272,13 @@ class TestAgentLoopTTLParam:
kwargs = session.get_history.call_args.kwargs
assert isinstance(kwargs.get("max_tokens"), int)
assert kwargs["max_tokens"] > 0
assert set(kwargs) == {"max_messages", "max_tokens", "extend_to_user"}
assert set(kwargs) == {
"max_messages",
"max_tokens",
"extend_to_user",
"include_media",
}
assert kwargs["include_media"] is True
@pytest.mark.asyncio
async def test_session_file_cap_archives_and_trims_old_messages(self, tmp_path):

View File

@ -170,6 +170,34 @@ class TestConsolidatorSummarize:
entries = store.read_unprocessed_history(since_cursor=0)
assert entries[0]["session_key"] == "telegram:chat-1"
async def test_summarize_preserves_media_manifest_deterministically(
self,
consolidator,
mock_provider,
store,
runtime,
):
mock_provider.chat_with_retry.return_value = MagicMock(
content="User shared a screenshot.",
finish_reason="stop",
)
messages = [{
"role": "user",
"content": "",
"media": ["/media/screenshot.png"],
}]
result = await consolidator.archive(messages, runtime=runtime)
assert result == (
"Archived attachments:\n- [image: /media/screenshot.png]\n\n"
"User shared a screenshot."
)
prompt = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
assert "[image: /media/screenshot.png]" in prompt
entries = store.read_unprocessed_history(since_cursor=0)
assert "[image: /media/screenshot.png]" in entries[0]["content"]
async def test_summarize_raw_dumps_on_llm_failure(
self, consolidator, mock_provider, store, runtime
):
@ -992,6 +1020,18 @@ class TestRawArchiveTruncation:
assert len(entries) == 1
assert "hello" in entries[0]["content"]
def test_raw_archive_preserves_late_media_path_before_truncation(self, store):
messages = [
{"role": "user", "content": "x" * 20_000},
{"role": "user", "content": "", "media": ["/media/late.png"]},
]
store.raw_archive(messages)
entry = store.read_unprocessed_history(since_cursor=0)[0]["content"]
assert "Archived attachments:" in entry
assert "[image: /media/late.png]" in entry
def test_raw_archive_excludes_model_only_runtime_context(self, store):
content, marker = append_runtime_context(
"ship the feature",

View File

@ -5,7 +5,11 @@ from pathlib import Path
import pytest
from nanobot.agent.context import ContextBuilder
from nanobot.runtime_context import RuntimeContextBlock
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
RuntimeContextBlock,
append_runtime_context,
)
# ---------------------------------------------------------------------------
# Helpers
@ -259,10 +263,12 @@ class TestBuildUserContent:
result = builder.build_user_content("hello", [])
assert result == "hello"
def test_nonexistent_media_file_returns_string(self, tmp_path):
def test_nonexistent_media_file_returns_explicit_placeholder(self, tmp_path):
builder = _builder(tmp_path)
result = builder.build_user_content("hello", ["/nonexistent/image.png"])
assert result == "hello"
assert isinstance(result, list)
assert "unavailable" in result[0]["text"].lower()
assert result[1] == {"type": "text", "text": "hello"}
def test_non_image_file_returns_string(self, tmp_path):
txt = tmp_path / "doc.txt"
@ -438,3 +444,55 @@ class TestBuildMessages:
user_msg = messages[-1]["content"]
assert isinstance(user_msg, list)
assert any(b.get("type") == "image_url" for b in user_msg)
def test_persisted_media_rehydrates_to_identical_image_content(self, tmp_path):
png = tmp_path / "stable.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
builder = _builder(tmp_path)
first_content = builder.build_user_content("describe", [str(png)])
history = [
{
"role": "user",
"content": "describe",
"_media_paths": [str(png)],
},
{"role": "assistant", "content": "done"},
]
messages = builder.build_messages(history, "next")
assert messages[1]["content"] == first_content
assert "_media_paths" not in messages[1]
def test_persisted_media_and_runtime_context_rehydrate_identically(self, tmp_path):
png = tmp_path / "stable-context.png"
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
builder = _builder(tmp_path)
blocks = [
RuntimeContextBlock(
source="cli_apps",
content="CLI App Attachment: @drawio (tool=run_cli_app).",
)
]
first_content = builder.build_messages(
[],
"describe",
media=[str(png)],
runtime_context_blocks=blocks,
)[-1]["content"]
persisted_content, marker = append_runtime_context("describe", blocks)
history = [
{
"role": "user",
"content": persisted_content,
"_media_paths": [str(png)],
RUNTIME_CONTEXT_HISTORY_META: marker,
},
{"role": "assistant", "content": "done"},
]
messages = builder.build_messages(history, "next")
assert messages[1]["content"] == first_content
assert "_media_paths" not in messages[1]
assert RUNTIME_CONTEXT_HISTORY_META not in messages[1]

View File

@ -95,6 +95,33 @@ def test_resolver_resolves_preset_without_mutating_selected_runtime() -> None:
assert resolved.generation == GenerationSettings(0.5, 512, None)
def test_static_presets_keep_image_capability_request_scoped() -> None:
provider = _provider()
provider.supports_image_input = None
resolver = ModelRuntimeResolver(
_runtime(provider),
model_presets={
"vision": ModelPresetConfig(
model="shared-model",
supports_image_input=True,
),
"text": ModelPresetConfig(
model="shared-model",
supports_image_input=False,
),
},
)
vision = resolver.resolve_preset("vision")
text = resolver.resolve_preset("text")
assert vision.provider is provider
assert text.provider is provider
assert vision.supports_image_input is True
assert text.supports_image_input is False
assert provider.supports_image_input is None
def test_resolver_reuses_preset_until_runtime_config_is_invalidated() -> None:
initial = _runtime()
preset = ModelPresetConfig(model="fast-model")

View File

@ -537,7 +537,7 @@ class TestRunOnboardExitBehavior:
def fake_configure_general_settings(config, section):
if section == "Agent Settings":
config.agents.defaults.model = "test/provider-model"
config.resolve_default_preset().model = "test/provider-model"
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
@ -1997,7 +1997,7 @@ class TestModelPresetWizard:
config.model_presets["fast"] = ModelPresetConfig(model="gpt-4.1-mini")
config.model_presets["power"] = ModelPresetConfig(model="gpt-4.1")
_sync_preset_cache(config)
assert _MODEL_PRESET_CACHE == {"fast", "power"}
assert _MODEL_PRESET_CACHE == {"default", "fast", "power"}
_MODEL_PRESET_CACHE.clear()
def test_model_preset_add(self, monkeypatch):
@ -2106,10 +2106,9 @@ class TestModelPresetWizard:
assert defaults.model_preset == "fast"
_MODEL_PRESET_CACHE.clear()
def test_model_preset_field_handler_clear(self, monkeypatch):
"""_handle_model_preset_field should clear preset when Clear value is chosen."""
def test_model_preset_field_handler_selects_default(self, monkeypatch):
"""The concrete default preset replaces the legacy clear selection."""
from nanobot.cli.onboard import (
_CLEAR_CHOICE,
_MODEL_PRESET_CACHE,
_handle_model_preset_field,
)
@ -2118,11 +2117,11 @@ class TestModelPresetWizard:
_MODEL_PRESET_CACHE.clear()
_MODEL_PRESET_CACHE.add("fast")
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: _CLEAR_CHOICE)
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "default")
defaults = AgentDefaults(model_preset="fast")
_handle_model_preset_field(defaults, "model_preset", "Model Preset", "fast")
assert defaults.model_preset is None
assert defaults.model_preset == "default"
_MODEL_PRESET_CACHE.clear()
def test_main_menu_dispatch_includes_model_presets(self):
@ -2208,13 +2207,13 @@ class TestModelPresetWizard:
def test_provider_field_handler(self, monkeypatch):
"""_handle_provider_field should set provider from choices."""
from nanobot.cli.onboard import _handle_provider_field
from nanobot.config.schema import AgentDefaults
from nanobot.config.schema import ModelPresetConfig
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "anthropic")
defaults = AgentDefaults()
_handle_provider_field(defaults, "provider", "Provider", "auto")
assert defaults.provider == "anthropic"
preset = ModelPresetConfig(model="anthropic/claude-opus-4-5")
_handle_provider_field(preset, "provider", "Provider", "auto")
assert preset.provider == "anthropic"
def test_search_provider_field_handler(self, monkeypatch):
"""_handle_search_provider_field should set the search engine from choices."""
@ -2235,7 +2234,10 @@ class TestModelPresetWizard:
_handle_search_provider_field,
_resolve_field_handler,
)
from nanobot.config.schema import AgentDefaults
from nanobot.config.schema import ModelPresetConfig
assert _resolve_field_handler(WebSearchConfig(), "provider") is _handle_search_provider_field
assert _resolve_field_handler(AgentDefaults(), "provider") is _handle_provider_field
assert (
_resolve_field_handler(ModelPresetConfig(model="test"), "provider")
is _handle_provider_field
)

View File

@ -3,7 +3,7 @@
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from loguru import logger
@ -46,6 +46,7 @@ def _fallback(
context_window_tokens: int = 65_536,
temperature: float = 0.1,
reasoning_effort: str | None = None,
supports_image_input: bool | None = None,
) -> ModelPresetConfig:
return ModelPresetConfig(
model=model,
@ -54,6 +55,7 @@ def _fallback(
context_window_tokens=context_window_tokens,
temperature=temperature,
reasoning_effort=reasoning_effort,
supports_image_input=supports_image_input,
)
@ -93,33 +95,35 @@ def test_fallback_models_default_empty() -> None:
assert defaults.fallback_models == []
def test_fallback_models_accept_preset_refs_and_inline_configs() -> None:
from nanobot.config.schema import Config, InlineFallbackConfig
def test_fallback_models_accept_preset_refs() -> None:
from nanobot.config.schema import Config
config = Config.model_validate({
"agents": {
"defaults": {
"fallbackModels": [
"deep",
{
"provider": "openai",
"model": "gpt-4.1",
"maxTokens": 4096,
},
]
"fallbackModels": ["deep"]
}
},
"modelPresets": {
"default": {"provider": "openai", "model": "gpt-4.1"},
"deep": {"provider": "anthropic", "model": "claude-opus-4-7"}
},
})
assert config.agents.defaults.fallback_models[0] == "deep"
assert config.agents.defaults.fallback_models[1] == InlineFallbackConfig(
provider="openai",
model="gpt-4.1",
max_tokens=4096,
)
assert config.agents.defaults.fallback_models == ["deep"]
def test_fallback_models_reject_inline_configs_after_schema_migration() -> None:
from nanobot.config.schema import Config
with pytest.raises(ValueError):
Config.model_validate({
"agents": {
"defaults": {
"fallbackModels": [{"provider": "openai", "model": "gpt-4.1"}]
}
}
})
def test_fallback_model_preset_ref_must_exist() -> None:
@ -128,7 +132,7 @@ def test_fallback_model_preset_ref_must_exist() -> None:
with pytest.raises(ValueError, match="fallback_models.*not found"):
Config.model_validate({
"agents": {"defaults": {"fallbackModels": ["missing"]}},
"modelPresets": {},
"modelPresets": {"default": {"model": "primary"}},
})
@ -144,6 +148,7 @@ def test_provider_signature_tracks_fallback_presets_and_provider_config() -> Non
}
},
"modelPresets": {
"default": {"model": "primary", "provider": "openai"},
"fast": {"model": "openai/gpt-4.1", "provider": "openai"},
"deep": {"model": "anthropic/claude-sonnet-4-6", "provider": "anthropic"},
},
@ -190,6 +195,7 @@ def test_provider_snapshot_uses_smallest_fallback_context_window() -> None:
}
},
"modelPresets": {
"default": {"model": "primary", "provider": "openai"},
"fast": {
"model": "openai/gpt-4.1",
"provider": "openai",
@ -213,36 +219,49 @@ def test_provider_snapshot_uses_smallest_fallback_context_window() -> None:
assert snapshot.context_window_tokens == 64000
def test_inline_fallback_reasoning_effort_does_not_inherit_primary() -> None:
def test_provider_signature_tracks_fallback_image_capability() -> None:
from nanobot.config.schema import Config
from nanobot.providers.factory import provider_signature
config = Config.model_validate({
base = {
"agents": {
"defaults": {
"modelPreset": "fast",
"fallbackModels": [
{"provider": "openai", "model": "gpt-4.1"}
],
"fallbackModels": ["fallback"],
}
},
"modelPresets": {
"default": {"model": "primary"},
"fast": {
"model": "anthropic/claude-opus-4-5",
"provider": "anthropic",
"reasoningEffort": "high",
}
},
"fallback": {
"provider": "openai",
"model": "gpt-4.1",
"supportsImageInput": False,
},
},
"providers": {
"anthropic": {"apiKey": "primary-key"},
"openai": {"apiKey": "fallback-key"},
},
})
}
changed = {
**base,
"modelPresets": {
**base["modelPresets"],
"fallback": {
**base["modelPresets"]["fallback"],
"supportsImageInput": True,
},
},
}
signature = provider_signature(config)
fallback_signatures = signature[-1]
assert fallback_signatures[0][13] is None
assert provider_signature(Config.model_validate(base)) != provider_signature(
Config.model_validate(changed)
)
# -- FallbackProvider tests --
@ -333,6 +352,204 @@ class TestFallbackOnPrimaryError:
for line in logs
)
@pytest.mark.asyncio
async def test_primary_and_fallback_apply_their_own_image_capability(self) -> None:
image_messages = [{
"role": "user",
"content": [{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abc"},
}],
}]
primary = _FakeProvider("primary", _error_response())
primary.supports_image_input = True
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
factory = MagicMock(return_value=fallback)
fb = FallbackProvider(
primary=primary,
fallback_presets=[
_fallback("fallback-a", supports_image_input=False)
],
provider_factory=factory,
)
result = await fb.chat(messages=image_messages, model="primary-model")
assert result.content == "fallback ok"
primary_content = primary.chat_calls[0]["messages"][0]["content"]
fallback_content = fallback.chat_calls[0]["messages"][0]["content"]
assert any(block.get("type") == "image_url" for block in primary_content)
assert all(block.get("type") != "image_url" for block in fallback_content)
@pytest.mark.asyncio
async def test_text_only_primary_does_not_remove_images_from_vision_fallback(self) -> None:
image_messages = [{
"role": "user",
"content": [{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abc"},
}],
}]
primary = _FakeProvider("primary", _error_response())
primary.supports_image_input = False
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
factory = MagicMock(return_value=fallback)
fb = FallbackProvider(
primary=primary,
fallback_presets=[
_fallback("fallback-a", supports_image_input=True)
],
provider_factory=factory,
)
result = await fb.chat(messages=image_messages, model="primary-model")
assert result.content == "fallback ok"
primary_content = primary.chat_calls[0]["messages"][0]["content"]
fallback_content = fallback.chat_calls[0]["messages"][0]["content"]
assert all(block.get("type") != "image_url" for block in primary_content)
assert any(block.get("type") == "image_url" for block in fallback_content)
@pytest.mark.asyncio
async def test_explicit_vision_rejection_advances_to_vision_fallback(self) -> None:
image_messages = [{
"role": "user",
"content": [{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abc"},
}],
}]
primary = _FakeProvider(
"primary",
_make_response(
"image input is not supported",
finish_reason="error",
error_kind="invalid_request",
error_status_code=400,
),
)
primary.supports_image_input = True
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
fb = FallbackProvider(
primary=primary,
fallback_presets=[
_fallback("fallback-a", supports_image_input=True)
],
provider_factory=MagicMock(return_value=fallback),
)
result = await fb.chat(messages=image_messages, model="primary-model")
assert result.content == "fallback ok"
fallback_content = fallback.chat_calls[0]["messages"][0]["content"]
assert any(block.get("type") == "image_url" for block in fallback_content)
assert fb._primary_failures == 0
@pytest.mark.asyncio
async def test_auto_primary_retries_without_images_through_retry_wrapper(self) -> None:
image_messages = [{
"role": "user",
"content": [{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abc"},
}],
}]
primary = _FakeProvider("primary")
primary.chat = AsyncMock(side_effect=[
_make_response(
"image input is not supported",
finish_reason="error",
error_kind="invalid_request",
),
_make_response("primary text fallback ok"),
])
fallback_factory = MagicMock()
fb = FallbackProvider(
primary=primary,
fallback_presets=[_fallback("fallback-a", supports_image_input=True)],
provider_factory=fallback_factory,
)
result = await fb.chat_with_retry(
messages=image_messages,
model="primary-model",
)
assert result.content == "primary text fallback ok"
assert primary.chat.await_count == 2
retry_content = primary.chat.await_args_list[1].kwargs["messages"][0]["content"]
assert all(block.get("type") != "image_url" for block in retry_content)
fallback_factory.assert_not_called()
@pytest.mark.asyncio
async def test_streaming_vision_rejection_advances_to_text_fallback(self) -> None:
image_messages = [{
"role": "user",
"content": [{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abc"},
}],
}]
primary = _FakeProvider("primary")
primary.supports_image_input = True
primary.chat_stream = AsyncMock(return_value=_make_response(
"image input is not supported",
finish_reason="error",
error_kind="invalid_request",
error_status_code=400,
))
fallback = _FakeProvider("fallback")
fallback.chat_stream = AsyncMock(return_value=_make_response("fallback ok"))
fb = FallbackProvider(
primary=primary,
fallback_presets=[
_fallback("fallback-a", supports_image_input=False)
],
provider_factory=MagicMock(return_value=fallback),
)
result = await fb.chat_stream(
messages=image_messages,
model="primary-model",
on_content_delta=AsyncMock(),
)
assert result.content == "fallback ok"
fallback_content = fallback.chat_stream.await_args.kwargs["messages"][0]["content"]
assert all(block.get("type") != "image_url" for block in fallback_content)
@pytest.mark.asyncio
async def test_auto_capability_does_not_retry_after_streaming_content(self) -> None:
image_messages = [{
"role": "user",
"content": [{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abc"},
}],
}]
primary = _FakeProvider(
"primary",
_make_response(
"model does not support images",
finish_reason="error",
error_kind="invalid_request",
),
)
fb = FallbackProvider(
primary=primary,
fallback_presets=[_fallback("fallback-a")],
provider_factory=MagicMock(),
)
result = await fb.chat_stream(
messages=image_messages,
model="primary-model",
on_content_delta=AsyncMock(),
)
assert result.finish_reason == "error"
assert len(primary.chat_stream_calls) == 1
class TestNoFallbackWhenContentStreamed:
@pytest.mark.asyncio

View File

@ -19,9 +19,11 @@ async def test_active_run_keeps_provider_captured_at_admission() -> None:
second_provider = MagicMock(spec=LLMProvider)
first_provider.generation = GenerationSettings(temperature=0.2, max_tokens=2048)
second_provider.generation = GenerationSettings(temperature=0.9, max_tokens=512)
first_provider.supports_image_input = False
first_calls = 0
second_calls = 0
request_temperatures: list[float] = []
request_image_capabilities: list[bool | None] = []
selected_runtime = LLMRuntime.capture(
first_provider,
"captured-model",
@ -33,6 +35,7 @@ async def test_active_run_keeps_provider_captured_at_admission() -> None:
nonlocal first_calls, selected_runtime
first_calls += 1
request_temperatures.append(kwargs["temperature"])
request_image_capabilities.append(kwargs["supports_image_input"])
selected_runtime = LLMRuntime.capture(
second_provider,
"future-model",
@ -68,4 +71,5 @@ async def test_active_run_keeps_provider_captured_at_admission() -> None:
assert first_calls == 2
assert second_calls == 0
assert request_temperatures == [0.2, 0.2]
assert request_image_capabilities == [False, False]
assert selected_runtime.provider is second_provider

View File

@ -302,9 +302,9 @@ def test_settings_context_window_refreshes_runtime_state(
config_path = tmp_path / "config.json"
config = Config()
config.agents.defaults.workspace = str(tmp_path / "workspace")
config.agents.defaults.model = "openai/gpt-4o"
config.agents.defaults.provider = "openai"
config.agents.defaults.context_window_tokens = 65_536
config.resolve_default_preset().model = "openai/gpt-4o"
config.resolve_default_preset().provider = "openai"
config.resolve_default_preset().context_window_tokens = 65_536
config.providers.openai.api_key = "sk-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)

View File

@ -378,13 +378,14 @@ def test_from_config_injects_default_preset(tmp_path) -> None:
from nanobot.config.schema import Config
config = Config.model_validate({
"agents": {"defaults": {"model": "openai/gpt-4.1", "workspace": str(tmp_path)}},
"agents": {"defaults": {"workspace": str(tmp_path)}},
"modelPresets": {"default": {"model": "openai/gpt-4.1"}},
})
fake_provider = _provider("openai/gpt-4.1")
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
loop = AgentLoop.from_config(config)
assert loop.model == "openai/gpt-4.1"
assert loop.model_preset is None
assert loop.model_preset == "default"
assert "default" in loop.model_presets
assert loop.model_presets["default"].model == "openai/gpt-4.1"
@ -394,8 +395,11 @@ def test_from_config_static_preset_loader_does_not_enable_hot_reload(tmp_path) -
from nanobot.config.schema import Config
config = Config.model_validate({
"agents": {"defaults": {"model": "openai/gpt-4.1", "workspace": str(tmp_path)}},
"model_presets": {"fast": {"model": "openai/gpt-4.1-mini"}},
"agents": {"defaults": {"workspace": str(tmp_path)}},
"model_presets": {
"default": {"model": "openai/gpt-4.1"},
"fast": {"model": "openai/gpt-4.1-mini"},
},
})
fake_provider = _provider("openai/gpt-4.1")
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):

View File

@ -405,6 +405,44 @@ def test_get_history_synthesizes_breadcrumb_for_image_only_turn():
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
def test_get_history_can_return_internal_media_refs_without_breadcrumbs():
session = Session(key="test:media-internal")
session.messages.append(
{"role": "user", "content": "look", "media": ["/m/a.png", "/m/b.png"]}
)
history = session.get_history(max_messages=500, include_media=True)
assert history == [{
"role": "user",
"content": "look",
"_media_paths": ["/m/a.png", "/m/b.png"],
}]
def test_get_history_keeps_runtime_context_boundary_with_internal_media_refs():
content, marker = append_runtime_context(
"look",
[RuntimeContextBlock(source="test", content="trusted runtime context")],
)
session = Session(key="test:media-runtime-context")
session.messages.append({
"role": "user",
"content": content,
"media": ["/m/a.png"],
RUNTIME_CONTEXT_HISTORY_META: marker,
})
history = session.get_history(max_messages=500, include_media=True)
assert history == [{
"role": "user",
"content": content,
"_media_paths": ["/m/a.png"],
RUNTIME_CONTEXT_HISTORY_META: marker,
}]
def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
session = Session(key="test:cli-app")
session.messages.append(

View File

@ -451,14 +451,14 @@ def test_onboard_wizard_preserves_explicit_config_in_next_steps(tmp_path, monkey
def test_config_matches_github_copilot_codex_with_hyphen_prefix():
config = Config()
config.agents.defaults.model = "github-copilot/gpt-5.3-codex"
config.resolve_default_preset().model = "github-copilot/gpt-5.3-codex"
assert config.get_provider_name() == "github_copilot"
def test_config_matches_openai_codex_with_hyphen_prefix():
config = Config()
config.agents.defaults.model = "openai-codex/gpt-5.6-sol"
config.resolve_default_preset().model = "openai-codex/gpt-5.6-sol"
assert config.get_provider_name() == "openai_codex"
@ -676,9 +676,9 @@ def test_provider_login_can_set_openai_codex_as_main_provider(tmp_path):
assert "Set openai-codex as the main provider" in result.stdout
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
assert saved.agents.defaults.provider == "openai_codex"
assert saved.agents.defaults.model == "openai-codex/gpt-5.6-sol"
assert saved.agents.defaults.model_preset is None
assert saved.resolve_default_preset().provider == "openai_codex"
assert saved.resolve_default_preset().model == "openai-codex/gpt-5.6-sol"
assert saved.agents.defaults.model_preset == "default"
assert make_provider(saved).__class__.__name__ == "OpenAICodexProvider"
@ -705,9 +705,9 @@ def test_provider_login_can_set_github_copilot_as_main_provider(tmp_path):
assert "Set github-copilot as the main provider" in result.stdout
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
assert saved.agents.defaults.provider == "github_copilot"
assert saved.agents.defaults.model == "github-copilot/gpt-5.4-mini"
assert saved.agents.defaults.model_preset is None
assert saved.resolve_default_preset().provider == "github_copilot"
assert saved.resolve_default_preset().model == "github-copilot/gpt-5.4-mini"
assert saved.agents.defaults.model_preset == "default"
assert make_provider(saved).__class__.__name__ == "GitHubCopilotProvider"
@ -734,10 +734,10 @@ def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
assert "Set xai-grok as the main provider" in result.stdout
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
assert saved.agents.defaults.provider == "xai_grok"
assert saved.agents.defaults.model == "xai-grok/grok-4.5"
assert saved.agents.defaults.context_window_tokens == 500_000
assert saved.agents.defaults.model_preset is None
assert saved.resolve_default_preset().provider == "xai_grok"
assert saved.resolve_default_preset().model == "xai-grok/grok-4.5"
assert saved.resolve_default_preset().context_window_tokens == 500_000
assert saved.agents.defaults.model_preset == "default"
assert make_provider(saved).__class__.__name__ == "XAIGrokProvider"
@ -765,8 +765,8 @@ def test_provider_login_model_implies_set_main_provider(tmp_path):
assert "Set github-copilot as the main provider" in result.stdout
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
assert saved.agents.defaults.provider == "github_copilot"
assert saved.agents.defaults.model == "github-copilot/gpt-5.4-mini"
assert saved.resolve_default_preset().provider == "github_copilot"
assert saved.resolve_default_preset().model == "github-copilot/gpt-5.4-mini"
assert make_provider(saved).__class__.__name__ == "GitHubCopilotProvider"
@ -899,7 +899,7 @@ def test_provider_login_xai_grok_runs_browser_flow_with_configured_proxy(monkeyp
def test_config_matches_explicit_ollama_prefix_without_api_key():
config = Config()
config.agents.defaults.model = "ollama/llama3.2"
config.resolve_default_preset().model = "ollama/llama3.2"
assert config.get_provider_name() == "ollama"
assert config.get_api_base() == "http://localhost:11434/v1"
@ -907,8 +907,8 @@ def test_config_matches_explicit_ollama_prefix_without_api_key():
def test_config_explicit_ollama_provider_uses_default_localhost_api_base():
config = Config()
config.agents.defaults.provider = "ollama"
config.agents.defaults.model = "llama3.2"
config.resolve_default_preset().provider = "ollama"
config.resolve_default_preset().model = "llama3.2"
assert config.get_provider_name() == "ollama"
assert config.get_api_base() == "http://localhost:11434/v1"
@ -917,8 +917,8 @@ def test_config_explicit_ollama_provider_uses_default_localhost_api_base():
def test_config_accepts_camel_case_explicit_provider_name_for_coding_plan():
config = Config.model_validate(
{
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "volcengineCodingPlan",
"model": "doubao-1-5-pro",
}
@ -938,8 +938,8 @@ def test_config_accepts_camel_case_explicit_provider_name_for_coding_plan():
def test_config_accepts_lm_studio_without_api_key_and_uses_default_localhost_api_base():
config = Config.model_validate(
{
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "lm_studio",
"model": "local-model",
}
@ -960,8 +960,8 @@ def test_config_accepts_lm_studio_without_api_key_and_uses_default_localhost_api
def test_config_accepts_atomic_chat_without_api_key_and_uses_default_localhost_api_base():
config = Config.model_validate(
{
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "atomic_chat",
"model": "local-model",
}
@ -993,8 +993,8 @@ def test_find_by_name_accepts_camel_case_and_hyphen_aliases():
def test_config_explicit_longcat_provider_resolves_provider_name():
config = Config.model_validate(
{
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "longcat",
"model": "LongCat-Flash-Chat",
}
@ -1014,7 +1014,9 @@ def test_config_explicit_longcat_provider_resolves_provider_name():
def test_config_auto_detects_longcat_from_model_keyword():
config = Config.model_validate(
{
"agents": {"defaults": {"provider": "auto", "model": "longcat/LongCat-Flash-Chat"}},
"modelPresets": {
"default": {"provider": "auto", "model": "longcat/LongCat-Flash-Chat"}
},
"providers": {"longcat": {"apiKey": "test-key"}},
}
)
@ -1025,8 +1027,8 @@ def test_config_auto_detects_longcat_from_model_keyword():
def test_config_explicit_xiaomi_mimo_provider_uses_default_api_base():
config = Config.model_validate(
{
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "xiaomi_mimo",
"model": "MiniMax-M1-80k",
}
@ -1046,7 +1048,9 @@ def test_config_explicit_xiaomi_mimo_provider_uses_default_api_base():
def test_config_auto_detects_xiaomi_mimo_from_model_keyword():
config = Config.model_validate(
{
"agents": {"defaults": {"provider": "auto", "model": "mimo/MiniMax-M1-80k"}},
"modelPresets": {
"default": {"provider": "auto", "model": "mimo/MiniMax-M1-80k"}
},
"providers": {"xiaomiMimo": {"apiKey": "test-key"}},
}
)
@ -1058,8 +1062,8 @@ def test_config_auto_detects_xiaomi_mimo_from_model_keyword():
def test_config_explicit_minimax_anthropic_provider_uses_default_api_base():
config = Config.model_validate(
{
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "minimax_anthropic",
"model": "MiniMax-M2.7-highspeed",
}
@ -1080,7 +1084,7 @@ def test_config_explicit_minimax_anthropic_provider_uses_default_api_base():
def test_config_auto_detects_ollama_from_local_api_base():
config = Config.model_validate(
{
"agents": {"defaults": {"provider": "auto", "model": "llama3.2"}},
"modelPresets": {"default": {"provider": "auto", "model": "llama3.2"}},
"providers": {"ollama": {"apiBase": "http://localhost:11434/v1"}},
}
)
@ -1092,7 +1096,7 @@ def test_config_auto_detects_ollama_from_local_api_base():
def test_config_prefers_ollama_over_vllm_when_both_local_providers_configured():
config = Config.model_validate(
{
"agents": {"defaults": {"provider": "auto", "model": "llama3.2"}},
"modelPresets": {"default": {"provider": "auto", "model": "llama3.2"}},
"providers": {
"vllm": {"apiBase": "http://localhost:8000"},
"ollama": {"apiBase": "http://localhost:11434/v1"},
@ -1107,7 +1111,7 @@ def test_config_prefers_ollama_over_vllm_when_both_local_providers_configured():
def test_config_falls_back_to_vllm_when_ollama_not_configured():
config = Config.model_validate(
{
"agents": {"defaults": {"provider": "auto", "model": "llama3.2"}},
"modelPresets": {"default": {"provider": "auto", "model": "llama3.2"}},
"providers": {
"vllm": {"apiBase": "http://localhost:8000"},
},
@ -1133,8 +1137,8 @@ def test_make_provider_uses_github_copilot_backend():
config = Config.model_validate(
{
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "github-copilot",
"model": "github-copilot/gpt-4.1",
}
@ -1152,8 +1156,8 @@ def test_openai_codex_proxy_config_affects_provider_and_signature():
def config_with_proxy(proxy: str) -> Config:
return Config.model_validate(
{
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "openai-codex",
"model": "openai-codex/gpt-5.5",
}
@ -1177,8 +1181,8 @@ def test_openai_codex_proxy_config_affects_provider_and_signature():
def test_provider_proxy_rejects_unsupported_backend():
config = Config.model_validate(
{
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "anthropic",
"model": "anthropic/claude-opus-4-5",
}
@ -1253,7 +1257,9 @@ def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
def test_make_provider_passes_extra_headers_to_custom_provider():
config = Config.model_validate(
{
"agents": {"defaults": {"provider": "custom", "model": "gpt-4o-mini"}},
"modelPresets": {
"default": {"provider": "custom", "model": "gpt-4o-mini"}
},
"providers": {
"custom": {
"apiKey": "test-key",
@ -1281,7 +1287,9 @@ def test_make_provider_passes_extra_headers_to_custom_provider():
def test_make_provider_treats_dynamic_custom_provider_as_direct():
config = Config.model_validate(
{
"agents": {"defaults": {"provider": "my-company-api", "model": "gpt-4o-mini"}},
"modelPresets": {
"default": {"provider": "my-company-api", "model": "gpt-4o-mini"}
},
"providers": {
"my-company-api": {
"apiBase": "https://example.com/v1",
@ -1305,7 +1313,12 @@ def test_make_provider_treats_dynamic_custom_provider_as_direct():
def test_make_provider_strips_dynamic_custom_route_prefix_from_request_model():
config = Config.model_validate(
{
"agents": {"defaults": {"provider": "auto", "model": "my-company-api/gpt-4o-mini"}},
"modelPresets": {
"default": {
"provider": "auto",
"model": "my-company-api/gpt-4o-mini",
}
},
"providers": {
"my-company-api": {
"apiBase": "https://example.com/v1",
@ -1343,8 +1356,8 @@ def test_make_provider_strips_dynamic_custom_route_prefix_from_request_model():
def test_make_provider_preserves_namespaced_model_for_forced_dynamic_provider():
config = Config.model_validate(
{
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "my-company-api",
"model": "openai/gpt-4o-mini",
}
@ -1374,8 +1387,8 @@ def test_make_provider_preserves_namespaced_model_for_forced_dynamic_provider():
def test_make_provider_strips_dynamic_custom_route_prefix_once():
config = Config.model_validate(
{
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "auto",
"model": "my-company-api/openai/gpt-4o-mini",
}
@ -1405,7 +1418,9 @@ def test_make_provider_strips_dynamic_custom_route_prefix_once():
def test_make_provider_rejects_dynamic_custom_provider_without_api_base():
config = Config.model_validate(
{
"agents": {"defaults": {"provider": "my-company-api", "model": "gpt-4o-mini"}},
"modelPresets": {
"default": {"provider": "my-company-api", "model": "gpt-4o-mini"}
},
"providers": {
"my-company-api": {
"apiKey": "sk-test",
@ -1421,7 +1436,9 @@ def test_make_provider_rejects_dynamic_custom_provider_without_api_base():
def test_make_provider_rejects_auto_dynamic_custom_prefix_without_api_base():
config = Config.model_validate(
{
"agents": {"defaults": {"provider": "auto", "model": "companyProxy/gpt-4o"}},
"modelPresets": {
"default": {"provider": "auto", "model": "companyProxy/gpt-4o"}
},
"providers": {
"otherProxy": {
"apiBase": "https://other.example.test/v1",
@ -1824,10 +1841,11 @@ def _stop_gateway_provider(_config) -> object:
def _test_provider_snapshot(provider: object, config: Config) -> ProviderSnapshot:
default_preset = config.resolve_default_preset()
return ProviderSnapshot(
provider=provider,
model=config.agents.defaults.model,
context_window_tokens=config.agents.defaults.context_window_tokens,
model=default_preset.model,
context_window_tokens=default_preset.context_window_tokens,
signature=("test",),
)

View File

@ -17,7 +17,7 @@ def test_save_config_round_trips(tmp_path: Path) -> None:
path = tmp_path / "config.json"
save_config(Config(), path)
loaded = load_config(path)
assert loaded.agents.defaults.model
assert loaded.resolve_default_preset().model
@pytest.mark.skipif(os.name == "nt", reason="Windows does not expose POSIX file modes")

View File

@ -10,7 +10,7 @@ from nanobot.config.schema import ApiConfig
def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
config = load_config(tmp_path / "missing.json")
assert config.agents.defaults.model
assert config.resolve_default_preset().model
def test_load_config_reports_malformed_environment_safely(

View File

@ -35,8 +35,8 @@ def test_load_config_keeps_max_tokens_and_ignores_legacy_memory_window(tmp_path)
config = load_config(config_path)
assert config.agents.defaults.max_tokens == 1234
assert config.agents.defaults.context_window_tokens == 200_000
assert config.resolve_default_preset().max_tokens == 1234
assert config.resolve_default_preset().context_window_tokens == 200_000
assert not hasattr(config.agents.defaults, "memory_window")
@ -60,9 +60,12 @@ def test_save_config_writes_context_window_tokens_but_not_memory_window(tmp_path
save_config(config, config_path)
saved = json.loads(config_path.read_text(encoding="utf-8"))
defaults = saved["agents"]["defaults"]
default_preset = saved["modelPresets"]["default"]
assert defaults["maxTokens"] == 2222
assert defaults["contextWindowTokens"] == 200_000
assert default_preset["maxTokens"] == 2222
assert default_preset["contextWindowTokens"] == 200_000
assert "maxTokens" not in defaults
assert "contextWindowTokens" not in defaults
assert "memoryWindow" not in defaults
@ -105,7 +108,7 @@ def test_load_config_ignores_legacy_max_messages(tmp_path, field_name) -> None:
config = load_config(config_path)
assert config.agents.defaults.max_tokens == 1234
assert config.resolve_default_preset().max_tokens == 1234
assert not hasattr(config.agents.defaults, "max_messages")
@ -124,6 +127,58 @@ def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
assert "max_messages" not in saved["agents"]["defaults"]
def test_load_config_rewrites_legacy_model_fields_to_default_preset(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({
"agents": {
"defaults": {
"model": "openai/gpt-4.1",
"provider": "openai",
"temperature": 0,
}
}
}),
encoding="utf-8",
)
config = load_config(config_path)
saved = json.loads(config_path.read_text(encoding="utf-8"))
assert config.agents.defaults.model_preset == "default"
assert config.resolve_default_preset().model == "openai/gpt-4.1"
assert saved["agents"]["defaults"]["modelPreset"] == "default"
assert "model" not in saved["agents"]["defaults"]
assert saved["modelPresets"]["default"]["model"] == "openai/gpt-4.1"
assert saved["modelPresets"]["default"]["temperature"] == 0
def test_load_config_migrates_inline_fallback_to_named_preset(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({
"agents": {
"defaults": {
"model": "openai/gpt-4.1",
"provider": "openai",
"fallbackModels": [{
"model": "anthropic/claude-sonnet-4",
"provider": "anthropic",
}],
}
}
}),
encoding="utf-8",
)
config = load_config(config_path)
saved = json.loads(config_path.read_text(encoding="utf-8"))
assert config.agents.defaults.fallback_models == ["claude-sonnet-4"]
assert saved["agents"]["defaults"]["fallbackModels"] == ["claude-sonnet-4"]
assert saved["modelPresets"]["claude-sonnet-4"]["provider"] == "anthropic"
def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) -> None:
from nanobot.channels.plugin import load_channel_package
@ -296,3 +351,14 @@ def test_load_config_accepts_remote_package_install_aliases(tmp_path) -> None:
assert load_config(camel_path).tools.webui_allow_remote_package_install is True
assert load_config(snake_path).tools.webui_allow_remote_package_install is True
def test_load_config_does_not_rewrite_unrelated_partial_config(tmp_path) -> None:
config_path = tmp_path / "config.json"
raw = '{"channels":{"telegram":{"enabled":false}}}'
config_path.write_text(raw, encoding="utf-8")
config = load_config(config_path)
assert config.resolve_default_preset().model == "anthropic/claude-opus-4-5"
assert config_path.read_text(encoding="utf-8") == raw

View File

@ -109,7 +109,7 @@ class TestResolveConfig:
)
config = load_config(config_path)
config.agents.defaults.max_tokens = 1234
config.resolve_default_preset().max_tokens = 1234
save_config(config, config_path)
saved = json.loads(config_path.read_text(encoding="utf-8"))

View File

@ -11,12 +11,8 @@ from nanobot.config.schema import Config
def test_resolve_preset_returns_defaults_when_no_preset() -> None:
config = Config()
resolved = config.resolve_preset()
assert resolved.model == config.agents.defaults.model
assert resolved.provider == config.agents.defaults.provider
assert resolved.max_tokens == config.agents.defaults.max_tokens
assert resolved.context_window_tokens == config.agents.defaults.context_window_tokens
assert resolved.temperature == config.agents.defaults.temperature
assert resolved.reasoning_effort == config.agents.defaults.reasoning_effort
assert resolved is config.model_presets["default"]
assert config.agents.defaults.model_preset == "default"
def test_model_preset_catalog_missing_env_reports_explicit_config_path(
@ -119,8 +115,8 @@ def test_custom_provider_fallback_uses_model_extra_without_pydantic_warnings() -
def test_dynamic_custom_provider_prefix_matches_camel_case_key() -> None:
config = Config.model_validate({
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "auto",
"model": "companyProxy/gpt-4o-mini",
}
@ -141,8 +137,8 @@ def test_dynamic_custom_provider_prefix_matches_camel_case_key() -> None:
def test_dynamic_custom_provider_prefix_does_not_fall_through_when_base_missing() -> None:
config = Config.model_validate({
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "auto",
"model": "companyProxy/gpt-4o-mini",
}
@ -161,7 +157,7 @@ def test_dynamic_custom_provider_prefix_does_not_fall_through_when_base_missing(
assert config.get_api_base() is None
def test_legacy_defaults_config_without_presets_still_resolves() -> None:
def test_schema_no_longer_resolves_legacy_agent_model_fields() -> None:
config = Config.model_validate({
"agents": {
"defaults": {
@ -176,19 +172,17 @@ def test_legacy_defaults_config_without_presets_still_resolves() -> None:
})
resolved = config.resolve_preset()
assert config.agents.defaults.model_preset is None
assert config.model_presets == {}
assert resolved.model == "openai/gpt-4.1"
assert resolved.provider == "openai"
assert resolved.max_tokens == 4096
assert resolved.context_window_tokens == 128_000
assert resolved.temperature == 0.2
assert resolved.reasoning_effort == "low"
assert config.agents.defaults.model_preset == "default"
assert resolved.model == "anthropic/claude-opus-4-5"
dumped_defaults = config.agents.defaults.model_dump(mode="json", by_alias=True)
assert "model" not in dumped_defaults
assert "provider" not in dumped_defaults
def test_resolve_preset_returns_active_preset() -> None:
config = Config.model_validate({
"model_presets": {
"default": {"model": "anthropic/claude-opus-4-5"},
"fast": {
"model": "openai/gpt-4.1",
"provider": "openai",
@ -213,16 +207,15 @@ def test_resolve_preset_returns_active_preset() -> None:
assert resolved.reasoning_effort == "low"
def test_default_preset_is_agents_defaults_even_when_named_preset_is_active() -> None:
def test_default_preset_is_concrete_when_named_preset_is_active() -> None:
config = Config.model_validate({
"agents": {
"defaults": {
"model": "openai/gpt-4.1",
"provider": "openai",
"modelPreset": "fast",
}
},
"modelPresets": {
"default": {"model": "openai/gpt-4.1", "provider": "openai"},
"fast": {"model": "openai/gpt-4.1-mini", "provider": "openai"},
},
})
@ -234,6 +227,7 @@ def test_default_preset_is_agents_defaults_even_when_named_preset_is_active() ->
def test_model_presets_accepts_camel_case_root_key() -> None:
config = Config.model_validate({
"modelPresets": {
"default": {"model": "anthropic/claude-opus-4-5"},
"fast": {
"model": "openai/gpt-4.1",
"provider": "openai",
@ -248,6 +242,7 @@ def test_model_presets_accepts_camel_case_root_key() -> None:
def test_model_presets_serializes_with_camel_case_root_key() -> None:
config = Config.model_validate({
"model_presets": {
"default": {"model": "anthropic/claude-opus-4-5"},
"fast": {
"model": "openai/gpt-4.1",
"provider": "openai",
@ -265,6 +260,7 @@ def test_model_presets_serializes_with_camel_case_root_key() -> None:
def test_resolve_preset_can_target_named_preset_without_activating() -> None:
config = Config.model_validate({
"model_presets": {
"default": {"model": "anthropic/claude-opus-4-5"},
"fast": {"model": "openai/gpt-4.1", "provider": "openai"},
"deep": {"model": "anthropic/claude-opus-4-5", "provider": "anthropic"},
},
@ -291,6 +287,7 @@ def test_validator_rejects_unknown_preset() -> None:
def test_validator_accepts_dream_model_preset() -> None:
config = Config.model_validate({
"modelPresets": {
"default": {"model": "anthropic/claude-opus-4-5"},
"dream": {"model": "anthropic/claude-haiku-4-5", "provider": "anthropic"},
},
"agents": {"defaults": {"dream": {"modelOverride": "dream"}}},
@ -308,10 +305,9 @@ def test_validator_rejects_unknown_dream_model_preset() -> None:
def test_model_preset_accepts_explicit_default_name() -> None:
config = Config.model_validate({
"agents": {
"defaults": {
"modelPresets": {
"default": {
"model": "openai/gpt-4.1",
"modelPreset": "default",
}
}
})
@ -319,13 +315,11 @@ def test_model_preset_accepts_explicit_default_name() -> None:
assert config.resolve_preset().model == "openai/gpt-4.1"
def test_model_presets_rejects_reserved_default_name() -> None:
import pytest
with pytest.raises(ValueError, match="model_preset name 'default' is reserved"):
def test_model_presets_requires_default_name() -> None:
with pytest.raises(ValueError, match="must define a 'default' preset"):
Config.model_validate({
"modelPresets": {
"default": {"model": "custom-model"},
"custom": {"model": "custom-model"},
},
})
@ -342,6 +336,7 @@ def test_match_provider_uses_preset_model() -> None:
"openai": {"apiKey": "sk-test"},
},
"model_presets": {
"default": {"model": "anthropic/claude-opus-4-5"},
"fast": {
"model": "openai/gpt-4.1",
"provider": "openai",
@ -363,6 +358,7 @@ def test_match_provider_uses_preset_provider_when_forced() -> None:
"anthropic": {"apiKey": "sk-test"},
},
"model_presets": {
"default": {"model": "anthropic/claude-opus-4-5"},
"fast": {
"model": "anthropic/claude-opus-4-5",
"provider": "anthropic",
@ -383,8 +379,8 @@ def test_match_provider_routes_forced_novita_model_api_models() -> None:
"providers": {
"novita": {"apiKey": "sk-test"},
},
"agents": {
"defaults": {
"modelPresets": {
"default": {
"model": "deepseek-v4-pro",
"provider": "novita",
}
@ -400,8 +396,8 @@ def test_transcription_only_provider_is_not_chat_fallback() -> None:
"providers": {
"assemblyai": {"apiKey": "aai-test"},
},
"agents": {
"defaults": {
"modelPresets": {
"default": {
"model": "assemblyai/universal-3-pro",
}
},

View File

@ -61,7 +61,9 @@ def test_bedrock_provider_is_registered_and_matches_without_api_key() -> None:
assert hasattr(ProvidersConfig(), "bedrock")
cfg = Config.model_validate({
"agents": {"defaults": {"model": "bedrock/global.anthropic.claude-opus-4-7"}},
"modelPresets": {
"default": {"model": "bedrock/global.anthropic.claude-opus-4-7"},
},
"providers": {"bedrock": {"region": "us-east-1"}},
})

View File

@ -67,6 +67,7 @@ class TestCustomProviderThinkingStyle:
{
"agents": {"defaults": {"modelPreset": "primary"}},
"modelPresets": {
"default": {"model": "anthropic/claude-opus-4-5"},
"primary": {"model": "tenant-model", "provider": "tenant"},
},
"providers": {
@ -92,6 +93,7 @@ class TestCustomProviderThinkingStyle:
}
},
"modelPresets": {
"default": {"model": "anthropic/claude-opus-4-5"},
"primary": {"model": "openai/gpt-4.1", "provider": "openai"},
"fallback": {"model": "tenant-model", "provider": "tenant"},
},

View File

@ -77,6 +77,7 @@ class TestProviderSignatureIncludesExtraQuery:
base = {
"agents": {"defaults": {"modelPreset": "fast"}},
"modelPresets": {
"default": {"model": "anthropic/claude-opus-4-5"},
"fast": {"model": "custom/test-model", "provider": "custom"},
},
"providers": {

View File

@ -236,8 +236,8 @@ async def test_codex_provider_applies_extra_body_from_config(monkeypatch) -> Non
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
config = Config.model_validate({
"agents": {
"defaults": {
"modelPresets": {
"default": {
"model": "openai-codex/gpt-5.6-sol",
"provider": "openai_codex",
},

View File

@ -35,6 +35,10 @@ def test_provider_signature_tracks_default_extra_headers() -> None:
},
},
"modelPresets": {
"default": {
"provider": "auto",
"model": "anthropic/claude-opus-4-5",
},
"primary": {
"provider": "kimi_coding",
"model": "kimi-for-coding",

View File

@ -293,23 +293,16 @@ _IMAGE_MSG_NO_META = [
@pytest.mark.asyncio
async def test_non_transient_error_with_images_retries_without_images() -> None:
"""Any non-transient error retries once with images stripped when images are present."""
async def test_unrelated_non_transient_error_with_images_is_not_hidden() -> None:
"""Only an explicit unsupported-image error may trigger image fallback."""
provider = ScriptedProvider([
LLMResponse(content="API调用参数有误,请检查文档", finish_reason="error"),
LLMResponse(content="ok, no image"),
])
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG))
assert response.content == "ok, no image"
assert provider.calls == 2
msgs_on_retry = provider.last_kwargs["messages"]
for msg in msgs_on_retry:
content = msg.get("content")
if isinstance(content, list):
assert all(b.get("type") != "image_url" for b in content)
assert any("not delivered" in (b.get("text") or "").lower() for b in content)
assert response.content == "API调用参数有误,请检查文档"
assert provider.calls == 1
@pytest.mark.asyncio
@ -349,7 +342,7 @@ async def test_non_transient_error_without_images_no_retry() -> None:
async def test_image_fallback_returns_error_on_second_failure() -> None:
"""If the image-stripped retry also fails, return that error."""
provider = ScriptedProvider([
LLMResponse(content="some model error", finish_reason="error"),
LLMResponse(content="model does not support images", finish_reason="error"),
LLMResponse(content="still failing", finish_reason="error"),
])
@ -364,7 +357,7 @@ async def test_image_fallback_returns_error_on_second_failure() -> None:
async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
"""When _meta is absent, fallback placeholder is non-descriptive."""
provider = ScriptedProvider([
LLMResponse(content="error", finish_reason="error"),
LLMResponse(content="image input is not supported", finish_reason="error"),
LLMResponse(content="ok"),
])
@ -379,6 +372,75 @@ async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
assert any("not delivered" in (b.get("text") or "").lower() for b in content)
@pytest.mark.asyncio
async def test_text_only_preset_strips_images_before_first_request() -> None:
provider = ScriptedProvider([LLMResponse(content="ok")])
provider.supports_image_input = False
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG))
assert response.content == "ok"
assert provider.calls == 1
content = provider.last_kwargs["messages"][0]["content"]
assert all(block.get("type") != "image_url" for block in content)
assert any("not delivered" in (block.get("text") or "").lower() for block in content)
@pytest.mark.asyncio
async def test_explicit_image_support_does_not_silently_downgrade() -> None:
provider = ScriptedProvider([
LLMResponse(content="model does not support images", finish_reason="error"),
])
provider.supports_image_input = True
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG))
assert response.finish_reason == "error"
assert provider.calls == 1
content = provider.last_kwargs["messages"][0]["content"]
assert any(block.get("type") == "image_url" for block in content)
@pytest.mark.asyncio
async def test_image_capability_override_is_request_scoped_under_concurrency() -> None:
class ConcurrentProvider(LLMProvider):
def __init__(self) -> None:
super().__init__()
self.entered = 0
self.ready = asyncio.Event()
self.received_image_flags: list[bool] = []
def get_default_model(self) -> str:
return "test-model"
async def chat(self, **kwargs) -> LLMResponse:
content = kwargs["messages"][0]["content"]
self.received_image_flags.append(
any(block.get("type") == "image_url" for block in content)
)
self.entered += 1
if self.entered == 2:
self.ready.set()
await self.ready.wait()
return LLMResponse(content="ok")
provider = ConcurrentProvider()
await asyncio.gather(
provider.chat_with_retry(
messages=copy.deepcopy(_IMAGE_MSG),
supports_image_input=False,
),
provider.chat_with_retry(
messages=copy.deepcopy(_IMAGE_MSG),
supports_image_input=True,
),
)
assert sorted(provider.received_image_flags) == [False, True]
assert provider.supports_image_input is None
@pytest.mark.asyncio
async def test_chat_with_retry_uses_retry_after_and_emits_wait_progress(monkeypatch) -> None:
provider = ScriptedProvider([

View File

@ -280,8 +280,8 @@ async def test_factory_builds_xai_provider_and_applies_explicit_body_overrides(m
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
config = Config.model_validate(
{
"agents": {
"defaults": {
"modelPresets": {
"default": {
"model": "xai-grok/grok-4.5",
"provider": "xai_grok",
}

View File

@ -111,7 +111,7 @@ def test_from_config_accepts_default_model_override(tmp_path):
)
assert bot.runtime.model == "openai/gpt-4.1-mini"
assert bot._loop.model_preset is None
assert bot._loop.model_preset == "default"
def test_from_config_accepts_default_model_preset(tmp_path):
@ -249,8 +249,8 @@ def test_sdk_make_provider_uses_github_copilot_backend():
config = Config.model_validate(
{
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "github-copilot",
"model": "github-copilot/gpt-4.1",
}
@ -884,7 +884,7 @@ async def test_run_model_preset_override_is_per_run(tmp_path):
config=bot._config,
)
assert bot._loop.runtime_resolver.runtime is original_runtime
assert bot._loop.model_preset is None
assert bot._loop.model_preset == "default"
@pytest.mark.asyncio

View File

@ -8,7 +8,7 @@ import httpx
import pytest
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.registry import find_by_name
from nanobot.webui.settings_api import (
WebUISettingsError,
@ -180,14 +180,12 @@ def _dynamic_provider_config(
}
}
}
config = Config.model_validate(raw_config)
if defaults:
raw_config["agents"] = {
"defaults": {
"provider": DYNAMIC_PROVIDER_NAME,
"model": "gpt-4o-mini",
}
}
return Config.model_validate(raw_config)
default_preset = config.resolve_default_preset()
default_preset.provider = DYNAMIC_PROVIDER_NAME
default_preset.model = "gpt-4o-mini"
return config
def test_create_model_configuration_writes_label_without_changing_call_order(
@ -196,8 +194,8 @@ def test_create_model_configuration_writes_label_without_changing_call_order(
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.agents.defaults.model = "openai/gpt-4o"
config.agents.defaults.provider = "openai"
config.resolve_default_preset().model = "openai/gpt-4o"
config.resolve_default_preset().provider = "openai"
config.providers.openai.api_key = "sk-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
@ -217,7 +215,7 @@ def test_create_model_configuration_writes_label_without_changing_call_order(
assert rows["fast-writing"]["label"] == "Fast writing"
saved = load_config(config_path)
assert saved.agents.defaults.model_preset is None
assert saved.agents.defaults.model_preset == "default"
assert saved.model_presets["fast-writing"].label == "Fast writing"
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
assert saved.model_presets["fast-writing"].provider == "openai"
@ -335,7 +333,7 @@ def test_update_model_configuration_edits_named_preset_without_selecting(
assert payload["agent"]["model_preset"] == "default"
assert payload["agent"]["model"] == "anthropic/claude-opus-4-5"
saved = load_config(config_path)
assert saved.agents.defaults.model_preset is None
assert saved.agents.defaults.model_preset == "default"
assert saved.model_presets["codex"].label == "Codex"
assert saved.model_presets["codex"].provider == "openai_codex"
assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5"
@ -348,6 +346,7 @@ def test_settings_payload_exposes_named_model_call_order(
config_path = tmp_path / "config.json"
config = Config()
config.model_presets = {
"default": config.resolve_default_preset(),
"primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"),
"backup": ModelPresetConfig(model="anthropic/claude-sonnet-4", provider="anthropic"),
}
@ -369,6 +368,7 @@ def test_update_model_call_order_sets_primary_and_fallbacks(
config_path = tmp_path / "config.json"
config = Config()
config.model_presets = {
"default": config.resolve_default_preset(),
"primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"),
"backup": ModelPresetConfig(model="anthropic/claude-sonnet-4", provider="anthropic"),
}
@ -384,7 +384,7 @@ def test_update_model_call_order_sets_primary_and_fallbacks(
assert saved.agents.defaults.fallback_models == ["primary"]
def test_update_model_call_order_requires_named_primary(
def test_update_model_call_order_accepts_default_as_primary(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@ -394,50 +394,60 @@ def test_update_model_call_order_requires_named_primary(
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
with pytest.raises(WebUISettingsError) as error:
update_model_call_order({"order": [json.dumps(["backup"])]})
payload = update_model_call_order({"order": [json.dumps(["backup", "default"])]})
assert error.value.status == 409
assert load_config(config_path).agents.defaults.model_preset is None
assert payload["model_call_order"] == ["backup", "default"]
saved = load_config(config_path)
assert saved.agents.defaults.model_preset == "backup"
assert saved.agents.defaults.fallback_models == ["default"]
def test_migrate_model_configurations_preserves_legacy_chain(
def test_loading_settings_migrates_legacy_chain_once(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.agents.defaults.model = "openai/gpt-4o"
config.agents.defaults.provider = "openai"
config.agents.defaults.max_tokens = 4096
config.agents.defaults.temperature = 0.25
config.agents.defaults.fallback_models = [
InlineFallbackConfig(
model="anthropic/claude-sonnet-4",
provider="anthropic",
)
]
save_config(config, config_path)
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"model": "openai/gpt-4o",
"provider": "openai",
"maxTokens": 4096,
"temperature": 0.25,
"fallbackModels": [
{
"model": "anthropic/claude-sonnet-4",
"provider": "anthropic",
}
],
}
}
}
),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
legacy_payload = settings_payload()
assert legacy_payload["model_call_order"] == []
assert legacy_payload["model_call_order_editable"] is False
payload = migrate_model_configurations()
payload = settings_payload()
assert payload["model_call_order_editable"] is True
assert payload["model_call_order"] == ["gpt-4o", "claude-sonnet-4"]
assert payload["model_call_order"] == ["default", "claude-sonnet-4"]
saved = load_config(config_path)
assert saved.agents.defaults.model_preset == "gpt-4o"
assert saved.agents.defaults.model_preset == "default"
assert saved.agents.defaults.fallback_models == ["claude-sonnet-4"]
assert saved.model_presets["gpt-4o"].temperature == 0.25
assert saved.model_presets["default"].model == "openai/gpt-4o"
assert saved.model_presets["default"].temperature == 0.25
assert saved.model_presets["claude-sonnet-4"].max_tokens == 4096
assert saved.model_presets["claude-sonnet-4"].temperature == 0.25
repeated = migrate_model_configurations()
assert repeated["model_call_order"] == ["gpt-4o", "claude-sonnet-4"]
assert set(load_config(config_path).model_presets) == {"gpt-4o", "claude-sonnet-4"}
assert repeated["model_call_order"] == ["default", "claude-sonnet-4"]
assert set(load_config(config_path).model_presets) == {
"default",
"claude-sonnet-4",
}
def test_model_configuration_advanced_options_round_trip(
@ -459,6 +469,7 @@ def test_model_configuration_advanced_options_round_trip(
"context_window_tokens": ["262144"],
"temperature": ["0.4"],
"reasoning_effort": ["high"],
"supports_image_input": ["true"],
}
)
row = next(row for row in created["model_presets"] if row["name"] == "reasoning")
@ -466,6 +477,7 @@ def test_model_configuration_advanced_options_round_trip(
assert row["context_window_tokens"] == 262144
assert row["temperature"] == 0.4
assert row["reasoning_effort"] == "high"
assert row["supports_image_input"] is True
updated = update_model_configuration(
{
@ -473,12 +485,14 @@ def test_model_configuration_advanced_options_round_trip(
"max_tokens": ["8192"],
"temperature": ["0"],
"reasoning_effort": [""],
"supports_image_input": ["false"],
}
)
row = next(row for row in updated["model_presets"] if row["name"] == "reasoning")
assert row["max_tokens"] == 8192
assert row["temperature"] == 0
assert row["reasoning_effort"] is None
assert row["supports_image_input"] is False
def test_delete_model_configuration_requires_removing_it_from_call_order(
@ -488,6 +502,7 @@ def test_delete_model_configuration_requires_removing_it_from_call_order(
config_path = tmp_path / "config.json"
config = Config()
config.model_presets = {
"default": config.resolve_default_preset(),
"primary": ModelPresetConfig(model="openai/gpt-4.1"),
"spare": ModelPresetConfig(model="openai/gpt-4.1-mini"),
}
@ -754,7 +769,7 @@ def test_update_agent_settings_accepts_context_window_options(
assert payload["agent"]["context_window_tokens"] == 200000
saved = load_config(config_path)
assert saved.agents.defaults.context_window_tokens == 200000
assert saved.resolve_default_preset().context_window_tokens == 200000
def test_update_model_configuration_preserves_custom_context_windows(
@ -799,7 +814,7 @@ def test_update_context_window_rejects_unknown_values(
update_agent_settings({"context_window_tokens": ["128000"]})
def test_update_model_configuration_rejects_default_preset(
def test_update_model_configuration_edits_default_preset(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@ -807,8 +822,16 @@ def test_update_model_configuration_rejects_default_preset(
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
with pytest.raises(WebUISettingsError, match="model configuration is required"):
update_model_configuration({"name": ["default"], "model": ["openai/gpt-4.1"]})
payload = update_model_configuration({
"name": ["default"],
"model": ["openai/gpt-4.1"],
"supports_image_input": ["true"],
})
assert payload["agent"]["model"] == "openai/gpt-4.1"
saved = load_config(config_path)
assert saved.resolve_default_preset().model == "openai/gpt-4.1"
assert saved.resolve_default_preset().supports_image_input is True
def test_settings_payload_includes_oauth_provider_status(
@ -900,8 +923,8 @@ def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monke
config_path = tmp_path / "config.json"
config = Config.model_validate({
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
"agents": {
"defaults": {
"modelPresets": {
"default": {
"provider": "opencode_zen",
"model": "opencode/deepseek-v4-pro",
}

View File

@ -229,6 +229,7 @@ interface AgentSettingsDraft {
contextWindowTokens: number;
temperature: number;
reasoningEffort: string;
imageInputSupport: "auto" | "supported" | "text_only";
timezone: string;
botName: string;
botIcon: string;
@ -426,12 +427,29 @@ interface SettingsViewProps {
function modelPresetValue(payload: SettingsPayload): string {
return (
payload.agent.model_preset ??
payload.model_call_order?.[0] ??
payload.model_presets.find((preset) => !preset.is_default)?.name ??
""
payload.model_presets.find((preset) => preset.is_default)?.name ??
"default"
);
}
function imageInputSupportMode(
value: boolean | null | undefined,
): AgentSettingsDraft["imageInputSupport"] {
if (value === true) return "supported";
if (value === false) return "text_only";
return "auto";
}
function imageInputSupportValue(
value: AgentSettingsDraft["imageInputSupport"],
): boolean | null {
if (value === "supported") return true;
if (value === "text_only") return false;
return null;
}
function normalizeContextWindowTokens(value: number | null | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 200_000;
}
@ -470,6 +488,7 @@ const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
contextWindowTokens: 200_000,
temperature: 0.1,
reasoningEffort: "",
imageInputSupport: "auto",
timezone: "UTC",
botName: "nanobot",
botIcon: "",
@ -526,7 +545,7 @@ function agentDraftFromPayload(
const activePresetName = preferredPresetName ?? modelPresetValue(payload);
const activePreset =
payload.model_presets.find(
(preset) => !preset.is_default && preset.name === activePresetName,
(preset) => preset.name === activePresetName,
) ?? null;
return {
model: activePreset?.model ?? payload.agent.model,
@ -539,6 +558,7 @@ function agentDraftFromPayload(
),
temperature: activePreset?.temperature ?? payload.agent.temperature,
reasoningEffort: activePreset?.reasoning_effort ?? "",
imageInputSupport: imageInputSupportMode(activePreset?.supports_image_input),
timezone: payload.agent.timezone,
botName: payload.agent.bot_name,
botIcon: payload.agent.bot_icon,
@ -1034,7 +1054,7 @@ export function SettingsView({
const modelDirty = useMemo(() => {
if (!settings) return false;
const selectedPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === form.modelPreset,
(preset) => preset.name === form.modelPreset,
);
if (!selectedPreset) return false;
return (
@ -1044,6 +1064,7 @@ export function SettingsView({
form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) ||
form.temperature !== selectedPreset.temperature ||
form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "") ||
form.imageInputSupport !== imageInputSupportMode(selectedPreset.supports_image_input) ||
form.presetLabel.trim() !== selectedPreset.label
);
}, [form, settings]);
@ -1198,6 +1219,7 @@ export function SettingsView({
contextWindowTokens: form.contextWindowTokens,
temperature: form.temperature,
reasoningEffort: form.reasoningEffort || null,
supportsImageInput: imageInputSupportValue(form.imageInputSupport),
});
const createdPreset = payload.created_model_preset;
const nextOrder = createdPreset ? [...modelCallOrder, createdPreset] : null;
@ -1228,7 +1250,7 @@ export function SettingsView({
if (!modelDirty) return;
const selectedPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === form.modelPreset,
(preset) => preset.name === form.modelPreset,
);
if (!selectedPreset) return;
const reasoningEffort = form.reasoningEffort || null;
@ -1253,6 +1275,10 @@ export function SettingsView({
form.temperature !== selectedPreset.temperature ? form.temperature : undefined,
reasoningEffort:
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
supportsImageInput:
form.imageInputSupport !== imageInputSupportMode(selectedPreset.supports_image_input)
? imageInputSupportValue(form.imageInputSupport)
: undefined,
});
applyPayload(payload);
setForm(agentDraftFromPayload(payload, selectedPreset.name));
@ -1268,7 +1294,7 @@ export function SettingsView({
const beginModelPresetCreation = () => {
if (!settings || saving || modelCallOrderSaving || modelConfigurationSaving) return;
const primaryPreset = settings.model_presets.find(
(preset) => !preset.is_default && preset.name === settings.model_call_order?.[0],
(preset) => preset.name === settings.model_call_order?.[0],
);
const currentProvider = primaryPreset?.provider === "auto"
? primaryPreset.resolved_provider ?? settings.agent.resolved_provider
@ -1290,6 +1316,7 @@ export function SettingsView({
),
temperature: primaryPreset?.temperature ?? settings.agent.temperature,
reasoningEffort: primaryPreset?.reasoning_effort ?? settings.agent.reasoning_effort ?? "",
imageInputSupport: imageInputSupportMode(primaryPreset?.supports_image_input),
}));
setModelPresetCreating(true);
};
@ -3168,7 +3195,7 @@ function ModelsSettings({
const [advancedOpen, setAdvancedOpen] = useState(false);
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState<number | null>(null);
const namedPresets = settings.model_presets.filter((preset) => !preset.is_default);
const namedPresets = settings.model_presets;
const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset]));
const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name));
const callOrderOccurrences = new Map<string, number>();
@ -3244,6 +3271,7 @@ function ModelsSettings({
contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens),
temperature: preset.temperature,
reasoningEffort: preset.reasoning_effort ?? "",
imageInputSupport: imageInputSupportMode(preset.supports_image_input),
}));
setEditorOpen(true);
};
@ -3655,6 +3683,7 @@ function ModelsSettings({
contextWindowTokens={form.contextWindowTokens}
temperature={form.temperature}
reasoningEffort={form.reasoningEffort}
imageInputSupport={form.imageInputSupport}
onChange={(value) => setForm((prev) => ({ ...prev, ...value }))}
/>
</div>
@ -3673,7 +3702,7 @@ function ModelsSettings({
>
{tx("settings.actions.cancel", "Cancel")}
</Button>
) : selectedPreset ? (
) : selectedPreset && !selectedPreset.is_default ? (
<Button
size="sm"
variant="ghost"
@ -3726,17 +3755,23 @@ function ModelAdvancedFields({
contextWindowTokens,
temperature,
reasoningEffort,
imageInputSupport,
onChange,
}: {
maxTokens: number;
contextWindowTokens: number;
temperature: number;
reasoningEffort: string;
imageInputSupport: AgentSettingsDraft["imageInputSupport"];
onChange: (
value: Partial<
Pick<
AgentSettingsDraft,
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
| "maxTokens"
| "contextWindowTokens"
| "temperature"
| "reasoningEffort"
| "imageInputSupport"
>
>,
) => void;
@ -3811,6 +3846,33 @@ function ModelAdvancedFields({
className="h-9 rounded-[12px] text-[13px]"
/>
</label>
<div>
<span className="mb-2 block text-[12px] font-medium text-muted-foreground">
{tx("settings.models.imageInput", "Image input")}
</span>
<SegmentedControl
value={imageInputSupport}
options={[
{
value: "auto",
label: tx("settings.values.auto", "Auto"),
},
{
value: "supported",
label: tx("settings.models.imageInputSupported", "Supported"),
},
{
value: "text_only",
label: tx("settings.models.imageInputTextOnly", "Text only"),
},
]}
onChange={(value) =>
onChange({
imageInputSupport: value,
})
}
/>
</div>
</div>
);
}
@ -9761,14 +9823,14 @@ function StatusPill({
);
}
function SegmentedControl({
function SegmentedControl<T extends string>({
value,
options,
onChange,
}: {
value: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
value: T;
options: Array<{ value: T; label: string }>;
onChange: (value: T) => void;
}) {
return (
<div className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground">

View File

@ -774,7 +774,11 @@ function appendModelGenerationSettings(
query: URLSearchParams,
configuration: Pick<
ModelConfigurationCreate,
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
| "maxTokens"
| "contextWindowTokens"
| "temperature"
| "reasoningEffort"
| "supportsImageInput"
>,
): void {
if (configuration.maxTokens !== undefined) {
@ -789,6 +793,14 @@ function appendModelGenerationSettings(
if (configuration.reasoningEffort !== undefined) {
query.set("reasoning_effort", configuration.reasoningEffort ?? "");
}
if (configuration.supportsImageInput !== undefined) {
query.set(
"supports_image_input",
configuration.supportsImageInput === null
? "auto"
: String(configuration.supportsImageInput),
);
}
}
export async function createModelConfiguration(

View File

@ -432,6 +432,7 @@ export interface SettingsPayload {
context_window_tokens: number;
temperature: number;
reasoning_effort: string | null;
supports_image_input: boolean | null;
reasoning_effort_values?: string[];
}>;
model_call_order: string[];
@ -968,6 +969,7 @@ export interface ModelConfigurationCreate {
contextWindowTokens?: number;
temperature?: number;
reasoningEffort?: string | null;
supportsImageInput?: boolean | null;
}
export interface ModelConfigurationUpdate {
@ -979,6 +981,7 @@ export interface ModelConfigurationUpdate {
contextWindowTokens?: number;
temperature?: number;
reasoningEffort?: string | null;
supportsImageInput?: boolean | null;
}
export interface ProviderSettingsUpdate {

View File

@ -3004,7 +3004,7 @@ describe("SettingsView Apps catalog", () => {
expect(await screen.findByRole("button", { name: "private/image-v2" })).toBeInTheDocument();
});
it("does not expose the synthetic default configuration as a WebUI preset", async () => {
it("exposes the concrete default configuration as an editable preset", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
@ -3070,11 +3070,11 @@ describe("SettingsView Apps catalog", () => {
expect((await screen.findAllByText("MiniMax-M3")).length).toBeGreaterThan(0);
expect(screen.getAllByText("fast").length).toBeGreaterThan(0);
expect(screen.queryByText("Default")).not.toBeInTheDocument();
expect(screen.queryByText("openai-codex/gpt-5.5")).not.toBeInTheDocument();
expect(screen.getByText("Default")).toBeInTheDocument();
expect(screen.getByText("openai-codex/gpt-5.5")).toBeInTheDocument();
});
it("does not expose the synthetic default preset in the overview summary", async () => {
it("keeps the default preset suffix out of the overview summary", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,