mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
fix(config): enforce preset-only model selection
This commit is contained in:
parent
f239b45900
commit
8bd53d6e26
@ -59,7 +59,7 @@ Provider metadata is centralized in `nanobot/providers/registry.py`. Configurati
|
|||||||
|
|
||||||
Provider selection uses:
|
Provider selection uses:
|
||||||
|
|
||||||
- explicit `agents.defaults.provider` or preset provider;
|
- the active model preset's explicit provider;
|
||||||
- provider registry keywords;
|
- provider registry keywords;
|
||||||
- API key prefixes and API base URL hints;
|
- API key prefixes and API base URL hints;
|
||||||
- local provider fallback when `apiBase` is configured;
|
- local provider fallback when `apiBase` is configured;
|
||||||
|
|||||||
@ -57,7 +57,7 @@ To switch presets for future turns:
|
|||||||
/model default
|
/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
|
## Local triggers
|
||||||
|
|
||||||
|
|||||||
@ -87,9 +87,9 @@ The WebUI launcher is the normal browser entry point. Underneath, the gateway ke
|
|||||||
|
|
||||||
## Provider and Model Selection
|
## 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.
|
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.
|
3. OAuth providers such as OpenAI Codex and GitHub Copilot require explicit login and explicit provider/model selection inside the active preset.
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
> - **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`.
|
> - **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.
|
> - **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.
|
> - **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"`.
|
> - **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.<name>.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`.
|
> - **Provider-scoped proxy**: `providers.<name>.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
|
||||||
|
|
||||||
Model presets let you name a complete model configuration and select one per session with `/model <preset>`. 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 <preset>`. 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
|
```json
|
||||||
{
|
{
|
||||||
"modelPresets": {
|
|
||||||
"fast": {
|
|
||||||
"provider": "openrouter",
|
|
||||||
"model": "anthropic/claude-sonnet-4.5",
|
|
||||||
"maxTokens": 4096,
|
|
||||||
"contextWindowTokens": 65536
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"modelPreset": "fast",
|
"modelPreset": "fast",
|
||||||
@ -1367,6 +1359,14 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {
|
||||||
|
"label": "Default",
|
||||||
|
"model": "claude-opus-4-5",
|
||||||
|
"provider": "anthropic",
|
||||||
|
"maxTokens": 8192,
|
||||||
|
"contextWindowTokens": 200000,
|
||||||
|
"supportsImageInput": true
|
||||||
|
},
|
||||||
"fast": {
|
"fast": {
|
||||||
"label": "Fast",
|
"label": "Fast",
|
||||||
"model": "gpt-4.1-mini",
|
"model": "gpt-4.1-mini",
|
||||||
@ -1374,7 +1374,8 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide
|
|||||||
"maxTokens": 4096,
|
"maxTokens": 4096,
|
||||||
"contextWindowTokens": 128000,
|
"contextWindowTokens": 128000,
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"reasoningEffort": "low"
|
"reasoningEffort": "low",
|
||||||
|
"supportsImageInput": true
|
||||||
},
|
},
|
||||||
"deep": {
|
"deep": {
|
||||||
"label": "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 |
|
| 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. |
|
| `contextWindowTokens` | Context window size used by prompt building and consolidation decisions. |
|
||||||
| `temperature` | Sampling temperature. |
|
| `temperature` | Sampling temperature. |
|
||||||
| `reasoningEffort` | Optional reasoning/thinking setting. Provider support varies. |
|
| `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 <preset>` 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 <preset>` 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
|
### 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:
|
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.
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
Preset fallback chain:
|
Preset fallback chain:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {
|
||||||
|
"model": "gpt-4.1-mini",
|
||||||
|
"provider": "openai",
|
||||||
|
"maxTokens": 4096,
|
||||||
|
"contextWindowTokens": 128000,
|
||||||
|
"temperature": 0.2
|
||||||
|
},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "gpt-4.1-mini",
|
"model": "gpt-4.1-mini",
|
||||||
"provider": "openai",
|
"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 <preset>` switching and any fallback chain that references it.
|
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 <preset>` switching and any fallback chain that references it. `fallbackModels` belongs under `agents.defaults`, not inside individual `modelPresets` entries.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,7 @@ Match the recipe to the credential or endpoint you already have:
|
|||||||
5. Run `nanobot agent -m "Hello!"`.
|
5. Run `nanobot agent -m "Hello!"`.
|
||||||
6. If the CLI works, then connect WebUI, gateway, or chat apps.
|
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
|
## Secret Setup
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ For every setup, answer three questions:
|
|||||||
2. What model name does that provider expect?
|
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?
|
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
|
## Choose a Provider Without Guessing
|
||||||
|
|
||||||
@ -462,14 +462,14 @@ Each command authenticates the selected provider and makes its current default m
|
|||||||
|
|
||||||
## Provider Resolution
|
## 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`;
|
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:
|
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.
|
- `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.
|
- 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.
|
- 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
|
```json
|
||||||
{
|
{
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {
|
||||||
|
"label": "Default",
|
||||||
|
"provider": "anthropic",
|
||||||
|
"model": "claude-opus-4-5",
|
||||||
|
"maxTokens": 8192,
|
||||||
|
"contextWindowTokens": 200000,
|
||||||
|
"temperature": 0.1
|
||||||
|
},
|
||||||
"fast": {
|
"fast": {
|
||||||
"label": "Fast",
|
"label": "Fast",
|
||||||
"provider": "openrouter",
|
"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
|
## Fallback Models
|
||||||
|
|
||||||
@ -525,6 +533,14 @@ Fallbacks are useful for transient provider failures, rate limits, or model avai
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {
|
||||||
|
"label": "Default",
|
||||||
|
"provider": "openrouter",
|
||||||
|
"model": "anthropic/claude-sonnet-4.5",
|
||||||
|
"maxTokens": 4096,
|
||||||
|
"contextWindowTokens": 65536,
|
||||||
|
"temperature": 0.1
|
||||||
|
},
|
||||||
"fast": {
|
"fast": {
|
||||||
"label": "Fast",
|
"label": "Fast",
|
||||||
"provider": "openrouter",
|
"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`.
|
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.
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`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.
|
`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.
|
||||||
|
|
||||||
|
|||||||
@ -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
|
state for that instance. See [multiple-instances.md](multiple-instances.md) for
|
||||||
multi-instance CLI and gateway examples.
|
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:
|
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="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:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
bot = Nanobot.from_config(model_preset="fast")
|
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")
|
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
|
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.
|
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
|
## 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.
|
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`. |
|
| `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. |
|
| `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`. |
|
| `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 `FileNotFoundError` if an explicit config path does not exist.
|
||||||
Raises `ValueError` if both `model` and `model_preset` are provided.
|
|
||||||
|
|
||||||
### `await bot.run(...)`
|
### `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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
|
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
|
default when that session has no saved selection. A per-run `model_preset` override
|
||||||
mutually exclusive per-run overrides; they do not change the saved session selection
|
does not change the saved session selection or `bot.runtime.model` after the run
|
||||||
or `bot.runtime.model` after the run completes.
|
completes.
|
||||||
|
|
||||||
### `await bot.run_streamed(...)`
|
### `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. |
|
| `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
|
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.
|
mutating the instance default. Runs sharing one session key remain serialized.
|
||||||
|
|
||||||
### `StreamEvent`
|
### `StreamEvent`
|
||||||
|
|||||||
@ -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. |
|
| 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. |
|
| Model not found | The model ID belongs to a different provider or gateway. |
|
||||||
| Provider cannot be inferred | Pin `modelPresets.<name>.provider` in the active preset instead of using `"auto"`. For legacy direct configs, pin `agents.defaults.provider`. |
|
| Provider cannot be inferred | Pin `modelPresets.<name>.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. |
|
| 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. |
|
| 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`. |
|
| OAuth provider fails | Run the matching login command: `openai-codex`, `xai-grok`, or `github-copilot`, normally with `--set-main`. |
|
||||||
|
|||||||
@ -13,10 +13,7 @@ from nanobot.agent.loop import AgentLoop
|
|||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient
|
from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient
|
||||||
from nanobot.sdk.runtime import (
|
from nanobot.sdk.runtime import build_process_direct_kwargs
|
||||||
build_process_direct_kwargs,
|
|
||||||
ensure_single_model_selector,
|
|
||||||
)
|
|
||||||
from nanobot.sdk.streaming import RunStream, SDKStreamEmitter, SDKStreamingHook
|
from nanobot.sdk.streaming import RunStream, SDKStreamEmitter, SDKStreamingHook
|
||||||
from nanobot.sdk.types import (
|
from nanobot.sdk.types import (
|
||||||
STREAM_EVENT_REASONING_COMPLETED,
|
STREAM_EVENT_REASONING_COMPLETED,
|
||||||
@ -84,7 +81,6 @@ class Nanobot:
|
|||||||
config_path: str | Path | None = None,
|
config_path: str | Path | None = None,
|
||||||
*,
|
*,
|
||||||
workspace: str | Path | None = None,
|
workspace: str | Path | None = None,
|
||||||
model: str | None = None,
|
|
||||||
model_preset: str | None = None,
|
model_preset: str | None = None,
|
||||||
) -> Nanobot:
|
) -> Nanobot:
|
||||||
"""Create a Nanobot instance from a config file.
|
"""Create a Nanobot instance from a config file.
|
||||||
@ -93,12 +89,10 @@ class Nanobot:
|
|||||||
config_path: Path to ``config.json``. Defaults to
|
config_path: Path to ``config.json``. Defaults to
|
||||||
``~/.nanobot/config.json``.
|
``~/.nanobot/config.json``.
|
||||||
workspace: Override the workspace directory from config.
|
workspace: Override the workspace directory from config.
|
||||||
model: Override the instance default model.
|
|
||||||
model_preset: Override the instance default model preset.
|
model_preset: Override the instance default model preset.
|
||||||
"""
|
"""
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
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
|
resolved: Path | None = None
|
||||||
if config_path is not None:
|
if config_path is not None:
|
||||||
resolved = Path(config_path).expanduser().resolve()
|
resolved = Path(config_path).expanduser().resolve()
|
||||||
@ -113,12 +107,7 @@ class Nanobot:
|
|||||||
config.agents.defaults.workspace = str(
|
config.agents.defaults.workspace = str(
|
||||||
Path(workspace).expanduser().resolve()
|
Path(workspace).expanduser().resolve()
|
||||||
)
|
)
|
||||||
if model is not None:
|
if model_preset 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:
|
|
||||||
config.agents.defaults.model_preset = model_preset
|
config.agents.defaults.model_preset = model_preset
|
||||||
|
|
||||||
loop = AgentLoop.from_config(
|
loop = AgentLoop.from_config(
|
||||||
@ -140,7 +129,6 @@ class Nanobot:
|
|||||||
ephemeral: bool = False,
|
ephemeral: bool = False,
|
||||||
attributes: Mapping[str, Any] | None = None,
|
attributes: Mapping[str, Any] | None = None,
|
||||||
hooks: list[AgentHook] | None = None,
|
hooks: list[AgentHook] | None = None,
|
||||||
model: str | None = None,
|
|
||||||
model_preset: str | None = None,
|
model_preset: str | None = None,
|
||||||
) -> RunResult:
|
) -> RunResult:
|
||||||
"""Run the agent once and return the result.
|
"""Run the agent once and return the result.
|
||||||
@ -158,13 +146,12 @@ class Nanobot:
|
|||||||
providers and turn-hook factories. Attributes are kept separate
|
providers and turn-hook factories. Attributes are kept separate
|
||||||
from nanobot's trusted internal message metadata.
|
from nanobot's trusted internal message metadata.
|
||||||
hooks: Optional lifecycle hooks for this run.
|
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.
|
model_preset: Override the model preset for this run only.
|
||||||
"""
|
"""
|
||||||
capture = SDKCaptureHook()
|
capture = SDKCaptureHook()
|
||||||
per_run_hooks = [capture, *(hooks or [])]
|
per_run_hooks = [capture, *(hooks or [])]
|
||||||
runtime = self._loop.runtime_resolver.resolve_override(
|
runtime = self._loop.runtime_resolver.resolve_override(
|
||||||
model=model,
|
model=None,
|
||||||
model_preset=model_preset,
|
model_preset=model_preset,
|
||||||
config=self._config,
|
config=self._config,
|
||||||
)
|
)
|
||||||
@ -199,12 +186,11 @@ class Nanobot:
|
|||||||
ephemeral: bool = False,
|
ephemeral: bool = False,
|
||||||
attributes: Mapping[str, Any] | None = None,
|
attributes: Mapping[str, Any] | None = None,
|
||||||
hooks: list[AgentHook] | None = None,
|
hooks: list[AgentHook] | None = None,
|
||||||
model: str | None = None,
|
|
||||||
model_preset: str | None = None,
|
model_preset: str | None = None,
|
||||||
) -> RunStream:
|
) -> RunStream:
|
||||||
"""Start a streamed run and return a handle for events and final result."""
|
"""Start a streamed run and return a handle for events and final result."""
|
||||||
override_runtime = self._loop.runtime_resolver.resolve_override(
|
override_runtime = self._loop.runtime_resolver.resolve_override(
|
||||||
model=model,
|
model=None,
|
||||||
model_preset=model_preset,
|
model_preset=model_preset,
|
||||||
config=self._config,
|
config=self._config,
|
||||||
)
|
)
|
||||||
@ -302,7 +288,6 @@ class Nanobot:
|
|||||||
ephemeral: bool = False,
|
ephemeral: bool = False,
|
||||||
attributes: Mapping[str, Any] | None = None,
|
attributes: Mapping[str, Any] | None = None,
|
||||||
hooks: list[AgentHook] | None = None,
|
hooks: list[AgentHook] | None = None,
|
||||||
model: str | None = None,
|
|
||||||
model_preset: str | None = None,
|
model_preset: str | None = None,
|
||||||
) -> AsyncIterator[StreamEvent]:
|
) -> AsyncIterator[StreamEvent]:
|
||||||
"""Stream events for one agent turn."""
|
"""Stream events for one agent turn."""
|
||||||
@ -316,7 +301,6 @@ class Nanobot:
|
|||||||
ephemeral=ephemeral,
|
ephemeral=ephemeral,
|
||||||
attributes=attributes,
|
attributes=attributes,
|
||||||
hooks=hooks,
|
hooks=hooks,
|
||||||
model=model,
|
|
||||||
model_preset=model_preset,
|
model_preset=model_preset,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -6,15 +6,6 @@ from collections.abc import Mapping
|
|||||||
from typing import Any
|
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(
|
def build_process_direct_kwargs(
|
||||||
*,
|
*,
|
||||||
session_key: str,
|
session_key: str,
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
|
"modelPreset": "default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"modelPresets": {
|
||||||
|
"default": {
|
||||||
"model": "anthropic/claude-opus-4-8",
|
"model": "anthropic/claude-opus-4-8",
|
||||||
"provider": "auto"
|
"provider": "auto",
|
||||||
|
"supportsImageInput": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|||||||
@ -153,6 +153,62 @@ def test_load_config_rewrites_legacy_model_fields_to_default_preset(tmp_path) ->
|
|||||||
assert saved["modelPresets"]["default"]["temperature"] == 0
|
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:
|
def test_load_config_migrates_inline_fallback_to_named_preset(tmp_path) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@ -101,17 +102,16 @@ def test_from_config_creates_instance(tmp_path):
|
|||||||
assert bot._loop.workspace == tmp_path
|
assert bot._loop.workspace == tmp_path
|
||||||
|
|
||||||
|
|
||||||
def test_from_config_accepts_default_model_override(tmp_path):
|
def test_public_sdk_model_selection_uses_presets_only():
|
||||||
config_path = _write_config(tmp_path)
|
for method in (
|
||||||
|
Nanobot.from_config,
|
||||||
bot = Nanobot.from_config(
|
Nanobot.run,
|
||||||
config_path,
|
Nanobot.run_streamed,
|
||||||
workspace=tmp_path,
|
Nanobot.stream,
|
||||||
model="openai/gpt-4.1-mini",
|
):
|
||||||
)
|
parameters = inspect.signature(method).parameters
|
||||||
|
assert "model" not in parameters
|
||||||
assert bot.runtime.model == "openai/gpt-4.1-mini"
|
assert "model_preset" in parameters
|
||||||
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):
|
||||||
@ -133,18 +133,6 @@ def test_from_config_accepts_default_model_preset(tmp_path):
|
|||||||
assert bot._loop.model_preset == "fast"
|
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():
|
def test_from_config_default_path():
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
@ -713,7 +701,7 @@ async def test_run_forwards_non_default_runtime_options(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
|
||||||
config_path = _write_config(tmp_path)
|
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
|
@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.bus.events import OutboundMessage
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
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()
|
release_first = asyncio.Event()
|
||||||
|
|
||||||
def fake_resolve(*, model, model_preset, config):
|
def fake_resolve(*, model, model_preset, config):
|
||||||
assert model is not None
|
assert model is None
|
||||||
assert model_preset is None
|
assert model_preset is not None
|
||||||
assert config is bot._config
|
assert config is bot._config
|
||||||
|
resolved_model = f"model:{model_preset}"
|
||||||
return runtime_from_provider_snapshot(ProviderSnapshot(
|
return runtime_from_provider_snapshot(ProviderSnapshot(
|
||||||
provider=_fake_provider(model, max_tokens=2048),
|
provider=_fake_provider(resolved_model, max_tokens=2048),
|
||||||
model=model,
|
model=resolved_model,
|
||||||
context_window_tokens=4096,
|
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)
|
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 = asyncio.create_task(bot.run(
|
||||||
"first",
|
"first",
|
||||||
session_key="sdk:first",
|
session_key="sdk:first",
|
||||||
model="model:first",
|
model_preset="first",
|
||||||
))
|
))
|
||||||
await asyncio.wait_for(first_entered.wait(), timeout=1)
|
await asyncio.wait_for(first_entered.wait(), timeout=1)
|
||||||
|
|
||||||
second = asyncio.create_task(bot.run(
|
second = asyncio.create_task(bot.run(
|
||||||
"second",
|
"second",
|
||||||
session_key="sdk:second",
|
session_key="sdk:second",
|
||||||
model="model:second",
|
model_preset="second",
|
||||||
))
|
))
|
||||||
await asyncio.wait_for(both_entered.wait(), timeout=1)
|
await asyncio.wait_for(both_entered.wait(), timeout=1)
|
||||||
assert not first.done()
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_run_model_preset_override_is_per_run(tmp_path):
|
async def test_run_model_preset_override_is_per_run(tmp_path):
|
||||||
from nanobot.bus.events import OutboundMessage
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_run_user_hooks_still_fire_alongside_capture(tmp_path):
|
async def test_run_user_hooks_still_fire_alongside_capture(tmp_path):
|
||||||
"""Capture hook must not displace user-provided hooks."""
|
"""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
|
@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.bus.events import OutboundMessage
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
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",
|
model="openai/gpt-4.1-mini",
|
||||||
context_window_tokens=4096,
|
context_window_tokens=4096,
|
||||||
signature=("sdk", "stream"),
|
signature=("sdk", "stream"),
|
||||||
|
model_preset="fast",
|
||||||
)
|
)
|
||||||
override_runtime = runtime_from_provider_snapshot(override)
|
override_runtime = runtime_from_provider_snapshot(override)
|
||||||
bot._loop.runtime_resolver.resolve_override = MagicMock(
|
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
|
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()]
|
events = [event async for event in run.stream_events()]
|
||||||
result = await run.wait()
|
result = await run.wait()
|
||||||
|
|
||||||
assert result.content == "ok"
|
assert result.content == "ok"
|
||||||
assert events[0].type == "run.started"
|
assert events[0].type == "run.started"
|
||||||
assert events[0].metadata["model"] == "openai/gpt-4.1-mini"
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_run_streamed_emits_tool_events(tmp_path):
|
async def test_run_streamed_emits_tool_events(tmp_path):
|
||||||
from nanobot.agent.hook import AgentHookContext
|
from nanobot.agent.hook import AgentHookContext
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user