mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +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.bus.events import InboundMessage
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_END,
|
RUNTIME_CONTEXT_END,
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
RUNTIME_CONTEXT_MESSAGE_META,
|
RUNTIME_CONTEXT_MESSAGE_META,
|
||||||
RUNTIME_CONTEXT_TAG,
|
RUNTIME_CONTEXT_TAG,
|
||||||
RuntimeContextBlock,
|
RuntimeContextBlock,
|
||||||
append_runtime_context,
|
append_runtime_context,
|
||||||
|
detach_runtime_context,
|
||||||
|
reattach_runtime_context,
|
||||||
)
|
)
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
detect_image_mime,
|
detect_image_mime,
|
||||||
@ -60,6 +63,9 @@ class ContextBuilder:
|
|||||||
_MAX_RECENT_HISTORY = 50
|
_MAX_RECENT_HISTORY = 50
|
||||||
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
||||||
_RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END
|
_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):
|
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
@ -224,7 +230,7 @@ class ContextBuilder:
|
|||||||
unified_session=unified_session,
|
unified_session=unified_session,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
*history,
|
*self._hydrate_history_media(history),
|
||||||
]
|
]
|
||||||
if messages[-1].get("role") == current_role:
|
if messages[-1].get("role") == current_role:
|
||||||
last = dict(messages[-1])
|
last = dict(messages[-1])
|
||||||
@ -254,6 +260,9 @@ class ContextBuilder:
|
|||||||
for path in image_paths:
|
for path in image_paths:
|
||||||
p = Path(path)
|
p = Path(path)
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
|
image_blocks.append(
|
||||||
|
{"type": "text", "text": self._MISSING_IMAGE_TEXT}
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
raw = p.read_bytes()
|
raw = p.read_bytes()
|
||||||
# Re-detect from the bytes used for the request: the file may have
|
# Re-detect from the bytes used for the request: the file may have
|
||||||
@ -271,3 +280,45 @@ class ContextBuilder:
|
|||||||
if not image_blocks:
|
if not image_blocks:
|
||||||
return text
|
return text
|
||||||
return image_blocks + [{"type": "text", "text": 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 = (
|
initial_context_window = (
|
||||||
context_window_tokens
|
context_window_tokens
|
||||||
if context_window_tokens is not None
|
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 {}
|
configured_presets = model_presets or {}
|
||||||
self.runtime_resolver = ModelRuntimeResolver(
|
self.runtime_resolver = ModelRuntimeResolver(
|
||||||
@ -445,15 +445,18 @@ class AgentLoop:
|
|||||||
if bus is None:
|
if bus is None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
defaults = config.agents.defaults
|
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()
|
resolved = config.resolve_preset()
|
||||||
model = extra.pop("model", None) or resolved.model
|
model = extra.pop("model", None) or resolved.model
|
||||||
context_window_tokens = extra.pop("context_window_tokens", None) or resolved.context_window_tokens
|
context_window_tokens = extra.pop("context_window_tokens", None) or resolved.context_window_tokens
|
||||||
provider_snapshot_loader = extra.pop("provider_snapshot_loader", None)
|
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(
|
preset_snapshot_loader = extra.pop("preset_snapshot_loader", None)
|
||||||
config,
|
if preset_snapshot_loader is None and explicit_provider is None:
|
||||||
provider_snapshot_loader,
|
preset_snapshot_loader = preset_helpers.make_preset_snapshot_loader(
|
||||||
)
|
config,
|
||||||
|
provider_snapshot_loader,
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
@ -1616,6 +1619,7 @@ class AgentLoop:
|
|||||||
"max_messages": replay_max_messages,
|
"max_messages": replay_max_messages,
|
||||||
"max_tokens": self._replay_token_budget(runtime),
|
"max_tokens": self._replay_token_budget(runtime),
|
||||||
"extend_to_user": is_subagent,
|
"extend_to_user": is_subagent,
|
||||||
|
"include_media": True,
|
||||||
}
|
}
|
||||||
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
||||||
if is_subagent:
|
if is_subagent:
|
||||||
|
|||||||
@ -19,10 +19,12 @@ from nanobot.runtime_context import public_history_messages
|
|||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.gitstore import GitStore
|
from nanobot.utils.gitstore import GitStore
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
|
content_with_media_breadcrumbs,
|
||||||
ensure_dir,
|
ensure_dir,
|
||||||
estimate_message_tokens,
|
estimate_message_tokens,
|
||||||
estimate_prompt_tokens_chain,
|
estimate_prompt_tokens_chain,
|
||||||
find_legal_message_start,
|
find_legal_message_start,
|
||||||
|
image_placeholder_text,
|
||||||
recent_message_start_index,
|
recent_message_start_index,
|
||||||
strip_think,
|
strip_think,
|
||||||
truncate_text,
|
truncate_text,
|
||||||
@ -695,14 +697,58 @@ class MemoryStore:
|
|||||||
def _format_messages(messages: list[dict]) -> str:
|
def _format_messages(messages: list[dict]) -> str:
|
||||||
lines = []
|
lines = []
|
||||||
for message in messages:
|
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
|
continue
|
||||||
tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else ""
|
tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else ""
|
||||||
lines.append(
|
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)
|
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(
|
def raw_archive(
|
||||||
self,
|
self,
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
@ -712,10 +758,11 @@ class MemoryStore:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
|
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
|
||||||
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
|
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
|
||||||
formatted = truncate_text(
|
formatted = self._format_messages(public_history_messages(messages))
|
||||||
self._format_messages(public_history_messages(messages)),
|
manifest = self._media_manifest(messages)
|
||||||
limit,
|
if manifest:
|
||||||
)
|
formatted = f"{manifest}\n\n{formatted}"
|
||||||
|
formatted = truncate_text(formatted, limit)
|
||||||
self.append_history(
|
self.append_history(
|
||||||
f"[RAW] {len(messages)} messages\n"
|
f"[RAW] {len(messages)} messages\n"
|
||||||
f"{formatted}",
|
f"{formatted}",
|
||||||
@ -1020,6 +1067,11 @@ class Consolidator:
|
|||||||
self.store.raw_archive(messages, session_key=session_key)
|
self.store.raw_archive(messages, session_key=session_key)
|
||||||
return None
|
return None
|
||||||
summary = response.content or "[no summary]"
|
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(
|
self.store.append_history(
|
||||||
summary,
|
summary,
|
||||||
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
|
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
|
||||||
|
|||||||
@ -23,7 +23,7 @@ def default_selection_signature(
|
|||||||
|
|
||||||
|
|
||||||
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]:
|
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(
|
def load_model_preset_catalog(
|
||||||
@ -61,6 +61,7 @@ def build_static_preset_snapshot(
|
|||||||
signature=("model_preset", name, preset.model_dump_json()),
|
signature=("model_preset", name, preset.model_dump_json()),
|
||||||
generation=preset.to_generation_settings(),
|
generation=preset.to_generation_settings(),
|
||||||
model_preset=name,
|
model_preset=name,
|
||||||
|
supports_image_input=preset.supports_image_input,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -788,6 +788,7 @@ class AgentRunner:
|
|||||||
kwargs["temperature"] = generation.temperature
|
kwargs["temperature"] = generation.temperature
|
||||||
kwargs["max_tokens"] = generation.max_tokens
|
kwargs["max_tokens"] = generation.max_tokens
|
||||||
kwargs["reasoning_effort"] = generation.reasoning_effort
|
kwargs["reasoning_effort"] = generation.reasoning_effort
|
||||||
|
kwargs["supports_image_input"] = spec.runtime.supports_image_input
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
async def _request_model(
|
async def _request_model(
|
||||||
|
|||||||
@ -26,7 +26,7 @@ from nanobot.agent.tools.loader import ToolLoader
|
|||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
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.providers.base import LLMProvider
|
||||||
from nanobot.security.workspace_access import (
|
from nanobot.security.workspace_access import (
|
||||||
WorkspaceScope,
|
WorkspaceScope,
|
||||||
@ -121,7 +121,9 @@ class SubagentManager:
|
|||||||
self._compat_runtime = LLMRuntime.capture(
|
self._compat_runtime = LLMRuntime.capture(
|
||||||
provider,
|
provider,
|
||||||
model or provider.get_default_model(),
|
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.workspace = workspace
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
@ -161,7 +163,7 @@ class SubagentManager:
|
|||||||
context_window_tokens = (
|
context_window_tokens = (
|
||||||
self._compat_runtime.context_window_tokens
|
self._compat_runtime.context_window_tokens
|
||||||
if self._compat_runtime is not None
|
if self._compat_runtime is not None
|
||||||
else AgentDefaults().context_window_tokens
|
else ModelPresetConfig(model=model).context_window_tokens
|
||||||
)
|
)
|
||||||
self._compat_runtime = LLMRuntime.capture(
|
self._compat_runtime = LLMRuntime.capture(
|
||||||
provider,
|
provider,
|
||||||
|
|||||||
@ -2462,7 +2462,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
port = 29891
|
port = 29891
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config()
|
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.providers.openai.api_key = "secret-key"
|
||||||
config.model_presets["deep"] = ModelPresetConfig(
|
config.model_presets["deep"] = ModelPresetConfig(
|
||||||
model="anthropic/claude-opus-4-5",
|
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
|
assert bad_image.status_code == 400
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_config(config_path)
|
||||||
assert saved.agents.defaults.model == "atomic_chat/test"
|
assert saved.resolve_default_preset().model == "atomic_chat/test"
|
||||||
assert saved.agents.defaults.provider == "atomic_chat"
|
assert saved.resolve_default_preset().provider == "atomic_chat"
|
||||||
assert saved.agents.defaults.model_preset == "fast-writing"
|
assert saved.agents.defaults.model_preset == "fast-writing"
|
||||||
assert saved.agents.defaults.fallback_models == ["deep"]
|
assert saved.agents.defaults.fallback_models == ["deep"]
|
||||||
assert saved.model_presets["fast-writing"].label == "Codex"
|
assert saved.model_presets["fast-writing"].label == "Codex"
|
||||||
@ -3001,7 +3001,7 @@ def test_settings_payload_normalizes_camel_case_provider(
|
|||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.provider = "minimaxAnthropic"
|
config.resolve_default_preset().provider = "minimaxAnthropic"
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", 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."""
|
"""Return (resolved_model_name, preset_tag) for display strings."""
|
||||||
resolved = config.resolve_preset()
|
resolved = config.resolve_preset()
|
||||||
name = config.agents.defaults.model_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
|
return resolved.model, tag
|
||||||
|
|
||||||
|
|
||||||
@ -2969,11 +2969,13 @@ def _set_oauth_provider_as_main(
|
|||||||
|
|
||||||
config = load_config(resolved_config_path)
|
config = load_config(resolved_config_path)
|
||||||
selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
|
selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
|
||||||
config.agents.defaults.model_preset = None
|
default_preset = config.resolve_default_preset().model_copy(
|
||||||
config.agents.defaults.provider = provider_name
|
update={"provider": provider_name, "model": selected_model}
|
||||||
config.agents.defaults.model = selected_model
|
)
|
||||||
if provider_name == "xai_grok" and selected_model == "xai-grok/grok-4.5":
|
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)
|
save_config(config, resolved_config_path)
|
||||||
|
|
||||||
saved_path = resolved_config_path or get_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
|
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle the 'model_preset' field with a list of existing presets."""
|
"""Handle the 'model_preset' field with a list of existing presets."""
|
||||||
preset_names = sorted(_MODEL_PRESET_CACHE)
|
preset_names = sorted(_MODEL_PRESET_CACHE) or ["default"]
|
||||||
choices = [_CLEAR_CHOICE] + preset_names
|
choices = preset_names
|
||||||
default_choice = str(current_value) if current_value else _CLEAR_CHOICE
|
default_choice = str(current_value) if current_value else "default"
|
||||||
new_value = _select_with_back(field_display, choices, default=default_choice)
|
new_value = _select_with_back(field_display, choices, default=default_choice)
|
||||||
if new_value is _BACK_PRESSED:
|
if new_value is _BACK_PRESSED:
|
||||||
return
|
return
|
||||||
if new_value == _CLEAR_CHOICE:
|
if new_value is not None:
|
||||||
setattr(working_model, field_name, None)
|
|
||||||
elif new_value is not None:
|
|
||||||
setattr(working_model, field_name, new_value)
|
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
|
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle the 'fallback_models' field with preset-aware list management."""
|
"""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 []
|
items: list[Any] = list(current_value) if isinstance(current_value, list) else []
|
||||||
preset_names = sorted(_MODEL_PRESET_CACHE)
|
preset_names = sorted(_MODEL_PRESET_CACHE)
|
||||||
|
|
||||||
@ -802,10 +798,7 @@ def _handle_fallback_models_field(
|
|||||||
console.print(f"[bold]{field_display}[/bold]")
|
console.print(f"[bold]{field_display}[/bold]")
|
||||||
if items:
|
if items:
|
||||||
for idx, item in enumerate(items, 1):
|
for idx, item in enumerate(items, 1):
|
||||||
if isinstance(item, InlineFallbackConfig):
|
console.print(f" {idx}. {item}")
|
||||||
console.print(f" {idx}. {item.model} - {item.provider} inline")
|
|
||||||
else:
|
|
||||||
console.print(f" {idx}. {item}")
|
|
||||||
else:
|
else:
|
||||||
console.print(" [dim]empty[/dim]")
|
console.print(" [dim]empty[/dim]")
|
||||||
console.print()
|
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:
|
try:
|
||||||
config = Config.model_validate(data)
|
config = Config.model_validate(data)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
@ -122,6 +122,9 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
issues=issues,
|
issues=issues,
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
if migrated:
|
||||||
|
_write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
_apply_ssrf_whitelist(config)
|
_apply_ssrf_whitelist(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
@ -310,12 +313,191 @@ def _env_replace(match: re.Match[str]) -> str:
|
|||||||
return value
|
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."""
|
"""Migrate old config formats to current."""
|
||||||
|
changed = _migrate_legacy_model_config(data)
|
||||||
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
||||||
tools = data.get("tools", {})
|
tools = data.get("tools", {})
|
||||||
if not isinstance(tools, dict):
|
if not isinstance(tools, dict):
|
||||||
return data
|
return data, changed
|
||||||
exec_cfg = tools.get("exec", {})
|
exec_cfg = tools.get("exec", {})
|
||||||
if (
|
if (
|
||||||
isinstance(exec_cfg, dict)
|
isinstance(exec_cfg, dict)
|
||||||
@ -323,6 +505,7 @@ def _migrate_config(data: dict) -> dict:
|
|||||||
and "restrictToWorkspace" not in tools
|
and "restrictToWorkspace" not in tools
|
||||||
):
|
):
|
||||||
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
|
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
|
||||||
|
changed = True
|
||||||
|
|
||||||
# Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}.
|
# Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}.
|
||||||
# The old flat keys shipped in the initial MyTool landing; wrapping them in a
|
# 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:
|
if my_cfg is None:
|
||||||
my_cfg = {}
|
my_cfg = {}
|
||||||
tools["my"] = my_cfg
|
tools["my"] = my_cfg
|
||||||
|
changed = True
|
||||||
if not isinstance(my_cfg, dict):
|
if not isinstance(my_cfg, dict):
|
||||||
return data
|
return data, changed
|
||||||
if "myEnabled" in tools and "enable" not in my_cfg:
|
if "myEnabled" in tools and "enable" not in my_cfg:
|
||||||
my_cfg["enable"] = tools.pop("myEnabled")
|
my_cfg["enable"] = tools.pop("myEnabled")
|
||||||
|
changed = True
|
||||||
else:
|
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:
|
if "mySet" in tools and "allowSet" not in my_cfg:
|
||||||
my_cfg["allowSet"] = tools.pop("mySet")
|
my_cfg["allowSet"] = tools.pop("mySet")
|
||||||
|
changed = True
|
||||||
else:
|
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:
|
def _sentence(message: str) -> str:
|
||||||
|
|||||||
@ -79,20 +79,6 @@ class DreamConfig(Base):
|
|||||||
return f"every {hours}h"
|
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):
|
class ModelPresetConfig(Base):
|
||||||
"""A named set of model + generation parameters for quick switching."""
|
"""A named set of model + generation parameters for quick switching."""
|
||||||
|
|
||||||
@ -103,6 +89,7 @@ class ModelPresetConfig(Base):
|
|||||||
context_window_tokens: int = 200_000
|
context_window_tokens: int = 200_000
|
||||||
temperature: float = 0.1
|
temperature: float = 0.1
|
||||||
reasoning_effort: str | None = None
|
reasoning_effort: str | None = None
|
||||||
|
supports_image_input: bool | None = None
|
||||||
|
|
||||||
def to_generation_settings(self) -> Any:
|
def to_generation_settings(self) -> Any:
|
||||||
from nanobot.providers.base import GenerationSettings
|
from nanobot.providers.base import GenerationSettings
|
||||||
@ -117,16 +104,9 @@ class AgentDefaults(Base):
|
|||||||
"""Default agent configuration."""
|
"""Default agent configuration."""
|
||||||
|
|
||||||
workspace: str = "~/.nanobot/workspace"
|
workspace: str = "~/.nanobot/workspace"
|
||||||
model_preset: str | None = None # Active preset name — takes precedence over fields below
|
model_preset: str = "default"
|
||||||
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
|
|
||||||
context_block_limit: int | None = None
|
context_block_limit: int | None = None
|
||||||
temperature: float = 0.1
|
fallback_models: list[str] = Field(default_factory=list)
|
||||||
fallback_models: list[FallbackCandidate] = Field(default_factory=list)
|
|
||||||
max_tool_iterations: int = 200
|
max_tool_iterations: int = 200
|
||||||
max_concurrent_subagents: int = Field(default=1, ge=1)
|
max_concurrent_subagents: int = Field(default=1, ge=1)
|
||||||
fail_on_tool_error: bool = True
|
fail_on_tool_error: bool = True
|
||||||
@ -139,7 +119,6 @@ class AgentDefaults(Base):
|
|||||||
validation_alias=AliasChoices("toolHintMaxLength"),
|
validation_alias=AliasChoices("toolHintMaxLength"),
|
||||||
serialization_alias="toolHintMaxLength",
|
serialization_alias="toolHintMaxLength",
|
||||||
) # Max characters for tool hint display (e.g. "$ cd …/project && npm test")
|
) # 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"
|
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_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
|
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)
|
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
||||||
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
||||||
model_presets: dict[str, ModelPresetConfig] = Field(
|
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"),
|
validation_alias=AliasChoices("modelPresets", "model_presets"),
|
||||||
serialization_alias="modelPresets",
|
serialization_alias="modelPresets",
|
||||||
)
|
)
|
||||||
@ -431,33 +415,26 @@ class Config(BaseSettings):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _validate_model_preset(self) -> "Config":
|
def _validate_model_preset(self) -> "Config":
|
||||||
if "default" in self.model_presets:
|
if "default" not in self.model_presets:
|
||||||
raise ValueError("model_preset name 'default' is reserved for agents.defaults")
|
raise ValueError("model_presets must define a 'default' preset")
|
||||||
name = self.agents.defaults.model_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")
|
raise ValueError(f"model_preset {name!r} not found in model_presets")
|
||||||
dream_name = self.agents.defaults.dream.model_override
|
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")
|
raise ValueError(f"Dream model preset {dream_name!r} not found in model_presets")
|
||||||
for fallback in self.agents.defaults.fallback_models:
|
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")
|
raise ValueError(f"fallback_models entry {fallback!r} not found in model_presets")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def resolve_default_preset(self) -> ModelPresetConfig:
|
def resolve_default_preset(self) -> ModelPresetConfig:
|
||||||
"""Return the implicit `default` preset from agents.defaults fields."""
|
"""Return the concrete ``default`` model preset."""
|
||||||
d = self.agents.defaults
|
return self.model_presets["default"]
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
def resolve_preset(self, name: str | None = None) -> ModelPresetConfig:
|
def resolve_preset(self, name: str | None = None) -> ModelPresetConfig:
|
||||||
"""Return effective model params from a named preset or the implicit default."""
|
"""Return effective model params from a named preset."""
|
||||||
name = self.agents.defaults.model_preset if name is None else name
|
name = self.agents.defaults.model_preset if name is None else (name or "default")
|
||||||
if not name or name == "default":
|
|
||||||
return self.resolve_default_preset()
|
|
||||||
if name not in self.model_presets:
|
if name not in self.model_presets:
|
||||||
raise KeyError(f"model_preset {name!r} not found in model_presets")
|
raise KeyError(f"model_preset {name!r} not found in model_presets")
|
||||||
return self.model_presets[name]
|
return self.model_presets[name]
|
||||||
|
|||||||
@ -114,9 +114,10 @@ class Nanobot:
|
|||||||
Path(workspace).expanduser().resolve()
|
Path(workspace).expanduser().resolve()
|
||||||
)
|
)
|
||||||
if model is not None:
|
if model is not None:
|
||||||
config.agents.defaults.model_preset = None
|
config.model_presets["default"] = config.resolve_preset().model_copy(
|
||||||
config.agents.defaults.model = model
|
update={"model": model, "provider": "auto"}
|
||||||
config.agents.defaults.provider = "auto"
|
)
|
||||||
|
config.agents.defaults.model_preset = "default"
|
||||||
elif model_preset is not None:
|
elif model_preset is not None:
|
||||||
config.agents.defaults.model_preset = model_preset
|
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})
|
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429})
|
||||||
_TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
|
_TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
|
||||||
_NON_RETRYABLE_429_ERROR_TOKENS = frozenset({
|
_NON_RETRYABLE_429_ERROR_TOKENS = frozenset({
|
||||||
@ -272,6 +282,7 @@ class LLMProvider(ABC):
|
|||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.api_base = api_base
|
self.api_base = api_base
|
||||||
self.generation: GenerationSettings = GenerationSettings()
|
self.generation: GenerationSettings = GenerationSettings()
|
||||||
|
self.supports_image_input: bool | None = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
@ -602,6 +613,51 @@ class LLMProvider(ABC):
|
|||||||
result.append(msg)
|
result.append(msg)
|
||||||
return result if found else None
|
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
|
@staticmethod
|
||||||
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
|
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
|
||||||
"""Replace image_url blocks with text placeholder *in-place*.
|
"""Replace image_url blocks with text placeholder *in-place*.
|
||||||
@ -692,6 +748,7 @@ class LLMProvider(ABC):
|
|||||||
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
||||||
retry_mode: str = "standard",
|
retry_mode: str = "standard",
|
||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
supports_image_input: bool | None | object = _SENTINEL,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""Call chat_stream() with retry on transient provider failures."""
|
"""Call chat_stream() with retry on transient provider failures."""
|
||||||
if max_tokens is self._SENTINEL or max_tokens is None:
|
if max_tokens is self._SENTINEL or max_tokens is None:
|
||||||
@ -700,6 +757,14 @@ class LLMProvider(ABC):
|
|||||||
temperature = self.generation.temperature
|
temperature = self.generation.temperature
|
||||||
if reasoning_effort is self._SENTINEL:
|
if reasoning_effort is self._SENTINEL:
|
||||||
reasoning_effort = self.generation.reasoning_effort
|
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
|
has_streamed_content = False
|
||||||
|
|
||||||
@ -717,13 +782,19 @@ class LLMProvider(ABC):
|
|||||||
has_streamed_content = False
|
has_streamed_content = False
|
||||||
|
|
||||||
kw: dict[str, Any] = dict(
|
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,
|
max_tokens=max_tokens, temperature=temperature,
|
||||||
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
||||||
on_content_delta=_tracking_delta if on_content_delta is not None else None,
|
on_content_delta=_tracking_delta if on_content_delta is not None else None,
|
||||||
on_thinking_delta=on_thinking_delta,
|
on_thinking_delta=on_thinking_delta,
|
||||||
on_tool_call_delta=on_tool_call_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):
|
if on_stream_recover and getattr(self, "supports_stream_recover_callback", False):
|
||||||
kw["on_stream_recover"] = _recover_stream
|
kw["on_stream_recover"] = _recover_stream
|
||||||
return await self._run_with_retry(
|
return await self._run_with_retry(
|
||||||
@ -734,6 +805,7 @@ class LLMProvider(ABC):
|
|||||||
on_retry_wait=on_retry_wait,
|
on_retry_wait=on_retry_wait,
|
||||||
should_retry_guard=lambda: not has_streamed_content,
|
should_retry_guard=lambda: not has_streamed_content,
|
||||||
on_stream_recover=_recover_stream if on_stream_recover else None,
|
on_stream_recover=_recover_stream if on_stream_recover else None,
|
||||||
|
supports_image_input=outer_image_capability,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def chat_with_retry(
|
async def chat_with_retry(
|
||||||
@ -747,6 +819,7 @@ class LLMProvider(ABC):
|
|||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
retry_mode: str = "standard",
|
retry_mode: str = "standard",
|
||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
supports_image_input: bool | None | object = _SENTINEL,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""Call chat() with retry on transient provider failures.
|
"""Call chat() with retry on transient provider failures.
|
||||||
|
|
||||||
@ -763,18 +836,33 @@ class LLMProvider(ABC):
|
|||||||
temperature = self.generation.temperature
|
temperature = self.generation.temperature
|
||||||
if reasoning_effort is self._SENTINEL:
|
if reasoning_effort is self._SENTINEL:
|
||||||
reasoning_effort = self.generation.reasoning_effort
|
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(
|
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,
|
max_tokens=max_tokens, temperature=temperature,
|
||||||
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
||||||
)
|
)
|
||||||
|
kw.update(self._image_policy_request_kwargs(candidate_image_capability))
|
||||||
return await self._run_with_retry(
|
return await self._run_with_retry(
|
||||||
self._safe_chat,
|
self._safe_chat,
|
||||||
kw,
|
kw,
|
||||||
messages,
|
messages,
|
||||||
retry_mode=retry_mode,
|
retry_mode=retry_mode,
|
||||||
on_retry_wait=on_retry_wait,
|
on_retry_wait=on_retry_wait,
|
||||||
|
supports_image_input=outer_image_capability,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -882,6 +970,7 @@ class LLMProvider(ABC):
|
|||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None,
|
on_retry_wait: Callable[[str], Awaitable[None]] | None,
|
||||||
should_retry_guard: Callable[[], bool] | None = None,
|
should_retry_guard: Callable[[], bool] | None = None,
|
||||||
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
||||||
|
supports_image_input: bool | None | object = _SENTINEL,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
attempt = 0
|
attempt = 0
|
||||||
delays = list(self._CHAT_RETRY_DELAYS)
|
delays = list(self._CHAT_RETRY_DELAYS)
|
||||||
@ -928,9 +1017,19 @@ class LLMProvider(ABC):
|
|||||||
|
|
||||||
if not self._is_transient_response(response):
|
if not self._is_transient_response(response):
|
||||||
stripped = self._strip_image_content(original_messages)
|
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(
|
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 = dict(kw)
|
||||||
retry_kw["messages"] = stripped
|
retry_kw["messages"] = stripped
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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.base import GenerationSettings, LLMProvider
|
||||||
from nanobot.providers.fallback_provider import FallbackProvider
|
from nanobot.providers.fallback_provider import FallbackProvider
|
||||||
from nanobot.providers.registry import ProviderSpec, create_dynamic_spec, find_by_name
|
from nanobot.providers.registry import ProviderSpec, create_dynamic_spec, find_by_name
|
||||||
@ -19,6 +19,7 @@ class ProviderSnapshot:
|
|||||||
signature: tuple[object, ...]
|
signature: tuple[object, ...]
|
||||||
generation: GenerationSettings | None = None
|
generation: GenerationSettings | None = None
|
||||||
model_preset: str | None = None
|
model_preset: str | None = None
|
||||||
|
supports_image_input: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -205,37 +206,15 @@ def _make_provider_core(
|
|||||||
)
|
)
|
||||||
|
|
||||||
provider.generation = preset.to_generation_settings()
|
provider.generation = preset.to_generation_settings()
|
||||||
|
provider.supports_image_input = preset.supports_image_input
|
||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
def _inline_fallback_preset(
|
def _resolve_fallback_presets(config: Config, _primary: ModelPresetConfig) -> list[ModelPresetConfig]:
|
||||||
primary: ModelPresetConfig,
|
return [
|
||||||
fallback: InlineFallbackConfig,
|
config.model_presets[name]
|
||||||
) -> ModelPresetConfig:
|
for name in config.agents.defaults.fallback_models
|
||||||
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 make_provider(
|
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,
|
context_window_tokens=preset.context_window_tokens,
|
||||||
signature=("unconfigured", setup_error, preset.model),
|
signature=("unconfigured", setup_error, preset.model),
|
||||||
generation=provider.generation,
|
generation=provider.generation,
|
||||||
|
supports_image_input=preset.supports_image_input,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -310,6 +290,7 @@ def provider_signature(
|
|||||||
fallback.temperature,
|
fallback.temperature,
|
||||||
fallback.reasoning_effort,
|
fallback.reasoning_effort,
|
||||||
fallback.context_window_tokens,
|
fallback.context_window_tokens,
|
||||||
|
fallback.supports_image_input,
|
||||||
getattr(fp, "proxy", None) if fp else None,
|
getattr(fp, "proxy", None) if fp else None,
|
||||||
fp.thinking_style if fp else None,
|
fp.thinking_style if fp else None,
|
||||||
)
|
)
|
||||||
@ -331,6 +312,7 @@ def provider_signature(
|
|||||||
resolved.temperature,
|
resolved.temperature,
|
||||||
resolved.reasoning_effort,
|
resolved.reasoning_effort,
|
||||||
resolved.context_window_tokens,
|
resolved.context_window_tokens,
|
||||||
|
resolved.supports_image_input,
|
||||||
getattr(p, "proxy", None) if p else None,
|
getattr(p, "proxy", None) if p else None,
|
||||||
p.thinking_style if p else None,
|
p.thinking_style if p else None,
|
||||||
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
||||||
@ -360,6 +342,7 @@ def build_provider_snapshot(
|
|||||||
signature=provider_signature(config, preset=resolved),
|
signature=provider_signature(config, preset=resolved),
|
||||||
generation=resolved.to_generation_settings(),
|
generation=resolved.to_generation_settings(),
|
||||||
model_preset=selected_preset,
|
model_preset=selected_preset,
|
||||||
|
supports_image_input=resolved.supports_image_input,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -117,6 +117,9 @@ class FallbackProvider(LLMProvider):
|
|||||||
self._provider_factory = provider_factory
|
self._provider_factory = provider_factory
|
||||||
self._fallback_model_observer = fallback_model_observer
|
self._fallback_model_observer = fallback_model_observer
|
||||||
self._has_fallbacks = bool(fallback_presets)
|
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_failures = 0
|
||||||
self._primary_tripped_at: float | None = None
|
self._primary_tripped_at: float | None = None
|
||||||
|
|
||||||
@ -139,6 +142,19 @@ class FallbackProvider(LLMProvider):
|
|||||||
def supports_progress_deltas(self) -> bool:
|
def supports_progress_deltas(self) -> bool:
|
||||||
return bool(getattr(self._primary, "supports_progress_deltas", False))
|
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:
|
def _primary_available(self) -> bool:
|
||||||
"""Return True if the primary provider is not currently tripped."""
|
"""Return True if the primary provider is not currently tripped."""
|
||||||
if self._primary_tripped_at is None:
|
if self._primary_tripped_at is None:
|
||||||
@ -149,16 +165,39 @@ class FallbackProvider(LLMProvider):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def chat(self, **kwargs: Any) -> LLMResponse:
|
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:
|
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(
|
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:
|
async def chat_stream(self, **kwargs: Any) -> LLMResponse:
|
||||||
on_stream_recover = kwargs.pop("on_stream_recover", None)
|
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:
|
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]
|
has_streamed: list[bool] = [False]
|
||||||
original_delta = kwargs.get("on_content_delta")
|
original_delta = kwargs.get("on_content_delta")
|
||||||
@ -175,6 +214,7 @@ class FallbackProvider(LLMProvider):
|
|||||||
kwargs,
|
kwargs,
|
||||||
has_streamed=has_streamed,
|
has_streamed=has_streamed,
|
||||||
on_stream_recover=on_stream_recover,
|
on_stream_recover=on_stream_recover,
|
||||||
|
primary_supports_image_input=primary_supports_image_input,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _try_with_fallback(
|
async def _try_with_fallback(
|
||||||
@ -183,6 +223,7 @@ class FallbackProvider(LLMProvider):
|
|||||||
kwargs: dict[str, Any],
|
kwargs: dict[str, Any],
|
||||||
has_streamed: list[bool] | None,
|
has_streamed: list[bool] | None,
|
||||||
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
||||||
|
primary_supports_image_input: bool | None | object = LLMProvider._SENTINEL,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
primary_model = kwargs.get("model") or self._primary.get_default_model()
|
primary_model = kwargs.get("model") or self._primary.get_default_model()
|
||||||
primary_was_attempted = False
|
primary_was_attempted = False
|
||||||
@ -190,7 +231,13 @@ class FallbackProvider(LLMProvider):
|
|||||||
|
|
||||||
if self._primary_available():
|
if self._primary_available():
|
||||||
primary_was_attempted = True
|
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":
|
if response.finish_reason != "error":
|
||||||
self._primary_failures = 0
|
self._primary_failures = 0
|
||||||
self._primary_tripped_at = None
|
self._primary_tripped_at = None
|
||||||
@ -216,7 +263,8 @@ class FallbackProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
return response
|
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(
|
logger.warning(
|
||||||
"Primary model '{}' returned non-fallbackable error: {}",
|
"Primary model '{}' returned non-fallbackable error: {}",
|
||||||
primary_model,
|
primary_model,
|
||||||
@ -224,13 +272,14 @@ class FallbackProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
self._primary_failures += 1
|
if not image_rejected:
|
||||||
if self._primary_failures >= _PRIMARY_FAILURE_THRESHOLD:
|
self._primary_failures += 1
|
||||||
self._primary_tripped_at = time.monotonic()
|
if self._primary_failures >= _PRIMARY_FAILURE_THRESHOLD:
|
||||||
logger.warning(
|
self._primary_tripped_at = time.monotonic()
|
||||||
"Primary model '{}' circuit open after {} consecutive failures",
|
logger.warning(
|
||||||
primary_model, self._primary_failures,
|
"Primary model '{}' circuit open after {} consecutive failures",
|
||||||
)
|
primary_model, self._primary_failures,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug("Primary model '{}' circuit open; skipping", primary_model)
|
logger.debug("Primary model '{}' circuit open; skipping", primary_model)
|
||||||
|
|
||||||
@ -270,6 +319,7 @@ class FallbackProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
fallback_provider = self._provider_factory(fallback)
|
fallback_provider = self._provider_factory(fallback)
|
||||||
|
fallback_provider.supports_image_input = fallback.supports_image_input
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to create provider for fallback '{}': {}", fallback_model, exc
|
"Failed to create provider for fallback '{}': {}", fallback_model, exc
|
||||||
@ -288,7 +338,13 @@ class FallbackProvider(LLMProvider):
|
|||||||
fallback_kwargs.pop("reasoning_effort", None)
|
fallback_kwargs.pop("reasoning_effort", None)
|
||||||
else:
|
else:
|
||||||
fallback_kwargs["reasoning_effort"] = fallback.reasoning_effort
|
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":
|
if fallback_response.finish_reason != "error":
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -317,6 +373,49 @@ class FallbackProvider(LLMProvider):
|
|||||||
finish_reason="error",
|
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:
|
async def _notify_fallback_model(self, model: str) -> None:
|
||||||
if self._fallback_model_observer is None:
|
if self._fallback_model_observer is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -22,10 +22,10 @@ from nanobot.runtime_context import (
|
|||||||
public_history_message,
|
public_history_message,
|
||||||
)
|
)
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
|
content_with_media_breadcrumbs,
|
||||||
ensure_dir,
|
ensure_dir,
|
||||||
estimate_message_tokens,
|
estimate_message_tokens,
|
||||||
find_legal_message_start,
|
find_legal_message_start,
|
||||||
image_placeholder_text,
|
|
||||||
recent_message_start_index,
|
recent_message_start_index,
|
||||||
safe_filename,
|
safe_filename,
|
||||||
strip_think,
|
strip_think,
|
||||||
@ -165,6 +165,7 @@ class Session:
|
|||||||
max_tokens: int = 0,
|
max_tokens: int = 0,
|
||||||
extend_to_user: bool = False,
|
extend_to_user: bool = False,
|
||||||
include_runtime_context: bool = True,
|
include_runtime_context: bool = True,
|
||||||
|
include_media: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return unconsolidated messages for LLM input.
|
"""Return unconsolidated messages for LLM input.
|
||||||
|
|
||||||
@ -209,17 +210,17 @@ class Session:
|
|||||||
role = message.get("role")
|
role = message.get("role")
|
||||||
if role == "assistant" and isinstance(content, str):
|
if role == "assistant" and isinstance(content, str):
|
||||||
content = _sanitize_assistant_replay_text(content)
|
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")
|
media = message.get("media")
|
||||||
if role == "user" and isinstance(media, list) and media and isinstance(content, str):
|
media_paths = (
|
||||||
breadcrumbs = "\n".join(
|
[path for path in media if isinstance(path, str) and path]
|
||||||
image_placeholder_text(p) for p in media if isinstance(p, str) and p
|
if role == "user" and isinstance(media, list)
|
||||||
)
|
else []
|
||||||
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
)
|
||||||
|
# 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")
|
cli_apps = message.get("cli_apps")
|
||||||
if (
|
if (
|
||||||
include_runtime_context
|
include_runtime_context
|
||||||
@ -248,6 +249,11 @@ class Session:
|
|||||||
if not any(key in message for key in ("tool_calls", "reasoning_content", "thinking_blocks")):
|
if not any(key in message for key in ("tool_calls", "reasoning_content", "thinking_blocks")):
|
||||||
continue
|
continue
|
||||||
entry: dict[str, Any] = {"role": message["role"], "content": content}
|
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"):
|
for key in ("tool_calls", "tool_call_id", "name", "reasoning_content", "thinking_blocks"):
|
||||||
if key in message:
|
if key in message:
|
||||||
entry[key] = message[key]
|
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
|
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:
|
def truncate_text(text: str, max_chars: int) -> str:
|
||||||
"""Truncate text with a stable suffix."""
|
"""Truncate text with a stable suffix."""
|
||||||
if max_chars <= 0 or len(text) <= max_chars:
|
if max_chars <= 0 or len(text) <= max_chars:
|
||||||
|
|||||||
@ -10,6 +10,8 @@ from nanobot.providers.base import GenerationSettings, LLMProvider
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
|
|
||||||
|
_IMAGE_CAPABILITY_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class LLMRuntime:
|
class LLMRuntime:
|
||||||
@ -26,6 +28,7 @@ class LLMRuntime:
|
|||||||
context_window_tokens: int
|
context_window_tokens: int
|
||||||
model_preset: str | None = None
|
model_preset: str | None = None
|
||||||
snapshot_signature: tuple[object, ...] | None = None
|
snapshot_signature: tuple[object, ...] | None = None
|
||||||
|
supports_image_input: bool | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def capture(
|
def capture(
|
||||||
@ -36,10 +39,18 @@ class LLMRuntime:
|
|||||||
context_window_tokens: int,
|
context_window_tokens: int,
|
||||||
model_preset: str | None = None,
|
model_preset: str | None = None,
|
||||||
snapshot_signature: tuple[object, ...] | None = None,
|
snapshot_signature: tuple[object, ...] | None = None,
|
||||||
|
supports_image_input: bool | None | object = _IMAGE_CAPABILITY_UNSET,
|
||||||
) -> LLMRuntime:
|
) -> LLMRuntime:
|
||||||
"""Capture provider defaults without retaining mutable generation state."""
|
"""Capture provider defaults without retaining mutable generation state."""
|
||||||
defaults = GenerationSettings()
|
defaults = GenerationSettings()
|
||||||
generation = getattr(provider, "generation", defaults)
|
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(
|
return cls(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
model=model,
|
model=model,
|
||||||
@ -55,6 +66,11 @@ class LLMRuntime:
|
|||||||
context_window_tokens=context_window_tokens,
|
context_window_tokens=context_window_tokens,
|
||||||
model_preset=model_preset,
|
model_preset=model_preset,
|
||||||
snapshot_signature=snapshot_signature,
|
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(
|
def with_generation_overrides(
|
||||||
@ -94,6 +110,7 @@ def runtime_from_provider_snapshot(
|
|||||||
context_window_tokens=snapshot.context_window_tokens,
|
context_window_tokens=snapshot.context_window_tokens,
|
||||||
model_preset=snapshot.model_preset,
|
model_preset=snapshot.model_preset,
|
||||||
snapshot_signature=snapshot.signature,
|
snapshot_signature=snapshot.signature,
|
||||||
|
supports_image_input=snapshot.supports_image_input,
|
||||||
)
|
)
|
||||||
return LLMRuntime.capture(
|
return LLMRuntime.capture(
|
||||||
snapshot.provider,
|
snapshot.provider,
|
||||||
@ -101,4 +118,5 @@ def runtime_from_provider_snapshot(
|
|||||||
context_window_tokens=snapshot.context_window_tokens,
|
context_window_tokens=snapshot.context_window_tokens,
|
||||||
model_preset=snapshot.model_preset,
|
model_preset=snapshot.model_preset,
|
||||||
snapshot_signature=snapshot.signature,
|
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"}
|
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:
|
def _parse_context_window_tokens(value: str | None) -> int | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@ -945,28 +952,10 @@ def _provider_display_name_exists(
|
|||||||
return False
|
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]:
|
def _model_call_order_state(config: Any) -> tuple[list[str], bool]:
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
primary = defaults.model_preset
|
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
|
return [], False
|
||||||
order = [primary]
|
order = [primary]
|
||||||
for fallback in defaults.fallback_models:
|
for fallback in defaults.fallback_models:
|
||||||
@ -1088,7 +1077,7 @@ def settings_payload(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
active_preset_name = defaults.model_preset or "default"
|
active_preset_name = defaults.model_preset
|
||||||
effective_preset = config.resolve_preset()
|
effective_preset = config.resolve_preset()
|
||||||
|
|
||||||
provider_name = (
|
provider_name = (
|
||||||
@ -1132,32 +1121,7 @@ def settings_payload(
|
|||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
model_presets = [
|
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,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
for name, preset in config.model_presets.items():
|
for name, preset in config.model_presets.items():
|
||||||
resolved_preset_provider = (
|
resolved_preset_provider = (
|
||||||
config.get_provider_name(
|
config.get_provider_name(
|
||||||
@ -1171,7 +1135,7 @@ def settings_payload(
|
|||||||
"name": name,
|
"name": name,
|
||||||
"label": preset.label or name,
|
"label": preset.label or name,
|
||||||
"active": active_preset_name == name,
|
"active": active_preset_name == name,
|
||||||
"is_default": False,
|
"is_default": name == "default",
|
||||||
"model": preset.model,
|
"model": preset.model,
|
||||||
"provider": preset.provider,
|
"provider": preset.provider,
|
||||||
"resolved_provider": resolved_preset_provider,
|
"resolved_provider": resolved_preset_provider,
|
||||||
@ -1179,6 +1143,7 @@ def settings_payload(
|
|||||||
"context_window_tokens": preset.context_window_tokens,
|
"context_window_tokens": preset.context_window_tokens,
|
||||||
"temperature": preset.temperature,
|
"temperature": preset.temperature,
|
||||||
"reasoning_effort": preset.reasoning_effort,
|
"reasoning_effort": preset.reasoning_effort,
|
||||||
|
"supports_image_input": preset.supports_image_input,
|
||||||
"reasoning_effort_values": _reasoning_effort_values_for(
|
"reasoning_effort_values": _reasoning_effort_values_for(
|
||||||
resolved_preset_provider, preset.model
|
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]:
|
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
|
default_preset = config.resolve_default_preset()
|
||||||
changed = False
|
changed = False
|
||||||
restart_required = False
|
restart_required = False
|
||||||
|
|
||||||
if "model_preset" in query or "modelPreset" in query:
|
if "model_preset" in query or "modelPreset" in query:
|
||||||
preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip()
|
preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip()
|
||||||
preset_value = None if not preset or preset == "default" else preset
|
preset_value = preset or "default"
|
||||||
if preset_value is not None and preset_value not in config.model_presets:
|
if preset_value not in config.model_presets:
|
||||||
raise WebUISettingsError("unknown model preset")
|
raise WebUISettingsError("unknown model preset")
|
||||||
if defaults.model_preset != preset_value:
|
if defaults.model_preset != preset_value:
|
||||||
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()
|
model = model.strip()
|
||||||
if not model:
|
if not model:
|
||||||
raise WebUISettingsError("model is required")
|
raise WebUISettingsError("model is required")
|
||||||
if defaults.model != model:
|
if default_preset.model != model:
|
||||||
defaults.model = model
|
default_preset.model = model
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
provider = _query_first(query, "provider")
|
provider = _query_first(query, "provider")
|
||||||
@ -1347,8 +1313,8 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
if not provider:
|
if not provider:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
_validate_configured_provider(config, provider)
|
_validate_configured_provider(config, provider)
|
||||||
if defaults.provider != provider:
|
if default_preset.provider != provider:
|
||||||
defaults.provider = provider
|
default_preset.provider = provider
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
context_window_tokens = _parse_context_window_tokens(
|
context_window_tokens = _parse_context_window_tokens(
|
||||||
@ -1356,9 +1322,9 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
context_window_tokens is not None
|
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
|
changed = True
|
||||||
|
|
||||||
timezone = _query_first(query, "timezone")
|
timezone = _query_first(query, "timezone")
|
||||||
@ -1449,6 +1415,9 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
reasoning_effort = (
|
reasoning_effort = (
|
||||||
_query_first_alias(query, "reasoning_effort", "reasoningEffort") or ""
|
_query_first_alias(query, "reasoning_effort", "reasoningEffort") or ""
|
||||||
).strip() or None
|
).strip() or None
|
||||||
|
supports_image_input = _parse_image_input_support(
|
||||||
|
_query_first_alias(query, "supports_image_input", "supportsImageInput")
|
||||||
|
)
|
||||||
config.model_presets[name] = ModelPresetConfig(
|
config.model_presets[name] = ModelPresetConfig(
|
||||||
label=label,
|
label=label,
|
||||||
model=model,
|
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,
|
temperature=temperature if temperature is not None else base.temperature,
|
||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
|
supports_image_input=supports_image_input,
|
||||||
)
|
)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
payload = settings_payload()
|
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]:
|
def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name or name == "default":
|
if not name:
|
||||||
raise WebUISettingsError("model configuration is required")
|
raise WebUISettingsError("model configuration is required")
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@ -1539,6 +1509,14 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
preset.reasoning_effort = reasoning_effort
|
preset.reasoning_effort = reasoning_effort
|
||||||
changed = True
|
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:
|
if changed:
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return settings_payload()
|
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]:
|
def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, Any]:
|
||||||
"""Materialize legacy primary/inline model settings as named presets."""
|
"""Compatibility endpoint; loading config now performs this migration."""
|
||||||
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)
|
|
||||||
return settings_payload()
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name or name == "default":
|
if not name:
|
||||||
raise WebUISettingsError("model configuration is required")
|
raise WebUISettingsError("model configuration is required")
|
||||||
|
if name == "default":
|
||||||
|
raise WebUISettingsError("default model configuration cannot be deleted", status=409)
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
if name not in config.model_presets:
|
if name not in config.model_presets:
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.runner import AgentRunSpec
|
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.providers.base import GenerationSettings, LLMProvider
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ def make_run_spec(provider: LLMProvider, **kwargs: Any) -> AgentRunSpec:
|
|||||||
model = kwargs.pop("model")
|
model = kwargs.pop("model")
|
||||||
context_window_tokens = kwargs.pop(
|
context_window_tokens = kwargs.pop(
|
||||||
"context_window_tokens",
|
"context_window_tokens",
|
||||||
AgentDefaults().context_window_tokens,
|
ModelPresetConfig(model=model).context_window_tokens,
|
||||||
)
|
)
|
||||||
provider_generation = getattr(provider, "generation", None)
|
provider_generation = getattr(provider, "generation", None)
|
||||||
defaults = GenerationSettings()
|
defaults = GenerationSettings()
|
||||||
|
|||||||
@ -272,7 +272,13 @@ class TestAgentLoopTTLParam:
|
|||||||
kwargs = session.get_history.call_args.kwargs
|
kwargs = session.get_history.call_args.kwargs
|
||||||
assert isinstance(kwargs.get("max_tokens"), int)
|
assert isinstance(kwargs.get("max_tokens"), int)
|
||||||
assert kwargs["max_tokens"] > 0
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_session_file_cap_archives_and_trims_old_messages(self, tmp_path):
|
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)
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
assert entries[0]["session_key"] == "telegram:chat-1"
|
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(
|
async def test_summarize_raw_dumps_on_llm_failure(
|
||||||
self, consolidator, mock_provider, store, runtime
|
self, consolidator, mock_provider, store, runtime
|
||||||
):
|
):
|
||||||
@ -992,6 +1020,18 @@ class TestRawArchiveTruncation:
|
|||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
assert "hello" in entries[0]["content"]
|
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):
|
def test_raw_archive_excludes_model_only_runtime_context(self, store):
|
||||||
content, marker = append_runtime_context(
|
content, marker = append_runtime_context(
|
||||||
"ship the feature",
|
"ship the feature",
|
||||||
|
|||||||
@ -5,7 +5,11 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
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
|
# Helpers
|
||||||
@ -259,10 +263,12 @@ class TestBuildUserContent:
|
|||||||
result = builder.build_user_content("hello", [])
|
result = builder.build_user_content("hello", [])
|
||||||
assert result == "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)
|
builder = _builder(tmp_path)
|
||||||
result = builder.build_user_content("hello", ["/nonexistent/image.png"])
|
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):
|
def test_non_image_file_returns_string(self, tmp_path):
|
||||||
txt = tmp_path / "doc.txt"
|
txt = tmp_path / "doc.txt"
|
||||||
@ -438,3 +444,55 @@ class TestBuildMessages:
|
|||||||
user_msg = messages[-1]["content"]
|
user_msg = messages[-1]["content"]
|
||||||
assert isinstance(user_msg, list)
|
assert isinstance(user_msg, list)
|
||||||
assert any(b.get("type") == "image_url" for b in user_msg)
|
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)
|
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:
|
def test_resolver_reuses_preset_until_runtime_config_is_invalidated() -> None:
|
||||||
initial = _runtime()
|
initial = _runtime()
|
||||||
preset = ModelPresetConfig(model="fast-model")
|
preset = ModelPresetConfig(model="fast-model")
|
||||||
|
|||||||
@ -537,7 +537,7 @@ class TestRunOnboardExitBehavior:
|
|||||||
|
|
||||||
def fake_configure_general_settings(config, section):
|
def fake_configure_general_settings(config, section):
|
||||||
if section == "Agent Settings":
|
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, "_show_main_menu_header", lambda: None)
|
||||||
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
|
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["fast"] = ModelPresetConfig(model="gpt-4.1-mini")
|
||||||
config.model_presets["power"] = ModelPresetConfig(model="gpt-4.1")
|
config.model_presets["power"] = ModelPresetConfig(model="gpt-4.1")
|
||||||
_sync_preset_cache(config)
|
_sync_preset_cache(config)
|
||||||
assert _MODEL_PRESET_CACHE == {"fast", "power"}
|
assert _MODEL_PRESET_CACHE == {"default", "fast", "power"}
|
||||||
_MODEL_PRESET_CACHE.clear()
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
|
||||||
def test_model_preset_add(self, monkeypatch):
|
def test_model_preset_add(self, monkeypatch):
|
||||||
@ -2106,10 +2106,9 @@ class TestModelPresetWizard:
|
|||||||
assert defaults.model_preset == "fast"
|
assert defaults.model_preset == "fast"
|
||||||
_MODEL_PRESET_CACHE.clear()
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
|
||||||
def test_model_preset_field_handler_clear(self, monkeypatch):
|
def test_model_preset_field_handler_selects_default(self, monkeypatch):
|
||||||
"""_handle_model_preset_field should clear preset when Clear value is chosen."""
|
"""The concrete default preset replaces the legacy clear selection."""
|
||||||
from nanobot.cli.onboard import (
|
from nanobot.cli.onboard import (
|
||||||
_CLEAR_CHOICE,
|
|
||||||
_MODEL_PRESET_CACHE,
|
_MODEL_PRESET_CACHE,
|
||||||
_handle_model_preset_field,
|
_handle_model_preset_field,
|
||||||
)
|
)
|
||||||
@ -2118,11 +2117,11 @@ class TestModelPresetWizard:
|
|||||||
_MODEL_PRESET_CACHE.clear()
|
_MODEL_PRESET_CACHE.clear()
|
||||||
_MODEL_PRESET_CACHE.add("fast")
|
_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")
|
defaults = AgentDefaults(model_preset="fast")
|
||||||
_handle_model_preset_field(defaults, "model_preset", "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()
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
|
||||||
def test_main_menu_dispatch_includes_model_presets(self):
|
def test_main_menu_dispatch_includes_model_presets(self):
|
||||||
@ -2208,13 +2207,13 @@ class TestModelPresetWizard:
|
|||||||
def test_provider_field_handler(self, monkeypatch):
|
def test_provider_field_handler(self, monkeypatch):
|
||||||
"""_handle_provider_field should set provider from choices."""
|
"""_handle_provider_field should set provider from choices."""
|
||||||
from nanobot.cli.onboard import _handle_provider_field
|
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")
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "anthropic")
|
||||||
|
|
||||||
defaults = AgentDefaults()
|
preset = ModelPresetConfig(model="anthropic/claude-opus-4-5")
|
||||||
_handle_provider_field(defaults, "provider", "Provider", "auto")
|
_handle_provider_field(preset, "provider", "Provider", "auto")
|
||||||
assert defaults.provider == "anthropic"
|
assert preset.provider == "anthropic"
|
||||||
|
|
||||||
def test_search_provider_field_handler(self, monkeypatch):
|
def test_search_provider_field_handler(self, monkeypatch):
|
||||||
"""_handle_search_provider_field should set the search engine from choices."""
|
"""_handle_search_provider_field should set the search engine from choices."""
|
||||||
@ -2235,7 +2234,10 @@ class TestModelPresetWizard:
|
|||||||
_handle_search_provider_field,
|
_handle_search_provider_field,
|
||||||
_resolve_field_handler,
|
_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(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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@ -46,6 +46,7 @@ def _fallback(
|
|||||||
context_window_tokens: int = 65_536,
|
context_window_tokens: int = 65_536,
|
||||||
temperature: float = 0.1,
|
temperature: float = 0.1,
|
||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
|
supports_image_input: bool | None = None,
|
||||||
) -> ModelPresetConfig:
|
) -> ModelPresetConfig:
|
||||||
return ModelPresetConfig(
|
return ModelPresetConfig(
|
||||||
model=model,
|
model=model,
|
||||||
@ -54,6 +55,7 @@ def _fallback(
|
|||||||
context_window_tokens=context_window_tokens,
|
context_window_tokens=context_window_tokens,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
reasoning_effort=reasoning_effort,
|
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 == []
|
assert defaults.fallback_models == []
|
||||||
|
|
||||||
|
|
||||||
def test_fallback_models_accept_preset_refs_and_inline_configs() -> None:
|
def test_fallback_models_accept_preset_refs() -> None:
|
||||||
from nanobot.config.schema import Config, InlineFallbackConfig
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"fallbackModels": [
|
"fallbackModels": ["deep"]
|
||||||
"deep",
|
|
||||||
{
|
|
||||||
"provider": "openai",
|
|
||||||
"model": "gpt-4.1",
|
|
||||||
"maxTokens": 4096,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"provider": "openai", "model": "gpt-4.1"},
|
||||||
"deep": {"provider": "anthropic", "model": "claude-opus-4-7"}
|
"deep": {"provider": "anthropic", "model": "claude-opus-4-7"}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
assert config.agents.defaults.fallback_models[0] == "deep"
|
assert config.agents.defaults.fallback_models == ["deep"]
|
||||||
assert config.agents.defaults.fallback_models[1] == InlineFallbackConfig(
|
|
||||||
provider="openai",
|
|
||||||
model="gpt-4.1",
|
def test_fallback_models_reject_inline_configs_after_schema_migration() -> None:
|
||||||
max_tokens=4096,
|
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:
|
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"):
|
with pytest.raises(ValueError, match="fallback_models.*not found"):
|
||||||
Config.model_validate({
|
Config.model_validate({
|
||||||
"agents": {"defaults": {"fallbackModels": ["missing"]}},
|
"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": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "primary", "provider": "openai"},
|
||||||
"fast": {"model": "openai/gpt-4.1", "provider": "openai"},
|
"fast": {"model": "openai/gpt-4.1", "provider": "openai"},
|
||||||
"deep": {"model": "anthropic/claude-sonnet-4-6", "provider": "anthropic"},
|
"deep": {"model": "anthropic/claude-sonnet-4-6", "provider": "anthropic"},
|
||||||
},
|
},
|
||||||
@ -190,6 +195,7 @@ def test_provider_snapshot_uses_smallest_fallback_context_window() -> None:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "primary", "provider": "openai"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "openai/gpt-4.1",
|
"model": "openai/gpt-4.1",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
@ -213,36 +219,49 @@ def test_provider_snapshot_uses_smallest_fallback_context_window() -> None:
|
|||||||
assert snapshot.context_window_tokens == 64000
|
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.config.schema import Config
|
||||||
from nanobot.providers.factory import provider_signature
|
from nanobot.providers.factory import provider_signature
|
||||||
|
|
||||||
config = Config.model_validate({
|
base = {
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"modelPreset": "fast",
|
"modelPreset": "fast",
|
||||||
"fallbackModels": [
|
"fallbackModels": ["fallback"],
|
||||||
{"provider": "openai", "model": "gpt-4.1"}
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "primary"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "anthropic/claude-opus-4-5",
|
"model": "anthropic/claude-opus-4-5",
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
"reasoningEffort": "high",
|
"reasoningEffort": "high",
|
||||||
}
|
},
|
||||||
|
"fallback": {
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"supportsImageInput": False,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"anthropic": {"apiKey": "primary-key"},
|
"anthropic": {"apiKey": "primary-key"},
|
||||||
"openai": {"apiKey": "fallback-key"},
|
"openai": {"apiKey": "fallback-key"},
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
changed = {
|
||||||
|
**base,
|
||||||
|
"modelPresets": {
|
||||||
|
**base["modelPresets"],
|
||||||
|
"fallback": {
|
||||||
|
**base["modelPresets"]["fallback"],
|
||||||
|
"supportsImageInput": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
signature = provider_signature(config)
|
assert provider_signature(Config.model_validate(base)) != provider_signature(
|
||||||
fallback_signatures = signature[-1]
|
Config.model_validate(changed)
|
||||||
|
)
|
||||||
assert fallback_signatures[0][13] is None
|
|
||||||
|
|
||||||
|
|
||||||
# -- FallbackProvider tests --
|
# -- FallbackProvider tests --
|
||||||
@ -333,6 +352,204 @@ class TestFallbackOnPrimaryError:
|
|||||||
for line in logs
|
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:
|
class TestNoFallbackWhenContentStreamed:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@ -19,9 +19,11 @@ async def test_active_run_keeps_provider_captured_at_admission() -> None:
|
|||||||
second_provider = MagicMock(spec=LLMProvider)
|
second_provider = MagicMock(spec=LLMProvider)
|
||||||
first_provider.generation = GenerationSettings(temperature=0.2, max_tokens=2048)
|
first_provider.generation = GenerationSettings(temperature=0.2, max_tokens=2048)
|
||||||
second_provider.generation = GenerationSettings(temperature=0.9, max_tokens=512)
|
second_provider.generation = GenerationSettings(temperature=0.9, max_tokens=512)
|
||||||
|
first_provider.supports_image_input = False
|
||||||
first_calls = 0
|
first_calls = 0
|
||||||
second_calls = 0
|
second_calls = 0
|
||||||
request_temperatures: list[float] = []
|
request_temperatures: list[float] = []
|
||||||
|
request_image_capabilities: list[bool | None] = []
|
||||||
selected_runtime = LLMRuntime.capture(
|
selected_runtime = LLMRuntime.capture(
|
||||||
first_provider,
|
first_provider,
|
||||||
"captured-model",
|
"captured-model",
|
||||||
@ -33,6 +35,7 @@ async def test_active_run_keeps_provider_captured_at_admission() -> None:
|
|||||||
nonlocal first_calls, selected_runtime
|
nonlocal first_calls, selected_runtime
|
||||||
first_calls += 1
|
first_calls += 1
|
||||||
request_temperatures.append(kwargs["temperature"])
|
request_temperatures.append(kwargs["temperature"])
|
||||||
|
request_image_capabilities.append(kwargs["supports_image_input"])
|
||||||
selected_runtime = LLMRuntime.capture(
|
selected_runtime = LLMRuntime.capture(
|
||||||
second_provider,
|
second_provider,
|
||||||
"future-model",
|
"future-model",
|
||||||
@ -68,4 +71,5 @@ async def test_active_run_keeps_provider_captured_at_admission() -> None:
|
|||||||
assert first_calls == 2
|
assert first_calls == 2
|
||||||
assert second_calls == 0
|
assert second_calls == 0
|
||||||
assert request_temperatures == [0.2, 0.2]
|
assert request_temperatures == [0.2, 0.2]
|
||||||
|
assert request_image_capabilities == [False, False]
|
||||||
assert selected_runtime.provider is second_provider
|
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_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.workspace = str(tmp_path / "workspace")
|
config.agents.defaults.workspace = str(tmp_path / "workspace")
|
||||||
config.agents.defaults.model = "openai/gpt-4o"
|
config.resolve_default_preset().model = "openai/gpt-4o"
|
||||||
config.agents.defaults.provider = "openai"
|
config.resolve_default_preset().provider = "openai"
|
||||||
config.agents.defaults.context_window_tokens = 65_536
|
config.resolve_default_preset().context_window_tokens = 65_536
|
||||||
config.providers.openai.api_key = "sk-test"
|
config.providers.openai.api_key = "sk-test"
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", 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
|
from nanobot.config.schema import Config
|
||||||
config = Config.model_validate({
|
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")
|
fake_provider = _provider("openai/gpt-4.1")
|
||||||
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
|
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
|
||||||
loop = AgentLoop.from_config(config)
|
loop = AgentLoop.from_config(config)
|
||||||
assert loop.model == "openai/gpt-4.1"
|
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 "default" in loop.model_presets
|
||||||
assert loop.model_presets["default"].model == "openai/gpt-4.1"
|
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
|
from nanobot.config.schema import Config
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {"defaults": {"model": "openai/gpt-4.1", "workspace": str(tmp_path)}},
|
"agents": {"defaults": {"workspace": str(tmp_path)}},
|
||||||
"model_presets": {"fast": {"model": "openai/gpt-4.1-mini"}},
|
"model_presets": {
|
||||||
|
"default": {"model": "openai/gpt-4.1"},
|
||||||
|
"fast": {"model": "openai/gpt-4.1-mini"},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
fake_provider = _provider("openai/gpt-4.1")
|
fake_provider = _provider("openai/gpt-4.1")
|
||||||
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
|
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]"}
|
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():
|
def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
|
||||||
session = Session(key="test:cli-app")
|
session = Session(key="test:cli-app")
|
||||||
session.messages.append(
|
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():
|
def test_config_matches_github_copilot_codex_with_hyphen_prefix():
|
||||||
config = Config()
|
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"
|
assert config.get_provider_name() == "github_copilot"
|
||||||
|
|
||||||
|
|
||||||
def test_config_matches_openai_codex_with_hyphen_prefix():
|
def test_config_matches_openai_codex_with_hyphen_prefix():
|
||||||
config = Config()
|
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"
|
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
|
assert "Set openai-codex as the main provider" in result.stdout
|
||||||
|
|
||||||
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
||||||
assert saved.agents.defaults.provider == "openai_codex"
|
assert saved.resolve_default_preset().provider == "openai_codex"
|
||||||
assert saved.agents.defaults.model == "openai-codex/gpt-5.6-sol"
|
assert saved.resolve_default_preset().model == "openai-codex/gpt-5.6-sol"
|
||||||
assert saved.agents.defaults.model_preset is None
|
assert saved.agents.defaults.model_preset == "default"
|
||||||
assert make_provider(saved).__class__.__name__ == "OpenAICodexProvider"
|
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
|
assert "Set github-copilot as the main provider" in result.stdout
|
||||||
|
|
||||||
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
||||||
assert saved.agents.defaults.provider == "github_copilot"
|
assert saved.resolve_default_preset().provider == "github_copilot"
|
||||||
assert saved.agents.defaults.model == "github-copilot/gpt-5.4-mini"
|
assert saved.resolve_default_preset().model == "github-copilot/gpt-5.4-mini"
|
||||||
assert saved.agents.defaults.model_preset is None
|
assert saved.agents.defaults.model_preset == "default"
|
||||||
assert make_provider(saved).__class__.__name__ == "GitHubCopilotProvider"
|
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
|
assert "Set xai-grok as the main provider" in result.stdout
|
||||||
|
|
||||||
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
||||||
assert saved.agents.defaults.provider == "xai_grok"
|
assert saved.resolve_default_preset().provider == "xai_grok"
|
||||||
assert saved.agents.defaults.model == "xai-grok/grok-4.5"
|
assert saved.resolve_default_preset().model == "xai-grok/grok-4.5"
|
||||||
assert saved.agents.defaults.context_window_tokens == 500_000
|
assert saved.resolve_default_preset().context_window_tokens == 500_000
|
||||||
assert saved.agents.defaults.model_preset is None
|
assert saved.agents.defaults.model_preset == "default"
|
||||||
assert make_provider(saved).__class__.__name__ == "XAIGrokProvider"
|
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
|
assert "Set github-copilot as the main provider" in result.stdout
|
||||||
|
|
||||||
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
||||||
assert saved.agents.defaults.provider == "github_copilot"
|
assert saved.resolve_default_preset().provider == "github_copilot"
|
||||||
assert saved.agents.defaults.model == "github-copilot/gpt-5.4-mini"
|
assert saved.resolve_default_preset().model == "github-copilot/gpt-5.4-mini"
|
||||||
assert make_provider(saved).__class__.__name__ == "GitHubCopilotProvider"
|
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():
|
def test_config_matches_explicit_ollama_prefix_without_api_key():
|
||||||
config = Config()
|
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_provider_name() == "ollama"
|
||||||
assert config.get_api_base() == "http://localhost:11434/v1"
|
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():
|
def test_config_explicit_ollama_provider_uses_default_localhost_api_base():
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.provider = "ollama"
|
config.resolve_default_preset().provider = "ollama"
|
||||||
config.agents.defaults.model = "llama3.2"
|
config.resolve_default_preset().model = "llama3.2"
|
||||||
|
|
||||||
assert config.get_provider_name() == "ollama"
|
assert config.get_provider_name() == "ollama"
|
||||||
assert config.get_api_base() == "http://localhost:11434/v1"
|
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():
|
def test_config_accepts_camel_case_explicit_provider_name_for_coding_plan():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "volcengineCodingPlan",
|
"provider": "volcengineCodingPlan",
|
||||||
"model": "doubao-1-5-pro",
|
"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():
|
def test_config_accepts_lm_studio_without_api_key_and_uses_default_localhost_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "lm_studio",
|
"provider": "lm_studio",
|
||||||
"model": "local-model",
|
"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():
|
def test_config_accepts_atomic_chat_without_api_key_and_uses_default_localhost_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "atomic_chat",
|
"provider": "atomic_chat",
|
||||||
"model": "local-model",
|
"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():
|
def test_config_explicit_longcat_provider_resolves_provider_name():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "longcat",
|
"provider": "longcat",
|
||||||
"model": "LongCat-Flash-Chat",
|
"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():
|
def test_config_auto_detects_longcat_from_model_keyword():
|
||||||
config = Config.model_validate(
|
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"}},
|
"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():
|
def test_config_explicit_xiaomi_mimo_provider_uses_default_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "xiaomi_mimo",
|
"provider": "xiaomi_mimo",
|
||||||
"model": "MiniMax-M1-80k",
|
"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():
|
def test_config_auto_detects_xiaomi_mimo_from_model_keyword():
|
||||||
config = Config.model_validate(
|
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"}},
|
"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():
|
def test_config_explicit_minimax_anthropic_provider_uses_default_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "minimax_anthropic",
|
"provider": "minimax_anthropic",
|
||||||
"model": "MiniMax-M2.7-highspeed",
|
"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():
|
def test_config_auto_detects_ollama_from_local_api_base():
|
||||||
config = Config.model_validate(
|
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"}},
|
"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():
|
def test_config_prefers_ollama_over_vllm_when_both_local_providers_configured():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "auto", "model": "llama3.2"}},
|
"modelPresets": {"default": {"provider": "auto", "model": "llama3.2"}},
|
||||||
"providers": {
|
"providers": {
|
||||||
"vllm": {"apiBase": "http://localhost:8000"},
|
"vllm": {"apiBase": "http://localhost:8000"},
|
||||||
"ollama": {"apiBase": "http://localhost:11434/v1"},
|
"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():
|
def test_config_falls_back_to_vllm_when_ollama_not_configured():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "auto", "model": "llama3.2"}},
|
"modelPresets": {"default": {"provider": "auto", "model": "llama3.2"}},
|
||||||
"providers": {
|
"providers": {
|
||||||
"vllm": {"apiBase": "http://localhost:8000"},
|
"vllm": {"apiBase": "http://localhost:8000"},
|
||||||
},
|
},
|
||||||
@ -1133,8 +1137,8 @@ def test_make_provider_uses_github_copilot_backend():
|
|||||||
|
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "github-copilot",
|
"provider": "github-copilot",
|
||||||
"model": "github-copilot/gpt-4.1",
|
"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:
|
def config_with_proxy(proxy: str) -> Config:
|
||||||
return Config.model_validate(
|
return Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "openai-codex",
|
"provider": "openai-codex",
|
||||||
"model": "openai-codex/gpt-5.5",
|
"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():
|
def test_provider_proxy_rejects_unsupported_backend():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
"model": "anthropic/claude-opus-4-5",
|
"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():
|
def test_make_provider_passes_extra_headers_to_custom_provider():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "custom", "model": "gpt-4o-mini"}},
|
"modelPresets": {
|
||||||
|
"default": {"provider": "custom", "model": "gpt-4o-mini"}
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"custom": {
|
"custom": {
|
||||||
"apiKey": "test-key",
|
"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():
|
def test_make_provider_treats_dynamic_custom_provider_as_direct():
|
||||||
config = Config.model_validate(
|
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": {
|
"providers": {
|
||||||
"my-company-api": {
|
"my-company-api": {
|
||||||
"apiBase": "https://example.com/v1",
|
"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():
|
def test_make_provider_strips_dynamic_custom_route_prefix_from_request_model():
|
||||||
config = Config.model_validate(
|
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": {
|
"providers": {
|
||||||
"my-company-api": {
|
"my-company-api": {
|
||||||
"apiBase": "https://example.com/v1",
|
"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():
|
def test_make_provider_preserves_namespaced_model_for_forced_dynamic_provider():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "my-company-api",
|
"provider": "my-company-api",
|
||||||
"model": "openai/gpt-4o-mini",
|
"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():
|
def test_make_provider_strips_dynamic_custom_route_prefix_once():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "auto",
|
"provider": "auto",
|
||||||
"model": "my-company-api/openai/gpt-4o-mini",
|
"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():
|
def test_make_provider_rejects_dynamic_custom_provider_without_api_base():
|
||||||
config = Config.model_validate(
|
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": {
|
"providers": {
|
||||||
"my-company-api": {
|
"my-company-api": {
|
||||||
"apiKey": "sk-test",
|
"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():
|
def test_make_provider_rejects_auto_dynamic_custom_prefix_without_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "auto", "model": "companyProxy/gpt-4o"}},
|
"modelPresets": {
|
||||||
|
"default": {"provider": "auto", "model": "companyProxy/gpt-4o"}
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"otherProxy": {
|
"otherProxy": {
|
||||||
"apiBase": "https://other.example.test/v1",
|
"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:
|
def _test_provider_snapshot(provider: object, config: Config) -> ProviderSnapshot:
|
||||||
|
default_preset = config.resolve_default_preset()
|
||||||
return ProviderSnapshot(
|
return ProviderSnapshot(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
model=config.agents.defaults.model,
|
model=default_preset.model,
|
||||||
context_window_tokens=config.agents.defaults.context_window_tokens,
|
context_window_tokens=default_preset.context_window_tokens,
|
||||||
signature=("test",),
|
signature=("test",),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ def test_save_config_round_trips(tmp_path: Path) -> None:
|
|||||||
path = tmp_path / "config.json"
|
path = tmp_path / "config.json"
|
||||||
save_config(Config(), path)
|
save_config(Config(), path)
|
||||||
loaded = load_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")
|
@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:
|
def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
|
||||||
config = load_config(tmp_path / "missing.json")
|
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(
|
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)
|
config = load_config(config_path)
|
||||||
|
|
||||||
assert config.agents.defaults.max_tokens == 1234
|
assert config.resolve_default_preset().max_tokens == 1234
|
||||||
assert config.agents.defaults.context_window_tokens == 200_000
|
assert config.resolve_default_preset().context_window_tokens == 200_000
|
||||||
assert not hasattr(config.agents.defaults, "memory_window")
|
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)
|
save_config(config, config_path)
|
||||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
defaults = saved["agents"]["defaults"]
|
defaults = saved["agents"]["defaults"]
|
||||||
|
default_preset = saved["modelPresets"]["default"]
|
||||||
|
|
||||||
assert defaults["maxTokens"] == 2222
|
assert default_preset["maxTokens"] == 2222
|
||||||
assert defaults["contextWindowTokens"] == 200_000
|
assert default_preset["contextWindowTokens"] == 200_000
|
||||||
|
assert "maxTokens" not in defaults
|
||||||
|
assert "contextWindowTokens" not in defaults
|
||||||
assert "memoryWindow" 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)
|
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")
|
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"]
|
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:
|
def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) -> None:
|
||||||
from nanobot.channels.plugin import load_channel_package
|
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(camel_path).tools.webui_allow_remote_package_install is True
|
||||||
assert load_config(snake_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 = load_config(config_path)
|
||||||
config.agents.defaults.max_tokens = 1234
|
config.resolve_default_preset().max_tokens = 1234
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
|
|
||||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
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:
|
def test_resolve_preset_returns_defaults_when_no_preset() -> None:
|
||||||
config = Config()
|
config = Config()
|
||||||
resolved = config.resolve_preset()
|
resolved = config.resolve_preset()
|
||||||
assert resolved.model == config.agents.defaults.model
|
assert resolved is config.model_presets["default"]
|
||||||
assert resolved.provider == config.agents.defaults.provider
|
assert config.agents.defaults.model_preset == "default"
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_model_preset_catalog_missing_env_reports_explicit_config_path(
|
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:
|
def test_dynamic_custom_provider_prefix_matches_camel_case_key() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "auto",
|
"provider": "auto",
|
||||||
"model": "companyProxy/gpt-4o-mini",
|
"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:
|
def test_dynamic_custom_provider_prefix_does_not_fall_through_when_base_missing() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "auto",
|
"provider": "auto",
|
||||||
"model": "companyProxy/gpt-4o-mini",
|
"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
|
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({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
@ -176,19 +172,17 @@ def test_legacy_defaults_config_without_presets_still_resolves() -> None:
|
|||||||
})
|
})
|
||||||
|
|
||||||
resolved = config.resolve_preset()
|
resolved = config.resolve_preset()
|
||||||
assert config.agents.defaults.model_preset is None
|
assert config.agents.defaults.model_preset == "default"
|
||||||
assert config.model_presets == {}
|
assert resolved.model == "anthropic/claude-opus-4-5"
|
||||||
assert resolved.model == "openai/gpt-4.1"
|
dumped_defaults = config.agents.defaults.model_dump(mode="json", by_alias=True)
|
||||||
assert resolved.provider == "openai"
|
assert "model" not in dumped_defaults
|
||||||
assert resolved.max_tokens == 4096
|
assert "provider" not in dumped_defaults
|
||||||
assert resolved.context_window_tokens == 128_000
|
|
||||||
assert resolved.temperature == 0.2
|
|
||||||
assert resolved.reasoning_effort == "low"
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_preset_returns_active_preset() -> None:
|
def test_resolve_preset_returns_active_preset() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"model_presets": {
|
"model_presets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "openai/gpt-4.1",
|
"model": "openai/gpt-4.1",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
@ -213,16 +207,15 @@ def test_resolve_preset_returns_active_preset() -> None:
|
|||||||
assert resolved.reasoning_effort == "low"
|
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({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "openai/gpt-4.1",
|
|
||||||
"provider": "openai",
|
|
||||||
"modelPreset": "fast",
|
"modelPreset": "fast",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "openai/gpt-4.1", "provider": "openai"},
|
||||||
"fast": {"model": "openai/gpt-4.1-mini", "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:
|
def test_model_presets_accepts_camel_case_root_key() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "openai/gpt-4.1",
|
"model": "openai/gpt-4.1",
|
||||||
"provider": "openai",
|
"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:
|
def test_model_presets_serializes_with_camel_case_root_key() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"model_presets": {
|
"model_presets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "openai/gpt-4.1",
|
"model": "openai/gpt-4.1",
|
||||||
"provider": "openai",
|
"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:
|
def test_resolve_preset_can_target_named_preset_without_activating() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"model_presets": {
|
"model_presets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {"model": "openai/gpt-4.1", "provider": "openai"},
|
"fast": {"model": "openai/gpt-4.1", "provider": "openai"},
|
||||||
"deep": {"model": "anthropic/claude-opus-4-5", "provider": "anthropic"},
|
"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:
|
def test_validator_accepts_dream_model_preset() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"dream": {"model": "anthropic/claude-haiku-4-5", "provider": "anthropic"},
|
"dream": {"model": "anthropic/claude-haiku-4-5", "provider": "anthropic"},
|
||||||
},
|
},
|
||||||
"agents": {"defaults": {"dream": {"modelOverride": "dream"}}},
|
"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:
|
def test_model_preset_accepts_explicit_default_name() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"model": "openai/gpt-4.1",
|
"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"
|
assert config.resolve_preset().model == "openai/gpt-4.1"
|
||||||
|
|
||||||
|
|
||||||
def test_model_presets_rejects_reserved_default_name() -> None:
|
def test_model_presets_requires_default_name() -> None:
|
||||||
import pytest
|
with pytest.raises(ValueError, match="must define a 'default' preset"):
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="model_preset name 'default' is reserved"):
|
|
||||||
Config.model_validate({
|
Config.model_validate({
|
||||||
"modelPresets": {
|
"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"},
|
"openai": {"apiKey": "sk-test"},
|
||||||
},
|
},
|
||||||
"model_presets": {
|
"model_presets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "openai/gpt-4.1",
|
"model": "openai/gpt-4.1",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
@ -363,6 +358,7 @@ def test_match_provider_uses_preset_provider_when_forced() -> None:
|
|||||||
"anthropic": {"apiKey": "sk-test"},
|
"anthropic": {"apiKey": "sk-test"},
|
||||||
},
|
},
|
||||||
"model_presets": {
|
"model_presets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "anthropic/claude-opus-4-5",
|
"model": "anthropic/claude-opus-4-5",
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
@ -383,8 +379,8 @@ def test_match_provider_routes_forced_novita_model_api_models() -> None:
|
|||||||
"providers": {
|
"providers": {
|
||||||
"novita": {"apiKey": "sk-test"},
|
"novita": {"apiKey": "sk-test"},
|
||||||
},
|
},
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"model": "deepseek-v4-pro",
|
"model": "deepseek-v4-pro",
|
||||||
"provider": "novita",
|
"provider": "novita",
|
||||||
}
|
}
|
||||||
@ -400,8 +396,8 @@ def test_transcription_only_provider_is_not_chat_fallback() -> None:
|
|||||||
"providers": {
|
"providers": {
|
||||||
"assemblyai": {"apiKey": "aai-test"},
|
"assemblyai": {"apiKey": "aai-test"},
|
||||||
},
|
},
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"model": "assemblyai/universal-3-pro",
|
"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")
|
assert hasattr(ProvidersConfig(), "bedrock")
|
||||||
|
|
||||||
cfg = Config.model_validate({
|
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"}},
|
"providers": {"bedrock": {"region": "us-east-1"}},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -67,6 +67,7 @@ class TestCustomProviderThinkingStyle:
|
|||||||
{
|
{
|
||||||
"agents": {"defaults": {"modelPreset": "primary"}},
|
"agents": {"defaults": {"modelPreset": "primary"}},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"primary": {"model": "tenant-model", "provider": "tenant"},
|
"primary": {"model": "tenant-model", "provider": "tenant"},
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
@ -92,6 +93,7 @@ class TestCustomProviderThinkingStyle:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"primary": {"model": "openai/gpt-4.1", "provider": "openai"},
|
"primary": {"model": "openai/gpt-4.1", "provider": "openai"},
|
||||||
"fallback": {"model": "tenant-model", "provider": "tenant"},
|
"fallback": {"model": "tenant-model", "provider": "tenant"},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -77,6 +77,7 @@ class TestProviderSignatureIncludesExtraQuery:
|
|||||||
base = {
|
base = {
|
||||||
"agents": {"defaults": {"modelPreset": "fast"}},
|
"agents": {"defaults": {"modelPreset": "fast"}},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {"model": "custom/test-model", "provider": "custom"},
|
"fast": {"model": "custom/test-model", "provider": "custom"},
|
||||||
},
|
},
|
||||||
"providers": {
|
"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)
|
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"model": "openai-codex/gpt-5.6-sol",
|
"model": "openai-codex/gpt-5.6-sol",
|
||||||
"provider": "openai_codex",
|
"provider": "openai_codex",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -35,6 +35,10 @@ def test_provider_signature_tracks_default_extra_headers() -> None:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {
|
||||||
|
"provider": "auto",
|
||||||
|
"model": "anthropic/claude-opus-4-5",
|
||||||
|
},
|
||||||
"primary": {
|
"primary": {
|
||||||
"provider": "kimi_coding",
|
"provider": "kimi_coding",
|
||||||
"model": "kimi-for-coding",
|
"model": "kimi-for-coding",
|
||||||
|
|||||||
@ -293,23 +293,16 @@ _IMAGE_MSG_NO_META = [
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_non_transient_error_with_images_retries_without_images() -> None:
|
async def test_unrelated_non_transient_error_with_images_is_not_hidden() -> None:
|
||||||
"""Any non-transient error retries once with images stripped when images are present."""
|
"""Only an explicit unsupported-image error may trigger image fallback."""
|
||||||
provider = ScriptedProvider([
|
provider = ScriptedProvider([
|
||||||
LLMResponse(content="API调用参数有误,请检查文档", finish_reason="error"),
|
LLMResponse(content="API调用参数有误,请检查文档", finish_reason="error"),
|
||||||
LLMResponse(content="ok, no image"),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG))
|
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG))
|
||||||
|
|
||||||
assert response.content == "ok, no image"
|
assert response.content == "API调用参数有误,请检查文档"
|
||||||
assert provider.calls == 2
|
assert provider.calls == 1
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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:
|
async def test_image_fallback_returns_error_on_second_failure() -> None:
|
||||||
"""If the image-stripped retry also fails, return that error."""
|
"""If the image-stripped retry also fails, return that error."""
|
||||||
provider = ScriptedProvider([
|
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"),
|
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:
|
async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
|
||||||
"""When _meta is absent, fallback placeholder is non-descriptive."""
|
"""When _meta is absent, fallback placeholder is non-descriptive."""
|
||||||
provider = ScriptedProvider([
|
provider = ScriptedProvider([
|
||||||
LLMResponse(content="error", finish_reason="error"),
|
LLMResponse(content="image input is not supported", finish_reason="error"),
|
||||||
LLMResponse(content="ok"),
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_chat_with_retry_uses_retry_after_and_emits_wait_progress(monkeypatch) -> None:
|
async def test_chat_with_retry_uses_retry_after_and_emits_wait_progress(monkeypatch) -> None:
|
||||||
provider = ScriptedProvider([
|
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)
|
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"model": "xai-grok/grok-4.5",
|
"model": "xai-grok/grok-4.5",
|
||||||
"provider": "xai_grok",
|
"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.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):
|
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(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "github-copilot",
|
"provider": "github-copilot",
|
||||||
"model": "github-copilot/gpt-4.1",
|
"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,
|
config=bot._config,
|
||||||
)
|
)
|
||||||
assert bot._loop.runtime_resolver.runtime is original_runtime
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.config.loader import load_config, save_config
|
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.providers.registry import find_by_name
|
||||||
from nanobot.webui.settings_api import (
|
from nanobot.webui.settings_api import (
|
||||||
WebUISettingsError,
|
WebUISettingsError,
|
||||||
@ -180,14 +180,12 @@ def _dynamic_provider_config(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
config = Config.model_validate(raw_config)
|
||||||
if defaults:
|
if defaults:
|
||||||
raw_config["agents"] = {
|
default_preset = config.resolve_default_preset()
|
||||||
"defaults": {
|
default_preset.provider = DYNAMIC_PROVIDER_NAME
|
||||||
"provider": DYNAMIC_PROVIDER_NAME,
|
default_preset.model = "gpt-4o-mini"
|
||||||
"model": "gpt-4o-mini",
|
return config
|
||||||
}
|
|
||||||
}
|
|
||||||
return Config.model_validate(raw_config)
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_model_configuration_writes_label_without_changing_call_order(
|
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:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.model = "openai/gpt-4o"
|
config.resolve_default_preset().model = "openai/gpt-4o"
|
||||||
config.agents.defaults.provider = "openai"
|
config.resolve_default_preset().provider = "openai"
|
||||||
config.providers.openai.api_key = "sk-test"
|
config.providers.openai.api_key = "sk-test"
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", 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"
|
assert rows["fast-writing"]["label"] == "Fast writing"
|
||||||
|
|
||||||
saved = load_config(config_path)
|
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"].label == "Fast writing"
|
||||||
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
|
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
|
||||||
assert saved.model_presets["fast-writing"].provider == "openai"
|
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_preset"] == "default"
|
||||||
assert payload["agent"]["model"] == "anthropic/claude-opus-4-5"
|
assert payload["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||||
saved = load_config(config_path)
|
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"].label == "Codex"
|
||||||
assert saved.model_presets["codex"].provider == "openai_codex"
|
assert saved.model_presets["codex"].provider == "openai_codex"
|
||||||
assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5"
|
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_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config = Config()
|
||||||
config.model_presets = {
|
config.model_presets = {
|
||||||
|
"default": config.resolve_default_preset(),
|
||||||
"primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"),
|
"primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"),
|
||||||
"backup": ModelPresetConfig(model="anthropic/claude-sonnet-4", provider="anthropic"),
|
"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_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config = Config()
|
||||||
config.model_presets = {
|
config.model_presets = {
|
||||||
|
"default": config.resolve_default_preset(),
|
||||||
"primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"),
|
"primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"),
|
||||||
"backup": ModelPresetConfig(model="anthropic/claude-sonnet-4", provider="anthropic"),
|
"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"]
|
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,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -394,50 +394,60 @@ def test_update_model_call_order_requires_named_primary(
|
|||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as error:
|
payload = update_model_call_order({"order": [json.dumps(["backup", "default"])]})
|
||||||
update_model_call_order({"order": [json.dumps(["backup"])]})
|
|
||||||
|
|
||||||
assert error.value.status == 409
|
assert payload["model_call_order"] == ["backup", "default"]
|
||||||
assert load_config(config_path).agents.defaults.model_preset is None
|
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,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config_path.write_text(
|
||||||
config.agents.defaults.model = "openai/gpt-4o"
|
json.dumps(
|
||||||
config.agents.defaults.provider = "openai"
|
{
|
||||||
config.agents.defaults.max_tokens = 4096
|
"agents": {
|
||||||
config.agents.defaults.temperature = 0.25
|
"defaults": {
|
||||||
config.agents.defaults.fallback_models = [
|
"model": "openai/gpt-4o",
|
||||||
InlineFallbackConfig(
|
"provider": "openai",
|
||||||
model="anthropic/claude-sonnet-4",
|
"maxTokens": 4096,
|
||||||
provider="anthropic",
|
"temperature": 0.25,
|
||||||
)
|
"fallbackModels": [
|
||||||
]
|
{
|
||||||
save_config(config, config_path)
|
"model": "anthropic/claude-sonnet-4",
|
||||||
|
"provider": "anthropic",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
legacy_payload = settings_payload()
|
payload = settings_payload()
|
||||||
assert legacy_payload["model_call_order"] == []
|
|
||||||
assert legacy_payload["model_call_order_editable"] is False
|
|
||||||
|
|
||||||
payload = migrate_model_configurations()
|
|
||||||
|
|
||||||
assert payload["model_call_order_editable"] is True
|
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)
|
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.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"].max_tokens == 4096
|
||||||
assert saved.model_presets["claude-sonnet-4"].temperature == 0.25
|
assert saved.model_presets["claude-sonnet-4"].temperature == 0.25
|
||||||
|
|
||||||
repeated = migrate_model_configurations()
|
repeated = migrate_model_configurations()
|
||||||
assert repeated["model_call_order"] == ["gpt-4o", "claude-sonnet-4"]
|
assert repeated["model_call_order"] == ["default", "claude-sonnet-4"]
|
||||||
assert set(load_config(config_path).model_presets) == {"gpt-4o", "claude-sonnet-4"}
|
assert set(load_config(config_path).model_presets) == {
|
||||||
|
"default",
|
||||||
|
"claude-sonnet-4",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_model_configuration_advanced_options_round_trip(
|
def test_model_configuration_advanced_options_round_trip(
|
||||||
@ -459,6 +469,7 @@ def test_model_configuration_advanced_options_round_trip(
|
|||||||
"context_window_tokens": ["262144"],
|
"context_window_tokens": ["262144"],
|
||||||
"temperature": ["0.4"],
|
"temperature": ["0.4"],
|
||||||
"reasoning_effort": ["high"],
|
"reasoning_effort": ["high"],
|
||||||
|
"supports_image_input": ["true"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
row = next(row for row in created["model_presets"] if row["name"] == "reasoning")
|
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["context_window_tokens"] == 262144
|
||||||
assert row["temperature"] == 0.4
|
assert row["temperature"] == 0.4
|
||||||
assert row["reasoning_effort"] == "high"
|
assert row["reasoning_effort"] == "high"
|
||||||
|
assert row["supports_image_input"] is True
|
||||||
|
|
||||||
updated = update_model_configuration(
|
updated = update_model_configuration(
|
||||||
{
|
{
|
||||||
@ -473,12 +485,14 @@ def test_model_configuration_advanced_options_round_trip(
|
|||||||
"max_tokens": ["8192"],
|
"max_tokens": ["8192"],
|
||||||
"temperature": ["0"],
|
"temperature": ["0"],
|
||||||
"reasoning_effort": [""],
|
"reasoning_effort": [""],
|
||||||
|
"supports_image_input": ["false"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
row = next(row for row in updated["model_presets"] if row["name"] == "reasoning")
|
row = next(row for row in updated["model_presets"] if row["name"] == "reasoning")
|
||||||
assert row["max_tokens"] == 8192
|
assert row["max_tokens"] == 8192
|
||||||
assert row["temperature"] == 0
|
assert row["temperature"] == 0
|
||||||
assert row["reasoning_effort"] is None
|
assert row["reasoning_effort"] is None
|
||||||
|
assert row["supports_image_input"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_delete_model_configuration_requires_removing_it_from_call_order(
|
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_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config = Config()
|
||||||
config.model_presets = {
|
config.model_presets = {
|
||||||
|
"default": config.resolve_default_preset(),
|
||||||
"primary": ModelPresetConfig(model="openai/gpt-4.1"),
|
"primary": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
"spare": ModelPresetConfig(model="openai/gpt-4.1-mini"),
|
"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
|
assert payload["agent"]["context_window_tokens"] == 200000
|
||||||
saved = load_config(config_path)
|
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(
|
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"]})
|
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,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -807,8 +822,16 @@ def test_update_model_configuration_rejects_default_preset(
|
|||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError, match="model configuration is required"):
|
payload = update_model_configuration({
|
||||||
update_model_configuration({"name": ["default"], "model": ["openai/gpt-4.1"]})
|
"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(
|
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_path = tmp_path / "config.json"
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
|
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "opencode_zen",
|
"provider": "opencode_zen",
|
||||||
"model": "opencode/deepseek-v4-pro",
|
"model": "opencode/deepseek-v4-pro",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -229,6 +229,7 @@ interface AgentSettingsDraft {
|
|||||||
contextWindowTokens: number;
|
contextWindowTokens: number;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
reasoningEffort: string;
|
reasoningEffort: string;
|
||||||
|
imageInputSupport: "auto" | "supported" | "text_only";
|
||||||
timezone: string;
|
timezone: string;
|
||||||
botName: string;
|
botName: string;
|
||||||
botIcon: string;
|
botIcon: string;
|
||||||
@ -426,12 +427,29 @@ interface SettingsViewProps {
|
|||||||
|
|
||||||
function modelPresetValue(payload: SettingsPayload): string {
|
function modelPresetValue(payload: SettingsPayload): string {
|
||||||
return (
|
return (
|
||||||
|
payload.agent.model_preset ??
|
||||||
payload.model_call_order?.[0] ??
|
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 {
|
function normalizeContextWindowTokens(value: number | null | undefined): number {
|
||||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 200_000;
|
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,
|
contextWindowTokens: 200_000,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
reasoningEffort: "",
|
reasoningEffort: "",
|
||||||
|
imageInputSupport: "auto",
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
botName: "nanobot",
|
botName: "nanobot",
|
||||||
botIcon: "",
|
botIcon: "",
|
||||||
@ -526,7 +545,7 @@ function agentDraftFromPayload(
|
|||||||
const activePresetName = preferredPresetName ?? modelPresetValue(payload);
|
const activePresetName = preferredPresetName ?? modelPresetValue(payload);
|
||||||
const activePreset =
|
const activePreset =
|
||||||
payload.model_presets.find(
|
payload.model_presets.find(
|
||||||
(preset) => !preset.is_default && preset.name === activePresetName,
|
(preset) => preset.name === activePresetName,
|
||||||
) ?? null;
|
) ?? null;
|
||||||
return {
|
return {
|
||||||
model: activePreset?.model ?? payload.agent.model,
|
model: activePreset?.model ?? payload.agent.model,
|
||||||
@ -539,6 +558,7 @@ function agentDraftFromPayload(
|
|||||||
),
|
),
|
||||||
temperature: activePreset?.temperature ?? payload.agent.temperature,
|
temperature: activePreset?.temperature ?? payload.agent.temperature,
|
||||||
reasoningEffort: activePreset?.reasoning_effort ?? "",
|
reasoningEffort: activePreset?.reasoning_effort ?? "",
|
||||||
|
imageInputSupport: imageInputSupportMode(activePreset?.supports_image_input),
|
||||||
timezone: payload.agent.timezone,
|
timezone: payload.agent.timezone,
|
||||||
botName: payload.agent.bot_name,
|
botName: payload.agent.bot_name,
|
||||||
botIcon: payload.agent.bot_icon,
|
botIcon: payload.agent.bot_icon,
|
||||||
@ -1034,7 +1054,7 @@ export function SettingsView({
|
|||||||
const modelDirty = useMemo(() => {
|
const modelDirty = useMemo(() => {
|
||||||
if (!settings) return false;
|
if (!settings) return false;
|
||||||
const selectedPreset = settings.model_presets.find(
|
const selectedPreset = settings.model_presets.find(
|
||||||
(preset) => !preset.is_default && preset.name === form.modelPreset,
|
(preset) => preset.name === form.modelPreset,
|
||||||
);
|
);
|
||||||
if (!selectedPreset) return false;
|
if (!selectedPreset) return false;
|
||||||
return (
|
return (
|
||||||
@ -1044,6 +1064,7 @@ export function SettingsView({
|
|||||||
form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) ||
|
form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) ||
|
||||||
form.temperature !== selectedPreset.temperature ||
|
form.temperature !== selectedPreset.temperature ||
|
||||||
form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "") ||
|
form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "") ||
|
||||||
|
form.imageInputSupport !== imageInputSupportMode(selectedPreset.supports_image_input) ||
|
||||||
form.presetLabel.trim() !== selectedPreset.label
|
form.presetLabel.trim() !== selectedPreset.label
|
||||||
);
|
);
|
||||||
}, [form, settings]);
|
}, [form, settings]);
|
||||||
@ -1198,6 +1219,7 @@ export function SettingsView({
|
|||||||
contextWindowTokens: form.contextWindowTokens,
|
contextWindowTokens: form.contextWindowTokens,
|
||||||
temperature: form.temperature,
|
temperature: form.temperature,
|
||||||
reasoningEffort: form.reasoningEffort || null,
|
reasoningEffort: form.reasoningEffort || null,
|
||||||
|
supportsImageInput: imageInputSupportValue(form.imageInputSupport),
|
||||||
});
|
});
|
||||||
const createdPreset = payload.created_model_preset;
|
const createdPreset = payload.created_model_preset;
|
||||||
const nextOrder = createdPreset ? [...modelCallOrder, createdPreset] : null;
|
const nextOrder = createdPreset ? [...modelCallOrder, createdPreset] : null;
|
||||||
@ -1228,7 +1250,7 @@ export function SettingsView({
|
|||||||
|
|
||||||
if (!modelDirty) return;
|
if (!modelDirty) return;
|
||||||
const selectedPreset = settings.model_presets.find(
|
const selectedPreset = settings.model_presets.find(
|
||||||
(preset) => !preset.is_default && preset.name === form.modelPreset,
|
(preset) => preset.name === form.modelPreset,
|
||||||
);
|
);
|
||||||
if (!selectedPreset) return;
|
if (!selectedPreset) return;
|
||||||
const reasoningEffort = form.reasoningEffort || null;
|
const reasoningEffort = form.reasoningEffort || null;
|
||||||
@ -1253,6 +1275,10 @@ export function SettingsView({
|
|||||||
form.temperature !== selectedPreset.temperature ? form.temperature : undefined,
|
form.temperature !== selectedPreset.temperature ? form.temperature : undefined,
|
||||||
reasoningEffort:
|
reasoningEffort:
|
||||||
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
|
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
|
||||||
|
supportsImageInput:
|
||||||
|
form.imageInputSupport !== imageInputSupportMode(selectedPreset.supports_image_input)
|
||||||
|
? imageInputSupportValue(form.imageInputSupport)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
setForm(agentDraftFromPayload(payload, selectedPreset.name));
|
setForm(agentDraftFromPayload(payload, selectedPreset.name));
|
||||||
@ -1268,7 +1294,7 @@ export function SettingsView({
|
|||||||
const beginModelPresetCreation = () => {
|
const beginModelPresetCreation = () => {
|
||||||
if (!settings || saving || modelCallOrderSaving || modelConfigurationSaving) return;
|
if (!settings || saving || modelCallOrderSaving || modelConfigurationSaving) return;
|
||||||
const primaryPreset = settings.model_presets.find(
|
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"
|
const currentProvider = primaryPreset?.provider === "auto"
|
||||||
? primaryPreset.resolved_provider ?? settings.agent.resolved_provider
|
? primaryPreset.resolved_provider ?? settings.agent.resolved_provider
|
||||||
@ -1290,6 +1316,7 @@ export function SettingsView({
|
|||||||
),
|
),
|
||||||
temperature: primaryPreset?.temperature ?? settings.agent.temperature,
|
temperature: primaryPreset?.temperature ?? settings.agent.temperature,
|
||||||
reasoningEffort: primaryPreset?.reasoning_effort ?? settings.agent.reasoning_effort ?? "",
|
reasoningEffort: primaryPreset?.reasoning_effort ?? settings.agent.reasoning_effort ?? "",
|
||||||
|
imageInputSupport: imageInputSupportMode(primaryPreset?.supports_image_input),
|
||||||
}));
|
}));
|
||||||
setModelPresetCreating(true);
|
setModelPresetCreating(true);
|
||||||
};
|
};
|
||||||
@ -3168,7 +3195,7 @@ function ModelsSettings({
|
|||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
|
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
|
||||||
const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = 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 namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset]));
|
||||||
const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name));
|
const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name));
|
||||||
const callOrderOccurrences = new Map<string, number>();
|
const callOrderOccurrences = new Map<string, number>();
|
||||||
@ -3244,6 +3271,7 @@ function ModelsSettings({
|
|||||||
contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens),
|
contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens),
|
||||||
temperature: preset.temperature,
|
temperature: preset.temperature,
|
||||||
reasoningEffort: preset.reasoning_effort ?? "",
|
reasoningEffort: preset.reasoning_effort ?? "",
|
||||||
|
imageInputSupport: imageInputSupportMode(preset.supports_image_input),
|
||||||
}));
|
}));
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
};
|
};
|
||||||
@ -3655,6 +3683,7 @@ function ModelsSettings({
|
|||||||
contextWindowTokens={form.contextWindowTokens}
|
contextWindowTokens={form.contextWindowTokens}
|
||||||
temperature={form.temperature}
|
temperature={form.temperature}
|
||||||
reasoningEffort={form.reasoningEffort}
|
reasoningEffort={form.reasoningEffort}
|
||||||
|
imageInputSupport={form.imageInputSupport}
|
||||||
onChange={(value) => setForm((prev) => ({ ...prev, ...value }))}
|
onChange={(value) => setForm((prev) => ({ ...prev, ...value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -3673,7 +3702,7 @@ function ModelsSettings({
|
|||||||
>
|
>
|
||||||
{tx("settings.actions.cancel", "Cancel")}
|
{tx("settings.actions.cancel", "Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
) : selectedPreset ? (
|
) : selectedPreset && !selectedPreset.is_default ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -3726,17 +3755,23 @@ function ModelAdvancedFields({
|
|||||||
contextWindowTokens,
|
contextWindowTokens,
|
||||||
temperature,
|
temperature,
|
||||||
reasoningEffort,
|
reasoningEffort,
|
||||||
|
imageInputSupport,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
contextWindowTokens: number;
|
contextWindowTokens: number;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
reasoningEffort: string;
|
reasoningEffort: string;
|
||||||
|
imageInputSupport: AgentSettingsDraft["imageInputSupport"];
|
||||||
onChange: (
|
onChange: (
|
||||||
value: Partial<
|
value: Partial<
|
||||||
Pick<
|
Pick<
|
||||||
AgentSettingsDraft,
|
AgentSettingsDraft,
|
||||||
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
| "maxTokens"
|
||||||
|
| "contextWindowTokens"
|
||||||
|
| "temperature"
|
||||||
|
| "reasoningEffort"
|
||||||
|
| "imageInputSupport"
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
) => void;
|
) => void;
|
||||||
@ -3811,6 +3846,33 @@ function ModelAdvancedFields({
|
|||||||
className="h-9 rounded-[12px] text-[13px]"
|
className="h-9 rounded-[12px] text-[13px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -9761,14 +9823,14 @@ function StatusPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SegmentedControl({
|
function SegmentedControl<T extends string>({
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: T;
|
||||||
options: Array<{ value: string; label: string }>;
|
options: Array<{ value: T; label: string }>;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: T) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground">
|
<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,
|
query: URLSearchParams,
|
||||||
configuration: Pick<
|
configuration: Pick<
|
||||||
ModelConfigurationCreate,
|
ModelConfigurationCreate,
|
||||||
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
| "maxTokens"
|
||||||
|
| "contextWindowTokens"
|
||||||
|
| "temperature"
|
||||||
|
| "reasoningEffort"
|
||||||
|
| "supportsImageInput"
|
||||||
>,
|
>,
|
||||||
): void {
|
): void {
|
||||||
if (configuration.maxTokens !== undefined) {
|
if (configuration.maxTokens !== undefined) {
|
||||||
@ -789,6 +793,14 @@ function appendModelGenerationSettings(
|
|||||||
if (configuration.reasoningEffort !== undefined) {
|
if (configuration.reasoningEffort !== undefined) {
|
||||||
query.set("reasoning_effort", configuration.reasoningEffort ?? "");
|
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(
|
export async function createModelConfiguration(
|
||||||
|
|||||||
@ -432,6 +432,7 @@ export interface SettingsPayload {
|
|||||||
context_window_tokens: number;
|
context_window_tokens: number;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
reasoning_effort: string | null;
|
reasoning_effort: string | null;
|
||||||
|
supports_image_input: boolean | null;
|
||||||
reasoning_effort_values?: string[];
|
reasoning_effort_values?: string[];
|
||||||
}>;
|
}>;
|
||||||
model_call_order: string[];
|
model_call_order: string[];
|
||||||
@ -968,6 +969,7 @@ export interface ModelConfigurationCreate {
|
|||||||
contextWindowTokens?: number;
|
contextWindowTokens?: number;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
reasoningEffort?: string | null;
|
reasoningEffort?: string | null;
|
||||||
|
supportsImageInput?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelConfigurationUpdate {
|
export interface ModelConfigurationUpdate {
|
||||||
@ -979,6 +981,7 @@ export interface ModelConfigurationUpdate {
|
|||||||
contextWindowTokens?: number;
|
contextWindowTokens?: number;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
reasoningEffort?: string | null;
|
reasoningEffort?: string | null;
|
||||||
|
supportsImageInput?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderSettingsUpdate {
|
export interface ProviderSettingsUpdate {
|
||||||
|
|||||||
@ -3004,7 +3004,7 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
expect(await screen.findByRole("button", { name: "private/image-v2" })).toBeInTheDocument();
|
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 base = settingsPayload();
|
||||||
const payload: SettingsPayload = {
|
const payload: SettingsPayload = {
|
||||||
...base,
|
...base,
|
||||||
@ -3070,11 +3070,11 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
|
|
||||||
expect((await screen.findAllByText("MiniMax-M3")).length).toBeGreaterThan(0);
|
expect((await screen.findAllByText("MiniMax-M3")).length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("fast").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("fast").length).toBeGreaterThan(0);
|
||||||
expect(screen.queryByText("Default")).not.toBeInTheDocument();
|
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("openai-codex/gpt-5.5")).not.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 base = settingsPayload();
|
||||||
const payload: SettingsPayload = {
|
const payload: SettingsPayload = {
|
||||||
...base,
|
...base,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user