From f239b459004ae6d3bad905a23f0ad3f282ced0a2 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 29 Jul 2026 00:02:21 +0800 Subject: [PATCH] feat(config): add image-aware model presets --- nanobot/agent/context.py | 53 +++- nanobot/agent/loop.py | 16 +- nanobot/agent/memory.py | 64 +++- nanobot/agent/model_presets.py | 3 +- nanobot/agent/runner.py | 1 + nanobot/agent/subagent.py | 8 +- .../websocket/tests/test_websocket_channel.py | 8 +- nanobot/cli/commands.py | 12 +- nanobot/cli/onboard.py | 17 +- nanobot/config/loader.py | 200 ++++++++++++- nanobot/config/schema.py | 59 ++-- nanobot/nanobot.py | 7 +- nanobot/providers/base.py | 107 ++++++- nanobot/providers/factory.py | 41 +-- nanobot/providers/fallback_provider.py | 125 +++++++- nanobot/session/manager.py | 28 +- nanobot/utils/helpers.py | 18 ++ nanobot/utils/llm_runtime.py | 18 ++ nanobot/webui/settings_api.py | 150 +++------- tests/agent/runner_helpers.py | 4 +- tests/agent/test_auto_compact.py | 8 +- tests/agent/test_consolidator.py | 40 +++ tests/agent/test_context_builder.py | 64 +++- tests/agent/test_model_runtime_resolver.py | 27 ++ tests/agent/test_onboard_logic.py | 28 +- tests/agent/test_runner_fallback.py | 275 ++++++++++++++++-- tests/agent/test_runner_runtime_identity.py | 4 + tests/agent/test_runtime_refresh.py | 6 +- tests/agent/test_self_model_preset.py | 12 +- tests/agent/test_session_manager_history.py | 38 +++ tests/cli/test_commands.py | 120 ++++---- tests/config/test_config_atomic_save.py | 2 +- tests/config/test_config_load_errors.py | 2 +- tests/config/test_config_migration.py | 76 ++++- tests/config/test_env_interpolation.py | 2 +- tests/config/test_model_presets.py | 64 ++-- tests/providers/test_bedrock_provider.py | 4 +- tests/providers/test_custom_thinking_style.py | 2 + tests/providers/test_extra_query_config.py | 1 + tests/providers/test_openai_codex_provider.py | 4 +- .../test_provider_default_headers.py | 4 + tests/providers/test_provider_retry.py | 88 +++++- tests/providers/test_xai_grok_provider.py | 4 +- tests/test_nanobot_facade.py | 8 +- tests/webui/test_settings_api.py | 115 +++++--- .../src/components/settings/SettingsView.tsx | 88 +++++- webui/src/lib/api.ts | 14 +- webui/src/lib/types.ts | 3 + webui/src/tests/settings-view.test.tsx | 8 +- 49 files changed, 1558 insertions(+), 492 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index f96200921..ef3ad344d 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -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 diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 61de18fb2..98d2b7e2c 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -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: diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 3e92ebb7e..29c092e7c 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -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, diff --git a/nanobot/agent/model_presets.py b/nanobot/agent/model_presets.py index eb2a9a643..4b0c09ba6 100644 --- a/nanobot/agent/model_presets.py +++ b/nanobot/agent/model_presets.py @@ -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, ) diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 3e3811880..969476798 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -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( diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index b4f4cece8..b25b30c13 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -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, diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index a9f543eff..3d6f6808f 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -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) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 0df030eac..091ea2abc 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -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() diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index 226cc3fcf..81f4c5264 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -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() diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 2b7664941..dc88d38d5 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -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: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 7505dde18..72acd17b8 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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] diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index c1b339537..c91530599 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -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 diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 2b0ef3320..ab8e518d2 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -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 diff --git a/nanobot/providers/factory.py b/nanobot/providers/factory.py index 3f1a966bd..d3c146281 100644 --- a/nanobot/providers/factory.py +++ b/nanobot/providers/factory.py @@ -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, ) diff --git a/nanobot/providers/fallback_provider.py b/nanobot/providers/fallback_provider.py index 8b19890a5..e797b874b 100644 --- a/nanobot/providers/fallback_provider.py +++ b/nanobot/providers/fallback_provider.py @@ -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 diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 21c776eb3..9d6061e0d 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -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] diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 64e27770c..cdcd0f195 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -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: diff --git a/nanobot/utils/llm_runtime.py b/nanobot/utils/llm_runtime.py index 405b96b16..fc7bee853 100644 --- a/nanobot/utils/llm_runtime.py +++ b/nanobot/utils/llm_runtime.py @@ -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, ) diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 85c52f6c0..51a595c23 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -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: diff --git a/tests/agent/runner_helpers.py b/tests/agent/runner_helpers.py index db97a7f4b..89e6fa46f 100644 --- a/tests/agent/runner_helpers.py +++ b/tests/agent/runner_helpers.py @@ -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() diff --git a/tests/agent/test_auto_compact.py b/tests/agent/test_auto_compact.py index 957d04989..bbbdfbd25 100644 --- a/tests/agent/test_auto_compact.py +++ b/tests/agent/test_auto_compact.py @@ -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): diff --git a/tests/agent/test_consolidator.py b/tests/agent/test_consolidator.py index 1e48b595b..4b2d8793f 100644 --- a/tests/agent/test_consolidator.py +++ b/tests/agent/test_consolidator.py @@ -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", diff --git a/tests/agent/test_context_builder.py b/tests/agent/test_context_builder.py index ecc488506..1b6eb726a 100644 --- a/tests/agent/test_context_builder.py +++ b/tests/agent/test_context_builder.py @@ -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] diff --git a/tests/agent/test_model_runtime_resolver.py b/tests/agent/test_model_runtime_resolver.py index d3aa7dac8..7e35ab227 100644 --- a/tests/agent/test_model_runtime_resolver.py +++ b/tests/agent/test_model_runtime_resolver.py @@ -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") diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py index 7a28133ec..6839d1f4f 100644 --- a/tests/agent/test_onboard_logic.py +++ b/tests/agent/test_onboard_logic.py @@ -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 + ) diff --git a/tests/agent/test_runner_fallback.py b/tests/agent/test_runner_fallback.py index bf401d7d4..664f2e2b2 100644 --- a/tests/agent/test_runner_fallback.py +++ b/tests/agent/test_runner_fallback.py @@ -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 diff --git a/tests/agent/test_runner_runtime_identity.py b/tests/agent/test_runner_runtime_identity.py index 8458125d7..c00497ce3 100644 --- a/tests/agent/test_runner_runtime_identity.py +++ b/tests/agent/test_runner_runtime_identity.py @@ -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 diff --git a/tests/agent/test_runtime_refresh.py b/tests/agent/test_runtime_refresh.py index d8f24e9f3..6d453cfd8 100644 --- a/tests/agent/test_runtime_refresh.py +++ b/tests/agent/test_runtime_refresh.py @@ -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) diff --git a/tests/agent/test_self_model_preset.py b/tests/agent/test_self_model_preset.py index f0a1e1072..1284f5577 100644 --- a/tests/agent/test_self_model_preset.py +++ b/tests/agent/test_self_model_preset.py @@ -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): diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index 6241e1f93..96790d209 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -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( diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 85b46514c..ea73ccc73 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -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",), ) diff --git a/tests/config/test_config_atomic_save.py b/tests/config/test_config_atomic_save.py index 4ebde1ea8..35d341cef 100644 --- a/tests/config/test_config_atomic_save.py +++ b/tests/config/test_config_atomic_save.py @@ -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") diff --git a/tests/config/test_config_load_errors.py b/tests/config/test_config_load_errors.py index b5aeab83d..819217c18 100644 --- a/tests/config/test_config_load_errors.py +++ b/tests/config/test_config_load_errors.py @@ -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( diff --git a/tests/config/test_config_migration.py b/tests/config/test_config_migration.py index a07300e80..4e9da3472 100644 --- a/tests/config/test_config_migration.py +++ b/tests/config/test_config_migration.py @@ -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 diff --git a/tests/config/test_env_interpolation.py b/tests/config/test_env_interpolation.py index 9119e226c..81acb1fd4 100644 --- a/tests/config/test_env_interpolation.py +++ b/tests/config/test_env_interpolation.py @@ -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")) diff --git a/tests/config/test_model_presets.py b/tests/config/test_model_presets.py index 726a8f793..779ff1ccd 100644 --- a/tests/config/test_model_presets.py +++ b/tests/config/test_model_presets.py @@ -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", } }, diff --git a/tests/providers/test_bedrock_provider.py b/tests/providers/test_bedrock_provider.py index a1c175245..c115356b9 100644 --- a/tests/providers/test_bedrock_provider.py +++ b/tests/providers/test_bedrock_provider.py @@ -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"}}, }) diff --git a/tests/providers/test_custom_thinking_style.py b/tests/providers/test_custom_thinking_style.py index a539baeed..fecb713ea 100644 --- a/tests/providers/test_custom_thinking_style.py +++ b/tests/providers/test_custom_thinking_style.py @@ -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"}, }, diff --git a/tests/providers/test_extra_query_config.py b/tests/providers/test_extra_query_config.py index 79e985261..92bb08f2c 100644 --- a/tests/providers/test_extra_query_config.py +++ b/tests/providers/test_extra_query_config.py @@ -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": { diff --git a/tests/providers/test_openai_codex_provider.py b/tests/providers/test_openai_codex_provider.py index ea5d0c6e5..5e2d6459c 100644 --- a/tests/providers/test_openai_codex_provider.py +++ b/tests/providers/test_openai_codex_provider.py @@ -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", }, diff --git a/tests/providers/test_provider_default_headers.py b/tests/providers/test_provider_default_headers.py index fcac92ea9..9e41986cf 100644 --- a/tests/providers/test_provider_default_headers.py +++ b/tests/providers/test_provider_default_headers.py @@ -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", diff --git a/tests/providers/test_provider_retry.py b/tests/providers/test_provider_retry.py index a00ad7ee4..ae1862a98 100644 --- a/tests/providers/test_provider_retry.py +++ b/tests/providers/test_provider_retry.py @@ -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([ diff --git a/tests/providers/test_xai_grok_provider.py b/tests/providers/test_xai_grok_provider.py index f988ded7a..31251b910 100644 --- a/tests/providers/test_xai_grok_provider.py +++ b/tests/providers/test_xai_grok_provider.py @@ -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", } diff --git a/tests/test_nanobot_facade.py b/tests/test_nanobot_facade.py index fb091b27d..1c5ba27c0 100644 --- a/tests/test_nanobot_facade.py +++ b/tests/test_nanobot_facade.py @@ -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 diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 7cd2cce9f..2644b0fd2 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -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", } diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 1dcf16d2c..c605fe497 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -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(null); const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState(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(); @@ -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 }))} /> @@ -3673,7 +3702,7 @@ function ModelsSettings({ > {tx("settings.actions.cancel", "Cancel")} - ) : selectedPreset ? ( + ) : selectedPreset && !selectedPreset.is_default ? (