mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-05 17:08:33 +00:00
feat(config): add image-aware model presets
This commit is contained in:
parent
9070d7489a
commit
f239b45900
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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",),
|
||||
)
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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",
|
||||
}
|
||||
},
|
||||
|
||||
@ -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"}},
|
||||
})
|
||||
|
||||
|
||||
@ -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"},
|
||||
},
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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",
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -229,6 +229,7 @@ interface AgentSettingsDraft {
|
||||
contextWindowTokens: number;
|
||||
temperature: number;
|
||||
reasoningEffort: string;
|
||||
imageInputSupport: "auto" | "supported" | "text_only";
|
||||
timezone: string;
|
||||
botName: string;
|
||||
botIcon: string;
|
||||
@ -426,12 +427,29 @@ interface SettingsViewProps {
|
||||
|
||||
function modelPresetValue(payload: SettingsPayload): string {
|
||||
return (
|
||||
payload.agent.model_preset ??
|
||||
payload.model_call_order?.[0] ??
|
||||
payload.model_presets.find((preset) => !preset.is_default)?.name ??
|
||||
""
|
||||
payload.model_presets.find((preset) => preset.is_default)?.name ??
|
||||
"default"
|
||||
);
|
||||
}
|
||||
|
||||
function imageInputSupportMode(
|
||||
value: boolean | null | undefined,
|
||||
): AgentSettingsDraft["imageInputSupport"] {
|
||||
if (value === true) return "supported";
|
||||
if (value === false) return "text_only";
|
||||
return "auto";
|
||||
}
|
||||
|
||||
function imageInputSupportValue(
|
||||
value: AgentSettingsDraft["imageInputSupport"],
|
||||
): boolean | null {
|
||||
if (value === "supported") return true;
|
||||
if (value === "text_only") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeContextWindowTokens(value: number | null | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 200_000;
|
||||
}
|
||||
@ -470,6 +488,7 @@ const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
|
||||
contextWindowTokens: 200_000,
|
||||
temperature: 0.1,
|
||||
reasoningEffort: "",
|
||||
imageInputSupport: "auto",
|
||||
timezone: "UTC",
|
||||
botName: "nanobot",
|
||||
botIcon: "",
|
||||
@ -526,7 +545,7 @@ function agentDraftFromPayload(
|
||||
const activePresetName = preferredPresetName ?? modelPresetValue(payload);
|
||||
const activePreset =
|
||||
payload.model_presets.find(
|
||||
(preset) => !preset.is_default && preset.name === activePresetName,
|
||||
(preset) => preset.name === activePresetName,
|
||||
) ?? null;
|
||||
return {
|
||||
model: activePreset?.model ?? payload.agent.model,
|
||||
@ -539,6 +558,7 @@ function agentDraftFromPayload(
|
||||
),
|
||||
temperature: activePreset?.temperature ?? payload.agent.temperature,
|
||||
reasoningEffort: activePreset?.reasoning_effort ?? "",
|
||||
imageInputSupport: imageInputSupportMode(activePreset?.supports_image_input),
|
||||
timezone: payload.agent.timezone,
|
||||
botName: payload.agent.bot_name,
|
||||
botIcon: payload.agent.bot_icon,
|
||||
@ -1034,7 +1054,7 @@ export function SettingsView({
|
||||
const modelDirty = useMemo(() => {
|
||||
if (!settings) return false;
|
||||
const selectedPreset = settings.model_presets.find(
|
||||
(preset) => !preset.is_default && preset.name === form.modelPreset,
|
||||
(preset) => preset.name === form.modelPreset,
|
||||
);
|
||||
if (!selectedPreset) return false;
|
||||
return (
|
||||
@ -1044,6 +1064,7 @@ export function SettingsView({
|
||||
form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) ||
|
||||
form.temperature !== selectedPreset.temperature ||
|
||||
form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "") ||
|
||||
form.imageInputSupport !== imageInputSupportMode(selectedPreset.supports_image_input) ||
|
||||
form.presetLabel.trim() !== selectedPreset.label
|
||||
);
|
||||
}, [form, settings]);
|
||||
@ -1198,6 +1219,7 @@ export function SettingsView({
|
||||
contextWindowTokens: form.contextWindowTokens,
|
||||
temperature: form.temperature,
|
||||
reasoningEffort: form.reasoningEffort || null,
|
||||
supportsImageInput: imageInputSupportValue(form.imageInputSupport),
|
||||
});
|
||||
const createdPreset = payload.created_model_preset;
|
||||
const nextOrder = createdPreset ? [...modelCallOrder, createdPreset] : null;
|
||||
@ -1228,7 +1250,7 @@ export function SettingsView({
|
||||
|
||||
if (!modelDirty) return;
|
||||
const selectedPreset = settings.model_presets.find(
|
||||
(preset) => !preset.is_default && preset.name === form.modelPreset,
|
||||
(preset) => preset.name === form.modelPreset,
|
||||
);
|
||||
if (!selectedPreset) return;
|
||||
const reasoningEffort = form.reasoningEffort || null;
|
||||
@ -1253,6 +1275,10 @@ export function SettingsView({
|
||||
form.temperature !== selectedPreset.temperature ? form.temperature : undefined,
|
||||
reasoningEffort:
|
||||
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
|
||||
supportsImageInput:
|
||||
form.imageInputSupport !== imageInputSupportMode(selectedPreset.supports_image_input)
|
||||
? imageInputSupportValue(form.imageInputSupport)
|
||||
: undefined,
|
||||
});
|
||||
applyPayload(payload);
|
||||
setForm(agentDraftFromPayload(payload, selectedPreset.name));
|
||||
@ -1268,7 +1294,7 @@ export function SettingsView({
|
||||
const beginModelPresetCreation = () => {
|
||||
if (!settings || saving || modelCallOrderSaving || modelConfigurationSaving) return;
|
||||
const primaryPreset = settings.model_presets.find(
|
||||
(preset) => !preset.is_default && preset.name === settings.model_call_order?.[0],
|
||||
(preset) => preset.name === settings.model_call_order?.[0],
|
||||
);
|
||||
const currentProvider = primaryPreset?.provider === "auto"
|
||||
? primaryPreset.resolved_provider ?? settings.agent.resolved_provider
|
||||
@ -1290,6 +1316,7 @@ export function SettingsView({
|
||||
),
|
||||
temperature: primaryPreset?.temperature ?? settings.agent.temperature,
|
||||
reasoningEffort: primaryPreset?.reasoning_effort ?? settings.agent.reasoning_effort ?? "",
|
||||
imageInputSupport: imageInputSupportMode(primaryPreset?.supports_image_input),
|
||||
}));
|
||||
setModelPresetCreating(true);
|
||||
};
|
||||
@ -3168,7 +3195,7 @@ function ModelsSettings({
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
|
||||
const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState<number | null>(null);
|
||||
const namedPresets = settings.model_presets.filter((preset) => !preset.is_default);
|
||||
const namedPresets = settings.model_presets;
|
||||
const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset]));
|
||||
const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name));
|
||||
const callOrderOccurrences = new Map<string, number>();
|
||||
@ -3244,6 +3271,7 @@ function ModelsSettings({
|
||||
contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens),
|
||||
temperature: preset.temperature,
|
||||
reasoningEffort: preset.reasoning_effort ?? "",
|
||||
imageInputSupport: imageInputSupportMode(preset.supports_image_input),
|
||||
}));
|
||||
setEditorOpen(true);
|
||||
};
|
||||
@ -3655,6 +3683,7 @@ function ModelsSettings({
|
||||
contextWindowTokens={form.contextWindowTokens}
|
||||
temperature={form.temperature}
|
||||
reasoningEffort={form.reasoningEffort}
|
||||
imageInputSupport={form.imageInputSupport}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, ...value }))}
|
||||
/>
|
||||
</div>
|
||||
@ -3673,7 +3702,7 @@ function ModelsSettings({
|
||||
>
|
||||
{tx("settings.actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
) : selectedPreset ? (
|
||||
) : selectedPreset && !selectedPreset.is_default ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@ -3726,17 +3755,23 @@ function ModelAdvancedFields({
|
||||
contextWindowTokens,
|
||||
temperature,
|
||||
reasoningEffort,
|
||||
imageInputSupport,
|
||||
onChange,
|
||||
}: {
|
||||
maxTokens: number;
|
||||
contextWindowTokens: number;
|
||||
temperature: number;
|
||||
reasoningEffort: string;
|
||||
imageInputSupport: AgentSettingsDraft["imageInputSupport"];
|
||||
onChange: (
|
||||
value: Partial<
|
||||
Pick<
|
||||
AgentSettingsDraft,
|
||||
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
||||
| "maxTokens"
|
||||
| "contextWindowTokens"
|
||||
| "temperature"
|
||||
| "reasoningEffort"
|
||||
| "imageInputSupport"
|
||||
>
|
||||
>,
|
||||
) => void;
|
||||
@ -3811,6 +3846,33 @@ function ModelAdvancedFields({
|
||||
className="h-9 rounded-[12px] text-[13px]"
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<span className="mb-2 block text-[12px] font-medium text-muted-foreground">
|
||||
{tx("settings.models.imageInput", "Image input")}
|
||||
</span>
|
||||
<SegmentedControl
|
||||
value={imageInputSupport}
|
||||
options={[
|
||||
{
|
||||
value: "auto",
|
||||
label: tx("settings.values.auto", "Auto"),
|
||||
},
|
||||
{
|
||||
value: "supported",
|
||||
label: tx("settings.models.imageInputSupported", "Supported"),
|
||||
},
|
||||
{
|
||||
value: "text_only",
|
||||
label: tx("settings.models.imageInputTextOnly", "Text only"),
|
||||
},
|
||||
]}
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
imageInputSupport: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -9761,14 +9823,14 @@ function StatusPill({
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentedControl({
|
||||
function SegmentedControl<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
onChange: (value: string) => void;
|
||||
value: T;
|
||||
options: Array<{ value: T; label: string }>;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground">
|
||||
|
||||
@ -774,7 +774,11 @@ function appendModelGenerationSettings(
|
||||
query: URLSearchParams,
|
||||
configuration: Pick<
|
||||
ModelConfigurationCreate,
|
||||
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
||||
| "maxTokens"
|
||||
| "contextWindowTokens"
|
||||
| "temperature"
|
||||
| "reasoningEffort"
|
||||
| "supportsImageInput"
|
||||
>,
|
||||
): void {
|
||||
if (configuration.maxTokens !== undefined) {
|
||||
@ -789,6 +793,14 @@ function appendModelGenerationSettings(
|
||||
if (configuration.reasoningEffort !== undefined) {
|
||||
query.set("reasoning_effort", configuration.reasoningEffort ?? "");
|
||||
}
|
||||
if (configuration.supportsImageInput !== undefined) {
|
||||
query.set(
|
||||
"supports_image_input",
|
||||
configuration.supportsImageInput === null
|
||||
? "auto"
|
||||
: String(configuration.supportsImageInput),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createModelConfiguration(
|
||||
|
||||
@ -432,6 +432,7 @@ export interface SettingsPayload {
|
||||
context_window_tokens: number;
|
||||
temperature: number;
|
||||
reasoning_effort: string | null;
|
||||
supports_image_input: boolean | null;
|
||||
reasoning_effort_values?: string[];
|
||||
}>;
|
||||
model_call_order: string[];
|
||||
@ -968,6 +969,7 @@ export interface ModelConfigurationCreate {
|
||||
contextWindowTokens?: number;
|
||||
temperature?: number;
|
||||
reasoningEffort?: string | null;
|
||||
supportsImageInput?: boolean | null;
|
||||
}
|
||||
|
||||
export interface ModelConfigurationUpdate {
|
||||
@ -979,6 +981,7 @@ export interface ModelConfigurationUpdate {
|
||||
contextWindowTokens?: number;
|
||||
temperature?: number;
|
||||
reasoningEffort?: string | null;
|
||||
supportsImageInput?: boolean | null;
|
||||
}
|
||||
|
||||
export interface ProviderSettingsUpdate {
|
||||
|
||||
@ -3004,7 +3004,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
expect(await screen.findByRole("button", { name: "private/image-v2" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not expose the synthetic default configuration as a WebUI preset", async () => {
|
||||
it("exposes the concrete default configuration as an editable preset", async () => {
|
||||
const base = settingsPayload();
|
||||
const payload: SettingsPayload = {
|
||||
...base,
|
||||
@ -3070,11 +3070,11 @@ describe("SettingsView Apps catalog", () => {
|
||||
|
||||
expect((await screen.findAllByText("MiniMax-M3")).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("fast").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("Default")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("openai-codex/gpt-5.5")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||
expect(screen.getByText("openai-codex/gpt-5.5")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not expose the synthetic default preset in the overview summary", async () => {
|
||||
it("keeps the default preset suffix out of the overview summary", async () => {
|
||||
const base = settingsPayload();
|
||||
const payload: SettingsPayload = {
|
||||
...base,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user