diff --git a/docs/architecture.md b/docs/architecture.md index ddd6deeb2..2358bc3f3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,7 +59,7 @@ Provider metadata is centralized in `nanobot/providers/registry.py`. Configurati Provider selection uses: -- explicit `agents.defaults.provider` or preset provider; +- the active model preset's explicit provider; - provider registry keywords; - API key prefixes and API base URL hints; - local provider fallback when `apiBase` is configured; diff --git a/docs/chat-commands.md b/docs/chat-commands.md index 2e006243e..b5816de25 100644 --- a/docs/chat-commands.md +++ b/docs/chat-commands.md @@ -57,7 +57,7 @@ To switch presets for future turns: /model default ``` -Preset names come from the top-level `modelPresets` config. Switching affects only the current session and persists the selection in that session, so later turns keep using it across process restarts. It does not rewrite `config.json`, does not change other sessions, and does not alter an in-progress turn's captured model. Sessions without a saved selection follow `agents.defaults.modelPreset` (or the implicit `default` preset when it is omitted). See [Configuration: Model presets](./configuration.md#model-presets) for setup details. +Preset names come from the top-level `modelPresets` config. Switching affects only the current session and persists the selection in that session, so later turns keep using it across process restarts. It does not rewrite `config.json`, does not change other sessions, and does not alter an in-progress turn's captured model. Sessions without a saved selection follow `agents.defaults.modelPreset`, or the concrete `modelPresets.default` entry when it is omitted. See [Configuration: Model presets](./configuration.md#model-presets) for setup details. ## Local triggers diff --git a/docs/concepts.md b/docs/concepts.md index 0e4cdcbfb..43294fe8f 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -87,9 +87,9 @@ The WebUI launcher is the normal browser entry point. Underneath, the gateway ke ## Provider and Model Selection -The active model should normally come from a named `modelPresets` entry selected by `agents.defaults.modelPreset`. Direct `agents.defaults.provider` and `agents.defaults.model` still form the implicit `default` preset for older or minimal configs. The active provider is resolved in this order: +The active model comes from the named `modelPresets` entry selected by `agents.defaults.modelPreset`, or from the concrete `modelPresets.default` entry when that selector is omitted. The active provider is resolved in this order: -1. If the active preset provider or implicit default provider is not `"auto"`, nanobot uses that provider. +1. If the active preset provider is not `"auto"`, nanobot uses that provider. 2. If provider is `"auto"`, nanobot tries to infer the provider from the model name, configured API keys, local provider base URLs, or gateway providers. 3. OAuth providers such as OpenAI Codex and GitHub Copilot require explicit login and explicit provider/model selection inside the active preset. diff --git a/docs/configuration.md b/docs/configuration.md index 7658c0855..36af22ec5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -259,7 +259,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client > - **ModelScope**: If you're using ModelScope's OpenAI-compatible endpoint, set `"apiBase": "https://api-inference.modelscope.cn/v1"` in your modelscope provider config. > - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.ai/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`. > - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config. -> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default. +> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Set `reasoningEffort: "none"` on the active model preset to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default. > - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config. > - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`. > - **Provider-scoped proxy**: `providers..proxy` routes only that provider through an HTTP proxy. It is supported for OpenAI-compatible providers, `openai_codex`, and `xai_grok`. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`. @@ -1346,20 +1346,12 @@ Contributor notes for adding new providers live in [`development.md`](./developm ## Model Presets -Model presets let you name a complete model configuration and select one per session with `/model `. They are the recommended way to configure models because the same names can be reused for new-session defaults, chat-command switching, and fallback chains. +Model presets let you name a complete model configuration and select one per session with `/model `. Configure all model, provider, generation, context-window, and image-input settings under top-level `modelPresets`; `agents.defaults` only selects preset names. -Existing configs do not need to change. Direct `agents.defaults.model`, `provider`, `maxTokens`, `contextWindowTokens`, `temperature`, and `reasoningEffort` fields still define the implicit `default` preset. For new configs, prefer top-level `modelPresets` plus `agents.defaults.modelPreset`. +On first load, nanobot migrates legacy model fields from `agents.defaults` and inline fallback objects in `config.json` into named presets, then atomically rewrites the file. If a concrete `modelPresets.default` and legacy direct fields both exist, the concrete preset wins and the legacy fields are removed. Legacy model fields supplied through nested `NANOBOT_AGENTS` environment settings are not supported. ```json { - "modelPresets": { - "fast": { - "provider": "openrouter", - "model": "anthropic/claude-sonnet-4.5", - "maxTokens": 4096, - "contextWindowTokens": 65536 - } - }, "agents": { "defaults": { "modelPreset": "fast", @@ -1367,6 +1359,14 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide } }, "modelPresets": { + "default": { + "label": "Default", + "model": "claude-opus-4-5", + "provider": "anthropic", + "maxTokens": 8192, + "contextWindowTokens": 200000, + "supportsImageInput": true + }, "fast": { "label": "Fast", "model": "gpt-4.1-mini", @@ -1374,7 +1374,8 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide "maxTokens": 4096, "contextWindowTokens": 128000, "temperature": 0.2, - "reasoningEffort": "low" + "reasoningEffort": "low", + "supportsImageInput": true }, "deep": { "label": "Deep", @@ -1396,7 +1397,7 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide } ``` -`modelPresets` is a top-level object. The keys under it (`fast`, `deep`, `coding`, etc.) are user-defined preset names. Each preset supports: +`modelPresets` is a top-level object. `default` is required; its other keys (`fast`, `deep`, `coding`, etc.) are user-defined preset names. Each preset supports: | Field | Description | |-------|-------------| @@ -1407,25 +1408,30 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide | `contextWindowTokens` | Context window size used by prompt building and consolidation decisions. | | `temperature` | Sampling temperature. | | `reasoningEffort` | Optional reasoning/thinking setting. Provider support varies. | +| `supportsImageInput` | `true` always sends images, `false` strips them before the first request, and `null`/omitted uses automatic retry-on-unsupported behavior. | -`default` is reserved and always means the implicit preset built from direct `agents.defaults.*` fields; do not define `modelPresets.default`. Use `/model default` to switch back to those direct fields in an existing config. +Every config has a concrete `modelPresets.default` entry. Use `/model default` to switch a session back to it. Configure the default model by editing that preset, not by adding model fields under `agents.defaults`. -Set `agents.defaults.modelPreset` to choose the preset followed by sessions that have no saved model selection. When `modelPreset` is `null` or omitted, such sessions follow the implicit `default` preset from direct `agents.defaults.*` fields. `/model ` saves an override in the current session, so its future turns keep that preset across process restarts while other sessions remain unchanged. The command does not write the selection back to `config.json`. +Set `agents.defaults.modelPreset` to choose the preset followed by sessions that have no saved model selection. When it is omitted, such sessions use `modelPresets.default`. `/model ` saves an override in the current session, so its future turns keep that preset across process restarts while other sessions remain unchanged. The command does not write the selection back to `config.json`. ### Model Fallbacks -`agents.defaults.fallbackModels` defines an ordered failover chain for the active model configuration. The primary model is still selected by `agents.defaults.modelPreset` or, in older configs, by the implicit `default` preset from direct `agents.defaults.*` fields. +`agents.defaults.fallbackModels` defines an ordered failover chain for the active model configuration. The primary model is selected by `agents.defaults.modelPreset`, or by `modelPresets.default` when that selector is omitted. -Each fallback candidate can be either: - -- A preset name from `modelPresets`, such as `"deep"`. This is the recommended form. The preset's full model, provider, generation, and context-window config is used. -- An inline fallback object with at least `provider` and `model`. Optional `maxTokens`, `contextWindowTokens`, and `temperature` fields inherit from the active primary config when omitted. `reasoningEffort` does not inherit; omit it to leave reasoning off for that fallback, or set it explicitly for models that support reasoning. +Each fallback candidate is a preset name from `modelPresets`, such as `"deep"`. The preset's complete model, provider, generation, context-window, and image-input configuration is used. Preset fallback chain: ```json { "modelPresets": { + "default": { + "model": "gpt-4.1-mini", + "provider": "openai", + "maxTokens": 4096, + "contextWindowTokens": 128000, + "temperature": 0.2 + }, "fast": { "model": "gpt-4.1-mini", "provider": "openai", @@ -1456,37 +1462,7 @@ Preset fallback chain: } ``` -String entries are preset names, not raw model names. In the example above, `"deep"` means `modelPresets.deep`; nanobot will not interpret it as a provider model ID. Changing a preset updates both `/model ` switching and any fallback chain that references it. - -Inline fallback object: - -```json -{ - "modelPresets": { - "fast": { - "provider": "openrouter", - "model": "anthropic/claude-sonnet-4.5", - "maxTokens": 4096, - "contextWindowTokens": 65536 - } - }, - "agents": { - "defaults": { - "modelPreset": "fast", - "fallbackModels": [ - { - "provider": "deepseek", - "model": "deepseek-v4-pro", - "maxTokens": 4096, - "contextWindowTokens": 262144 - } - ] - } - } -} -``` - -Use inline objects only when a fallback is not worth naming as a reusable preset. `fallbackModels` belongs under `agents.defaults`, not inside individual `modelPresets` entries. +String entries are preset names, not raw model names. In the example above, `"deep"` means `modelPresets.deep`; nanobot will not interpret it as a provider model ID. Changing a preset updates both `/model ` switching and any fallback chain that references it. `fallbackModels` belongs under `agents.defaults`, not inside individual `modelPresets` entries. Failover normally runs when the primary provider returns a fallbackable model/provider error before any answer text has been streamed. Stream-stall timeouts are the recovery exception: if the provider already emitted partial answer text and then stalls, nanobot closes the current stream segment and retries/fails over in a new segment. Typical fallback cases include timeouts, connection errors, 5xx server errors, 429 rate limits, overloads, authentication/permission failures such as invalid or expired credentials, and quota/balance exhaustion. It does not run for malformed requests, content filtering/refusals, or context-length/message-format errors. diff --git a/docs/provider-cookbook.md b/docs/provider-cookbook.md index f7a6b40db..d3c1c2f8c 100644 --- a/docs/provider-cookbook.md +++ b/docs/provider-cookbook.md @@ -34,7 +34,7 @@ Match the recipe to the credential or endpoint you already have: 5. Run `nanobot agent -m "Hello!"`. 6. If the CLI works, then connect WebUI, gateway, or chat apps. -The active model should normally come from `agents.defaults.modelPreset`, and that name should point to an entry in `modelPresets`. Direct `agents.defaults.provider` and `agents.defaults.model` still work for older configs, but presets are easier to switch and easier to reuse as fallbacks. +The active model comes from `agents.defaults.modelPreset`, and that name must point to an entry in `modelPresets`. Configure model/provider settings in presets so they can be switched and reused as fallbacks. ## Secret Setup diff --git a/docs/providers.md b/docs/providers.md index 2d2ea452b..6a8711cd0 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -10,7 +10,7 @@ For every setup, answer three questions: 2. What model name does that provider expect? 3. Does the provider need `apiKey`, `apiBase`, OAuth login, cloud credentials, or only a local server URL? -Prefer a named `modelPresets` entry for the model/provider pair, then select it with `agents.defaults.modelPreset`. Direct `agents.defaults.provider` and `agents.defaults.model` still work for existing configs, but presets make runtime `/model` switching and fallback chains clearer. Pin `provider` inside the preset while setting up; you can switch back to `"auto"` later. +Define the model/provider pair as a named `modelPresets` entry, then select it with `agents.defaults.modelPreset`. Pin `provider` inside the preset while setting up; you can switch back to `"auto"` later. ## Choose a Provider Without Guessing @@ -462,14 +462,14 @@ Each command authenticates the selected provider and makes its current default m ## Provider Resolution -The recommended path is a named preset selected by `agents.defaults.modelPreset`. The effective model parameters come from: +The effective model parameters come from: 1. the named `modelPresets` entry referenced by `agents.defaults.modelPreset`; -2. otherwise the implicit `default` preset built from `agents.defaults.model`, `provider`, `maxTokens`, `contextWindowTokens`, `temperature`, and related fields. +2. otherwise the concrete `modelPresets.default` entry. Provider selection follows this practical rule: -- Explicit `provider` in the active preset or implicit default config wins. +- Explicit `provider` in the active preset wins. - `provider: "auto"` tries model-name keywords, configured keys, local base URLs, and gateway providers. - Gateway providers such as OpenRouter and AiHubMix can route many model families, so the model name must be valid for that gateway. - Local providers should normally be explicit because generic local model names such as `llama3.2` do not always contain provider keywords. @@ -491,6 +491,14 @@ Model presets are the recommended model configuration surface. Use them when you ```json { "modelPresets": { + "default": { + "label": "Default", + "provider": "anthropic", + "model": "claude-opus-4-5", + "maxTokens": 8192, + "contextWindowTokens": 200000, + "temperature": 0.1 + }, "fast": { "label": "Fast", "provider": "openrouter", @@ -516,7 +524,7 @@ Model presets are the recommended model configuration surface. Use them when you } ``` -The preset name `default` is reserved for the implicit `agents.defaults` settings. Do not define `modelPresets.default`; use `/model default` to return to the direct `agents.defaults.*` fields in older configs. +Every config has a concrete `modelPresets.default` entry. Use `/model default` to return to it. Legacy direct model fields in `agents.defaults` are migrated from `config.json` on first load; configure presets only after migration. ## Fallback Models @@ -525,6 +533,14 @@ Fallbacks are useful for transient provider failures, rate limits, or model avai ```json { "modelPresets": { + "default": { + "label": "Default", + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4.5", + "maxTokens": 4096, + "contextWindowTokens": 65536, + "temperature": 0.1 + }, "fast": { "label": "Fast", "provider": "openrouter", @@ -559,35 +575,7 @@ Fallbacks are useful for transient provider failures, rate limits, or model avai } ``` -String entries in `fallbackModels` are preset names, not raw model names. nanobot tries them in order after the active preset. Each fallback preset uses its own `provider`, `model`, `maxTokens`, `contextWindowTokens`, `temperature`, and optional `reasoningEffort`. - -Use inline fallback objects only when a model is not worth naming as a preset: - -```json -{ - "modelPresets": { - "fast": { - "provider": "openrouter", - "model": "anthropic/claude-sonnet-4.5", - "maxTokens": 4096, - "contextWindowTokens": 65536 - } - }, - "agents": { - "defaults": { - "modelPreset": "fast", - "fallbackModels": [ - { - "provider": "deepseek", - "model": "deepseek-v4-pro", - "maxTokens": 4096, - "contextWindowTokens": 262144 - } - ] - } - } -} -``` +String entries in `fallbackModels` are preset names, not raw model names. nanobot tries them in order after the active preset. Each fallback preset uses its own `provider`, `model`, `maxTokens`, `contextWindowTokens`, `temperature`, optional `reasoningEffort`, and `supportsImageInput` policy. `fallbackModels` belongs under `agents.defaults`, not inside each preset. If fallback candidates use smaller context windows, nanobot builds context using the smallest window in the active chain so every candidate can receive the same prompt. See [`configuration.md#model-fallbacks`](./configuration.md#model-fallbacks) for failure conditions. diff --git a/docs/python-sdk.md b/docs/python-sdk.md index 508ad5f3d..7f724393d 100644 --- a/docs/python-sdk.md +++ b/docs/python-sdk.md @@ -266,21 +266,10 @@ The config controls what nanobot may use. The workspace is where nanobot keeps state for that instance. See [multiple-instances.md](multiple-instances.md) for multi-instance CLI and gateway examples. -### Choose a default or per-run model +### Choose a default or per-run model preset -Set the SDK instance default model when you create the bot: - -```python -bot = Nanobot.from_config(model="openai/gpt-4.1") -``` - -Override the model for one run without changing the instance default: - -```python -result = await bot.run("Summarize this file", model="openai/gpt-4.1-mini") -``` - -Model presets from `config.json` work the same way: +Define complete model choices under `modelPresets` in `config.json`, then select +them by name for the SDK instance or for one run: ```python bot = Nanobot.from_config(model_preset="fast") @@ -288,7 +277,8 @@ bot = Nanobot.from_config(model_preset="fast") result = await bot.run("Think deeply about this bug", model_preset="reasoning") ``` -`model` and `model_preset` are mutually exclusive. +The public SDK accepts preset names rather than raw model IDs. This keeps provider, +generation, context-window, fallback, and image-input settings together. For first setup, prefer named presets in `config.json`. Mixing an API key from one provider with a model ID from another is the most common first-run failure. @@ -463,7 +453,7 @@ configuration docs remain the source of truth for the runtime around it: ## API Reference -### `Nanobot.from_config(config_path=None, *, workspace=None, model=None, model_preset=None)` +### `Nanobot.from_config(config_path=None, *, workspace=None, model_preset=None)` Create a `Nanobot` instance from a config file. @@ -471,11 +461,9 @@ Create a `Nanobot` instance from a config file. |-------|------|---------|-------------| | `config_path` | `str \| Path \| None` | `None` | Path to `config.json`. Defaults to `~/.nanobot/config.json`. | | `workspace` | `str \| Path \| None` | `None` | Override the workspace directory from config. | -| `model` | `str \| None` | `None` | Override the instance default model. | | `model_preset` | `str \| None` | `None` | Override the instance default model preset from `config.json`. | Raises `FileNotFoundError` if an explicit config path does not exist. -Raises `ValueError` if both `model` and `model_preset` are provided. ### `await bot.run(...)` @@ -492,13 +480,12 @@ Run the agent once and return a `RunResult`. | `ephemeral` | `bool` | `False` | Run without persisting the turn or compacting session history. | | `attributes` | `Mapping[str, Any] \| None` | `None` | Caller-owned request data for host integrations. It is available to context providers and turn-hook factories, but is not added to trusted message metadata or persisted in session messages. | | `hooks` | `list[AgentHook] \| None` | `None` | Lifecycle hooks for this run only. | -| `model` | `str \| None` | `None` | Override the model for this run only. | | `model_preset` | `str \| None` | `None` | Override the model preset for this run only. | Without an override, a run uses the preset saved in its session, or the configured -default when that session has no saved selection. `model` and `model_preset` are -mutually exclusive per-run overrides; they do not change the saved session selection -or `bot.runtime.model` after the run completes. +default when that session has no saved selection. A per-run `model_preset` override +does not change the saved session selection or `bot.runtime.model` after the run +completes. ### `await bot.run_streamed(...)` @@ -535,7 +522,7 @@ async for event in bot.stream("Generate a long answer"): | `await aclose()` | Close the stream; equivalent cleanup primitive for `async with` / manual lifecycle code. | SDK runs with different session keys may overlap, including runs with per-run -`model` or `model_preset` overrides. Each run receives an immutable runtime without +`model_preset` overrides. Each run receives an immutable runtime without mutating the instance default. Runs sharing one session key remain serialized. ### `StreamEvent` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f82ab7010..8af78c82b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -145,7 +145,7 @@ If you need a known-good snippet instead of diagnosis, use [`provider-cookbook.m |---|---| | 401, unauthorized, invalid API key | Key is missing, expired, pasted with whitespace, or under the wrong provider key. | | Model not found | The model ID belongs to a different provider or gateway. | -| Provider cannot be inferred | Pin `modelPresets..provider` in the active preset instead of using `"auto"`. For legacy direct configs, pin `agents.defaults.provider`. | +| Provider cannot be inferred | Pin `modelPresets..provider` in the active preset instead of using `"auto"`. | | Local model connection refused | Ollama, vLLM, LM Studio, or another local server is not running, or `apiBase` points to the wrong port. | | Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. | | OAuth provider fails | Run the matching login command: `openai-codex`, `xai-grok`, or `github-copilot`, normally with `--set-main`. | diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index c91530599..a60f33319 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -13,10 +13,7 @@ from nanobot.agent.loop import AgentLoop from nanobot.config.schema import Config from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient -from nanobot.sdk.runtime import ( - build_process_direct_kwargs, - ensure_single_model_selector, -) +from nanobot.sdk.runtime import build_process_direct_kwargs from nanobot.sdk.streaming import RunStream, SDKStreamEmitter, SDKStreamingHook from nanobot.sdk.types import ( STREAM_EVENT_REASONING_COMPLETED, @@ -84,7 +81,6 @@ class Nanobot: config_path: str | Path | None = None, *, workspace: str | Path | None = None, - model: str | None = None, model_preset: str | None = None, ) -> Nanobot: """Create a Nanobot instance from a config file. @@ -93,12 +89,10 @@ class Nanobot: config_path: Path to ``config.json``. Defaults to ``~/.nanobot/config.json``. workspace: Override the workspace directory from config. - model: Override the instance default model. model_preset: Override the instance default model preset. """ from nanobot.config.loader import load_config, resolve_config_env_vars - ensure_single_model_selector(model=model, model_preset=model_preset) resolved: Path | None = None if config_path is not None: resolved = Path(config_path).expanduser().resolve() @@ -113,12 +107,7 @@ class Nanobot: config.agents.defaults.workspace = str( Path(workspace).expanduser().resolve() ) - if model is not None: - config.model_presets["default"] = config.resolve_preset().model_copy( - update={"model": model, "provider": "auto"} - ) - config.agents.defaults.model_preset = "default" - elif model_preset is not None: + if model_preset is not None: config.agents.defaults.model_preset = model_preset loop = AgentLoop.from_config( @@ -140,7 +129,6 @@ class Nanobot: ephemeral: bool = False, attributes: Mapping[str, Any] | None = None, hooks: list[AgentHook] | None = None, - model: str | None = None, model_preset: str | None = None, ) -> RunResult: """Run the agent once and return the result. @@ -158,13 +146,12 @@ class Nanobot: providers and turn-hook factories. Attributes are kept separate from nanobot's trusted internal message metadata. hooks: Optional lifecycle hooks for this run. - model: Override the model for this run only. model_preset: Override the model preset for this run only. """ capture = SDKCaptureHook() per_run_hooks = [capture, *(hooks or [])] runtime = self._loop.runtime_resolver.resolve_override( - model=model, + model=None, model_preset=model_preset, config=self._config, ) @@ -199,12 +186,11 @@ class Nanobot: ephemeral: bool = False, attributes: Mapping[str, Any] | None = None, hooks: list[AgentHook] | None = None, - model: str | None = None, model_preset: str | None = None, ) -> RunStream: """Start a streamed run and return a handle for events and final result.""" override_runtime = self._loop.runtime_resolver.resolve_override( - model=model, + model=None, model_preset=model_preset, config=self._config, ) @@ -302,7 +288,6 @@ class Nanobot: ephemeral: bool = False, attributes: Mapping[str, Any] | None = None, hooks: list[AgentHook] | None = None, - model: str | None = None, model_preset: str | None = None, ) -> AsyncIterator[StreamEvent]: """Stream events for one agent turn.""" @@ -316,7 +301,6 @@ class Nanobot: ephemeral=ephemeral, attributes=attributes, hooks=hooks, - model=model, model_preset=model_preset, ) try: diff --git a/nanobot/sdk/runtime.py b/nanobot/sdk/runtime.py index 663905c72..bd4cabadf 100644 --- a/nanobot/sdk/runtime.py +++ b/nanobot/sdk/runtime.py @@ -6,15 +6,6 @@ from collections.abc import Mapping from typing import Any -def ensure_single_model_selector( - *, - model: str | None, - model_preset: str | None, -) -> None: - if model is not None and model_preset is not None: - raise ValueError("model and model_preset are mutually exclusive") - - def build_process_direct_kwargs( *, session_key: str, diff --git a/render-config.json b/render-config.json index f68c0ba9f..89ff3684d 100644 --- a/render-config.json +++ b/render-config.json @@ -1,8 +1,14 @@ { "agents": { "defaults": { + "modelPreset": "default" + } + }, + "modelPresets": { + "default": { "model": "anthropic/claude-opus-4-8", - "provider": "auto" + "provider": "auto", + "supportsImageInput": null } }, "providers": { diff --git a/tests/config/test_config_migration.py b/tests/config/test_config_migration.py index 4e9da3472..8898773b9 100644 --- a/tests/config/test_config_migration.py +++ b/tests/config/test_config_migration.py @@ -153,6 +153,62 @@ def test_load_config_rewrites_legacy_model_fields_to_default_preset(tmp_path) -> assert saved["modelPresets"]["default"]["temperature"] == 0 +def test_load_config_prefers_existing_default_preset_over_legacy_fields(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({ + "modelPresets": { + "default": { + "model": "anthropic/claude-opus-4-5", + "provider": "anthropic", + "maxTokens": 8192, + } + }, + "agents": { + "defaults": { + "model": "openai/gpt-4.1", + "provider": "openai", + "maxTokens": 4096, + } + }, + }), + encoding="utf-8", + ) + + config = load_config(config_path) + saved = json.loads(config_path.read_text(encoding="utf-8")) + + assert config.resolve_default_preset().model == "anthropic/claude-opus-4-5" + assert config.resolve_default_preset().provider == "anthropic" + assert config.resolve_default_preset().max_tokens == 8192 + assert saved["modelPresets"]["default"]["model"] == "anthropic/claude-opus-4-5" + assert "model" not in saved["agents"]["defaults"] + assert "provider" not in saved["agents"]["defaults"] + assert "maxTokens" not in saved["agents"]["defaults"] + + +def test_load_config_does_not_migrate_legacy_model_fields_from_environment( + tmp_path, + monkeypatch, +) -> None: + monkeypatch.setenv( + "NANOBOT_AGENTS", + json.dumps({ + "defaults": { + "model": "openai/gpt-4.1", + "provider": "openai", + "maxTokens": 4096, + } + }), + ) + + config = load_config(tmp_path / "missing-config.json") + + assert config.resolve_default_preset().model == "anthropic/claude-opus-4-5" + assert config.resolve_default_preset().provider == "auto" + assert config.resolve_default_preset().max_tokens == 8192 + + def test_load_config_migrates_inline_fallback_to_named_preset(tmp_path) -> None: config_path = tmp_path / "config.json" config_path.write_text( diff --git a/tests/test_nanobot_facade.py b/tests/test_nanobot_facade.py index 1c5ba27c0..53c21a2e0 100644 --- a/tests/test_nanobot_facade.py +++ b/tests/test_nanobot_facade.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import inspect import json from pathlib import Path from types import SimpleNamespace @@ -101,17 +102,16 @@ def test_from_config_creates_instance(tmp_path): assert bot._loop.workspace == tmp_path -def test_from_config_accepts_default_model_override(tmp_path): - config_path = _write_config(tmp_path) - - bot = Nanobot.from_config( - config_path, - workspace=tmp_path, - model="openai/gpt-4.1-mini", - ) - - assert bot.runtime.model == "openai/gpt-4.1-mini" - assert bot._loop.model_preset == "default" +def test_public_sdk_model_selection_uses_presets_only(): + for method in ( + Nanobot.from_config, + Nanobot.run, + Nanobot.run_streamed, + Nanobot.stream, + ): + parameters = inspect.signature(method).parameters + assert "model" not in parameters + assert "model_preset" in parameters def test_from_config_accepts_default_model_preset(tmp_path): @@ -133,18 +133,6 @@ def test_from_config_accepts_default_model_preset(tmp_path): assert bot._loop.model_preset == "fast" -def test_from_config_rejects_multiple_model_selectors(tmp_path): - config_path = _write_config(tmp_path) - - with pytest.raises(ValueError, match="mutually exclusive"): - Nanobot.from_config( - config_path, - workspace=tmp_path, - model="openai/gpt-4.1", - model_preset="fast", - ) - - def test_from_config_default_path(): from nanobot.config.schema import Config @@ -713,7 +701,7 @@ async def test_run_forwards_non_default_runtime_options(tmp_path): @pytest.mark.asyncio -async def test_run_allows_parallel_sessions_without_model_override(tmp_path): +async def test_run_allows_parallel_sessions_without_preset_override(tmp_path): from nanobot.bus.events import OutboundMessage config_path = _write_config(tmp_path) @@ -741,7 +729,7 @@ async def test_run_allows_parallel_sessions_without_model_override(tmp_path): @pytest.mark.asyncio -async def test_run_model_overrides_can_overlap_without_default_mutation(tmp_path): +async def test_run_preset_overrides_can_overlap_without_default_mutation(tmp_path): from nanobot.bus.events import OutboundMessage from nanobot.providers.factory import ProviderSnapshot @@ -755,14 +743,16 @@ async def test_run_model_overrides_can_overlap_without_default_mutation(tmp_path release_first = asyncio.Event() def fake_resolve(*, model, model_preset, config): - assert model is not None - assert model_preset is None + assert model is None + assert model_preset is not None assert config is bot._config + resolved_model = f"model:{model_preset}" return runtime_from_provider_snapshot(ProviderSnapshot( - provider=_fake_provider(model, max_tokens=2048), - model=model, + provider=_fake_provider(resolved_model, max_tokens=2048), + model=resolved_model, context_window_tokens=4096, - signature=("sdk", model), + signature=("sdk", model_preset), + model_preset=model_preset, )) bot._loop.runtime_resolver.resolve_override = MagicMock(side_effect=fake_resolve) @@ -782,14 +772,14 @@ async def test_run_model_overrides_can_overlap_without_default_mutation(tmp_path first = asyncio.create_task(bot.run( "first", session_key="sdk:first", - model="model:first", + model_preset="first", )) await asyncio.wait_for(first_entered.wait(), timeout=1) second = asyncio.create_task(bot.run( "second", session_key="sdk:second", - model="model:second", + model_preset="second", )) await asyncio.wait_for(both_entered.wait(), timeout=1) assert not first.done() @@ -806,49 +796,6 @@ async def test_run_model_overrides_can_overlap_without_default_mutation(tmp_path } assert bot._loop.runtime_resolver.runtime is original_runtime - -@pytest.mark.asyncio -async def test_run_model_override_is_per_run_without_default_mutation(tmp_path): - from nanobot.bus.events import OutboundMessage - from nanobot.providers.factory import ProviderSnapshot - - config_path = _write_config(tmp_path) - bot = Nanobot.from_config(config_path, workspace=tmp_path) - original_runtime = bot._loop.runtime_resolver.runtime - override_provider = _fake_provider("override-provider", max_tokens=2048) - override = ProviderSnapshot( - provider=override_provider, - model="openai/gpt-4.1-mini", - context_window_tokens=4096, - signature=("sdk", "override"), - ) - override_runtime = runtime_from_provider_snapshot(override) - bot._loop.runtime_resolver.resolve_override = MagicMock( - return_value=override_runtime - ) - - async def fake_process_direct(message, *, session_key, hooks, runtime): - assert runtime is override_runtime - assert not hasattr(bot._loop.runner, "provider") - assert runtime.model == "openai/gpt-4.1-mini" - assert runtime.context_window_tokens == 4096 - assert bot._loop.runtime_resolver.runtime is original_runtime - return OutboundMessage(channel="cli", chat_id="direct", content="ok") - - bot._loop.process_direct = fake_process_direct - - result = await bot.run("hi", model="openai/gpt-4.1-mini") - - assert result.content == "ok" - bot._loop.runtime_resolver.resolve_override.assert_called_once_with( - model="openai/gpt-4.1-mini", - model_preset=None, - config=bot._config, - ) - assert not hasattr(bot._loop.runner, "provider") - assert bot._loop.runtime_resolver.runtime is original_runtime - - @pytest.mark.asyncio async def test_run_model_preset_override_is_per_run(tmp_path): from nanobot.bus.events import OutboundMessage @@ -887,15 +834,6 @@ async def test_run_model_preset_override_is_per_run(tmp_path): assert bot._loop.model_preset == "default" -@pytest.mark.asyncio -async def test_run_rejects_multiple_model_selectors(tmp_path): - config_path = _write_config(tmp_path) - bot = Nanobot.from_config(config_path, workspace=tmp_path) - - with pytest.raises(ValueError, match="mutually exclusive"): - await bot.run("hi", model="openai/gpt-4.1", model_preset="fast") - - @pytest.mark.asyncio async def test_run_user_hooks_still_fire_alongside_capture(tmp_path): """Capture hook must not displace user-provided hooks.""" @@ -1165,7 +1103,7 @@ async def test_run_streamed_forwards_runtime_options(tmp_path): @pytest.mark.asyncio -async def test_run_streamed_model_override_reports_admitted_runtime(tmp_path): +async def test_run_streamed_preset_override_reports_admitted_runtime(tmp_path): from nanobot.bus.events import OutboundMessage from nanobot.providers.factory import ProviderSnapshot @@ -1178,6 +1116,7 @@ async def test_run_streamed_model_override_reports_admitted_runtime(tmp_path): model="openai/gpt-4.1-mini", context_window_tokens=4096, signature=("sdk", "stream"), + model_preset="fast", ) override_runtime = runtime_from_provider_snapshot(override) bot._loop.runtime_resolver.resolve_override = MagicMock( @@ -1203,30 +1142,17 @@ async def test_run_streamed_model_override_reports_admitted_runtime(tmp_path): bot._loop.process_direct = fake_process_direct - run = await bot.run_streamed("hi", model="openai/gpt-4.1-mini") + run = await bot.run_streamed("hi", model_preset="fast") events = [event async for event in run.stream_events()] result = await run.wait() assert result.content == "ok" assert events[0].type == "run.started" assert events[0].metadata["model"] == "openai/gpt-4.1-mini" - assert events[0].metadata["model_preset"] is None + assert events[0].metadata["model_preset"] == "fast" assert bot._loop.runtime_resolver.runtime is original_runtime -@pytest.mark.asyncio -async def test_stream_rejects_multiple_model_selectors(tmp_path): - config_path = _write_config(tmp_path) - bot = Nanobot.from_config(config_path, workspace=tmp_path) - - with pytest.raises(ValueError, match="mutually exclusive"): - _ = [event async for event in bot.stream( - "hi", - model="openai/gpt-4.1", - model_preset="fast", - )] - - @pytest.mark.asyncio async def test_run_streamed_emits_tool_events(tmp_path): from nanobot.agent.hook import AgentHookContext