mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ed4ccb68e | ||
|
|
4e7c57eb1a | ||
|
|
8bd53d6e26 | ||
|
|
f239b45900 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+27
-51
@@ -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 and logs a warning. If a concrete `modelPresets.default` and legacy direct fields both exist, the concrete preset wins and the warning explains that the conflicting legacy fields were removed. Legacy model fields supplied through nested `NANOBOT_AGENTS` environment settings are not supported and produce a warning with instructions to move them into `modelPresets`.
|
||||||
|
|
||||||
```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
|
||||||
|
|
||||||
|
|||||||
+22
-34
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+10
-23
@@ -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`. |
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ from nanobot.apps.cli import utils as cli_app_utils
|
|||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_END,
|
RUNTIME_CONTEXT_END,
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
RUNTIME_CONTEXT_MESSAGE_META,
|
RUNTIME_CONTEXT_MESSAGE_META,
|
||||||
RUNTIME_CONTEXT_TAG,
|
RUNTIME_CONTEXT_TAG,
|
||||||
RuntimeContextBlock,
|
RuntimeContextBlock,
|
||||||
append_runtime_context,
|
append_runtime_context,
|
||||||
|
detach_runtime_context,
|
||||||
|
reattach_runtime_context,
|
||||||
)
|
)
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
detect_image_mime,
|
detect_image_mime,
|
||||||
@@ -60,6 +63,9 @@ class ContextBuilder:
|
|||||||
_MAX_RECENT_HISTORY = 50
|
_MAX_RECENT_HISTORY = 50
|
||||||
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
||||||
_RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END
|
_RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END
|
||||||
|
_MISSING_IMAGE_TEXT = (
|
||||||
|
"[Image attachment unavailable — do not describe or reference it]"
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
@@ -224,7 +230,7 @@ class ContextBuilder:
|
|||||||
unified_session=unified_session,
|
unified_session=unified_session,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
*history,
|
*self._hydrate_history_media(history),
|
||||||
]
|
]
|
||||||
if messages[-1].get("role") == current_role:
|
if messages[-1].get("role") == current_role:
|
||||||
last = dict(messages[-1])
|
last = dict(messages[-1])
|
||||||
@@ -254,6 +260,9 @@ class ContextBuilder:
|
|||||||
for path in image_paths:
|
for path in image_paths:
|
||||||
p = Path(path)
|
p = Path(path)
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
|
image_blocks.append(
|
||||||
|
{"type": "text", "text": self._MISSING_IMAGE_TEXT}
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
raw = p.read_bytes()
|
raw = p.read_bytes()
|
||||||
# Re-detect from the bytes used for the request: the file may have
|
# Re-detect from the bytes used for the request: the file may have
|
||||||
@@ -271,3 +280,45 @@ class ContextBuilder:
|
|||||||
if not image_blocks:
|
if not image_blocks:
|
||||||
return text
|
return text
|
||||||
return image_blocks + [{"type": "text", "text": text}]
|
return image_blocks + [{"type": "text", "text": text}]
|
||||||
|
|
||||||
|
def _hydrate_history_media(
|
||||||
|
self,
|
||||||
|
history: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Rebuild persisted user media into the same blocks used on first send."""
|
||||||
|
hydrated: list[dict[str, Any]] = []
|
||||||
|
for message in history:
|
||||||
|
clean = dict(message)
|
||||||
|
media_paths = clean.pop("_media_paths", None)
|
||||||
|
runtime_context = clean.pop(RUNTIME_CONTEXT_HISTORY_META, None)
|
||||||
|
if (
|
||||||
|
clean.get("role") == "user"
|
||||||
|
and isinstance(clean.get("content"), str)
|
||||||
|
and isinstance(media_paths, list)
|
||||||
|
and media_paths
|
||||||
|
):
|
||||||
|
visible_content = clean["content"]
|
||||||
|
detached = (
|
||||||
|
detach_runtime_context(visible_content, runtime_context)
|
||||||
|
if isinstance(runtime_context, Mapping)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if detached is not None:
|
||||||
|
visible_content, sources, context_blocks = detached
|
||||||
|
hydrated_content = self.build_user_content(
|
||||||
|
visible_content,
|
||||||
|
image_paths=[
|
||||||
|
path
|
||||||
|
for path in media_paths
|
||||||
|
if isinstance(path, str) and path
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if detached is not None:
|
||||||
|
hydrated_content, _ = reattach_runtime_context(
|
||||||
|
hydrated_content,
|
||||||
|
sources,
|
||||||
|
context_blocks,
|
||||||
|
)
|
||||||
|
clean["content"] = hydrated_content
|
||||||
|
hydrated.append(clean)
|
||||||
|
return hydrated
|
||||||
|
|||||||
+12
-6
@@ -299,7 +299,7 @@ class AgentLoop:
|
|||||||
initial_context_window = (
|
initial_context_window = (
|
||||||
context_window_tokens
|
context_window_tokens
|
||||||
if context_window_tokens is not None
|
if context_window_tokens is not None
|
||||||
else defaults.context_window_tokens
|
else ModelPresetConfig(model=initial_model).context_window_tokens
|
||||||
)
|
)
|
||||||
configured_presets = model_presets or {}
|
configured_presets = model_presets or {}
|
||||||
self.runtime_resolver = ModelRuntimeResolver(
|
self.runtime_resolver = ModelRuntimeResolver(
|
||||||
@@ -445,15 +445,20 @@ class AgentLoop:
|
|||||||
if bus is None:
|
if bus is None:
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
provider = extra.pop("provider", None) or make_provider(config)
|
explicit_provider = extra.pop("provider", None)
|
||||||
|
provider = explicit_provider or make_provider(config)
|
||||||
resolved = config.resolve_preset()
|
resolved = config.resolve_preset()
|
||||||
model = extra.pop("model", None) or resolved.model
|
model = extra.pop("model", None) or resolved.model
|
||||||
context_window_tokens = extra.pop("context_window_tokens", None) or resolved.context_window_tokens
|
context_window_tokens = extra.pop("context_window_tokens", None) or resolved.context_window_tokens
|
||||||
provider_snapshot_loader = extra.pop("provider_snapshot_loader", None)
|
provider_snapshot_loader = extra.pop("provider_snapshot_loader", None)
|
||||||
preset_snapshot_loader = extra.pop("preset_snapshot_loader", None) or preset_helpers.make_preset_snapshot_loader(
|
preset_snapshot_loader = extra.pop("preset_snapshot_loader", None)
|
||||||
config,
|
if preset_snapshot_loader is None and (
|
||||||
provider_snapshot_loader,
|
explicit_provider is None or provider_snapshot_loader is not None
|
||||||
)
|
):
|
||||||
|
preset_snapshot_loader = preset_helpers.make_preset_snapshot_loader(
|
||||||
|
config,
|
||||||
|
provider_snapshot_loader,
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
@@ -1616,6 +1621,7 @@ class AgentLoop:
|
|||||||
"max_messages": replay_max_messages,
|
"max_messages": replay_max_messages,
|
||||||
"max_tokens": self._replay_token_budget(runtime),
|
"max_tokens": self._replay_token_budget(runtime),
|
||||||
"extend_to_user": is_subagent,
|
"extend_to_user": is_subagent,
|
||||||
|
"include_media": True,
|
||||||
}
|
}
|
||||||
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
ctx.history = ctx.session.get_history(**_hist_kwargs)
|
||||||
if is_subagent:
|
if is_subagent:
|
||||||
|
|||||||
+58
-6
@@ -19,10 +19,12 @@ from nanobot.runtime_context import public_history_messages
|
|||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.gitstore import GitStore
|
from nanobot.utils.gitstore import GitStore
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
|
content_with_media_breadcrumbs,
|
||||||
ensure_dir,
|
ensure_dir,
|
||||||
estimate_message_tokens,
|
estimate_message_tokens,
|
||||||
estimate_prompt_tokens_chain,
|
estimate_prompt_tokens_chain,
|
||||||
find_legal_message_start,
|
find_legal_message_start,
|
||||||
|
image_placeholder_text,
|
||||||
recent_message_start_index,
|
recent_message_start_index,
|
||||||
strip_think,
|
strip_think,
|
||||||
truncate_text,
|
truncate_text,
|
||||||
@@ -695,14 +697,58 @@ class MemoryStore:
|
|||||||
def _format_messages(messages: list[dict]) -> str:
|
def _format_messages(messages: list[dict]) -> str:
|
||||||
lines = []
|
lines = []
|
||||||
for message in messages:
|
for message in messages:
|
||||||
if not message.get("content"):
|
content = message.get("content") or ""
|
||||||
|
media = message.get("media")
|
||||||
|
media_paths = (
|
||||||
|
[
|
||||||
|
path.replace("\r", " ").replace("\n", " ")
|
||||||
|
for path in media[:16]
|
||||||
|
if isinstance(path, str) and path
|
||||||
|
]
|
||||||
|
if isinstance(media, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
content = content_with_media_breadcrumbs(
|
||||||
|
message.get("role"),
|
||||||
|
content,
|
||||||
|
media_paths,
|
||||||
|
)
|
||||||
|
if not content:
|
||||||
continue
|
continue
|
||||||
tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else ""
|
tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else ""
|
||||||
lines.append(
|
lines.append(
|
||||||
f"[{message.get('timestamp', '?')[:16]}] {message['role'].upper()}{tools}: {message['content']}"
|
f"[{message.get('timestamp', '?')[:16]}] "
|
||||||
|
f"{message['role'].upper()}{tools}: {content}"
|
||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _media_manifest(messages: list[dict]) -> str:
|
||||||
|
paths: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for message in messages:
|
||||||
|
media = message.get("media")
|
||||||
|
if not isinstance(media, list):
|
||||||
|
continue
|
||||||
|
for raw_path in media:
|
||||||
|
if not isinstance(raw_path, str) or not raw_path:
|
||||||
|
continue
|
||||||
|
path = raw_path.replace("\r", " ").replace("\n", " ")
|
||||||
|
if path in seen:
|
||||||
|
continue
|
||||||
|
seen.add(path)
|
||||||
|
paths.append(path)
|
||||||
|
if len(paths) >= 64:
|
||||||
|
break
|
||||||
|
if len(paths) >= 64:
|
||||||
|
break
|
||||||
|
if not paths:
|
||||||
|
return ""
|
||||||
|
return "Archived attachments:\n" + "\n".join(
|
||||||
|
f"- {image_placeholder_text(path)}"
|
||||||
|
for path in paths
|
||||||
|
)
|
||||||
|
|
||||||
def raw_archive(
|
def raw_archive(
|
||||||
self,
|
self,
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
@@ -712,10 +758,11 @@ class MemoryStore:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
|
"""Fallback: dump raw messages to history.jsonl without LLM summarization."""
|
||||||
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
|
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
|
||||||
formatted = truncate_text(
|
formatted = self._format_messages(public_history_messages(messages))
|
||||||
self._format_messages(public_history_messages(messages)),
|
manifest = self._media_manifest(messages)
|
||||||
limit,
|
if manifest:
|
||||||
)
|
formatted = f"{manifest}\n\n{formatted}"
|
||||||
|
formatted = truncate_text(formatted, limit)
|
||||||
self.append_history(
|
self.append_history(
|
||||||
f"[RAW] {len(messages)} messages\n"
|
f"[RAW] {len(messages)} messages\n"
|
||||||
f"{formatted}",
|
f"{formatted}",
|
||||||
@@ -1020,6 +1067,11 @@ class Consolidator:
|
|||||||
self.store.raw_archive(messages, session_key=session_key)
|
self.store.raw_archive(messages, session_key=session_key)
|
||||||
return None
|
return None
|
||||||
summary = response.content or "[no summary]"
|
summary = response.content or "[no summary]"
|
||||||
|
manifest = MemoryStore._media_manifest(messages)
|
||||||
|
if manifest:
|
||||||
|
# Keep the deterministic manifest before generated prose so normal
|
||||||
|
# archive truncation preserves attachment references first.
|
||||||
|
summary = f"{manifest}\n\n{summary}"
|
||||||
self.store.append_history(
|
self.store.append_history(
|
||||||
summary,
|
summary,
|
||||||
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
|
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def default_selection_signature(
|
|||||||
|
|
||||||
|
|
||||||
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]:
|
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]:
|
||||||
return {**config.model_presets, "default": config.resolve_default_preset()}
|
return dict(config.model_presets)
|
||||||
|
|
||||||
|
|
||||||
def load_model_preset_catalog(
|
def load_model_preset_catalog(
|
||||||
@@ -61,6 +61,7 @@ def build_static_preset_snapshot(
|
|||||||
signature=("model_preset", name, preset.model_dump_json()),
|
signature=("model_preset", name, preset.model_dump_json()),
|
||||||
generation=preset.to_generation_settings(),
|
generation=preset.to_generation_settings(),
|
||||||
model_preset=name,
|
model_preset=name,
|
||||||
|
supports_image_input=preset.supports_image_input,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -788,6 +788,7 @@ class AgentRunner:
|
|||||||
kwargs["temperature"] = generation.temperature
|
kwargs["temperature"] = generation.temperature
|
||||||
kwargs["max_tokens"] = generation.max_tokens
|
kwargs["max_tokens"] = generation.max_tokens
|
||||||
kwargs["reasoning_effort"] = generation.reasoning_effort
|
kwargs["reasoning_effort"] = generation.reasoning_effort
|
||||||
|
kwargs["supports_image_input"] = spec.runtime.supports_image_input
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
async def _request_model(
|
async def _request_model(
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from nanobot.agent.tools.loader import ToolLoader
|
|||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import AgentDefaults, ToolsConfig
|
from nanobot.config.schema import AgentDefaults, ModelPresetConfig, ToolsConfig
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.security.workspace_access import (
|
from nanobot.security.workspace_access import (
|
||||||
WorkspaceScope,
|
WorkspaceScope,
|
||||||
@@ -121,7 +121,9 @@ class SubagentManager:
|
|||||||
self._compat_runtime = LLMRuntime.capture(
|
self._compat_runtime = LLMRuntime.capture(
|
||||||
provider,
|
provider,
|
||||||
model or provider.get_default_model(),
|
model or provider.get_default_model(),
|
||||||
context_window_tokens=defaults.context_window_tokens,
|
context_window_tokens=ModelPresetConfig(
|
||||||
|
model=model or provider.get_default_model()
|
||||||
|
).context_window_tokens,
|
||||||
)
|
)
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
@@ -161,7 +163,7 @@ class SubagentManager:
|
|||||||
context_window_tokens = (
|
context_window_tokens = (
|
||||||
self._compat_runtime.context_window_tokens
|
self._compat_runtime.context_window_tokens
|
||||||
if self._compat_runtime is not None
|
if self._compat_runtime is not None
|
||||||
else AgentDefaults().context_window_tokens
|
else ModelPresetConfig(model=model).context_window_tokens
|
||||||
)
|
)
|
||||||
self._compat_runtime = LLMRuntime.capture(
|
self._compat_runtime = LLMRuntime.capture(
|
||||||
provider,
|
provider,
|
||||||
|
|||||||
@@ -2462,7 +2462,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
port = 29891
|
port = 29891
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.model = "openai/gpt-4o"
|
config.resolve_default_preset().model = "openai/gpt-4o"
|
||||||
config.providers.openai.api_key = "secret-key"
|
config.providers.openai.api_key = "secret-key"
|
||||||
config.model_presets["deep"] = ModelPresetConfig(
|
config.model_presets["deep"] = ModelPresetConfig(
|
||||||
model="anthropic/claude-opus-4-5",
|
model="anthropic/claude-opus-4-5",
|
||||||
@@ -2795,8 +2795,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert bad_image.status_code == 400
|
assert bad_image.status_code == 400
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_config(config_path)
|
||||||
assert saved.agents.defaults.model == "atomic_chat/test"
|
assert saved.resolve_default_preset().model == "atomic_chat/test"
|
||||||
assert saved.agents.defaults.provider == "atomic_chat"
|
assert saved.resolve_default_preset().provider == "atomic_chat"
|
||||||
assert saved.agents.defaults.model_preset == "fast-writing"
|
assert saved.agents.defaults.model_preset == "fast-writing"
|
||||||
assert saved.agents.defaults.fallback_models == ["deep"]
|
assert saved.agents.defaults.fallback_models == ["deep"]
|
||||||
assert saved.model_presets["fast-writing"].label == "Codex"
|
assert saved.model_presets["fast-writing"].label == "Codex"
|
||||||
@@ -3001,7 +3001,7 @@ def test_settings_payload_normalizes_camel_case_provider(
|
|||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.provider = "minimaxAnthropic"
|
config.resolve_default_preset().provider = "minimaxAnthropic"
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
|||||||
@@ -794,7 +794,7 @@ def _model_display(config: Config) -> tuple[str, str]:
|
|||||||
"""Return (resolved_model_name, preset_tag) for display strings."""
|
"""Return (resolved_model_name, preset_tag) for display strings."""
|
||||||
resolved = config.resolve_preset()
|
resolved = config.resolve_preset()
|
||||||
name = config.agents.defaults.model_preset
|
name = config.agents.defaults.model_preset
|
||||||
tag = f" (preset: {name})" if name else ""
|
tag = f" (preset: {name})" if name != "default" else ""
|
||||||
return resolved.model, tag
|
return resolved.model, tag
|
||||||
|
|
||||||
|
|
||||||
@@ -2969,11 +2969,13 @@ def _set_oauth_provider_as_main(
|
|||||||
|
|
||||||
config = load_config(resolved_config_path)
|
config = load_config(resolved_config_path)
|
||||||
selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
|
selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
|
||||||
config.agents.defaults.model_preset = None
|
default_preset = config.resolve_default_preset().model_copy(
|
||||||
config.agents.defaults.provider = provider_name
|
update={"provider": provider_name, "model": selected_model}
|
||||||
config.agents.defaults.model = selected_model
|
)
|
||||||
if provider_name == "xai_grok" and selected_model == "xai-grok/grok-4.5":
|
if provider_name == "xai_grok" and selected_model == "xai-grok/grok-4.5":
|
||||||
config.agents.defaults.context_window_tokens = 500_000
|
default_preset.context_window_tokens = 500_000
|
||||||
|
config.model_presets["default"] = default_preset
|
||||||
|
config.agents.defaults.model_preset = "default"
|
||||||
save_config(config, resolved_config_path)
|
save_config(config, resolved_config_path)
|
||||||
|
|
||||||
saved_path = resolved_config_path or get_config_path()
|
saved_path = resolved_config_path or get_config_path()
|
||||||
|
|||||||
+5
-12
@@ -755,15 +755,13 @@ def _handle_model_preset_field(
|
|||||||
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle the 'model_preset' field with a list of existing presets."""
|
"""Handle the 'model_preset' field with a list of existing presets."""
|
||||||
preset_names = sorted(_MODEL_PRESET_CACHE)
|
preset_names = sorted(_MODEL_PRESET_CACHE) or ["default"]
|
||||||
choices = [_CLEAR_CHOICE] + preset_names
|
choices = preset_names
|
||||||
default_choice = str(current_value) if current_value else _CLEAR_CHOICE
|
default_choice = str(current_value) if current_value else "default"
|
||||||
new_value = _select_with_back(field_display, choices, default=default_choice)
|
new_value = _select_with_back(field_display, choices, default=default_choice)
|
||||||
if new_value is _BACK_PRESSED:
|
if new_value is _BACK_PRESSED:
|
||||||
return
|
return
|
||||||
if new_value == _CLEAR_CHOICE:
|
if new_value is not None:
|
||||||
setattr(working_model, field_name, None)
|
|
||||||
elif new_value is not None:
|
|
||||||
setattr(working_model, field_name, new_value)
|
setattr(working_model, field_name, new_value)
|
||||||
|
|
||||||
|
|
||||||
@@ -792,8 +790,6 @@ def _handle_fallback_models_field(
|
|||||||
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
working_model: BaseModel, field_name: str, field_display: str, current_value: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle the 'fallback_models' field with preset-aware list management."""
|
"""Handle the 'fallback_models' field with preset-aware list management."""
|
||||||
from nanobot.config.schema import InlineFallbackConfig
|
|
||||||
|
|
||||||
items: list[Any] = list(current_value) if isinstance(current_value, list) else []
|
items: list[Any] = list(current_value) if isinstance(current_value, list) else []
|
||||||
preset_names = sorted(_MODEL_PRESET_CACHE)
|
preset_names = sorted(_MODEL_PRESET_CACHE)
|
||||||
|
|
||||||
@@ -802,10 +798,7 @@ def _handle_fallback_models_field(
|
|||||||
console.print(f"[bold]{field_display}[/bold]")
|
console.print(f"[bold]{field_display}[/bold]")
|
||||||
if items:
|
if items:
|
||||||
for idx, item in enumerate(items, 1):
|
for idx, item in enumerate(items, 1):
|
||||||
if isinstance(item, InlineFallbackConfig):
|
console.print(f" {idx}. {item}")
|
||||||
console.print(f" {idx}. {item.model} - {item.provider} inline")
|
|
||||||
else:
|
|
||||||
console.print(f" {idx}. {item}")
|
|
||||||
else:
|
else:
|
||||||
console.print(" [dim]empty[/dim]")
|
console.print(" [dim]empty[/dim]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|||||||
+274
-7
@@ -6,6 +6,7 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
from pydantic_settings import SettingsError
|
from pydantic_settings import SettingsError
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ from nanobot.utils.helpers import _write_text_atomic
|
|||||||
# Global variable to store current config path (for multi-instance support)
|
# Global variable to store current config path (for multi-instance support)
|
||||||
_current_config_path: Path | None = None
|
_current_config_path: Path | None = None
|
||||||
_schema_refs_ready = False
|
_schema_refs_ready = False
|
||||||
|
_warned_legacy_model_env = False
|
||||||
|
|
||||||
|
|
||||||
def set_config_path(path: Path) -> None:
|
def set_config_path(path: Path) -> None:
|
||||||
@@ -67,6 +69,7 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
summary="Environment-based configuration is invalid.",
|
summary="Environment-based configuration is invalid.",
|
||||||
issues=validation_issues(exc),
|
issues=validation_issues(exc),
|
||||||
) from exc
|
) from exc
|
||||||
|
_warn_unsupported_legacy_model_env(path)
|
||||||
_apply_ssrf_whitelist(config)
|
_apply_ssrf_whitelist(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
@@ -110,7 +113,8 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
data = _migrate_config(data)
|
legacy_model_migration = _legacy_model_migration_kind(data)
|
||||||
|
data, migrated = _migrate_config(data)
|
||||||
try:
|
try:
|
||||||
config = Config.model_validate(data)
|
config = Config.model_validate(data)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
@@ -122,6 +126,23 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
issues=issues,
|
issues=issues,
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
if migrated:
|
||||||
|
_write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
if legacy_model_migration:
|
||||||
|
detail = (
|
||||||
|
"Existing modelPresets.default took precedence; conflicting "
|
||||||
|
"legacy agents.defaults fields were removed."
|
||||||
|
if legacy_model_migration == "conflict"
|
||||||
|
else "Legacy settings were converted to named model presets."
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"Migrated legacy model configuration in {}. {} "
|
||||||
|
"Review the rewritten file before downgrading nanobot.",
|
||||||
|
path,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
_warn_unsupported_legacy_model_env(path)
|
||||||
_apply_ssrf_whitelist(config)
|
_apply_ssrf_whitelist(config)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
@@ -310,12 +331,254 @@ def _env_replace(match: re.Match[str]) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _migrate_config(data: dict) -> dict:
|
_LEGACY_DEFAULT_PRESET = {
|
||||||
|
"label": "Default",
|
||||||
|
"model": "anthropic/claude-opus-4-5",
|
||||||
|
"provider": "auto",
|
||||||
|
"maxTokens": 8192,
|
||||||
|
"contextWindowTokens": 200_000,
|
||||||
|
"temperature": 0.1,
|
||||||
|
"reasoningEffort": None,
|
||||||
|
}
|
||||||
|
_LEGACY_MODEL_FIELD_ALIASES = {
|
||||||
|
"model": ("model",),
|
||||||
|
"provider": ("provider",),
|
||||||
|
"maxTokens": ("maxTokens", "max_tokens"),
|
||||||
|
"contextWindowTokens": ("contextWindowTokens", "context_window_tokens"),
|
||||||
|
"temperature": ("temperature",),
|
||||||
|
"reasoningEffort": ("reasoningEffort", "reasoning_effort"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_model_migration_kind(data: dict[str, Any]) -> str | None:
|
||||||
|
"""Classify a pending model migration without exposing configured values."""
|
||||||
|
if not _needs_legacy_model_migration(data):
|
||||||
|
return None
|
||||||
|
|
||||||
|
agents = data.get("agents")
|
||||||
|
defaults = agents.get("defaults") if isinstance(agents, dict) else None
|
||||||
|
presets = data.get("modelPresets", data.get("model_presets"))
|
||||||
|
has_legacy_fields = isinstance(defaults, dict) and any(
|
||||||
|
alias in defaults
|
||||||
|
for aliases in _LEGACY_MODEL_FIELD_ALIASES.values()
|
||||||
|
for alias in aliases
|
||||||
|
)
|
||||||
|
if has_legacy_fields and isinstance(presets, dict) and "default" in presets:
|
||||||
|
return "conflict"
|
||||||
|
return "migrated"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_unsupported_legacy_model_env() -> bool:
|
||||||
|
for env_name in ("NANOBOT_AGENTS", "NANOBOT_AGENTS__DEFAULTS"):
|
||||||
|
raw = os.environ.get(env_name)
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
data = (
|
||||||
|
{"agents": parsed}
|
||||||
|
if env_name == "NANOBOT_AGENTS"
|
||||||
|
else {"agents": {"defaults": parsed}}
|
||||||
|
)
|
||||||
|
if isinstance(parsed, dict) and _needs_legacy_model_migration(data):
|
||||||
|
return True
|
||||||
|
|
||||||
|
legacy_suffixes = {
|
||||||
|
alias.upper()
|
||||||
|
for aliases in _LEGACY_MODEL_FIELD_ALIASES.values()
|
||||||
|
for alias in aliases
|
||||||
|
}
|
||||||
|
prefix = "NANOBOT_AGENTS__DEFAULTS__"
|
||||||
|
for env_name in os.environ:
|
||||||
|
upper_name = env_name.upper()
|
||||||
|
if not upper_name.startswith(prefix):
|
||||||
|
continue
|
||||||
|
suffix = upper_name[len(prefix):]
|
||||||
|
if suffix in legacy_suffixes:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_unsupported_legacy_model_env(config_path: Path) -> None:
|
||||||
|
global _warned_legacy_model_env
|
||||||
|
if _warned_legacy_model_env or not _has_unsupported_legacy_model_env():
|
||||||
|
return
|
||||||
|
logger.warning(
|
||||||
|
"Ignoring unsupported legacy model settings from NANOBOT_AGENTS. "
|
||||||
|
"Move them to modelPresets in {}.",
|
||||||
|
config_path,
|
||||||
|
)
|
||||||
|
_warned_legacy_model_env = True
|
||||||
|
|
||||||
|
|
||||||
|
def _pop_alias(mapping: dict[str, Any], aliases: tuple[str, ...]) -> tuple[bool, Any]:
|
||||||
|
found = False
|
||||||
|
value: Any = None
|
||||||
|
for alias in aliases:
|
||||||
|
if alias in mapping:
|
||||||
|
if not found:
|
||||||
|
value = mapping[alias]
|
||||||
|
found = True
|
||||||
|
mapping.pop(alias, None)
|
||||||
|
return found, value
|
||||||
|
|
||||||
|
|
||||||
|
def _preset_value(preset: dict[str, Any], camel: str, snake: str) -> Any:
|
||||||
|
return preset.get(camel, preset.get(snake))
|
||||||
|
|
||||||
|
|
||||||
|
def _first_not_none(*values: Any) -> Any:
|
||||||
|
return next((value for value in values if value is not None), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_legacy_fallback_name(presets: dict[str, Any], model: Any) -> str:
|
||||||
|
tail = str(model or "fallback").rsplit("/", 1)[-1].strip().lower()
|
||||||
|
base = re.sub(r"[^a-z0-9]+", "-", tail).strip("-") or "fallback"
|
||||||
|
name = base
|
||||||
|
suffix = 2
|
||||||
|
while name in presets:
|
||||||
|
name = f"{base}-{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_legacy_model_migration(data: dict[str, Any]) -> bool:
|
||||||
|
agents = data.get("agents")
|
||||||
|
defaults = agents.get("defaults") if isinstance(agents, dict) else None
|
||||||
|
if isinstance(defaults, dict):
|
||||||
|
if any(
|
||||||
|
alias in defaults
|
||||||
|
for aliases in _LEGACY_MODEL_FIELD_ALIASES.values()
|
||||||
|
for alias in aliases
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
if "model_preset" in defaults:
|
||||||
|
return True
|
||||||
|
active = defaults.get("modelPreset")
|
||||||
|
if "modelPreset" in defaults and (
|
||||||
|
not isinstance(active, str) or not active.strip()
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
fallbacks = defaults.get(
|
||||||
|
"fallbackModels",
|
||||||
|
defaults.get("fallback_models"),
|
||||||
|
)
|
||||||
|
if isinstance(fallbacks, list) and any(
|
||||||
|
isinstance(fallback, dict) for fallback in fallbacks
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
presets = data.get("modelPresets", data.get("model_presets"))
|
||||||
|
return isinstance(presets, dict) and "default" not in presets
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_legacy_model_config(data: dict[str, Any]) -> bool:
|
||||||
|
"""Move concrete model settings into named presets before schema validation."""
|
||||||
|
if not _needs_legacy_model_migration(data):
|
||||||
|
return False
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
agents = data.setdefault("agents", {})
|
||||||
|
if not isinstance(agents, dict):
|
||||||
|
return False
|
||||||
|
defaults = agents.setdefault("defaults", {})
|
||||||
|
if not isinstance(defaults, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
presets_key = "modelPresets" if "modelPresets" in data else "model_presets"
|
||||||
|
if presets_key not in data:
|
||||||
|
presets_key = "modelPresets"
|
||||||
|
data[presets_key] = {}
|
||||||
|
changed = True
|
||||||
|
presets = data[presets_key]
|
||||||
|
if not isinstance(presets, dict):
|
||||||
|
return changed
|
||||||
|
|
||||||
|
migrated_default = dict(_LEGACY_DEFAULT_PRESET)
|
||||||
|
legacy_values_found = False
|
||||||
|
for destination, aliases in _LEGACY_MODEL_FIELD_ALIASES.items():
|
||||||
|
found, value = _pop_alias(defaults, aliases)
|
||||||
|
if found:
|
||||||
|
migrated_default[destination] = value
|
||||||
|
legacy_values_found = True
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if "default" not in presets:
|
||||||
|
presets["default"] = migrated_default
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
had_canonical_active = "modelPreset" in defaults
|
||||||
|
active_found, active = _pop_alias(defaults, ("modelPreset", "model_preset"))
|
||||||
|
normalized_active = active.strip() if isinstance(active, str) else ""
|
||||||
|
normalized_active = normalized_active or "default"
|
||||||
|
if not active_found or active != normalized_active or not had_canonical_active:
|
||||||
|
changed = True
|
||||||
|
defaults["modelPreset"] = normalized_active
|
||||||
|
|
||||||
|
fallback_key = (
|
||||||
|
"fallbackModels"
|
||||||
|
if "fallbackModels" in defaults
|
||||||
|
else "fallback_models"
|
||||||
|
if "fallback_models" in defaults
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if fallback_key is not None and isinstance(defaults[fallback_key], list):
|
||||||
|
primary = presets.get(normalized_active)
|
||||||
|
if not isinstance(primary, dict):
|
||||||
|
primary = presets["default"]
|
||||||
|
migrated_fallbacks: list[Any] = []
|
||||||
|
for fallback in defaults[fallback_key]:
|
||||||
|
if isinstance(fallback, str):
|
||||||
|
migrated_fallbacks.append(fallback)
|
||||||
|
continue
|
||||||
|
if not isinstance(fallback, dict):
|
||||||
|
migrated_fallbacks.append(fallback)
|
||||||
|
continue
|
||||||
|
name = _unique_legacy_fallback_name(presets, fallback.get("model"))
|
||||||
|
presets[name] = {
|
||||||
|
"label": str(fallback.get("model") or name),
|
||||||
|
"model": fallback.get("model"),
|
||||||
|
"provider": fallback.get("provider"),
|
||||||
|
"maxTokens": _first_not_none(
|
||||||
|
_preset_value(fallback, "maxTokens", "max_tokens"),
|
||||||
|
_preset_value(primary, "maxTokens", "max_tokens"),
|
||||||
|
_LEGACY_DEFAULT_PRESET["maxTokens"],
|
||||||
|
),
|
||||||
|
"contextWindowTokens": _first_not_none(
|
||||||
|
_preset_value(fallback, "contextWindowTokens", "context_window_tokens"),
|
||||||
|
_preset_value(primary, "contextWindowTokens", "context_window_tokens"),
|
||||||
|
_LEGACY_DEFAULT_PRESET["contextWindowTokens"],
|
||||||
|
),
|
||||||
|
"temperature": (
|
||||||
|
fallback["temperature"]
|
||||||
|
if fallback.get("temperature") is not None
|
||||||
|
else primary.get("temperature", _LEGACY_DEFAULT_PRESET["temperature"])
|
||||||
|
),
|
||||||
|
"reasoningEffort": _preset_value(
|
||||||
|
fallback,
|
||||||
|
"reasoningEffort",
|
||||||
|
"reasoning_effort",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
migrated_fallbacks.append(name)
|
||||||
|
changed = True
|
||||||
|
if fallback_key != "fallbackModels":
|
||||||
|
defaults.pop(fallback_key, None)
|
||||||
|
changed = True
|
||||||
|
defaults["fallbackModels"] = migrated_fallbacks
|
||||||
|
|
||||||
|
return changed or legacy_values_found
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_config(data: dict) -> tuple[dict, bool]:
|
||||||
"""Migrate old config formats to current."""
|
"""Migrate old config formats to current."""
|
||||||
|
changed = _migrate_legacy_model_config(data)
|
||||||
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
||||||
tools = data.get("tools", {})
|
tools = data.get("tools", {})
|
||||||
if not isinstance(tools, dict):
|
if not isinstance(tools, dict):
|
||||||
return data
|
return data, changed
|
||||||
exec_cfg = tools.get("exec", {})
|
exec_cfg = tools.get("exec", {})
|
||||||
if (
|
if (
|
||||||
isinstance(exec_cfg, dict)
|
isinstance(exec_cfg, dict)
|
||||||
@@ -323,6 +586,7 @@ def _migrate_config(data: dict) -> dict:
|
|||||||
and "restrictToWorkspace" not in tools
|
and "restrictToWorkspace" not in tools
|
||||||
):
|
):
|
||||||
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
|
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
|
||||||
|
changed = True
|
||||||
|
|
||||||
# Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}.
|
# Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}.
|
||||||
# The old flat keys shipped in the initial MyTool landing; wrapping them in a
|
# The old flat keys shipped in the initial MyTool landing; wrapping them in a
|
||||||
@@ -332,18 +596,21 @@ def _migrate_config(data: dict) -> dict:
|
|||||||
if my_cfg is None:
|
if my_cfg is None:
|
||||||
my_cfg = {}
|
my_cfg = {}
|
||||||
tools["my"] = my_cfg
|
tools["my"] = my_cfg
|
||||||
|
changed = True
|
||||||
if not isinstance(my_cfg, dict):
|
if not isinstance(my_cfg, dict):
|
||||||
return data
|
return data, changed
|
||||||
if "myEnabled" in tools and "enable" not in my_cfg:
|
if "myEnabled" in tools and "enable" not in my_cfg:
|
||||||
my_cfg["enable"] = tools.pop("myEnabled")
|
my_cfg["enable"] = tools.pop("myEnabled")
|
||||||
|
changed = True
|
||||||
else:
|
else:
|
||||||
tools.pop("myEnabled", None)
|
changed = tools.pop("myEnabled", None) is not None or changed
|
||||||
if "mySet" in tools and "allowSet" not in my_cfg:
|
if "mySet" in tools and "allowSet" not in my_cfg:
|
||||||
my_cfg["allowSet"] = tools.pop("mySet")
|
my_cfg["allowSet"] = tools.pop("mySet")
|
||||||
|
changed = True
|
||||||
else:
|
else:
|
||||||
tools.pop("mySet", None)
|
changed = tools.pop("mySet", None) is not None or changed
|
||||||
|
|
||||||
return data
|
return data, changed
|
||||||
|
|
||||||
|
|
||||||
def _sentence(message: str) -> str:
|
def _sentence(message: str) -> str:
|
||||||
|
|||||||
+18
-41
@@ -79,20 +79,6 @@ class DreamConfig(Base):
|
|||||||
return f"every {hours}h"
|
return f"every {hours}h"
|
||||||
|
|
||||||
|
|
||||||
class InlineFallbackConfig(Base):
|
|
||||||
"""One inline fallback model configuration."""
|
|
||||||
|
|
||||||
model: str
|
|
||||||
provider: str
|
|
||||||
max_tokens: int | None = None
|
|
||||||
context_window_tokens: int | None = None
|
|
||||||
temperature: float | None = None
|
|
||||||
reasoning_effort: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
FallbackCandidate = str | InlineFallbackConfig
|
|
||||||
|
|
||||||
|
|
||||||
class ModelPresetConfig(Base):
|
class ModelPresetConfig(Base):
|
||||||
"""A named set of model + generation parameters for quick switching."""
|
"""A named set of model + generation parameters for quick switching."""
|
||||||
|
|
||||||
@@ -103,6 +89,7 @@ class ModelPresetConfig(Base):
|
|||||||
context_window_tokens: int = 200_000
|
context_window_tokens: int = 200_000
|
||||||
temperature: float = 0.1
|
temperature: float = 0.1
|
||||||
reasoning_effort: str | None = None
|
reasoning_effort: str | None = None
|
||||||
|
supports_image_input: bool | None = None
|
||||||
|
|
||||||
def to_generation_settings(self) -> Any:
|
def to_generation_settings(self) -> Any:
|
||||||
from nanobot.providers.base import GenerationSettings
|
from nanobot.providers.base import GenerationSettings
|
||||||
@@ -117,16 +104,9 @@ class AgentDefaults(Base):
|
|||||||
"""Default agent configuration."""
|
"""Default agent configuration."""
|
||||||
|
|
||||||
workspace: str = "~/.nanobot/workspace"
|
workspace: str = "~/.nanobot/workspace"
|
||||||
model_preset: str | None = None # Active preset name — takes precedence over fields below
|
model_preset: str = "default"
|
||||||
model: str = "anthropic/claude-opus-4-5"
|
|
||||||
provider: str = (
|
|
||||||
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
|
|
||||||
)
|
|
||||||
max_tokens: int = 8192
|
|
||||||
context_window_tokens: int = 200_000
|
|
||||||
context_block_limit: int | None = None
|
context_block_limit: int | None = None
|
||||||
temperature: float = 0.1
|
fallback_models: list[str] = Field(default_factory=list)
|
||||||
fallback_models: list[FallbackCandidate] = Field(default_factory=list)
|
|
||||||
max_tool_iterations: int = 200
|
max_tool_iterations: int = 200
|
||||||
max_concurrent_subagents: int = Field(default=1, ge=1)
|
max_concurrent_subagents: int = Field(default=1, ge=1)
|
||||||
fail_on_tool_error: bool = True
|
fail_on_tool_error: bool = True
|
||||||
@@ -139,7 +119,6 @@ class AgentDefaults(Base):
|
|||||||
validation_alias=AliasChoices("toolHintMaxLength"),
|
validation_alias=AliasChoices("toolHintMaxLength"),
|
||||||
serialization_alias="toolHintMaxLength",
|
serialization_alias="toolHintMaxLength",
|
||||||
) # Max characters for tool hint display (e.g. "$ cd …/project && npm test")
|
) # Max characters for tool hint display (e.g. "$ cd …/project && npm test")
|
||||||
reasoning_effort: str | None = None # low / medium / high / adaptive / none — LLM thinking effort; None preserves the provider default
|
|
||||||
timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
|
timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
|
||||||
bot_name: str = "nanobot" # Display name shown in CLI prompts (e.g. "{name} is thinking...")
|
bot_name: str = "nanobot" # Display name shown in CLI prompts (e.g. "{name} is thinking...")
|
||||||
bot_icon: str = "🐈" # Short icon (emoji or text) shown next to the bot name in CLI; "" to omit
|
bot_icon: str = "🐈" # Short icon (emoji or text) shown next to the bot name in CLI; "" to omit
|
||||||
@@ -419,7 +398,12 @@ class Config(BaseSettings):
|
|||||||
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
||||||
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
||||||
model_presets: dict[str, ModelPresetConfig] = Field(
|
model_presets: dict[str, ModelPresetConfig] = Field(
|
||||||
default_factory=dict,
|
default_factory=lambda: {
|
||||||
|
"default": ModelPresetConfig(
|
||||||
|
label="Default",
|
||||||
|
model="anthropic/claude-opus-4-5",
|
||||||
|
)
|
||||||
|
},
|
||||||
validation_alias=AliasChoices("modelPresets", "model_presets"),
|
validation_alias=AliasChoices("modelPresets", "model_presets"),
|
||||||
serialization_alias="modelPresets",
|
serialization_alias="modelPresets",
|
||||||
)
|
)
|
||||||
@@ -431,33 +415,26 @@ class Config(BaseSettings):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _validate_model_preset(self) -> "Config":
|
def _validate_model_preset(self) -> "Config":
|
||||||
if "default" in self.model_presets:
|
if "default" not in self.model_presets:
|
||||||
raise ValueError("model_preset name 'default' is reserved for agents.defaults")
|
raise ValueError("model_presets must define a 'default' preset")
|
||||||
name = self.agents.defaults.model_preset
|
name = self.agents.defaults.model_preset
|
||||||
if name and name != "default" and name not in self.model_presets:
|
if name not in self.model_presets:
|
||||||
raise ValueError(f"model_preset {name!r} not found in model_presets")
|
raise ValueError(f"model_preset {name!r} not found in model_presets")
|
||||||
dream_name = self.agents.defaults.dream.model_override
|
dream_name = self.agents.defaults.dream.model_override
|
||||||
if dream_name and dream_name != "default" and dream_name not in self.model_presets:
|
if dream_name and dream_name not in self.model_presets:
|
||||||
raise ValueError(f"Dream model preset {dream_name!r} not found in model_presets")
|
raise ValueError(f"Dream model preset {dream_name!r} not found in model_presets")
|
||||||
for fallback in self.agents.defaults.fallback_models:
|
for fallback in self.agents.defaults.fallback_models:
|
||||||
if isinstance(fallback, str) and fallback not in self.model_presets:
|
if fallback not in self.model_presets:
|
||||||
raise ValueError(f"fallback_models entry {fallback!r} not found in model_presets")
|
raise ValueError(f"fallback_models entry {fallback!r} not found in model_presets")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def resolve_default_preset(self) -> ModelPresetConfig:
|
def resolve_default_preset(self) -> ModelPresetConfig:
|
||||||
"""Return the implicit `default` preset from agents.defaults fields."""
|
"""Return the concrete ``default`` model preset."""
|
||||||
d = self.agents.defaults
|
return self.model_presets["default"]
|
||||||
return ModelPresetConfig(
|
|
||||||
model=d.model, provider=d.provider, max_tokens=d.max_tokens,
|
|
||||||
context_window_tokens=d.context_window_tokens,
|
|
||||||
temperature=d.temperature, reasoning_effort=d.reasoning_effort,
|
|
||||||
)
|
|
||||||
|
|
||||||
def resolve_preset(self, name: str | None = None) -> ModelPresetConfig:
|
def resolve_preset(self, name: str | None = None) -> ModelPresetConfig:
|
||||||
"""Return effective model params from a named preset or the implicit default."""
|
"""Return effective model params from a named preset."""
|
||||||
name = self.agents.defaults.model_preset if name is None else name
|
name = self.agents.defaults.model_preset if name is None else (name or "default")
|
||||||
if not name or name == "default":
|
|
||||||
return self.resolve_default_preset()
|
|
||||||
if name not in self.model_presets:
|
if name not in self.model_presets:
|
||||||
raise KeyError(f"model_preset {name!r} not found in model_presets")
|
raise KeyError(f"model_preset {name!r} not found in model_presets")
|
||||||
return self.model_presets[name]
|
return self.model_presets[name]
|
||||||
|
|||||||
+4
-19
@@ -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,11 +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.agents.defaults.model_preset = None
|
|
||||||
config.agents.defaults.model = model
|
|
||||||
config.agents.defaults.provider = "auto"
|
|
||||||
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(
|
||||||
@@ -139,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.
|
||||||
@@ -157,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,
|
||||||
)
|
)
|
||||||
@@ -198,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,
|
||||||
)
|
)
|
||||||
@@ -301,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."""
|
||||||
@@ -315,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:
|
||||||
|
|||||||
+103
-4
@@ -218,6 +218,16 @@ class LLMProvider(ABC):
|
|||||||
"速率限制",
|
"速率限制",
|
||||||
"访问量过大",
|
"访问量过大",
|
||||||
)
|
)
|
||||||
|
_IMAGE_UNSUPPORTED_MARKERS = (
|
||||||
|
"does not support image",
|
||||||
|
"doesn't support image",
|
||||||
|
"images are not supported",
|
||||||
|
"image input is not supported",
|
||||||
|
"image input not supported",
|
||||||
|
"image_url is not supported",
|
||||||
|
"unsupported image input",
|
||||||
|
"vision is not supported",
|
||||||
|
)
|
||||||
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429})
|
_RETRYABLE_STATUS_CODES = frozenset({408, 409, 429})
|
||||||
_TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
|
_TRANSIENT_ERROR_KINDS = frozenset({"timeout", "connection"})
|
||||||
_NON_RETRYABLE_429_ERROR_TOKENS = frozenset({
|
_NON_RETRYABLE_429_ERROR_TOKENS = frozenset({
|
||||||
@@ -272,6 +282,7 @@ class LLMProvider(ABC):
|
|||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.api_base = api_base
|
self.api_base = api_base
|
||||||
self.generation: GenerationSettings = GenerationSettings()
|
self.generation: GenerationSettings = GenerationSettings()
|
||||||
|
self.supports_image_input: bool | None = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
@@ -602,6 +613,51 @@ class LLMProvider(ABC):
|
|||||||
result.append(msg)
|
result.append(msg)
|
||||||
return result if found else None
|
return result if found else None
|
||||||
|
|
||||||
|
def _messages_for_image_capability(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
supports_image_input: bool | None | object = _SENTINEL,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Apply an explicit text-only preset before making a provider request."""
|
||||||
|
capability = (
|
||||||
|
self.supports_image_input
|
||||||
|
if supports_image_input is self._SENTINEL
|
||||||
|
else supports_image_input
|
||||||
|
)
|
||||||
|
if capability is not False:
|
||||||
|
return messages
|
||||||
|
return self._strip_image_content(messages) or messages
|
||||||
|
|
||||||
|
def _outer_image_capability(
|
||||||
|
self,
|
||||||
|
supports_image_input: bool | None,
|
||||||
|
) -> bool | None:
|
||||||
|
"""Return the image policy applied by this provider's retry wrapper."""
|
||||||
|
return supports_image_input
|
||||||
|
|
||||||
|
def _image_policy_request_kwargs(
|
||||||
|
self,
|
||||||
|
supports_image_input: bool | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return provider-internal kwargs needed for candidate image policy."""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_image_unsupported_response(cls, response: LLMResponse) -> bool:
|
||||||
|
if response.finish_reason != "error":
|
||||||
|
return False
|
||||||
|
text = " ".join(
|
||||||
|
str(value or "")
|
||||||
|
for value in (
|
||||||
|
response.content,
|
||||||
|
response.error_kind,
|
||||||
|
response.error_type,
|
||||||
|
response.error_code,
|
||||||
|
)
|
||||||
|
).lower()
|
||||||
|
return any(marker in text for marker in cls._IMAGE_UNSUPPORTED_MARKERS)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
|
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
|
||||||
"""Replace image_url blocks with text placeholder *in-place*.
|
"""Replace image_url blocks with text placeholder *in-place*.
|
||||||
@@ -692,6 +748,7 @@ class LLMProvider(ABC):
|
|||||||
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
||||||
retry_mode: str = "standard",
|
retry_mode: str = "standard",
|
||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
supports_image_input: bool | None | object = _SENTINEL,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""Call chat_stream() with retry on transient provider failures."""
|
"""Call chat_stream() with retry on transient provider failures."""
|
||||||
if max_tokens is self._SENTINEL or max_tokens is None:
|
if max_tokens is self._SENTINEL or max_tokens is None:
|
||||||
@@ -700,6 +757,14 @@ class LLMProvider(ABC):
|
|||||||
temperature = self.generation.temperature
|
temperature = self.generation.temperature
|
||||||
if reasoning_effort is self._SENTINEL:
|
if reasoning_effort is self._SENTINEL:
|
||||||
reasoning_effort = self.generation.reasoning_effort
|
reasoning_effort = self.generation.reasoning_effort
|
||||||
|
candidate_image_capability = (
|
||||||
|
self.supports_image_input
|
||||||
|
if supports_image_input is self._SENTINEL
|
||||||
|
else supports_image_input
|
||||||
|
)
|
||||||
|
outer_image_capability = self._outer_image_capability(
|
||||||
|
candidate_image_capability
|
||||||
|
)
|
||||||
|
|
||||||
has_streamed_content = False
|
has_streamed_content = False
|
||||||
|
|
||||||
@@ -717,13 +782,19 @@ class LLMProvider(ABC):
|
|||||||
has_streamed_content = False
|
has_streamed_content = False
|
||||||
|
|
||||||
kw: dict[str, Any] = dict(
|
kw: dict[str, Any] = dict(
|
||||||
messages=messages, tools=tools, model=model,
|
messages=self._messages_for_image_capability(
|
||||||
|
messages,
|
||||||
|
supports_image_input=outer_image_capability,
|
||||||
|
),
|
||||||
|
tools=tools,
|
||||||
|
model=model,
|
||||||
max_tokens=max_tokens, temperature=temperature,
|
max_tokens=max_tokens, temperature=temperature,
|
||||||
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
||||||
on_content_delta=_tracking_delta if on_content_delta is not None else None,
|
on_content_delta=_tracking_delta if on_content_delta is not None else None,
|
||||||
on_thinking_delta=on_thinking_delta,
|
on_thinking_delta=on_thinking_delta,
|
||||||
on_tool_call_delta=on_tool_call_delta,
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
)
|
)
|
||||||
|
kw.update(self._image_policy_request_kwargs(candidate_image_capability))
|
||||||
if on_stream_recover and getattr(self, "supports_stream_recover_callback", False):
|
if on_stream_recover and getattr(self, "supports_stream_recover_callback", False):
|
||||||
kw["on_stream_recover"] = _recover_stream
|
kw["on_stream_recover"] = _recover_stream
|
||||||
return await self._run_with_retry(
|
return await self._run_with_retry(
|
||||||
@@ -734,6 +805,7 @@ class LLMProvider(ABC):
|
|||||||
on_retry_wait=on_retry_wait,
|
on_retry_wait=on_retry_wait,
|
||||||
should_retry_guard=lambda: not has_streamed_content,
|
should_retry_guard=lambda: not has_streamed_content,
|
||||||
on_stream_recover=_recover_stream if on_stream_recover else None,
|
on_stream_recover=_recover_stream if on_stream_recover else None,
|
||||||
|
supports_image_input=outer_image_capability,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def chat_with_retry(
|
async def chat_with_retry(
|
||||||
@@ -747,6 +819,7 @@ class LLMProvider(ABC):
|
|||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
retry_mode: str = "standard",
|
retry_mode: str = "standard",
|
||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
supports_image_input: bool | None | object = _SENTINEL,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""Call chat() with retry on transient provider failures.
|
"""Call chat() with retry on transient provider failures.
|
||||||
|
|
||||||
@@ -763,18 +836,33 @@ class LLMProvider(ABC):
|
|||||||
temperature = self.generation.temperature
|
temperature = self.generation.temperature
|
||||||
if reasoning_effort is self._SENTINEL:
|
if reasoning_effort is self._SENTINEL:
|
||||||
reasoning_effort = self.generation.reasoning_effort
|
reasoning_effort = self.generation.reasoning_effort
|
||||||
|
candidate_image_capability = (
|
||||||
|
self.supports_image_input
|
||||||
|
if supports_image_input is self._SENTINEL
|
||||||
|
else supports_image_input
|
||||||
|
)
|
||||||
|
outer_image_capability = self._outer_image_capability(
|
||||||
|
candidate_image_capability
|
||||||
|
)
|
||||||
|
|
||||||
kw: dict[str, Any] = dict(
|
kw: dict[str, Any] = dict(
|
||||||
messages=messages, tools=tools, model=model,
|
messages=self._messages_for_image_capability(
|
||||||
|
messages,
|
||||||
|
supports_image_input=outer_image_capability,
|
||||||
|
),
|
||||||
|
tools=tools,
|
||||||
|
model=model,
|
||||||
max_tokens=max_tokens, temperature=temperature,
|
max_tokens=max_tokens, temperature=temperature,
|
||||||
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
||||||
)
|
)
|
||||||
|
kw.update(self._image_policy_request_kwargs(candidate_image_capability))
|
||||||
return await self._run_with_retry(
|
return await self._run_with_retry(
|
||||||
self._safe_chat,
|
self._safe_chat,
|
||||||
kw,
|
kw,
|
||||||
messages,
|
messages,
|
||||||
retry_mode=retry_mode,
|
retry_mode=retry_mode,
|
||||||
on_retry_wait=on_retry_wait,
|
on_retry_wait=on_retry_wait,
|
||||||
|
supports_image_input=outer_image_capability,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -882,6 +970,7 @@ class LLMProvider(ABC):
|
|||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None,
|
on_retry_wait: Callable[[str], Awaitable[None]] | None,
|
||||||
should_retry_guard: Callable[[], bool] | None = None,
|
should_retry_guard: Callable[[], bool] | None = None,
|
||||||
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
||||||
|
supports_image_input: bool | None | object = _SENTINEL,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
attempt = 0
|
attempt = 0
|
||||||
delays = list(self._CHAT_RETRY_DELAYS)
|
delays = list(self._CHAT_RETRY_DELAYS)
|
||||||
@@ -928,9 +1017,19 @@ class LLMProvider(ABC):
|
|||||||
|
|
||||||
if not self._is_transient_response(response):
|
if not self._is_transient_response(response):
|
||||||
stripped = self._strip_image_content(original_messages)
|
stripped = self._strip_image_content(original_messages)
|
||||||
if stripped is not None and stripped != kw["messages"]:
|
if (
|
||||||
|
(
|
||||||
|
self.supports_image_input
|
||||||
|
if supports_image_input is self._SENTINEL
|
||||||
|
else supports_image_input
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
and self._is_image_unsupported_response(response)
|
||||||
|
and stripped is not None
|
||||||
|
and stripped != kw["messages"]
|
||||||
|
):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Non-transient LLM error with image content, retrying without images"
|
"Model rejected image input, retrying without images"
|
||||||
)
|
)
|
||||||
retry_kw = dict(kw)
|
retry_kw = dict(kw)
|
||||||
retry_kw["messages"] = stripped
|
retry_kw["messages"] = stripped
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig, ProviderConfig
|
from nanobot.config.schema import Config, ModelPresetConfig, ProviderConfig
|
||||||
from nanobot.providers.base import GenerationSettings, LLMProvider
|
from nanobot.providers.base import GenerationSettings, LLMProvider
|
||||||
from nanobot.providers.fallback_provider import FallbackProvider
|
from nanobot.providers.fallback_provider import FallbackProvider
|
||||||
from nanobot.providers.registry import ProviderSpec, create_dynamic_spec, find_by_name
|
from nanobot.providers.registry import ProviderSpec, create_dynamic_spec, find_by_name
|
||||||
@@ -19,6 +19,7 @@ class ProviderSnapshot:
|
|||||||
signature: tuple[object, ...]
|
signature: tuple[object, ...]
|
||||||
generation: GenerationSettings | None = None
|
generation: GenerationSettings | None = None
|
||||||
model_preset: str | None = None
|
model_preset: str | None = None
|
||||||
|
supports_image_input: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -205,37 +206,15 @@ def _make_provider_core(
|
|||||||
)
|
)
|
||||||
|
|
||||||
provider.generation = preset.to_generation_settings()
|
provider.generation = preset.to_generation_settings()
|
||||||
|
provider.supports_image_input = preset.supports_image_input
|
||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
def _inline_fallback_preset(
|
def _resolve_fallback_presets(config: Config, _primary: ModelPresetConfig) -> list[ModelPresetConfig]:
|
||||||
primary: ModelPresetConfig,
|
return [
|
||||||
fallback: InlineFallbackConfig,
|
config.model_presets[name]
|
||||||
) -> ModelPresetConfig:
|
for name in config.agents.defaults.fallback_models
|
||||||
return ModelPresetConfig(
|
]
|
||||||
model=fallback.model,
|
|
||||||
provider=fallback.provider,
|
|
||||||
max_tokens=fallback.max_tokens if fallback.max_tokens is not None else primary.max_tokens,
|
|
||||||
context_window_tokens=(
|
|
||||||
fallback.context_window_tokens
|
|
||||||
if fallback.context_window_tokens is not None
|
|
||||||
else primary.context_window_tokens
|
|
||||||
),
|
|
||||||
temperature=(
|
|
||||||
fallback.temperature if fallback.temperature is not None else primary.temperature
|
|
||||||
),
|
|
||||||
reasoning_effort=fallback.reasoning_effort,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_fallback_presets(config: Config, primary: ModelPresetConfig) -> list[ModelPresetConfig]:
|
|
||||||
presets: list[ModelPresetConfig] = []
|
|
||||||
for fallback in config.agents.defaults.fallback_models:
|
|
||||||
if isinstance(fallback, str):
|
|
||||||
presets.append(config.model_presets[fallback])
|
|
||||||
else:
|
|
||||||
presets.append(_inline_fallback_preset(primary, fallback))
|
|
||||||
return presets
|
|
||||||
|
|
||||||
|
|
||||||
def make_provider(
|
def make_provider(
|
||||||
@@ -277,6 +256,7 @@ def build_unconfigured_provider_snapshot(config: Config, setup_error: str) -> Pr
|
|||||||
context_window_tokens=preset.context_window_tokens,
|
context_window_tokens=preset.context_window_tokens,
|
||||||
signature=("unconfigured", setup_error, preset.model),
|
signature=("unconfigured", setup_error, preset.model),
|
||||||
generation=provider.generation,
|
generation=provider.generation,
|
||||||
|
supports_image_input=preset.supports_image_input,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -310,6 +290,7 @@ def provider_signature(
|
|||||||
fallback.temperature,
|
fallback.temperature,
|
||||||
fallback.reasoning_effort,
|
fallback.reasoning_effort,
|
||||||
fallback.context_window_tokens,
|
fallback.context_window_tokens,
|
||||||
|
fallback.supports_image_input,
|
||||||
getattr(fp, "proxy", None) if fp else None,
|
getattr(fp, "proxy", None) if fp else None,
|
||||||
fp.thinking_style if fp else None,
|
fp.thinking_style if fp else None,
|
||||||
)
|
)
|
||||||
@@ -331,6 +312,7 @@ def provider_signature(
|
|||||||
resolved.temperature,
|
resolved.temperature,
|
||||||
resolved.reasoning_effort,
|
resolved.reasoning_effort,
|
||||||
resolved.context_window_tokens,
|
resolved.context_window_tokens,
|
||||||
|
resolved.supports_image_input,
|
||||||
getattr(p, "proxy", None) if p else None,
|
getattr(p, "proxy", None) if p else None,
|
||||||
p.thinking_style if p else None,
|
p.thinking_style if p else None,
|
||||||
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
||||||
@@ -360,6 +342,7 @@ def build_provider_snapshot(
|
|||||||
signature=provider_signature(config, preset=resolved),
|
signature=provider_signature(config, preset=resolved),
|
||||||
generation=resolved.to_generation_settings(),
|
generation=resolved.to_generation_settings(),
|
||||||
model_preset=selected_preset,
|
model_preset=selected_preset,
|
||||||
|
supports_image_input=resolved.supports_image_input,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ class FallbackProvider(LLMProvider):
|
|||||||
self._provider_factory = provider_factory
|
self._provider_factory = provider_factory
|
||||||
self._fallback_model_observer = fallback_model_observer
|
self._fallback_model_observer = fallback_model_observer
|
||||||
self._has_fallbacks = bool(fallback_presets)
|
self._has_fallbacks = bool(fallback_presets)
|
||||||
|
# Candidate-specific image policy is applied inside _try_with_fallback;
|
||||||
|
# the outer retry wrapper preserves canonical images for the chain.
|
||||||
|
self.supports_image_input = getattr(primary, "supports_image_input", None)
|
||||||
self._primary_failures = 0
|
self._primary_failures = 0
|
||||||
self._primary_tripped_at: float | None = None
|
self._primary_tripped_at: float | None = None
|
||||||
|
|
||||||
@@ -139,6 +142,19 @@ class FallbackProvider(LLMProvider):
|
|||||||
def supports_progress_deltas(self) -> bool:
|
def supports_progress_deltas(self) -> bool:
|
||||||
return bool(getattr(self._primary, "supports_progress_deltas", False))
|
return bool(getattr(self._primary, "supports_progress_deltas", False))
|
||||||
|
|
||||||
|
def _outer_image_capability(
|
||||||
|
self,
|
||||||
|
supports_image_input: bool | None,
|
||||||
|
) -> bool | None:
|
||||||
|
"""Keep canonical images intact until each candidate applies its policy."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _image_policy_request_kwargs(
|
||||||
|
self,
|
||||||
|
supports_image_input: bool | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {"_primary_supports_image_input": supports_image_input}
|
||||||
|
|
||||||
def _primary_available(self) -> bool:
|
def _primary_available(self) -> bool:
|
||||||
"""Return True if the primary provider is not currently tripped."""
|
"""Return True if the primary provider is not currently tripped."""
|
||||||
if self._primary_tripped_at is None:
|
if self._primary_tripped_at is None:
|
||||||
@@ -149,16 +165,39 @@ class FallbackProvider(LLMProvider):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def chat(self, **kwargs: Any) -> LLMResponse:
|
async def chat(self, **kwargs: Any) -> LLMResponse:
|
||||||
|
primary_supports_image_input = kwargs.pop(
|
||||||
|
"_primary_supports_image_input",
|
||||||
|
getattr(self._primary, "supports_image_input", None),
|
||||||
|
)
|
||||||
if not self._has_fallbacks:
|
if not self._has_fallbacks:
|
||||||
return await self._primary.chat(**kwargs)
|
return await self._call_with_image_policy(
|
||||||
|
lambda p, kw: p.chat(**kw),
|
||||||
|
self._primary,
|
||||||
|
kwargs,
|
||||||
|
has_streamed=None,
|
||||||
|
supports_image_input=primary_supports_image_input,
|
||||||
|
)
|
||||||
return await self._try_with_fallback(
|
return await self._try_with_fallback(
|
||||||
lambda p, kw: p.chat(**kw), kwargs, has_streamed=None
|
lambda p, kw: p.chat(**kw),
|
||||||
|
kwargs,
|
||||||
|
has_streamed=None,
|
||||||
|
primary_supports_image_input=primary_supports_image_input,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def chat_stream(self, **kwargs: Any) -> LLMResponse:
|
async def chat_stream(self, **kwargs: Any) -> LLMResponse:
|
||||||
on_stream_recover = kwargs.pop("on_stream_recover", None)
|
on_stream_recover = kwargs.pop("on_stream_recover", None)
|
||||||
|
primary_supports_image_input = kwargs.pop(
|
||||||
|
"_primary_supports_image_input",
|
||||||
|
getattr(self._primary, "supports_image_input", None),
|
||||||
|
)
|
||||||
if not self._has_fallbacks:
|
if not self._has_fallbacks:
|
||||||
return await self._primary.chat_stream(**kwargs)
|
return await self._call_with_image_policy(
|
||||||
|
lambda p, kw: p.chat_stream(**kw),
|
||||||
|
self._primary,
|
||||||
|
kwargs,
|
||||||
|
has_streamed=None,
|
||||||
|
supports_image_input=primary_supports_image_input,
|
||||||
|
)
|
||||||
|
|
||||||
has_streamed: list[bool] = [False]
|
has_streamed: list[bool] = [False]
|
||||||
original_delta = kwargs.get("on_content_delta")
|
original_delta = kwargs.get("on_content_delta")
|
||||||
@@ -175,6 +214,7 @@ class FallbackProvider(LLMProvider):
|
|||||||
kwargs,
|
kwargs,
|
||||||
has_streamed=has_streamed,
|
has_streamed=has_streamed,
|
||||||
on_stream_recover=on_stream_recover,
|
on_stream_recover=on_stream_recover,
|
||||||
|
primary_supports_image_input=primary_supports_image_input,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _try_with_fallback(
|
async def _try_with_fallback(
|
||||||
@@ -183,6 +223,7 @@ class FallbackProvider(LLMProvider):
|
|||||||
kwargs: dict[str, Any],
|
kwargs: dict[str, Any],
|
||||||
has_streamed: list[bool] | None,
|
has_streamed: list[bool] | None,
|
||||||
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
||||||
|
primary_supports_image_input: bool | None | object = LLMProvider._SENTINEL,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
primary_model = kwargs.get("model") or self._primary.get_default_model()
|
primary_model = kwargs.get("model") or self._primary.get_default_model()
|
||||||
primary_was_attempted = False
|
primary_was_attempted = False
|
||||||
@@ -190,7 +231,13 @@ class FallbackProvider(LLMProvider):
|
|||||||
|
|
||||||
if self._primary_available():
|
if self._primary_available():
|
||||||
primary_was_attempted = True
|
primary_was_attempted = True
|
||||||
response = await call(self._primary, kwargs)
|
response = await self._call_with_image_policy(
|
||||||
|
call,
|
||||||
|
self._primary,
|
||||||
|
kwargs,
|
||||||
|
has_streamed=has_streamed,
|
||||||
|
supports_image_input=primary_supports_image_input,
|
||||||
|
)
|
||||||
if response.finish_reason != "error":
|
if response.finish_reason != "error":
|
||||||
self._primary_failures = 0
|
self._primary_failures = 0
|
||||||
self._primary_tripped_at = None
|
self._primary_tripped_at = None
|
||||||
@@ -216,7 +263,8 @@ class FallbackProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
if not self._should_fallback(response):
|
image_rejected = self._primary._is_image_unsupported_response(response)
|
||||||
|
if not image_rejected and not self._should_fallback(response):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Primary model '{}' returned non-fallbackable error: {}",
|
"Primary model '{}' returned non-fallbackable error: {}",
|
||||||
primary_model,
|
primary_model,
|
||||||
@@ -224,13 +272,14 @@ class FallbackProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
self._primary_failures += 1
|
if not image_rejected:
|
||||||
if self._primary_failures >= _PRIMARY_FAILURE_THRESHOLD:
|
self._primary_failures += 1
|
||||||
self._primary_tripped_at = time.monotonic()
|
if self._primary_failures >= _PRIMARY_FAILURE_THRESHOLD:
|
||||||
logger.warning(
|
self._primary_tripped_at = time.monotonic()
|
||||||
"Primary model '{}' circuit open after {} consecutive failures",
|
logger.warning(
|
||||||
primary_model, self._primary_failures,
|
"Primary model '{}' circuit open after {} consecutive failures",
|
||||||
)
|
primary_model, self._primary_failures,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug("Primary model '{}' circuit open; skipping", primary_model)
|
logger.debug("Primary model '{}' circuit open; skipping", primary_model)
|
||||||
|
|
||||||
@@ -270,6 +319,7 @@ class FallbackProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
fallback_provider = self._provider_factory(fallback)
|
fallback_provider = self._provider_factory(fallback)
|
||||||
|
fallback_provider.supports_image_input = fallback.supports_image_input
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to create provider for fallback '{}': {}", fallback_model, exc
|
"Failed to create provider for fallback '{}': {}", fallback_model, exc
|
||||||
@@ -288,7 +338,13 @@ class FallbackProvider(LLMProvider):
|
|||||||
fallback_kwargs.pop("reasoning_effort", None)
|
fallback_kwargs.pop("reasoning_effort", None)
|
||||||
else:
|
else:
|
||||||
fallback_kwargs["reasoning_effort"] = fallback.reasoning_effort
|
fallback_kwargs["reasoning_effort"] = fallback.reasoning_effort
|
||||||
fallback_response = await call(fallback_provider, fallback_kwargs)
|
fallback_response = await self._call_with_image_policy(
|
||||||
|
call,
|
||||||
|
fallback_provider,
|
||||||
|
fallback_kwargs,
|
||||||
|
has_streamed=has_streamed,
|
||||||
|
supports_image_input=fallback.supports_image_input,
|
||||||
|
)
|
||||||
|
|
||||||
if fallback_response.finish_reason != "error":
|
if fallback_response.finish_reason != "error":
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -317,6 +373,49 @@ class FallbackProvider(LLMProvider):
|
|||||||
finish_reason="error",
|
finish_reason="error",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _call_with_image_policy(
|
||||||
|
call: Callable[[LLMProvider, dict[str, Any]], Awaitable[LLMResponse]],
|
||||||
|
provider: LLMProvider,
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
*,
|
||||||
|
has_streamed: list[bool] | None,
|
||||||
|
supports_image_input: bool | None | object = LLMProvider._SENTINEL,
|
||||||
|
) -> LLMResponse:
|
||||||
|
original_messages = kwargs.get("messages")
|
||||||
|
if not isinstance(original_messages, list):
|
||||||
|
return await call(provider, kwargs)
|
||||||
|
|
||||||
|
prepared_kwargs = dict(kwargs)
|
||||||
|
prepared_kwargs["messages"] = provider._messages_for_image_capability(
|
||||||
|
original_messages,
|
||||||
|
supports_image_input=supports_image_input,
|
||||||
|
)
|
||||||
|
response = await call(provider, prepared_kwargs)
|
||||||
|
capability = (
|
||||||
|
provider.supports_image_input
|
||||||
|
if supports_image_input is LLMProvider._SENTINEL
|
||||||
|
else supports_image_input
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
capability is None
|
||||||
|
and provider._is_image_unsupported_response(response)
|
||||||
|
and (has_streamed is None or not has_streamed[0])
|
||||||
|
):
|
||||||
|
stripped = provider._strip_image_content(original_messages)
|
||||||
|
if stripped is not None and stripped != prepared_kwargs["messages"]:
|
||||||
|
logger.warning(
|
||||||
|
"Fallback candidate '{}' rejected image input, retrying without images",
|
||||||
|
prepared_kwargs.get("model") or provider.get_default_model(),
|
||||||
|
)
|
||||||
|
retry_kwargs = dict(prepared_kwargs)
|
||||||
|
retry_kwargs["messages"] = stripped
|
||||||
|
retry_response = await call(provider, retry_kwargs)
|
||||||
|
if retry_response.finish_reason != "error":
|
||||||
|
provider._strip_image_content_inplace(original_messages)
|
||||||
|
return retry_response
|
||||||
|
return response
|
||||||
|
|
||||||
async def _notify_fallback_model(self, model: str) -> None:
|
async def _notify_fallback_model(self, model: str) -> None:
|
||||||
if self._fallback_model_observer is None:
|
if self._fallback_model_observer is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+17
-11
@@ -22,10 +22,10 @@ from nanobot.runtime_context import (
|
|||||||
public_history_message,
|
public_history_message,
|
||||||
)
|
)
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
|
content_with_media_breadcrumbs,
|
||||||
ensure_dir,
|
ensure_dir,
|
||||||
estimate_message_tokens,
|
estimate_message_tokens,
|
||||||
find_legal_message_start,
|
find_legal_message_start,
|
||||||
image_placeholder_text,
|
|
||||||
recent_message_start_index,
|
recent_message_start_index,
|
||||||
safe_filename,
|
safe_filename,
|
||||||
strip_think,
|
strip_think,
|
||||||
@@ -165,6 +165,7 @@ class Session:
|
|||||||
max_tokens: int = 0,
|
max_tokens: int = 0,
|
||||||
extend_to_user: bool = False,
|
extend_to_user: bool = False,
|
||||||
include_runtime_context: bool = True,
|
include_runtime_context: bool = True,
|
||||||
|
include_media: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return unconsolidated messages for LLM input.
|
"""Return unconsolidated messages for LLM input.
|
||||||
|
|
||||||
@@ -209,17 +210,17 @@ class Session:
|
|||||||
role = message.get("role")
|
role = message.get("role")
|
||||||
if role == "assistant" and isinstance(content, str):
|
if role == "assistant" and isinstance(content, str):
|
||||||
content = _sanitize_assistant_replay_text(content)
|
content = _sanitize_assistant_replay_text(content)
|
||||||
# Synthesize an ``[image: path]`` breadcrumb from the persisted
|
|
||||||
# ``media`` kwarg so LLM replay still sees *something* where the
|
|
||||||
# image used to be. Without this, an image-only user turn
|
|
||||||
# replays as an empty user message — the assistant's reply then
|
|
||||||
# looks like it's responding to nothing.
|
|
||||||
media = message.get("media")
|
media = message.get("media")
|
||||||
if role == "user" and isinstance(media, list) and media and isinstance(content, str):
|
media_paths = (
|
||||||
breadcrumbs = "\n".join(
|
[path for path in media if isinstance(path, str) and path]
|
||||||
image_placeholder_text(p) for p in media if isinstance(p, str) and p
|
if role == "user" and isinstance(media, list)
|
||||||
)
|
else []
|
||||||
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
)
|
||||||
|
# General history consumers retain a compact breadcrumb. The agent
|
||||||
|
# loop asks for internal media refs and deterministically rebuilds
|
||||||
|
# image blocks at the request boundary.
|
||||||
|
if media_paths and not include_media:
|
||||||
|
content = content_with_media_breadcrumbs(role, content, media_paths)
|
||||||
cli_apps = message.get("cli_apps")
|
cli_apps = message.get("cli_apps")
|
||||||
if (
|
if (
|
||||||
include_runtime_context
|
include_runtime_context
|
||||||
@@ -248,6 +249,11 @@ class Session:
|
|||||||
if not any(key in message for key in ("tool_calls", "reasoning_content", "thinking_blocks")):
|
if not any(key in message for key in ("tool_calls", "reasoning_content", "thinking_blocks")):
|
||||||
continue
|
continue
|
||||||
entry: dict[str, Any] = {"role": message["role"], "content": content}
|
entry: dict[str, Any] = {"role": message["role"], "content": content}
|
||||||
|
if media_paths and include_media:
|
||||||
|
entry["_media_paths"] = media_paths
|
||||||
|
runtime_context = message.get(RUNTIME_CONTEXT_HISTORY_META)
|
||||||
|
if isinstance(runtime_context, dict):
|
||||||
|
entry[RUNTIME_CONTEXT_HISTORY_META] = deepcopy(runtime_context)
|
||||||
for key in ("tool_calls", "tool_call_id", "name", "reasoning_content", "thinking_blocks"):
|
for key in ("tool_calls", "tool_call_id", "name", "reasoning_content", "thinking_blocks"):
|
||||||
if key in message:
|
if key in message:
|
||||||
entry[key] = message[key]
|
entry[key] = message[key]
|
||||||
|
|||||||
@@ -367,6 +367,24 @@ def image_placeholder_text(path: str | None, *, empty: str = "[image]") -> str:
|
|||||||
return f"[image: {path}]" if path else empty
|
return f"[image: {path}]" if path else empty
|
||||||
|
|
||||||
|
|
||||||
|
def content_with_media_breadcrumbs(
|
||||||
|
role: object,
|
||||||
|
content: object,
|
||||||
|
media: object,
|
||||||
|
) -> object:
|
||||||
|
"""Append persisted media paths to user text using the canonical breadcrumb."""
|
||||||
|
if role != "user" or not isinstance(content, str) or not isinstance(media, list):
|
||||||
|
return content
|
||||||
|
breadcrumbs = "\n".join(
|
||||||
|
image_placeholder_text(path)
|
||||||
|
for path in media
|
||||||
|
if isinstance(path, str) and path
|
||||||
|
)
|
||||||
|
if not breadcrumbs:
|
||||||
|
return content
|
||||||
|
return f"{content}\n{breadcrumbs}" if content else breadcrumbs
|
||||||
|
|
||||||
|
|
||||||
def truncate_text(text: str, max_chars: int) -> str:
|
def truncate_text(text: str, max_chars: int) -> str:
|
||||||
"""Truncate text with a stable suffix."""
|
"""Truncate text with a stable suffix."""
|
||||||
if max_chars <= 0 or len(text) <= max_chars:
|
if max_chars <= 0 or len(text) <= max_chars:
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from nanobot.providers.base import GenerationSettings, LLMProvider
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
|
|
||||||
|
_IMAGE_CAPABILITY_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class LLMRuntime:
|
class LLMRuntime:
|
||||||
@@ -26,6 +28,7 @@ class LLMRuntime:
|
|||||||
context_window_tokens: int
|
context_window_tokens: int
|
||||||
model_preset: str | None = None
|
model_preset: str | None = None
|
||||||
snapshot_signature: tuple[object, ...] | None = None
|
snapshot_signature: tuple[object, ...] | None = None
|
||||||
|
supports_image_input: bool | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def capture(
|
def capture(
|
||||||
@@ -36,10 +39,18 @@ class LLMRuntime:
|
|||||||
context_window_tokens: int,
|
context_window_tokens: int,
|
||||||
model_preset: str | None = None,
|
model_preset: str | None = None,
|
||||||
snapshot_signature: tuple[object, ...] | None = None,
|
snapshot_signature: tuple[object, ...] | None = None,
|
||||||
|
supports_image_input: bool | None | object = _IMAGE_CAPABILITY_UNSET,
|
||||||
) -> LLMRuntime:
|
) -> LLMRuntime:
|
||||||
"""Capture provider defaults without retaining mutable generation state."""
|
"""Capture provider defaults without retaining mutable generation state."""
|
||||||
defaults = GenerationSettings()
|
defaults = GenerationSettings()
|
||||||
generation = getattr(provider, "generation", defaults)
|
generation = getattr(provider, "generation", defaults)
|
||||||
|
provider_image_capability = getattr(provider, "supports_image_input", None)
|
||||||
|
if not (
|
||||||
|
provider_image_capability is True
|
||||||
|
or provider_image_capability is False
|
||||||
|
or provider_image_capability is None
|
||||||
|
):
|
||||||
|
provider_image_capability = None
|
||||||
return cls(
|
return cls(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
model=model,
|
model=model,
|
||||||
@@ -55,6 +66,11 @@ class LLMRuntime:
|
|||||||
context_window_tokens=context_window_tokens,
|
context_window_tokens=context_window_tokens,
|
||||||
model_preset=model_preset,
|
model_preset=model_preset,
|
||||||
snapshot_signature=snapshot_signature,
|
snapshot_signature=snapshot_signature,
|
||||||
|
supports_image_input=(
|
||||||
|
provider_image_capability
|
||||||
|
if supports_image_input is _IMAGE_CAPABILITY_UNSET
|
||||||
|
else supports_image_input
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def with_generation_overrides(
|
def with_generation_overrides(
|
||||||
@@ -94,6 +110,7 @@ def runtime_from_provider_snapshot(
|
|||||||
context_window_tokens=snapshot.context_window_tokens,
|
context_window_tokens=snapshot.context_window_tokens,
|
||||||
model_preset=snapshot.model_preset,
|
model_preset=snapshot.model_preset,
|
||||||
snapshot_signature=snapshot.signature,
|
snapshot_signature=snapshot.signature,
|
||||||
|
supports_image_input=snapshot.supports_image_input,
|
||||||
)
|
)
|
||||||
return LLMRuntime.capture(
|
return LLMRuntime.capture(
|
||||||
snapshot.provider,
|
snapshot.provider,
|
||||||
@@ -101,4 +118,5 @@ def runtime_from_provider_snapshot(
|
|||||||
context_window_tokens=snapshot.context_window_tokens,
|
context_window_tokens=snapshot.context_window_tokens,
|
||||||
model_preset=snapshot.model_preset,
|
model_preset=snapshot.model_preset,
|
||||||
snapshot_signature=snapshot.signature,
|
snapshot_signature=snapshot.signature,
|
||||||
|
supports_image_input=snapshot.supports_image_input,
|
||||||
)
|
)
|
||||||
|
|||||||
+38
-112
@@ -857,6 +857,13 @@ def _parse_bool(value: str, field: str) -> bool:
|
|||||||
return normalized in {"1", "true", "yes"}
|
return normalized in {"1", "true", "yes"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_image_input_support(value: str | None) -> bool | None:
|
||||||
|
normalized = (value or "").strip().lower()
|
||||||
|
if normalized in {"", "auto"}:
|
||||||
|
return None
|
||||||
|
return _parse_bool(normalized, "supports_image_input")
|
||||||
|
|
||||||
|
|
||||||
def _parse_context_window_tokens(value: str | None) -> int | None:
|
def _parse_context_window_tokens(value: str | None) -> int | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -945,28 +952,10 @@ def _provider_display_name_exists(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _unique_model_configuration_name(config: Any, label: str) -> str:
|
|
||||||
"""Return a stable, unused preset name for a migrated model configuration."""
|
|
||||||
try:
|
|
||||||
base = _model_configuration_slug(label)
|
|
||||||
except WebUISettingsError:
|
|
||||||
base = "model"
|
|
||||||
candidate = base
|
|
||||||
suffix = 2
|
|
||||||
while candidate in config.model_presets:
|
|
||||||
candidate = f"{base}-{suffix}"
|
|
||||||
suffix += 1
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
def _model_configuration_label(model: str) -> str:
|
|
||||||
return model.rsplit("/", 1)[-1] or model
|
|
||||||
|
|
||||||
|
|
||||||
def _model_call_order_state(config: Any) -> tuple[list[str], bool]:
|
def _model_call_order_state(config: Any) -> tuple[list[str], bool]:
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
primary = defaults.model_preset
|
primary = defaults.model_preset
|
||||||
if not primary or primary == "default" or primary not in config.model_presets:
|
if primary not in config.model_presets:
|
||||||
return [], False
|
return [], False
|
||||||
order = [primary]
|
order = [primary]
|
||||||
for fallback in defaults.fallback_models:
|
for fallback in defaults.fallback_models:
|
||||||
@@ -1088,7 +1077,7 @@ def settings_payload(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
active_preset_name = defaults.model_preset or "default"
|
active_preset_name = defaults.model_preset
|
||||||
effective_preset = config.resolve_preset()
|
effective_preset = config.resolve_preset()
|
||||||
|
|
||||||
provider_name = (
|
provider_name = (
|
||||||
@@ -1132,32 +1121,7 @@ def settings_payload(
|
|||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
model_presets = [
|
model_presets = []
|
||||||
{
|
|
||||||
"name": "default",
|
|
||||||
"label": "Default",
|
|
||||||
"active": active_preset_name == "default",
|
|
||||||
"is_default": True,
|
|
||||||
"model": defaults.model,
|
|
||||||
"provider": defaults.provider,
|
|
||||||
"resolved_provider": config.get_provider_name(
|
|
||||||
defaults.model,
|
|
||||||
preset=config.resolve_default_preset(),
|
|
||||||
),
|
|
||||||
"max_tokens": defaults.max_tokens,
|
|
||||||
"context_window_tokens": defaults.context_window_tokens,
|
|
||||||
"temperature": defaults.temperature,
|
|
||||||
"reasoning_effort": defaults.reasoning_effort,
|
|
||||||
"reasoning_effort_values": _reasoning_effort_values_for(
|
|
||||||
config.get_provider_name(
|
|
||||||
defaults.model,
|
|
||||||
preset=config.resolve_default_preset(),
|
|
||||||
)
|
|
||||||
or defaults.provider,
|
|
||||||
defaults.model,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
for name, preset in config.model_presets.items():
|
for name, preset in config.model_presets.items():
|
||||||
resolved_preset_provider = (
|
resolved_preset_provider = (
|
||||||
config.get_provider_name(
|
config.get_provider_name(
|
||||||
@@ -1171,7 +1135,7 @@ def settings_payload(
|
|||||||
"name": name,
|
"name": name,
|
||||||
"label": preset.label or name,
|
"label": preset.label or name,
|
||||||
"active": active_preset_name == name,
|
"active": active_preset_name == name,
|
||||||
"is_default": False,
|
"is_default": name == "default",
|
||||||
"model": preset.model,
|
"model": preset.model,
|
||||||
"provider": preset.provider,
|
"provider": preset.provider,
|
||||||
"resolved_provider": resolved_preset_provider,
|
"resolved_provider": resolved_preset_provider,
|
||||||
@@ -1179,6 +1143,7 @@ def settings_payload(
|
|||||||
"context_window_tokens": preset.context_window_tokens,
|
"context_window_tokens": preset.context_window_tokens,
|
||||||
"temperature": preset.temperature,
|
"temperature": preset.temperature,
|
||||||
"reasoning_effort": preset.reasoning_effort,
|
"reasoning_effort": preset.reasoning_effort,
|
||||||
|
"supports_image_input": preset.supports_image_input,
|
||||||
"reasoning_effort_values": _reasoning_effort_values_for(
|
"reasoning_effort_values": _reasoning_effort_values_for(
|
||||||
resolved_preset_provider, preset.model
|
resolved_preset_provider, preset.model
|
||||||
),
|
),
|
||||||
@@ -1320,13 +1285,14 @@ def settings_usage_payload() -> dict[str, Any]:
|
|||||||
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
config = load_config()
|
config = load_config()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
|
default_preset = config.resolve_default_preset()
|
||||||
changed = False
|
changed = False
|
||||||
restart_required = False
|
restart_required = False
|
||||||
|
|
||||||
if "model_preset" in query or "modelPreset" in query:
|
if "model_preset" in query or "modelPreset" in query:
|
||||||
preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip()
|
preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip()
|
||||||
preset_value = None if not preset or preset == "default" else preset
|
preset_value = preset or "default"
|
||||||
if preset_value is not None and preset_value not in config.model_presets:
|
if preset_value not in config.model_presets:
|
||||||
raise WebUISettingsError("unknown model preset")
|
raise WebUISettingsError("unknown model preset")
|
||||||
if defaults.model_preset != preset_value:
|
if defaults.model_preset != preset_value:
|
||||||
defaults.model_preset = preset_value
|
defaults.model_preset = preset_value
|
||||||
@@ -1337,8 +1303,8 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
model = model.strip()
|
model = model.strip()
|
||||||
if not model:
|
if not model:
|
||||||
raise WebUISettingsError("model is required")
|
raise WebUISettingsError("model is required")
|
||||||
if defaults.model != model:
|
if default_preset.model != model:
|
||||||
defaults.model = model
|
default_preset.model = model
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
provider = _query_first(query, "provider")
|
provider = _query_first(query, "provider")
|
||||||
@@ -1347,8 +1313,8 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
if not provider:
|
if not provider:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
_validate_configured_provider(config, provider)
|
_validate_configured_provider(config, provider)
|
||||||
if defaults.provider != provider:
|
if default_preset.provider != provider:
|
||||||
defaults.provider = provider
|
default_preset.provider = provider
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
context_window_tokens = _parse_context_window_tokens(
|
context_window_tokens = _parse_context_window_tokens(
|
||||||
@@ -1356,9 +1322,9 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
context_window_tokens is not None
|
context_window_tokens is not None
|
||||||
and defaults.context_window_tokens != context_window_tokens
|
and default_preset.context_window_tokens != context_window_tokens
|
||||||
):
|
):
|
||||||
defaults.context_window_tokens = context_window_tokens
|
default_preset.context_window_tokens = context_window_tokens
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
timezone = _query_first(query, "timezone")
|
timezone = _query_first(query, "timezone")
|
||||||
@@ -1449,6 +1415,9 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
reasoning_effort = (
|
reasoning_effort = (
|
||||||
_query_first_alias(query, "reasoning_effort", "reasoningEffort") or ""
|
_query_first_alias(query, "reasoning_effort", "reasoningEffort") or ""
|
||||||
).strip() or None
|
).strip() or None
|
||||||
|
supports_image_input = _parse_image_input_support(
|
||||||
|
_query_first_alias(query, "supports_image_input", "supportsImageInput")
|
||||||
|
)
|
||||||
config.model_presets[name] = ModelPresetConfig(
|
config.model_presets[name] = ModelPresetConfig(
|
||||||
label=label,
|
label=label,
|
||||||
model=model,
|
model=model,
|
||||||
@@ -1461,6 +1430,7 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
),
|
),
|
||||||
temperature=temperature if temperature is not None else base.temperature,
|
temperature=temperature if temperature is not None else base.temperature,
|
||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
|
supports_image_input=supports_image_input,
|
||||||
)
|
)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
payload = settings_payload()
|
payload = settings_payload()
|
||||||
@@ -1470,7 +1440,7 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
|
|
||||||
def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name or name == "default":
|
if not name:
|
||||||
raise WebUISettingsError("model configuration is required")
|
raise WebUISettingsError("model configuration is required")
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -1539,6 +1509,14 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
preset.reasoning_effort = reasoning_effort
|
preset.reasoning_effort = reasoning_effort
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
|
if "supports_image_input" in query or "supportsImageInput" in query:
|
||||||
|
supports_image_input = _parse_image_input_support(
|
||||||
|
_query_first_alias(query, "supports_image_input", "supportsImageInput")
|
||||||
|
)
|
||||||
|
if preset.supports_image_input is not supports_image_input:
|
||||||
|
preset.supports_image_input = supports_image_input
|
||||||
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return settings_payload()
|
return settings_payload()
|
||||||
@@ -1584,68 +1562,16 @@ def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, Any]:
|
def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, Any]:
|
||||||
"""Materialize legacy primary/inline model settings as named presets."""
|
"""Compatibility endpoint; loading config now performs this migration."""
|
||||||
config = load_config()
|
|
||||||
defaults = config.agents.defaults
|
|
||||||
primary = config.resolve_preset()
|
|
||||||
created: list[str] = []
|
|
||||||
|
|
||||||
if not defaults.model_preset or defaults.model_preset == "default":
|
|
||||||
label = _model_configuration_label(primary.model)
|
|
||||||
name = _unique_model_configuration_name(config, label)
|
|
||||||
config.model_presets[name] = ModelPresetConfig(
|
|
||||||
label=label,
|
|
||||||
model=primary.model,
|
|
||||||
provider=primary.provider,
|
|
||||||
max_tokens=primary.max_tokens,
|
|
||||||
context_window_tokens=primary.context_window_tokens,
|
|
||||||
temperature=primary.temperature,
|
|
||||||
reasoning_effort=primary.reasoning_effort,
|
|
||||||
)
|
|
||||||
defaults.model_preset = name
|
|
||||||
created.append(name)
|
|
||||||
|
|
||||||
fallback_models: list[str] = []
|
|
||||||
for fallback in defaults.fallback_models:
|
|
||||||
if isinstance(fallback, str):
|
|
||||||
fallback_models.append(fallback)
|
|
||||||
continue
|
|
||||||
label = _model_configuration_label(fallback.model)
|
|
||||||
name = _unique_model_configuration_name(config, label)
|
|
||||||
config.model_presets[name] = ModelPresetConfig(
|
|
||||||
label=label,
|
|
||||||
model=fallback.model,
|
|
||||||
provider=fallback.provider,
|
|
||||||
max_tokens=(
|
|
||||||
fallback.max_tokens
|
|
||||||
if fallback.max_tokens is not None
|
|
||||||
else primary.max_tokens
|
|
||||||
),
|
|
||||||
context_window_tokens=(
|
|
||||||
fallback.context_window_tokens
|
|
||||||
if fallback.context_window_tokens is not None
|
|
||||||
else primary.context_window_tokens
|
|
||||||
),
|
|
||||||
temperature=(
|
|
||||||
fallback.temperature
|
|
||||||
if fallback.temperature is not None
|
|
||||||
else primary.temperature
|
|
||||||
),
|
|
||||||
reasoning_effort=fallback.reasoning_effort,
|
|
||||||
)
|
|
||||||
fallback_models.append(name)
|
|
||||||
created.append(name)
|
|
||||||
|
|
||||||
if created:
|
|
||||||
defaults.fallback_models = fallback_models
|
|
||||||
save_config(config)
|
|
||||||
return settings_payload()
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name or name == "default":
|
if not name:
|
||||||
raise WebUISettingsError("model configuration is required")
|
raise WebUISettingsError("model configuration is required")
|
||||||
|
if name == "default":
|
||||||
|
raise WebUISettingsError("default model configuration cannot be deleted", status=409)
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
if name not in config.model_presets:
|
if name not in config.model_presets:
|
||||||
|
|||||||
+7
-1
@@ -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": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.runner import AgentRunSpec
|
from nanobot.agent.runner import AgentRunSpec
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
from nanobot.providers.base import GenerationSettings, LLMProvider
|
from nanobot.providers.base import GenerationSettings, LLMProvider
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ def make_run_spec(provider: LLMProvider, **kwargs: Any) -> AgentRunSpec:
|
|||||||
model = kwargs.pop("model")
|
model = kwargs.pop("model")
|
||||||
context_window_tokens = kwargs.pop(
|
context_window_tokens = kwargs.pop(
|
||||||
"context_window_tokens",
|
"context_window_tokens",
|
||||||
AgentDefaults().context_window_tokens,
|
ModelPresetConfig(model=model).context_window_tokens,
|
||||||
)
|
)
|
||||||
provider_generation = getattr(provider, "generation", None)
|
provider_generation = getattr(provider, "generation", None)
|
||||||
defaults = GenerationSettings()
|
defaults = GenerationSettings()
|
||||||
|
|||||||
@@ -272,7 +272,13 @@ class TestAgentLoopTTLParam:
|
|||||||
kwargs = session.get_history.call_args.kwargs
|
kwargs = session.get_history.call_args.kwargs
|
||||||
assert isinstance(kwargs.get("max_tokens"), int)
|
assert isinstance(kwargs.get("max_tokens"), int)
|
||||||
assert kwargs["max_tokens"] > 0
|
assert kwargs["max_tokens"] > 0
|
||||||
assert set(kwargs) == {"max_messages", "max_tokens", "extend_to_user"}
|
assert set(kwargs) == {
|
||||||
|
"max_messages",
|
||||||
|
"max_tokens",
|
||||||
|
"extend_to_user",
|
||||||
|
"include_media",
|
||||||
|
}
|
||||||
|
assert kwargs["include_media"] is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_session_file_cap_archives_and_trims_old_messages(self, tmp_path):
|
async def test_session_file_cap_archives_and_trims_old_messages(self, tmp_path):
|
||||||
|
|||||||
@@ -170,6 +170,34 @@ class TestConsolidatorSummarize:
|
|||||||
entries = store.read_unprocessed_history(since_cursor=0)
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
assert entries[0]["session_key"] == "telegram:chat-1"
|
assert entries[0]["session_key"] == "telegram:chat-1"
|
||||||
|
|
||||||
|
async def test_summarize_preserves_media_manifest_deterministically(
|
||||||
|
self,
|
||||||
|
consolidator,
|
||||||
|
mock_provider,
|
||||||
|
store,
|
||||||
|
runtime,
|
||||||
|
):
|
||||||
|
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||||
|
content="User shared a screenshot.",
|
||||||
|
finish_reason="stop",
|
||||||
|
)
|
||||||
|
messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": "",
|
||||||
|
"media": ["/media/screenshot.png"],
|
||||||
|
}]
|
||||||
|
|
||||||
|
result = await consolidator.archive(messages, runtime=runtime)
|
||||||
|
|
||||||
|
assert result == (
|
||||||
|
"Archived attachments:\n- [image: /media/screenshot.png]\n\n"
|
||||||
|
"User shared a screenshot."
|
||||||
|
)
|
||||||
|
prompt = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
|
||||||
|
assert "[image: /media/screenshot.png]" in prompt
|
||||||
|
entries = store.read_unprocessed_history(since_cursor=0)
|
||||||
|
assert "[image: /media/screenshot.png]" in entries[0]["content"]
|
||||||
|
|
||||||
async def test_summarize_raw_dumps_on_llm_failure(
|
async def test_summarize_raw_dumps_on_llm_failure(
|
||||||
self, consolidator, mock_provider, store, runtime
|
self, consolidator, mock_provider, store, runtime
|
||||||
):
|
):
|
||||||
@@ -992,6 +1020,18 @@ class TestRawArchiveTruncation:
|
|||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
assert "hello" in entries[0]["content"]
|
assert "hello" in entries[0]["content"]
|
||||||
|
|
||||||
|
def test_raw_archive_preserves_late_media_path_before_truncation(self, store):
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": "x" * 20_000},
|
||||||
|
{"role": "user", "content": "", "media": ["/media/late.png"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
store.raw_archive(messages)
|
||||||
|
|
||||||
|
entry = store.read_unprocessed_history(since_cursor=0)[0]["content"]
|
||||||
|
assert "Archived attachments:" in entry
|
||||||
|
assert "[image: /media/late.png]" in entry
|
||||||
|
|
||||||
def test_raw_archive_excludes_model_only_runtime_context(self, store):
|
def test_raw_archive_excludes_model_only_runtime_context(self, store):
|
||||||
content, marker = append_runtime_context(
|
content, marker = append_runtime_context(
|
||||||
"ship the feature",
|
"ship the feature",
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.runtime_context import RuntimeContextBlock
|
from nanobot.runtime_context import (
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
|
RuntimeContextBlock,
|
||||||
|
append_runtime_context,
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -259,10 +263,12 @@ class TestBuildUserContent:
|
|||||||
result = builder.build_user_content("hello", [])
|
result = builder.build_user_content("hello", [])
|
||||||
assert result == "hello"
|
assert result == "hello"
|
||||||
|
|
||||||
def test_nonexistent_media_file_returns_string(self, tmp_path):
|
def test_nonexistent_media_file_returns_explicit_placeholder(self, tmp_path):
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
result = builder.build_user_content("hello", ["/nonexistent/image.png"])
|
result = builder.build_user_content("hello", ["/nonexistent/image.png"])
|
||||||
assert result == "hello"
|
assert isinstance(result, list)
|
||||||
|
assert "unavailable" in result[0]["text"].lower()
|
||||||
|
assert result[1] == {"type": "text", "text": "hello"}
|
||||||
|
|
||||||
def test_non_image_file_returns_string(self, tmp_path):
|
def test_non_image_file_returns_string(self, tmp_path):
|
||||||
txt = tmp_path / "doc.txt"
|
txt = tmp_path / "doc.txt"
|
||||||
@@ -438,3 +444,55 @@ class TestBuildMessages:
|
|||||||
user_msg = messages[-1]["content"]
|
user_msg = messages[-1]["content"]
|
||||||
assert isinstance(user_msg, list)
|
assert isinstance(user_msg, list)
|
||||||
assert any(b.get("type") == "image_url" for b in user_msg)
|
assert any(b.get("type") == "image_url" for b in user_msg)
|
||||||
|
|
||||||
|
def test_persisted_media_rehydrates_to_identical_image_content(self, tmp_path):
|
||||||
|
png = tmp_path / "stable.png"
|
||||||
|
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
|
||||||
|
builder = _builder(tmp_path)
|
||||||
|
first_content = builder.build_user_content("describe", [str(png)])
|
||||||
|
history = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "describe",
|
||||||
|
"_media_paths": [str(png)],
|
||||||
|
},
|
||||||
|
{"role": "assistant", "content": "done"},
|
||||||
|
]
|
||||||
|
|
||||||
|
messages = builder.build_messages(history, "next")
|
||||||
|
|
||||||
|
assert messages[1]["content"] == first_content
|
||||||
|
assert "_media_paths" not in messages[1]
|
||||||
|
|
||||||
|
def test_persisted_media_and_runtime_context_rehydrate_identically(self, tmp_path):
|
||||||
|
png = tmp_path / "stable-context.png"
|
||||||
|
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
|
||||||
|
builder = _builder(tmp_path)
|
||||||
|
blocks = [
|
||||||
|
RuntimeContextBlock(
|
||||||
|
source="cli_apps",
|
||||||
|
content="CLI App Attachment: @drawio (tool=run_cli_app).",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
first_content = builder.build_messages(
|
||||||
|
[],
|
||||||
|
"describe",
|
||||||
|
media=[str(png)],
|
||||||
|
runtime_context_blocks=blocks,
|
||||||
|
)[-1]["content"]
|
||||||
|
persisted_content, marker = append_runtime_context("describe", blocks)
|
||||||
|
history = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": persisted_content,
|
||||||
|
"_media_paths": [str(png)],
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META: marker,
|
||||||
|
},
|
||||||
|
{"role": "assistant", "content": "done"},
|
||||||
|
]
|
||||||
|
|
||||||
|
messages = builder.build_messages(history, "next")
|
||||||
|
|
||||||
|
assert messages[1]["content"] == first_content
|
||||||
|
assert "_media_paths" not in messages[1]
|
||||||
|
assert RUNTIME_CONTEXT_HISTORY_META not in messages[1]
|
||||||
|
|||||||
@@ -95,6 +95,33 @@ def test_resolver_resolves_preset_without_mutating_selected_runtime() -> None:
|
|||||||
assert resolved.generation == GenerationSettings(0.5, 512, None)
|
assert resolved.generation == GenerationSettings(0.5, 512, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_presets_keep_image_capability_request_scoped() -> None:
|
||||||
|
provider = _provider()
|
||||||
|
provider.supports_image_input = None
|
||||||
|
resolver = ModelRuntimeResolver(
|
||||||
|
_runtime(provider),
|
||||||
|
model_presets={
|
||||||
|
"vision": ModelPresetConfig(
|
||||||
|
model="shared-model",
|
||||||
|
supports_image_input=True,
|
||||||
|
),
|
||||||
|
"text": ModelPresetConfig(
|
||||||
|
model="shared-model",
|
||||||
|
supports_image_input=False,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
vision = resolver.resolve_preset("vision")
|
||||||
|
text = resolver.resolve_preset("text")
|
||||||
|
|
||||||
|
assert vision.provider is provider
|
||||||
|
assert text.provider is provider
|
||||||
|
assert vision.supports_image_input is True
|
||||||
|
assert text.supports_image_input is False
|
||||||
|
assert provider.supports_image_input is None
|
||||||
|
|
||||||
|
|
||||||
def test_resolver_reuses_preset_until_runtime_config_is_invalidated() -> None:
|
def test_resolver_reuses_preset_until_runtime_config_is_invalidated() -> None:
|
||||||
initial = _runtime()
|
initial = _runtime()
|
||||||
preset = ModelPresetConfig(model="fast-model")
|
preset = ModelPresetConfig(model="fast-model")
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ class TestRunOnboardExitBehavior:
|
|||||||
|
|
||||||
def fake_configure_general_settings(config, section):
|
def fake_configure_general_settings(config, section):
|
||||||
if section == "Agent Settings":
|
if section == "Agent Settings":
|
||||||
config.agents.defaults.model = "test/provider-model"
|
config.resolve_default_preset().model = "test/provider-model"
|
||||||
|
|
||||||
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
|
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
|
||||||
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", fake_select_with_back)
|
||||||
@@ -1997,7 +1997,7 @@ class TestModelPresetWizard:
|
|||||||
config.model_presets["fast"] = ModelPresetConfig(model="gpt-4.1-mini")
|
config.model_presets["fast"] = ModelPresetConfig(model="gpt-4.1-mini")
|
||||||
config.model_presets["power"] = ModelPresetConfig(model="gpt-4.1")
|
config.model_presets["power"] = ModelPresetConfig(model="gpt-4.1")
|
||||||
_sync_preset_cache(config)
|
_sync_preset_cache(config)
|
||||||
assert _MODEL_PRESET_CACHE == {"fast", "power"}
|
assert _MODEL_PRESET_CACHE == {"default", "fast", "power"}
|
||||||
_MODEL_PRESET_CACHE.clear()
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
|
||||||
def test_model_preset_add(self, monkeypatch):
|
def test_model_preset_add(self, monkeypatch):
|
||||||
@@ -2106,10 +2106,9 @@ class TestModelPresetWizard:
|
|||||||
assert defaults.model_preset == "fast"
|
assert defaults.model_preset == "fast"
|
||||||
_MODEL_PRESET_CACHE.clear()
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
|
||||||
def test_model_preset_field_handler_clear(self, monkeypatch):
|
def test_model_preset_field_handler_selects_default(self, monkeypatch):
|
||||||
"""_handle_model_preset_field should clear preset when Clear value is chosen."""
|
"""The concrete default preset replaces the legacy clear selection."""
|
||||||
from nanobot.cli.onboard import (
|
from nanobot.cli.onboard import (
|
||||||
_CLEAR_CHOICE,
|
|
||||||
_MODEL_PRESET_CACHE,
|
_MODEL_PRESET_CACHE,
|
||||||
_handle_model_preset_field,
|
_handle_model_preset_field,
|
||||||
)
|
)
|
||||||
@@ -2118,11 +2117,11 @@ class TestModelPresetWizard:
|
|||||||
_MODEL_PRESET_CACHE.clear()
|
_MODEL_PRESET_CACHE.clear()
|
||||||
_MODEL_PRESET_CACHE.add("fast")
|
_MODEL_PRESET_CACHE.add("fast")
|
||||||
|
|
||||||
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: _CLEAR_CHOICE)
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "default")
|
||||||
|
|
||||||
defaults = AgentDefaults(model_preset="fast")
|
defaults = AgentDefaults(model_preset="fast")
|
||||||
_handle_model_preset_field(defaults, "model_preset", "Model Preset", "fast")
|
_handle_model_preset_field(defaults, "model_preset", "Model Preset", "fast")
|
||||||
assert defaults.model_preset is None
|
assert defaults.model_preset == "default"
|
||||||
_MODEL_PRESET_CACHE.clear()
|
_MODEL_PRESET_CACHE.clear()
|
||||||
|
|
||||||
def test_main_menu_dispatch_includes_model_presets(self):
|
def test_main_menu_dispatch_includes_model_presets(self):
|
||||||
@@ -2208,13 +2207,13 @@ class TestModelPresetWizard:
|
|||||||
def test_provider_field_handler(self, monkeypatch):
|
def test_provider_field_handler(self, monkeypatch):
|
||||||
"""_handle_provider_field should set provider from choices."""
|
"""_handle_provider_field should set provider from choices."""
|
||||||
from nanobot.cli.onboard import _handle_provider_field
|
from nanobot.cli.onboard import _handle_provider_field
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "anthropic")
|
monkeypatch.setattr(onboard_wizard, "_select_with_back", lambda *a, **kw: "anthropic")
|
||||||
|
|
||||||
defaults = AgentDefaults()
|
preset = ModelPresetConfig(model="anthropic/claude-opus-4-5")
|
||||||
_handle_provider_field(defaults, "provider", "Provider", "auto")
|
_handle_provider_field(preset, "provider", "Provider", "auto")
|
||||||
assert defaults.provider == "anthropic"
|
assert preset.provider == "anthropic"
|
||||||
|
|
||||||
def test_search_provider_field_handler(self, monkeypatch):
|
def test_search_provider_field_handler(self, monkeypatch):
|
||||||
"""_handle_search_provider_field should set the search engine from choices."""
|
"""_handle_search_provider_field should set the search engine from choices."""
|
||||||
@@ -2235,7 +2234,10 @@ class TestModelPresetWizard:
|
|||||||
_handle_search_provider_field,
|
_handle_search_provider_field,
|
||||||
_resolve_field_handler,
|
_resolve_field_handler,
|
||||||
)
|
)
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
|
|
||||||
assert _resolve_field_handler(WebSearchConfig(), "provider") is _handle_search_provider_field
|
assert _resolve_field_handler(WebSearchConfig(), "provider") is _handle_search_provider_field
|
||||||
assert _resolve_field_handler(AgentDefaults(), "provider") is _handle_provider_field
|
assert (
|
||||||
|
_resolve_field_handler(ModelPresetConfig(model="test"), "provider")
|
||||||
|
is _handle_provider_field
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -46,6 +46,7 @@ def _fallback(
|
|||||||
context_window_tokens: int = 65_536,
|
context_window_tokens: int = 65_536,
|
||||||
temperature: float = 0.1,
|
temperature: float = 0.1,
|
||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
|
supports_image_input: bool | None = None,
|
||||||
) -> ModelPresetConfig:
|
) -> ModelPresetConfig:
|
||||||
return ModelPresetConfig(
|
return ModelPresetConfig(
|
||||||
model=model,
|
model=model,
|
||||||
@@ -54,6 +55,7 @@ def _fallback(
|
|||||||
context_window_tokens=context_window_tokens,
|
context_window_tokens=context_window_tokens,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
|
supports_image_input=supports_image_input,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,33 +95,35 @@ def test_fallback_models_default_empty() -> None:
|
|||||||
assert defaults.fallback_models == []
|
assert defaults.fallback_models == []
|
||||||
|
|
||||||
|
|
||||||
def test_fallback_models_accept_preset_refs_and_inline_configs() -> None:
|
def test_fallback_models_accept_preset_refs() -> None:
|
||||||
from nanobot.config.schema import Config, InlineFallbackConfig
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"fallbackModels": [
|
"fallbackModels": ["deep"]
|
||||||
"deep",
|
|
||||||
{
|
|
||||||
"provider": "openai",
|
|
||||||
"model": "gpt-4.1",
|
|
||||||
"maxTokens": 4096,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"provider": "openai", "model": "gpt-4.1"},
|
||||||
"deep": {"provider": "anthropic", "model": "claude-opus-4-7"}
|
"deep": {"provider": "anthropic", "model": "claude-opus-4-7"}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
assert config.agents.defaults.fallback_models[0] == "deep"
|
assert config.agents.defaults.fallback_models == ["deep"]
|
||||||
assert config.agents.defaults.fallback_models[1] == InlineFallbackConfig(
|
|
||||||
provider="openai",
|
|
||||||
model="gpt-4.1",
|
def test_fallback_models_reject_inline_configs_after_schema_migration() -> None:
|
||||||
max_tokens=4096,
|
from nanobot.config.schema import Config
|
||||||
)
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Config.model_validate({
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"fallbackModels": [{"provider": "openai", "model": "gpt-4.1"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def test_fallback_model_preset_ref_must_exist() -> None:
|
def test_fallback_model_preset_ref_must_exist() -> None:
|
||||||
@@ -128,7 +132,7 @@ def test_fallback_model_preset_ref_must_exist() -> None:
|
|||||||
with pytest.raises(ValueError, match="fallback_models.*not found"):
|
with pytest.raises(ValueError, match="fallback_models.*not found"):
|
||||||
Config.model_validate({
|
Config.model_validate({
|
||||||
"agents": {"defaults": {"fallbackModels": ["missing"]}},
|
"agents": {"defaults": {"fallbackModels": ["missing"]}},
|
||||||
"modelPresets": {},
|
"modelPresets": {"default": {"model": "primary"}},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -144,6 +148,7 @@ def test_provider_signature_tracks_fallback_presets_and_provider_config() -> Non
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "primary", "provider": "openai"},
|
||||||
"fast": {"model": "openai/gpt-4.1", "provider": "openai"},
|
"fast": {"model": "openai/gpt-4.1", "provider": "openai"},
|
||||||
"deep": {"model": "anthropic/claude-sonnet-4-6", "provider": "anthropic"},
|
"deep": {"model": "anthropic/claude-sonnet-4-6", "provider": "anthropic"},
|
||||||
},
|
},
|
||||||
@@ -190,6 +195,7 @@ def test_provider_snapshot_uses_smallest_fallback_context_window() -> None:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "primary", "provider": "openai"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "openai/gpt-4.1",
|
"model": "openai/gpt-4.1",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
@@ -213,36 +219,49 @@ def test_provider_snapshot_uses_smallest_fallback_context_window() -> None:
|
|||||||
assert snapshot.context_window_tokens == 64000
|
assert snapshot.context_window_tokens == 64000
|
||||||
|
|
||||||
|
|
||||||
def test_inline_fallback_reasoning_effort_does_not_inherit_primary() -> None:
|
def test_provider_signature_tracks_fallback_image_capability() -> None:
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.providers.factory import provider_signature
|
from nanobot.providers.factory import provider_signature
|
||||||
|
|
||||||
config = Config.model_validate({
|
base = {
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"modelPreset": "fast",
|
"modelPreset": "fast",
|
||||||
"fallbackModels": [
|
"fallbackModels": ["fallback"],
|
||||||
{"provider": "openai", "model": "gpt-4.1"}
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "primary"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "anthropic/claude-opus-4-5",
|
"model": "anthropic/claude-opus-4-5",
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
"reasoningEffort": "high",
|
"reasoningEffort": "high",
|
||||||
}
|
},
|
||||||
|
"fallback": {
|
||||||
|
"provider": "openai",
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"supportsImageInput": False,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"anthropic": {"apiKey": "primary-key"},
|
"anthropic": {"apiKey": "primary-key"},
|
||||||
"openai": {"apiKey": "fallback-key"},
|
"openai": {"apiKey": "fallback-key"},
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
changed = {
|
||||||
|
**base,
|
||||||
|
"modelPresets": {
|
||||||
|
**base["modelPresets"],
|
||||||
|
"fallback": {
|
||||||
|
**base["modelPresets"]["fallback"],
|
||||||
|
"supportsImageInput": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
signature = provider_signature(config)
|
assert provider_signature(Config.model_validate(base)) != provider_signature(
|
||||||
fallback_signatures = signature[-1]
|
Config.model_validate(changed)
|
||||||
|
)
|
||||||
assert fallback_signatures[0][13] is None
|
|
||||||
|
|
||||||
|
|
||||||
# -- FallbackProvider tests --
|
# -- FallbackProvider tests --
|
||||||
@@ -333,6 +352,204 @@ class TestFallbackOnPrimaryError:
|
|||||||
for line in logs
|
for line in logs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_primary_and_fallback_apply_their_own_image_capability(self) -> None:
|
||||||
|
image_messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64,abc"},
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
primary = _FakeProvider("primary", _error_response())
|
||||||
|
primary.supports_image_input = True
|
||||||
|
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
||||||
|
factory = MagicMock(return_value=fallback)
|
||||||
|
fb = FallbackProvider(
|
||||||
|
primary=primary,
|
||||||
|
fallback_presets=[
|
||||||
|
_fallback("fallback-a", supports_image_input=False)
|
||||||
|
],
|
||||||
|
provider_factory=factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fb.chat(messages=image_messages, model="primary-model")
|
||||||
|
|
||||||
|
assert result.content == "fallback ok"
|
||||||
|
primary_content = primary.chat_calls[0]["messages"][0]["content"]
|
||||||
|
fallback_content = fallback.chat_calls[0]["messages"][0]["content"]
|
||||||
|
assert any(block.get("type") == "image_url" for block in primary_content)
|
||||||
|
assert all(block.get("type") != "image_url" for block in fallback_content)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_text_only_primary_does_not_remove_images_from_vision_fallback(self) -> None:
|
||||||
|
image_messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64,abc"},
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
primary = _FakeProvider("primary", _error_response())
|
||||||
|
primary.supports_image_input = False
|
||||||
|
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
||||||
|
factory = MagicMock(return_value=fallback)
|
||||||
|
fb = FallbackProvider(
|
||||||
|
primary=primary,
|
||||||
|
fallback_presets=[
|
||||||
|
_fallback("fallback-a", supports_image_input=True)
|
||||||
|
],
|
||||||
|
provider_factory=factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fb.chat(messages=image_messages, model="primary-model")
|
||||||
|
|
||||||
|
assert result.content == "fallback ok"
|
||||||
|
primary_content = primary.chat_calls[0]["messages"][0]["content"]
|
||||||
|
fallback_content = fallback.chat_calls[0]["messages"][0]["content"]
|
||||||
|
assert all(block.get("type") != "image_url" for block in primary_content)
|
||||||
|
assert any(block.get("type") == "image_url" for block in fallback_content)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_explicit_vision_rejection_advances_to_vision_fallback(self) -> None:
|
||||||
|
image_messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64,abc"},
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
primary = _FakeProvider(
|
||||||
|
"primary",
|
||||||
|
_make_response(
|
||||||
|
"image input is not supported",
|
||||||
|
finish_reason="error",
|
||||||
|
error_kind="invalid_request",
|
||||||
|
error_status_code=400,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
primary.supports_image_input = True
|
||||||
|
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
||||||
|
fb = FallbackProvider(
|
||||||
|
primary=primary,
|
||||||
|
fallback_presets=[
|
||||||
|
_fallback("fallback-a", supports_image_input=True)
|
||||||
|
],
|
||||||
|
provider_factory=MagicMock(return_value=fallback),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fb.chat(messages=image_messages, model="primary-model")
|
||||||
|
|
||||||
|
assert result.content == "fallback ok"
|
||||||
|
fallback_content = fallback.chat_calls[0]["messages"][0]["content"]
|
||||||
|
assert any(block.get("type") == "image_url" for block in fallback_content)
|
||||||
|
assert fb._primary_failures == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_primary_retries_without_images_through_retry_wrapper(self) -> None:
|
||||||
|
image_messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64,abc"},
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
primary = _FakeProvider("primary")
|
||||||
|
primary.chat = AsyncMock(side_effect=[
|
||||||
|
_make_response(
|
||||||
|
"image input is not supported",
|
||||||
|
finish_reason="error",
|
||||||
|
error_kind="invalid_request",
|
||||||
|
),
|
||||||
|
_make_response("primary text fallback ok"),
|
||||||
|
])
|
||||||
|
fallback_factory = MagicMock()
|
||||||
|
fb = FallbackProvider(
|
||||||
|
primary=primary,
|
||||||
|
fallback_presets=[_fallback("fallback-a", supports_image_input=True)],
|
||||||
|
provider_factory=fallback_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fb.chat_with_retry(
|
||||||
|
messages=image_messages,
|
||||||
|
model="primary-model",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.content == "primary text fallback ok"
|
||||||
|
assert primary.chat.await_count == 2
|
||||||
|
retry_content = primary.chat.await_args_list[1].kwargs["messages"][0]["content"]
|
||||||
|
assert all(block.get("type") != "image_url" for block in retry_content)
|
||||||
|
fallback_factory.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_streaming_vision_rejection_advances_to_text_fallback(self) -> None:
|
||||||
|
image_messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64,abc"},
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
primary = _FakeProvider("primary")
|
||||||
|
primary.supports_image_input = True
|
||||||
|
primary.chat_stream = AsyncMock(return_value=_make_response(
|
||||||
|
"image input is not supported",
|
||||||
|
finish_reason="error",
|
||||||
|
error_kind="invalid_request",
|
||||||
|
error_status_code=400,
|
||||||
|
))
|
||||||
|
fallback = _FakeProvider("fallback")
|
||||||
|
fallback.chat_stream = AsyncMock(return_value=_make_response("fallback ok"))
|
||||||
|
fb = FallbackProvider(
|
||||||
|
primary=primary,
|
||||||
|
fallback_presets=[
|
||||||
|
_fallback("fallback-a", supports_image_input=False)
|
||||||
|
],
|
||||||
|
provider_factory=MagicMock(return_value=fallback),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fb.chat_stream(
|
||||||
|
messages=image_messages,
|
||||||
|
model="primary-model",
|
||||||
|
on_content_delta=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.content == "fallback ok"
|
||||||
|
fallback_content = fallback.chat_stream.await_args.kwargs["messages"][0]["content"]
|
||||||
|
assert all(block.get("type") != "image_url" for block in fallback_content)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auto_capability_does_not_retry_after_streaming_content(self) -> None:
|
||||||
|
image_messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64,abc"},
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
primary = _FakeProvider(
|
||||||
|
"primary",
|
||||||
|
_make_response(
|
||||||
|
"model does not support images",
|
||||||
|
finish_reason="error",
|
||||||
|
error_kind="invalid_request",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
fb = FallbackProvider(
|
||||||
|
primary=primary,
|
||||||
|
fallback_presets=[_fallback("fallback-a")],
|
||||||
|
provider_factory=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fb.chat_stream(
|
||||||
|
messages=image_messages,
|
||||||
|
model="primary-model",
|
||||||
|
on_content_delta=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.finish_reason == "error"
|
||||||
|
assert len(primary.chat_stream_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestNoFallbackWhenContentStreamed:
|
class TestNoFallbackWhenContentStreamed:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ async def test_active_run_keeps_provider_captured_at_admission() -> None:
|
|||||||
second_provider = MagicMock(spec=LLMProvider)
|
second_provider = MagicMock(spec=LLMProvider)
|
||||||
first_provider.generation = GenerationSettings(temperature=0.2, max_tokens=2048)
|
first_provider.generation = GenerationSettings(temperature=0.2, max_tokens=2048)
|
||||||
second_provider.generation = GenerationSettings(temperature=0.9, max_tokens=512)
|
second_provider.generation = GenerationSettings(temperature=0.9, max_tokens=512)
|
||||||
|
first_provider.supports_image_input = False
|
||||||
first_calls = 0
|
first_calls = 0
|
||||||
second_calls = 0
|
second_calls = 0
|
||||||
request_temperatures: list[float] = []
|
request_temperatures: list[float] = []
|
||||||
|
request_image_capabilities: list[bool | None] = []
|
||||||
selected_runtime = LLMRuntime.capture(
|
selected_runtime = LLMRuntime.capture(
|
||||||
first_provider,
|
first_provider,
|
||||||
"captured-model",
|
"captured-model",
|
||||||
@@ -33,6 +35,7 @@ async def test_active_run_keeps_provider_captured_at_admission() -> None:
|
|||||||
nonlocal first_calls, selected_runtime
|
nonlocal first_calls, selected_runtime
|
||||||
first_calls += 1
|
first_calls += 1
|
||||||
request_temperatures.append(kwargs["temperature"])
|
request_temperatures.append(kwargs["temperature"])
|
||||||
|
request_image_capabilities.append(kwargs["supports_image_input"])
|
||||||
selected_runtime = LLMRuntime.capture(
|
selected_runtime = LLMRuntime.capture(
|
||||||
second_provider,
|
second_provider,
|
||||||
"future-model",
|
"future-model",
|
||||||
@@ -68,4 +71,5 @@ async def test_active_run_keeps_provider_captured_at_admission() -> None:
|
|||||||
assert first_calls == 2
|
assert first_calls == 2
|
||||||
assert second_calls == 0
|
assert second_calls == 0
|
||||||
assert request_temperatures == [0.2, 0.2]
|
assert request_temperatures == [0.2, 0.2]
|
||||||
|
assert request_image_capabilities == [False, False]
|
||||||
assert selected_runtime.provider is second_provider
|
assert selected_runtime.provider is second_provider
|
||||||
|
|||||||
@@ -302,9 +302,9 @@ def test_settings_context_window_refreshes_runtime_state(
|
|||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.workspace = str(tmp_path / "workspace")
|
config.agents.defaults.workspace = str(tmp_path / "workspace")
|
||||||
config.agents.defaults.model = "openai/gpt-4o"
|
config.resolve_default_preset().model = "openai/gpt-4o"
|
||||||
config.agents.defaults.provider = "openai"
|
config.resolve_default_preset().provider = "openai"
|
||||||
config.agents.defaults.context_window_tokens = 65_536
|
config.resolve_default_preset().context_window_tokens = 65_536
|
||||||
config.providers.openai.api_key = "sk-test"
|
config.providers.openai.api_key = "sk-test"
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
@@ -321,3 +321,44 @@ def test_settings_context_window_refreshes_runtime_state(
|
|||||||
assert payload["requires_restart"] is False
|
assert payload["requires_restart"] is False
|
||||||
assert loop.context_window_tokens == 262_144
|
assert loop.context_window_tokens == 262_144
|
||||||
assert loop.llm_runtime().context_window_tokens == 262_144
|
assert loop.llm_runtime().context_window_tokens == 262_144
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_config_uses_snapshot_loader_for_preset_switch_with_injected_provider(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
config = Config()
|
||||||
|
config.agents.defaults.workspace = str(tmp_path / "workspace")
|
||||||
|
config.agents.defaults.model_preset = "default"
|
||||||
|
config.model_presets = {
|
||||||
|
"default": ModelPresetConfig(model="base-model", provider="openai"),
|
||||||
|
"fast": ModelPresetConfig(model="fast-model", provider="deepseek"),
|
||||||
|
}
|
||||||
|
initial_provider = _provider("base-model")
|
||||||
|
default_provider = _provider("base-model")
|
||||||
|
fast_provider = _provider("fast-model")
|
||||||
|
loaded_presets: list[str | None] = []
|
||||||
|
|
||||||
|
def loader(*, preset_name: str | None = None) -> ProviderSnapshot:
|
||||||
|
loaded_presets.append(preset_name)
|
||||||
|
provider = fast_provider if preset_name == "fast" else default_provider
|
||||||
|
model = "fast-model" if preset_name == "fast" else "base-model"
|
||||||
|
return ProviderSnapshot(
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
context_window_tokens=32_768,
|
||||||
|
signature=(model, preset_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
loop = AgentLoop.from_config(
|
||||||
|
config,
|
||||||
|
provider=initial_provider,
|
||||||
|
provider_snapshot_loader=loader,
|
||||||
|
)
|
||||||
|
|
||||||
|
runtime = loop.runtime_resolver.resolve_preset("fast")
|
||||||
|
|
||||||
|
assert runtime.provider is fast_provider
|
||||||
|
assert runtime.model == "fast-model"
|
||||||
|
assert runtime.model_preset == "fast"
|
||||||
|
assert loop.provider is default_provider
|
||||||
|
assert loaded_presets == ["default", "fast"]
|
||||||
|
|||||||
@@ -378,13 +378,14 @@ def test_from_config_injects_default_preset(tmp_path) -> None:
|
|||||||
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {"defaults": {"model": "openai/gpt-4.1", "workspace": str(tmp_path)}},
|
"agents": {"defaults": {"workspace": str(tmp_path)}},
|
||||||
|
"modelPresets": {"default": {"model": "openai/gpt-4.1"}},
|
||||||
})
|
})
|
||||||
fake_provider = _provider("openai/gpt-4.1")
|
fake_provider = _provider("openai/gpt-4.1")
|
||||||
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
|
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
|
||||||
loop = AgentLoop.from_config(config)
|
loop = AgentLoop.from_config(config)
|
||||||
assert loop.model == "openai/gpt-4.1"
|
assert loop.model == "openai/gpt-4.1"
|
||||||
assert loop.model_preset is None
|
assert loop.model_preset == "default"
|
||||||
assert "default" in loop.model_presets
|
assert "default" in loop.model_presets
|
||||||
assert loop.model_presets["default"].model == "openai/gpt-4.1"
|
assert loop.model_presets["default"].model == "openai/gpt-4.1"
|
||||||
|
|
||||||
@@ -394,8 +395,11 @@ def test_from_config_static_preset_loader_does_not_enable_hot_reload(tmp_path) -
|
|||||||
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {"defaults": {"model": "openai/gpt-4.1", "workspace": str(tmp_path)}},
|
"agents": {"defaults": {"workspace": str(tmp_path)}},
|
||||||
"model_presets": {"fast": {"model": "openai/gpt-4.1-mini"}},
|
"model_presets": {
|
||||||
|
"default": {"model": "openai/gpt-4.1"},
|
||||||
|
"fast": {"model": "openai/gpt-4.1-mini"},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
fake_provider = _provider("openai/gpt-4.1")
|
fake_provider = _provider("openai/gpt-4.1")
|
||||||
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
|
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
|
||||||
|
|||||||
@@ -405,6 +405,44 @@ def test_get_history_synthesizes_breadcrumb_for_image_only_turn():
|
|||||||
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
|
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_history_can_return_internal_media_refs_without_breadcrumbs():
|
||||||
|
session = Session(key="test:media-internal")
|
||||||
|
session.messages.append(
|
||||||
|
{"role": "user", "content": "look", "media": ["/m/a.png", "/m/b.png"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
history = session.get_history(max_messages=500, include_media=True)
|
||||||
|
|
||||||
|
assert history == [{
|
||||||
|
"role": "user",
|
||||||
|
"content": "look",
|
||||||
|
"_media_paths": ["/m/a.png", "/m/b.png"],
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_history_keeps_runtime_context_boundary_with_internal_media_refs():
|
||||||
|
content, marker = append_runtime_context(
|
||||||
|
"look",
|
||||||
|
[RuntimeContextBlock(source="test", content="trusted runtime context")],
|
||||||
|
)
|
||||||
|
session = Session(key="test:media-runtime-context")
|
||||||
|
session.messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
"media": ["/m/a.png"],
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META: marker,
|
||||||
|
})
|
||||||
|
|
||||||
|
history = session.get_history(max_messages=500, include_media=True)
|
||||||
|
|
||||||
|
assert history == [{
|
||||||
|
"role": "user",
|
||||||
|
"content": content,
|
||||||
|
"_media_paths": ["/m/a.png"],
|
||||||
|
RUNTIME_CONTEXT_HISTORY_META: marker,
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
|
def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
|
||||||
session = Session(key="test:cli-app")
|
session = Session(key="test:cli-app")
|
||||||
session.messages.append(
|
session.messages.append(
|
||||||
|
|||||||
+69
-51
@@ -451,14 +451,14 @@ def test_onboard_wizard_preserves_explicit_config_in_next_steps(tmp_path, monkey
|
|||||||
|
|
||||||
def test_config_matches_github_copilot_codex_with_hyphen_prefix():
|
def test_config_matches_github_copilot_codex_with_hyphen_prefix():
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.model = "github-copilot/gpt-5.3-codex"
|
config.resolve_default_preset().model = "github-copilot/gpt-5.3-codex"
|
||||||
|
|
||||||
assert config.get_provider_name() == "github_copilot"
|
assert config.get_provider_name() == "github_copilot"
|
||||||
|
|
||||||
|
|
||||||
def test_config_matches_openai_codex_with_hyphen_prefix():
|
def test_config_matches_openai_codex_with_hyphen_prefix():
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.model = "openai-codex/gpt-5.6-sol"
|
config.resolve_default_preset().model = "openai-codex/gpt-5.6-sol"
|
||||||
|
|
||||||
assert config.get_provider_name() == "openai_codex"
|
assert config.get_provider_name() == "openai_codex"
|
||||||
|
|
||||||
@@ -676,9 +676,9 @@ def test_provider_login_can_set_openai_codex_as_main_provider(tmp_path):
|
|||||||
assert "Set openai-codex as the main provider" in result.stdout
|
assert "Set openai-codex as the main provider" in result.stdout
|
||||||
|
|
||||||
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
||||||
assert saved.agents.defaults.provider == "openai_codex"
|
assert saved.resolve_default_preset().provider == "openai_codex"
|
||||||
assert saved.agents.defaults.model == "openai-codex/gpt-5.6-sol"
|
assert saved.resolve_default_preset().model == "openai-codex/gpt-5.6-sol"
|
||||||
assert saved.agents.defaults.model_preset is None
|
assert saved.agents.defaults.model_preset == "default"
|
||||||
assert make_provider(saved).__class__.__name__ == "OpenAICodexProvider"
|
assert make_provider(saved).__class__.__name__ == "OpenAICodexProvider"
|
||||||
|
|
||||||
|
|
||||||
@@ -705,9 +705,9 @@ def test_provider_login_can_set_github_copilot_as_main_provider(tmp_path):
|
|||||||
assert "Set github-copilot as the main provider" in result.stdout
|
assert "Set github-copilot as the main provider" in result.stdout
|
||||||
|
|
||||||
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
||||||
assert saved.agents.defaults.provider == "github_copilot"
|
assert saved.resolve_default_preset().provider == "github_copilot"
|
||||||
assert saved.agents.defaults.model == "github-copilot/gpt-5.4-mini"
|
assert saved.resolve_default_preset().model == "github-copilot/gpt-5.4-mini"
|
||||||
assert saved.agents.defaults.model_preset is None
|
assert saved.agents.defaults.model_preset == "default"
|
||||||
assert make_provider(saved).__class__.__name__ == "GitHubCopilotProvider"
|
assert make_provider(saved).__class__.__name__ == "GitHubCopilotProvider"
|
||||||
|
|
||||||
|
|
||||||
@@ -734,10 +734,10 @@ def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
|
|||||||
assert "Set xai-grok as the main provider" in result.stdout
|
assert "Set xai-grok as the main provider" in result.stdout
|
||||||
|
|
||||||
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
||||||
assert saved.agents.defaults.provider == "xai_grok"
|
assert saved.resolve_default_preset().provider == "xai_grok"
|
||||||
assert saved.agents.defaults.model == "xai-grok/grok-4.5"
|
assert saved.resolve_default_preset().model == "xai-grok/grok-4.5"
|
||||||
assert saved.agents.defaults.context_window_tokens == 500_000
|
assert saved.resolve_default_preset().context_window_tokens == 500_000
|
||||||
assert saved.agents.defaults.model_preset is None
|
assert saved.agents.defaults.model_preset == "default"
|
||||||
assert make_provider(saved).__class__.__name__ == "XAIGrokProvider"
|
assert make_provider(saved).__class__.__name__ == "XAIGrokProvider"
|
||||||
|
|
||||||
|
|
||||||
@@ -765,8 +765,8 @@ def test_provider_login_model_implies_set_main_provider(tmp_path):
|
|||||||
assert "Set github-copilot as the main provider" in result.stdout
|
assert "Set github-copilot as the main provider" in result.stdout
|
||||||
|
|
||||||
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
saved = Config.model_validate(json.loads(config_path.read_text(encoding="utf-8")))
|
||||||
assert saved.agents.defaults.provider == "github_copilot"
|
assert saved.resolve_default_preset().provider == "github_copilot"
|
||||||
assert saved.agents.defaults.model == "github-copilot/gpt-5.4-mini"
|
assert saved.resolve_default_preset().model == "github-copilot/gpt-5.4-mini"
|
||||||
assert make_provider(saved).__class__.__name__ == "GitHubCopilotProvider"
|
assert make_provider(saved).__class__.__name__ == "GitHubCopilotProvider"
|
||||||
|
|
||||||
|
|
||||||
@@ -899,7 +899,7 @@ def test_provider_login_xai_grok_runs_browser_flow_with_configured_proxy(monkeyp
|
|||||||
|
|
||||||
def test_config_matches_explicit_ollama_prefix_without_api_key():
|
def test_config_matches_explicit_ollama_prefix_without_api_key():
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.model = "ollama/llama3.2"
|
config.resolve_default_preset().model = "ollama/llama3.2"
|
||||||
|
|
||||||
assert config.get_provider_name() == "ollama"
|
assert config.get_provider_name() == "ollama"
|
||||||
assert config.get_api_base() == "http://localhost:11434/v1"
|
assert config.get_api_base() == "http://localhost:11434/v1"
|
||||||
@@ -907,8 +907,8 @@ def test_config_matches_explicit_ollama_prefix_without_api_key():
|
|||||||
|
|
||||||
def test_config_explicit_ollama_provider_uses_default_localhost_api_base():
|
def test_config_explicit_ollama_provider_uses_default_localhost_api_base():
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.provider = "ollama"
|
config.resolve_default_preset().provider = "ollama"
|
||||||
config.agents.defaults.model = "llama3.2"
|
config.resolve_default_preset().model = "llama3.2"
|
||||||
|
|
||||||
assert config.get_provider_name() == "ollama"
|
assert config.get_provider_name() == "ollama"
|
||||||
assert config.get_api_base() == "http://localhost:11434/v1"
|
assert config.get_api_base() == "http://localhost:11434/v1"
|
||||||
@@ -917,8 +917,8 @@ def test_config_explicit_ollama_provider_uses_default_localhost_api_base():
|
|||||||
def test_config_accepts_camel_case_explicit_provider_name_for_coding_plan():
|
def test_config_accepts_camel_case_explicit_provider_name_for_coding_plan():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "volcengineCodingPlan",
|
"provider": "volcengineCodingPlan",
|
||||||
"model": "doubao-1-5-pro",
|
"model": "doubao-1-5-pro",
|
||||||
}
|
}
|
||||||
@@ -938,8 +938,8 @@ def test_config_accepts_camel_case_explicit_provider_name_for_coding_plan():
|
|||||||
def test_config_accepts_lm_studio_without_api_key_and_uses_default_localhost_api_base():
|
def test_config_accepts_lm_studio_without_api_key_and_uses_default_localhost_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "lm_studio",
|
"provider": "lm_studio",
|
||||||
"model": "local-model",
|
"model": "local-model",
|
||||||
}
|
}
|
||||||
@@ -960,8 +960,8 @@ def test_config_accepts_lm_studio_without_api_key_and_uses_default_localhost_api
|
|||||||
def test_config_accepts_atomic_chat_without_api_key_and_uses_default_localhost_api_base():
|
def test_config_accepts_atomic_chat_without_api_key_and_uses_default_localhost_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "atomic_chat",
|
"provider": "atomic_chat",
|
||||||
"model": "local-model",
|
"model": "local-model",
|
||||||
}
|
}
|
||||||
@@ -993,8 +993,8 @@ def test_find_by_name_accepts_camel_case_and_hyphen_aliases():
|
|||||||
def test_config_explicit_longcat_provider_resolves_provider_name():
|
def test_config_explicit_longcat_provider_resolves_provider_name():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "longcat",
|
"provider": "longcat",
|
||||||
"model": "LongCat-Flash-Chat",
|
"model": "LongCat-Flash-Chat",
|
||||||
}
|
}
|
||||||
@@ -1014,7 +1014,9 @@ def test_config_explicit_longcat_provider_resolves_provider_name():
|
|||||||
def test_config_auto_detects_longcat_from_model_keyword():
|
def test_config_auto_detects_longcat_from_model_keyword():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "auto", "model": "longcat/LongCat-Flash-Chat"}},
|
"modelPresets": {
|
||||||
|
"default": {"provider": "auto", "model": "longcat/LongCat-Flash-Chat"}
|
||||||
|
},
|
||||||
"providers": {"longcat": {"apiKey": "test-key"}},
|
"providers": {"longcat": {"apiKey": "test-key"}},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1025,8 +1027,8 @@ def test_config_auto_detects_longcat_from_model_keyword():
|
|||||||
def test_config_explicit_xiaomi_mimo_provider_uses_default_api_base():
|
def test_config_explicit_xiaomi_mimo_provider_uses_default_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "xiaomi_mimo",
|
"provider": "xiaomi_mimo",
|
||||||
"model": "MiniMax-M1-80k",
|
"model": "MiniMax-M1-80k",
|
||||||
}
|
}
|
||||||
@@ -1046,7 +1048,9 @@ def test_config_explicit_xiaomi_mimo_provider_uses_default_api_base():
|
|||||||
def test_config_auto_detects_xiaomi_mimo_from_model_keyword():
|
def test_config_auto_detects_xiaomi_mimo_from_model_keyword():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "auto", "model": "mimo/MiniMax-M1-80k"}},
|
"modelPresets": {
|
||||||
|
"default": {"provider": "auto", "model": "mimo/MiniMax-M1-80k"}
|
||||||
|
},
|
||||||
"providers": {"xiaomiMimo": {"apiKey": "test-key"}},
|
"providers": {"xiaomiMimo": {"apiKey": "test-key"}},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1058,8 +1062,8 @@ def test_config_auto_detects_xiaomi_mimo_from_model_keyword():
|
|||||||
def test_config_explicit_minimax_anthropic_provider_uses_default_api_base():
|
def test_config_explicit_minimax_anthropic_provider_uses_default_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "minimax_anthropic",
|
"provider": "minimax_anthropic",
|
||||||
"model": "MiniMax-M2.7-highspeed",
|
"model": "MiniMax-M2.7-highspeed",
|
||||||
}
|
}
|
||||||
@@ -1080,7 +1084,7 @@ def test_config_explicit_minimax_anthropic_provider_uses_default_api_base():
|
|||||||
def test_config_auto_detects_ollama_from_local_api_base():
|
def test_config_auto_detects_ollama_from_local_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "auto", "model": "llama3.2"}},
|
"modelPresets": {"default": {"provider": "auto", "model": "llama3.2"}},
|
||||||
"providers": {"ollama": {"apiBase": "http://localhost:11434/v1"}},
|
"providers": {"ollama": {"apiBase": "http://localhost:11434/v1"}},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1092,7 +1096,7 @@ def test_config_auto_detects_ollama_from_local_api_base():
|
|||||||
def test_config_prefers_ollama_over_vllm_when_both_local_providers_configured():
|
def test_config_prefers_ollama_over_vllm_when_both_local_providers_configured():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "auto", "model": "llama3.2"}},
|
"modelPresets": {"default": {"provider": "auto", "model": "llama3.2"}},
|
||||||
"providers": {
|
"providers": {
|
||||||
"vllm": {"apiBase": "http://localhost:8000"},
|
"vllm": {"apiBase": "http://localhost:8000"},
|
||||||
"ollama": {"apiBase": "http://localhost:11434/v1"},
|
"ollama": {"apiBase": "http://localhost:11434/v1"},
|
||||||
@@ -1107,7 +1111,7 @@ def test_config_prefers_ollama_over_vllm_when_both_local_providers_configured():
|
|||||||
def test_config_falls_back_to_vllm_when_ollama_not_configured():
|
def test_config_falls_back_to_vllm_when_ollama_not_configured():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "auto", "model": "llama3.2"}},
|
"modelPresets": {"default": {"provider": "auto", "model": "llama3.2"}},
|
||||||
"providers": {
|
"providers": {
|
||||||
"vllm": {"apiBase": "http://localhost:8000"},
|
"vllm": {"apiBase": "http://localhost:8000"},
|
||||||
},
|
},
|
||||||
@@ -1133,8 +1137,8 @@ def test_make_provider_uses_github_copilot_backend():
|
|||||||
|
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "github-copilot",
|
"provider": "github-copilot",
|
||||||
"model": "github-copilot/gpt-4.1",
|
"model": "github-copilot/gpt-4.1",
|
||||||
}
|
}
|
||||||
@@ -1152,8 +1156,8 @@ def test_openai_codex_proxy_config_affects_provider_and_signature():
|
|||||||
def config_with_proxy(proxy: str) -> Config:
|
def config_with_proxy(proxy: str) -> Config:
|
||||||
return Config.model_validate(
|
return Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "openai-codex",
|
"provider": "openai-codex",
|
||||||
"model": "openai-codex/gpt-5.5",
|
"model": "openai-codex/gpt-5.5",
|
||||||
}
|
}
|
||||||
@@ -1177,8 +1181,8 @@ def test_openai_codex_proxy_config_affects_provider_and_signature():
|
|||||||
def test_provider_proxy_rejects_unsupported_backend():
|
def test_provider_proxy_rejects_unsupported_backend():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
"model": "anthropic/claude-opus-4-5",
|
"model": "anthropic/claude-opus-4-5",
|
||||||
}
|
}
|
||||||
@@ -1253,7 +1257,9 @@ def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
|
|||||||
def test_make_provider_passes_extra_headers_to_custom_provider():
|
def test_make_provider_passes_extra_headers_to_custom_provider():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "custom", "model": "gpt-4o-mini"}},
|
"modelPresets": {
|
||||||
|
"default": {"provider": "custom", "model": "gpt-4o-mini"}
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"custom": {
|
"custom": {
|
||||||
"apiKey": "test-key",
|
"apiKey": "test-key",
|
||||||
@@ -1281,7 +1287,9 @@ def test_make_provider_passes_extra_headers_to_custom_provider():
|
|||||||
def test_make_provider_treats_dynamic_custom_provider_as_direct():
|
def test_make_provider_treats_dynamic_custom_provider_as_direct():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "my-company-api", "model": "gpt-4o-mini"}},
|
"modelPresets": {
|
||||||
|
"default": {"provider": "my-company-api", "model": "gpt-4o-mini"}
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"my-company-api": {
|
"my-company-api": {
|
||||||
"apiBase": "https://example.com/v1",
|
"apiBase": "https://example.com/v1",
|
||||||
@@ -1305,7 +1313,12 @@ def test_make_provider_treats_dynamic_custom_provider_as_direct():
|
|||||||
def test_make_provider_strips_dynamic_custom_route_prefix_from_request_model():
|
def test_make_provider_strips_dynamic_custom_route_prefix_from_request_model():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "auto", "model": "my-company-api/gpt-4o-mini"}},
|
"modelPresets": {
|
||||||
|
"default": {
|
||||||
|
"provider": "auto",
|
||||||
|
"model": "my-company-api/gpt-4o-mini",
|
||||||
|
}
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"my-company-api": {
|
"my-company-api": {
|
||||||
"apiBase": "https://example.com/v1",
|
"apiBase": "https://example.com/v1",
|
||||||
@@ -1343,8 +1356,8 @@ def test_make_provider_strips_dynamic_custom_route_prefix_from_request_model():
|
|||||||
def test_make_provider_preserves_namespaced_model_for_forced_dynamic_provider():
|
def test_make_provider_preserves_namespaced_model_for_forced_dynamic_provider():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "my-company-api",
|
"provider": "my-company-api",
|
||||||
"model": "openai/gpt-4o-mini",
|
"model": "openai/gpt-4o-mini",
|
||||||
}
|
}
|
||||||
@@ -1374,8 +1387,8 @@ def test_make_provider_preserves_namespaced_model_for_forced_dynamic_provider():
|
|||||||
def test_make_provider_strips_dynamic_custom_route_prefix_once():
|
def test_make_provider_strips_dynamic_custom_route_prefix_once():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "auto",
|
"provider": "auto",
|
||||||
"model": "my-company-api/openai/gpt-4o-mini",
|
"model": "my-company-api/openai/gpt-4o-mini",
|
||||||
}
|
}
|
||||||
@@ -1405,7 +1418,9 @@ def test_make_provider_strips_dynamic_custom_route_prefix_once():
|
|||||||
def test_make_provider_rejects_dynamic_custom_provider_without_api_base():
|
def test_make_provider_rejects_dynamic_custom_provider_without_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "my-company-api", "model": "gpt-4o-mini"}},
|
"modelPresets": {
|
||||||
|
"default": {"provider": "my-company-api", "model": "gpt-4o-mini"}
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"my-company-api": {
|
"my-company-api": {
|
||||||
"apiKey": "sk-test",
|
"apiKey": "sk-test",
|
||||||
@@ -1421,7 +1436,9 @@ def test_make_provider_rejects_dynamic_custom_provider_without_api_base():
|
|||||||
def test_make_provider_rejects_auto_dynamic_custom_prefix_without_api_base():
|
def test_make_provider_rejects_auto_dynamic_custom_prefix_without_api_base():
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {"defaults": {"provider": "auto", "model": "companyProxy/gpt-4o"}},
|
"modelPresets": {
|
||||||
|
"default": {"provider": "auto", "model": "companyProxy/gpt-4o"}
|
||||||
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
"otherProxy": {
|
"otherProxy": {
|
||||||
"apiBase": "https://other.example.test/v1",
|
"apiBase": "https://other.example.test/v1",
|
||||||
@@ -1824,10 +1841,11 @@ def _stop_gateway_provider(_config) -> object:
|
|||||||
|
|
||||||
|
|
||||||
def _test_provider_snapshot(provider: object, config: Config) -> ProviderSnapshot:
|
def _test_provider_snapshot(provider: object, config: Config) -> ProviderSnapshot:
|
||||||
|
default_preset = config.resolve_default_preset()
|
||||||
return ProviderSnapshot(
|
return ProviderSnapshot(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
model=config.agents.defaults.model,
|
model=default_preset.model,
|
||||||
context_window_tokens=config.agents.defaults.context_window_tokens,
|
context_window_tokens=default_preset.context_window_tokens,
|
||||||
signature=("test",),
|
signature=("test",),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ def test_save_config_round_trips(tmp_path: Path) -> None:
|
|||||||
path = tmp_path / "config.json"
|
path = tmp_path / "config.json"
|
||||||
save_config(Config(), path)
|
save_config(Config(), path)
|
||||||
loaded = load_config(path)
|
loaded = load_config(path)
|
||||||
assert loaded.agents.defaults.model
|
assert loaded.resolve_default_preset().model
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(os.name == "nt", reason="Windows does not expose POSIX file modes")
|
@pytest.mark.skipif(os.name == "nt", reason="Windows does not expose POSIX file modes")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from nanobot.config.schema import ApiConfig
|
|||||||
def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
|
def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
|
||||||
config = load_config(tmp_path / "missing.json")
|
config = load_config(tmp_path / "missing.json")
|
||||||
|
|
||||||
assert config.agents.defaults.model
|
assert config.resolve_default_preset().model
|
||||||
|
|
||||||
|
|
||||||
def test_load_config_reports_malformed_environment_safely(
|
def test_load_config_reports_malformed_environment_safely(
|
||||||
|
|||||||
@@ -3,11 +3,26 @@ import socket
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_config, save_config
|
||||||
from nanobot.security.network import validate_url_target
|
from nanobot.security.network import validate_url_target
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def warning_messages():
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(
|
||||||
|
lambda message: messages.append(str(message)),
|
||||||
|
level="WARNING",
|
||||||
|
format="{message}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield messages
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
|
||||||
|
|
||||||
def _fake_resolve(host: str, results: list[str]):
|
def _fake_resolve(host: str, results: list[str]):
|
||||||
"""Return a getaddrinfo mock that maps the given host to fake IP results."""
|
"""Return a getaddrinfo mock that maps the given host to fake IP results."""
|
||||||
def _resolver(hostname, port, family=0, type_=0):
|
def _resolver(hostname, port, family=0, type_=0):
|
||||||
@@ -35,8 +50,8 @@ def test_load_config_keeps_max_tokens_and_ignores_legacy_memory_window(tmp_path)
|
|||||||
|
|
||||||
config = load_config(config_path)
|
config = load_config(config_path)
|
||||||
|
|
||||||
assert config.agents.defaults.max_tokens == 1234
|
assert config.resolve_default_preset().max_tokens == 1234
|
||||||
assert config.agents.defaults.context_window_tokens == 200_000
|
assert config.resolve_default_preset().context_window_tokens == 200_000
|
||||||
assert not hasattr(config.agents.defaults, "memory_window")
|
assert not hasattr(config.agents.defaults, "memory_window")
|
||||||
|
|
||||||
|
|
||||||
@@ -60,9 +75,12 @@ def test_save_config_writes_context_window_tokens_but_not_memory_window(tmp_path
|
|||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
defaults = saved["agents"]["defaults"]
|
defaults = saved["agents"]["defaults"]
|
||||||
|
default_preset = saved["modelPresets"]["default"]
|
||||||
|
|
||||||
assert defaults["maxTokens"] == 2222
|
assert default_preset["maxTokens"] == 2222
|
||||||
assert defaults["contextWindowTokens"] == 200_000
|
assert default_preset["contextWindowTokens"] == 200_000
|
||||||
|
assert "maxTokens" not in defaults
|
||||||
|
assert "contextWindowTokens" not in defaults
|
||||||
assert "memoryWindow" not in defaults
|
assert "memoryWindow" not in defaults
|
||||||
|
|
||||||
|
|
||||||
@@ -105,7 +123,7 @@ def test_load_config_ignores_legacy_max_messages(tmp_path, field_name) -> None:
|
|||||||
|
|
||||||
config = load_config(config_path)
|
config = load_config(config_path)
|
||||||
|
|
||||||
assert config.agents.defaults.max_tokens == 1234
|
assert config.resolve_default_preset().max_tokens == 1234
|
||||||
assert not hasattr(config.agents.defaults, "max_messages")
|
assert not hasattr(config.agents.defaults, "max_messages")
|
||||||
|
|
||||||
|
|
||||||
@@ -124,6 +142,163 @@ def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
|
|||||||
assert "max_messages" not in saved["agents"]["defaults"]
|
assert "max_messages" not in saved["agents"]["defaults"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_rewrites_legacy_model_fields_to_default_preset(
|
||||||
|
tmp_path,
|
||||||
|
warning_messages,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps({
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "openai/gpt-4.1",
|
||||||
|
"provider": "openai",
|
||||||
|
"temperature": 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_config(config_path)
|
||||||
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
assert config.agents.defaults.model_preset == "default"
|
||||||
|
assert config.resolve_default_preset().model == "openai/gpt-4.1"
|
||||||
|
assert saved["agents"]["defaults"]["modelPreset"] == "default"
|
||||||
|
assert "model" not in saved["agents"]["defaults"]
|
||||||
|
assert saved["modelPresets"]["default"]["model"] == "openai/gpt-4.1"
|
||||||
|
assert saved["modelPresets"]["default"]["temperature"] == 0
|
||||||
|
migration_warnings = [
|
||||||
|
message
|
||||||
|
for message in warning_messages
|
||||||
|
if "Migrated legacy model configuration" in message
|
||||||
|
]
|
||||||
|
assert len(migration_warnings) == 1
|
||||||
|
assert "Legacy settings were converted to named model presets" in migration_warnings[0]
|
||||||
|
assert "Review the rewritten file before downgrading" in migration_warnings[0]
|
||||||
|
|
||||||
|
load_config(config_path)
|
||||||
|
|
||||||
|
migration_warnings = [
|
||||||
|
message
|
||||||
|
for message in warning_messages
|
||||||
|
if "Migrated legacy model configuration" in message
|
||||||
|
]
|
||||||
|
assert len(migration_warnings) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_prefers_existing_default_preset_over_legacy_fields(
|
||||||
|
tmp_path,
|
||||||
|
warning_messages,
|
||||||
|
) -> 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"]
|
||||||
|
assert any(
|
||||||
|
"Existing modelPresets.default took precedence; conflicting "
|
||||||
|
"legacy agents.defaults fields were removed" in message
|
||||||
|
for message in warning_messages
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_does_not_migrate_legacy_model_fields_from_environment(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
warning_messages,
|
||||||
|
) -> 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
|
||||||
|
assert any(
|
||||||
|
"Ignoring unsupported legacy model settings from NANOBOT_AGENTS" in message
|
||||||
|
for message in warning_messages
|
||||||
|
)
|
||||||
|
|
||||||
|
load_config(tmp_path / "another-missing-config.json")
|
||||||
|
|
||||||
|
environment_warnings = [
|
||||||
|
message
|
||||||
|
for message in warning_messages
|
||||||
|
if "Ignoring unsupported legacy model settings from NANOBOT_AGENTS" in message
|
||||||
|
]
|
||||||
|
assert len(environment_warnings) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_migrates_inline_fallback_to_named_preset(
|
||||||
|
tmp_path,
|
||||||
|
warning_messages,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps({
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "openai/gpt-4.1",
|
||||||
|
"provider": "openai",
|
||||||
|
"fallbackModels": [{
|
||||||
|
"model": "anthropic/claude-sonnet-4",
|
||||||
|
"provider": "anthropic",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_config(config_path)
|
||||||
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
assert config.agents.defaults.fallback_models == ["claude-sonnet-4"]
|
||||||
|
assert saved["agents"]["defaults"]["fallbackModels"] == ["claude-sonnet-4"]
|
||||||
|
assert saved["modelPresets"]["claude-sonnet-4"]["provider"] == "anthropic"
|
||||||
|
assert any(
|
||||||
|
"Legacy settings were converted to named model presets." in message
|
||||||
|
for message in warning_messages
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) -> None:
|
def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) -> None:
|
||||||
from nanobot.channels.plugin import load_channel_package
|
from nanobot.channels.plugin import load_channel_package
|
||||||
|
|
||||||
@@ -296,3 +471,14 @@ def test_load_config_accepts_remote_package_install_aliases(tmp_path) -> None:
|
|||||||
|
|
||||||
assert load_config(camel_path).tools.webui_allow_remote_package_install is True
|
assert load_config(camel_path).tools.webui_allow_remote_package_install is True
|
||||||
assert load_config(snake_path).tools.webui_allow_remote_package_install is True
|
assert load_config(snake_path).tools.webui_allow_remote_package_install is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_does_not_rewrite_unrelated_partial_config(tmp_path) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
raw = '{"channels":{"telegram":{"enabled":false}}}'
|
||||||
|
config_path.write_text(raw, encoding="utf-8")
|
||||||
|
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
|
assert config.resolve_default_preset().model == "anthropic/claude-opus-4-5"
|
||||||
|
assert config_path.read_text(encoding="utf-8") == raw
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class TestResolveConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_config(config_path)
|
||||||
config.agents.defaults.max_tokens = 1234
|
config.resolve_default_preset().max_tokens = 1234
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
|
|
||||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
|||||||
@@ -11,12 +11,8 @@ from nanobot.config.schema import Config
|
|||||||
def test_resolve_preset_returns_defaults_when_no_preset() -> None:
|
def test_resolve_preset_returns_defaults_when_no_preset() -> None:
|
||||||
config = Config()
|
config = Config()
|
||||||
resolved = config.resolve_preset()
|
resolved = config.resolve_preset()
|
||||||
assert resolved.model == config.agents.defaults.model
|
assert resolved is config.model_presets["default"]
|
||||||
assert resolved.provider == config.agents.defaults.provider
|
assert config.agents.defaults.model_preset == "default"
|
||||||
assert resolved.max_tokens == config.agents.defaults.max_tokens
|
|
||||||
assert resolved.context_window_tokens == config.agents.defaults.context_window_tokens
|
|
||||||
assert resolved.temperature == config.agents.defaults.temperature
|
|
||||||
assert resolved.reasoning_effort == config.agents.defaults.reasoning_effort
|
|
||||||
|
|
||||||
|
|
||||||
def test_model_preset_catalog_missing_env_reports_explicit_config_path(
|
def test_model_preset_catalog_missing_env_reports_explicit_config_path(
|
||||||
@@ -119,8 +115,8 @@ def test_custom_provider_fallback_uses_model_extra_without_pydantic_warnings() -
|
|||||||
|
|
||||||
def test_dynamic_custom_provider_prefix_matches_camel_case_key() -> None:
|
def test_dynamic_custom_provider_prefix_matches_camel_case_key() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "auto",
|
"provider": "auto",
|
||||||
"model": "companyProxy/gpt-4o-mini",
|
"model": "companyProxy/gpt-4o-mini",
|
||||||
}
|
}
|
||||||
@@ -141,8 +137,8 @@ def test_dynamic_custom_provider_prefix_matches_camel_case_key() -> None:
|
|||||||
|
|
||||||
def test_dynamic_custom_provider_prefix_does_not_fall_through_when_base_missing() -> None:
|
def test_dynamic_custom_provider_prefix_does_not_fall_through_when_base_missing() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "auto",
|
"provider": "auto",
|
||||||
"model": "companyProxy/gpt-4o-mini",
|
"model": "companyProxy/gpt-4o-mini",
|
||||||
}
|
}
|
||||||
@@ -161,7 +157,7 @@ def test_dynamic_custom_provider_prefix_does_not_fall_through_when_base_missing(
|
|||||||
assert config.get_api_base() is None
|
assert config.get_api_base() is None
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_defaults_config_without_presets_still_resolves() -> None:
|
def test_schema_no_longer_resolves_legacy_agent_model_fields() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
@@ -176,19 +172,17 @@ def test_legacy_defaults_config_without_presets_still_resolves() -> None:
|
|||||||
})
|
})
|
||||||
|
|
||||||
resolved = config.resolve_preset()
|
resolved = config.resolve_preset()
|
||||||
assert config.agents.defaults.model_preset is None
|
assert config.agents.defaults.model_preset == "default"
|
||||||
assert config.model_presets == {}
|
assert resolved.model == "anthropic/claude-opus-4-5"
|
||||||
assert resolved.model == "openai/gpt-4.1"
|
dumped_defaults = config.agents.defaults.model_dump(mode="json", by_alias=True)
|
||||||
assert resolved.provider == "openai"
|
assert "model" not in dumped_defaults
|
||||||
assert resolved.max_tokens == 4096
|
assert "provider" not in dumped_defaults
|
||||||
assert resolved.context_window_tokens == 128_000
|
|
||||||
assert resolved.temperature == 0.2
|
|
||||||
assert resolved.reasoning_effort == "low"
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_preset_returns_active_preset() -> None:
|
def test_resolve_preset_returns_active_preset() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"model_presets": {
|
"model_presets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "openai/gpt-4.1",
|
"model": "openai/gpt-4.1",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
@@ -213,16 +207,15 @@ def test_resolve_preset_returns_active_preset() -> None:
|
|||||||
assert resolved.reasoning_effort == "low"
|
assert resolved.reasoning_effort == "low"
|
||||||
|
|
||||||
|
|
||||||
def test_default_preset_is_agents_defaults_even_when_named_preset_is_active() -> None:
|
def test_default_preset_is_concrete_when_named_preset_is_active() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "openai/gpt-4.1",
|
|
||||||
"provider": "openai",
|
|
||||||
"modelPreset": "fast",
|
"modelPreset": "fast",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "openai/gpt-4.1", "provider": "openai"},
|
||||||
"fast": {"model": "openai/gpt-4.1-mini", "provider": "openai"},
|
"fast": {"model": "openai/gpt-4.1-mini", "provider": "openai"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -234,6 +227,7 @@ def test_default_preset_is_agents_defaults_even_when_named_preset_is_active() ->
|
|||||||
def test_model_presets_accepts_camel_case_root_key() -> None:
|
def test_model_presets_accepts_camel_case_root_key() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "openai/gpt-4.1",
|
"model": "openai/gpt-4.1",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
@@ -248,6 +242,7 @@ def test_model_presets_accepts_camel_case_root_key() -> None:
|
|||||||
def test_model_presets_serializes_with_camel_case_root_key() -> None:
|
def test_model_presets_serializes_with_camel_case_root_key() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"model_presets": {
|
"model_presets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "openai/gpt-4.1",
|
"model": "openai/gpt-4.1",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
@@ -265,6 +260,7 @@ def test_model_presets_serializes_with_camel_case_root_key() -> None:
|
|||||||
def test_resolve_preset_can_target_named_preset_without_activating() -> None:
|
def test_resolve_preset_can_target_named_preset_without_activating() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"model_presets": {
|
"model_presets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {"model": "openai/gpt-4.1", "provider": "openai"},
|
"fast": {"model": "openai/gpt-4.1", "provider": "openai"},
|
||||||
"deep": {"model": "anthropic/claude-opus-4-5", "provider": "anthropic"},
|
"deep": {"model": "anthropic/claude-opus-4-5", "provider": "anthropic"},
|
||||||
},
|
},
|
||||||
@@ -291,6 +287,7 @@ def test_validator_rejects_unknown_preset() -> None:
|
|||||||
def test_validator_accepts_dream_model_preset() -> None:
|
def test_validator_accepts_dream_model_preset() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"dream": {"model": "anthropic/claude-haiku-4-5", "provider": "anthropic"},
|
"dream": {"model": "anthropic/claude-haiku-4-5", "provider": "anthropic"},
|
||||||
},
|
},
|
||||||
"agents": {"defaults": {"dream": {"modelOverride": "dream"}}},
|
"agents": {"defaults": {"dream": {"modelOverride": "dream"}}},
|
||||||
@@ -308,10 +305,9 @@ def test_validator_rejects_unknown_dream_model_preset() -> None:
|
|||||||
|
|
||||||
def test_model_preset_accepts_explicit_default_name() -> None:
|
def test_model_preset_accepts_explicit_default_name() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"model": "openai/gpt-4.1",
|
"model": "openai/gpt-4.1",
|
||||||
"modelPreset": "default",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -319,13 +315,11 @@ def test_model_preset_accepts_explicit_default_name() -> None:
|
|||||||
assert config.resolve_preset().model == "openai/gpt-4.1"
|
assert config.resolve_preset().model == "openai/gpt-4.1"
|
||||||
|
|
||||||
|
|
||||||
def test_model_presets_rejects_reserved_default_name() -> None:
|
def test_model_presets_requires_default_name() -> None:
|
||||||
import pytest
|
with pytest.raises(ValueError, match="must define a 'default' preset"):
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="model_preset name 'default' is reserved"):
|
|
||||||
Config.model_validate({
|
Config.model_validate({
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
"default": {"model": "custom-model"},
|
"custom": {"model": "custom-model"},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -342,6 +336,7 @@ def test_match_provider_uses_preset_model() -> None:
|
|||||||
"openai": {"apiKey": "sk-test"},
|
"openai": {"apiKey": "sk-test"},
|
||||||
},
|
},
|
||||||
"model_presets": {
|
"model_presets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "openai/gpt-4.1",
|
"model": "openai/gpt-4.1",
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
@@ -363,6 +358,7 @@ def test_match_provider_uses_preset_provider_when_forced() -> None:
|
|||||||
"anthropic": {"apiKey": "sk-test"},
|
"anthropic": {"apiKey": "sk-test"},
|
||||||
},
|
},
|
||||||
"model_presets": {
|
"model_presets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {
|
"fast": {
|
||||||
"model": "anthropic/claude-opus-4-5",
|
"model": "anthropic/claude-opus-4-5",
|
||||||
"provider": "anthropic",
|
"provider": "anthropic",
|
||||||
@@ -383,8 +379,8 @@ def test_match_provider_routes_forced_novita_model_api_models() -> None:
|
|||||||
"providers": {
|
"providers": {
|
||||||
"novita": {"apiKey": "sk-test"},
|
"novita": {"apiKey": "sk-test"},
|
||||||
},
|
},
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"model": "deepseek-v4-pro",
|
"model": "deepseek-v4-pro",
|
||||||
"provider": "novita",
|
"provider": "novita",
|
||||||
}
|
}
|
||||||
@@ -400,8 +396,8 @@ def test_transcription_only_provider_is_not_chat_fallback() -> None:
|
|||||||
"providers": {
|
"providers": {
|
||||||
"assemblyai": {"apiKey": "aai-test"},
|
"assemblyai": {"apiKey": "aai-test"},
|
||||||
},
|
},
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"model": "assemblyai/universal-3-pro",
|
"model": "assemblyai/universal-3-pro",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ def test_bedrock_provider_is_registered_and_matches_without_api_key() -> None:
|
|||||||
assert hasattr(ProvidersConfig(), "bedrock")
|
assert hasattr(ProvidersConfig(), "bedrock")
|
||||||
|
|
||||||
cfg = Config.model_validate({
|
cfg = Config.model_validate({
|
||||||
"agents": {"defaults": {"model": "bedrock/global.anthropic.claude-opus-4-7"}},
|
"modelPresets": {
|
||||||
|
"default": {"model": "bedrock/global.anthropic.claude-opus-4-7"},
|
||||||
|
},
|
||||||
"providers": {"bedrock": {"region": "us-east-1"}},
|
"providers": {"bedrock": {"region": "us-east-1"}},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class TestCustomProviderThinkingStyle:
|
|||||||
{
|
{
|
||||||
"agents": {"defaults": {"modelPreset": "primary"}},
|
"agents": {"defaults": {"modelPreset": "primary"}},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"primary": {"model": "tenant-model", "provider": "tenant"},
|
"primary": {"model": "tenant-model", "provider": "tenant"},
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
@@ -92,6 +93,7 @@ class TestCustomProviderThinkingStyle:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"primary": {"model": "openai/gpt-4.1", "provider": "openai"},
|
"primary": {"model": "openai/gpt-4.1", "provider": "openai"},
|
||||||
"fallback": {"model": "tenant-model", "provider": "tenant"},
|
"fallback": {"model": "tenant-model", "provider": "tenant"},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class TestProviderSignatureIncludesExtraQuery:
|
|||||||
base = {
|
base = {
|
||||||
"agents": {"defaults": {"modelPreset": "fast"}},
|
"agents": {"defaults": {"modelPreset": "fast"}},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {"model": "anthropic/claude-opus-4-5"},
|
||||||
"fast": {"model": "custom/test-model", "provider": "custom"},
|
"fast": {"model": "custom/test-model", "provider": "custom"},
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|||||||
@@ -236,8 +236,8 @@ async def test_codex_provider_applies_extra_body_from_config(monkeypatch) -> Non
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"model": "openai-codex/gpt-5.6-sol",
|
"model": "openai-codex/gpt-5.6-sol",
|
||||||
"provider": "openai_codex",
|
"provider": "openai_codex",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ def test_provider_signature_tracks_default_extra_headers() -> None:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
|
"default": {
|
||||||
|
"provider": "auto",
|
||||||
|
"model": "anthropic/claude-opus-4-5",
|
||||||
|
},
|
||||||
"primary": {
|
"primary": {
|
||||||
"provider": "kimi_coding",
|
"provider": "kimi_coding",
|
||||||
"model": "kimi-for-coding",
|
"model": "kimi-for-coding",
|
||||||
|
|||||||
@@ -293,23 +293,16 @@ _IMAGE_MSG_NO_META = [
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_non_transient_error_with_images_retries_without_images() -> None:
|
async def test_unrelated_non_transient_error_with_images_is_not_hidden() -> None:
|
||||||
"""Any non-transient error retries once with images stripped when images are present."""
|
"""Only an explicit unsupported-image error may trigger image fallback."""
|
||||||
provider = ScriptedProvider([
|
provider = ScriptedProvider([
|
||||||
LLMResponse(content="API调用参数有误,请检查文档", finish_reason="error"),
|
LLMResponse(content="API调用参数有误,请检查文档", finish_reason="error"),
|
||||||
LLMResponse(content="ok, no image"),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG))
|
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG))
|
||||||
|
|
||||||
assert response.content == "ok, no image"
|
assert response.content == "API调用参数有误,请检查文档"
|
||||||
assert provider.calls == 2
|
assert provider.calls == 1
|
||||||
msgs_on_retry = provider.last_kwargs["messages"]
|
|
||||||
for msg in msgs_on_retry:
|
|
||||||
content = msg.get("content")
|
|
||||||
if isinstance(content, list):
|
|
||||||
assert all(b.get("type") != "image_url" for b in content)
|
|
||||||
assert any("not delivered" in (b.get("text") or "").lower() for b in content)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -349,7 +342,7 @@ async def test_non_transient_error_without_images_no_retry() -> None:
|
|||||||
async def test_image_fallback_returns_error_on_second_failure() -> None:
|
async def test_image_fallback_returns_error_on_second_failure() -> None:
|
||||||
"""If the image-stripped retry also fails, return that error."""
|
"""If the image-stripped retry also fails, return that error."""
|
||||||
provider = ScriptedProvider([
|
provider = ScriptedProvider([
|
||||||
LLMResponse(content="some model error", finish_reason="error"),
|
LLMResponse(content="model does not support images", finish_reason="error"),
|
||||||
LLMResponse(content="still failing", finish_reason="error"),
|
LLMResponse(content="still failing", finish_reason="error"),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -364,7 +357,7 @@ async def test_image_fallback_returns_error_on_second_failure() -> None:
|
|||||||
async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
|
async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
|
||||||
"""When _meta is absent, fallback placeholder is non-descriptive."""
|
"""When _meta is absent, fallback placeholder is non-descriptive."""
|
||||||
provider = ScriptedProvider([
|
provider = ScriptedProvider([
|
||||||
LLMResponse(content="error", finish_reason="error"),
|
LLMResponse(content="image input is not supported", finish_reason="error"),
|
||||||
LLMResponse(content="ok"),
|
LLMResponse(content="ok"),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -379,6 +372,75 @@ async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
|
|||||||
assert any("not delivered" in (b.get("text") or "").lower() for b in content)
|
assert any("not delivered" in (b.get("text") or "").lower() for b in content)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_text_only_preset_strips_images_before_first_request() -> None:
|
||||||
|
provider = ScriptedProvider([LLMResponse(content="ok")])
|
||||||
|
provider.supports_image_input = False
|
||||||
|
|
||||||
|
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG))
|
||||||
|
|
||||||
|
assert response.content == "ok"
|
||||||
|
assert provider.calls == 1
|
||||||
|
content = provider.last_kwargs["messages"][0]["content"]
|
||||||
|
assert all(block.get("type") != "image_url" for block in content)
|
||||||
|
assert any("not delivered" in (block.get("text") or "").lower() for block in content)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_explicit_image_support_does_not_silently_downgrade() -> None:
|
||||||
|
provider = ScriptedProvider([
|
||||||
|
LLMResponse(content="model does not support images", finish_reason="error"),
|
||||||
|
])
|
||||||
|
provider.supports_image_input = True
|
||||||
|
|
||||||
|
response = await provider.chat_with_retry(messages=copy.deepcopy(_IMAGE_MSG))
|
||||||
|
|
||||||
|
assert response.finish_reason == "error"
|
||||||
|
assert provider.calls == 1
|
||||||
|
content = provider.last_kwargs["messages"][0]["content"]
|
||||||
|
assert any(block.get("type") == "image_url" for block in content)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_capability_override_is_request_scoped_under_concurrency() -> None:
|
||||||
|
class ConcurrentProvider(LLMProvider):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.entered = 0
|
||||||
|
self.ready = asyncio.Event()
|
||||||
|
self.received_image_flags: list[bool] = []
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
return "test-model"
|
||||||
|
|
||||||
|
async def chat(self, **kwargs) -> LLMResponse:
|
||||||
|
content = kwargs["messages"][0]["content"]
|
||||||
|
self.received_image_flags.append(
|
||||||
|
any(block.get("type") == "image_url" for block in content)
|
||||||
|
)
|
||||||
|
self.entered += 1
|
||||||
|
if self.entered == 2:
|
||||||
|
self.ready.set()
|
||||||
|
await self.ready.wait()
|
||||||
|
return LLMResponse(content="ok")
|
||||||
|
|
||||||
|
provider = ConcurrentProvider()
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
provider.chat_with_retry(
|
||||||
|
messages=copy.deepcopy(_IMAGE_MSG),
|
||||||
|
supports_image_input=False,
|
||||||
|
),
|
||||||
|
provider.chat_with_retry(
|
||||||
|
messages=copy.deepcopy(_IMAGE_MSG),
|
||||||
|
supports_image_input=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sorted(provider.received_image_flags) == [False, True]
|
||||||
|
assert provider.supports_image_input is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chat_with_retry_uses_retry_after_and_emits_wait_progress(monkeypatch) -> None:
|
async def test_chat_with_retry_uses_retry_after_and_emits_wait_progress(monkeypatch) -> None:
|
||||||
provider = ScriptedProvider([
|
provider = ScriptedProvider([
|
||||||
|
|||||||
@@ -280,8 +280,8 @@ async def test_factory_builds_xai_provider_and_applies_explicit_body_overrides(m
|
|||||||
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"model": "xai-grok/grok-4.5",
|
"model": "xai-grok/grok-4.5",
|
||||||
"provider": "xai_grok",
|
"provider": "xai_grok",
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-103
@@ -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 is None
|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -249,8 +237,8 @@ def test_sdk_make_provider_uses_github_copilot_backend():
|
|||||||
|
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{
|
{
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "github-copilot",
|
"provider": "github-copilot",
|
||||||
"model": "github-copilot/gpt-4.1",
|
"model": "github-copilot/gpt-4.1",
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -884,16 +831,7 @@ async def test_run_model_preset_override_is_per_run(tmp_path):
|
|||||||
config=bot._config,
|
config=bot._config,
|
||||||
)
|
)
|
||||||
assert bot._loop.runtime_resolver.runtime is original_runtime
|
assert bot._loop.runtime_resolver.runtime is original_runtime
|
||||||
assert bot._loop.model_preset is None
|
assert bot._loop.model_preset == "default"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_config, save_config
|
||||||
from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
from nanobot.webui.settings_api import (
|
from nanobot.webui.settings_api import (
|
||||||
WebUISettingsError,
|
WebUISettingsError,
|
||||||
@@ -180,14 +180,12 @@ def _dynamic_provider_config(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
config = Config.model_validate(raw_config)
|
||||||
if defaults:
|
if defaults:
|
||||||
raw_config["agents"] = {
|
default_preset = config.resolve_default_preset()
|
||||||
"defaults": {
|
default_preset.provider = DYNAMIC_PROVIDER_NAME
|
||||||
"provider": DYNAMIC_PROVIDER_NAME,
|
default_preset.model = "gpt-4o-mini"
|
||||||
"model": "gpt-4o-mini",
|
return config
|
||||||
}
|
|
||||||
}
|
|
||||||
return Config.model_validate(raw_config)
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_model_configuration_writes_label_without_changing_call_order(
|
def test_create_model_configuration_writes_label_without_changing_call_order(
|
||||||
@@ -196,8 +194,8 @@ def test_create_model_configuration_writes_label_without_changing_call_order(
|
|||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.model = "openai/gpt-4o"
|
config.resolve_default_preset().model = "openai/gpt-4o"
|
||||||
config.agents.defaults.provider = "openai"
|
config.resolve_default_preset().provider = "openai"
|
||||||
config.providers.openai.api_key = "sk-test"
|
config.providers.openai.api_key = "sk-test"
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
@@ -217,7 +215,7 @@ def test_create_model_configuration_writes_label_without_changing_call_order(
|
|||||||
assert rows["fast-writing"]["label"] == "Fast writing"
|
assert rows["fast-writing"]["label"] == "Fast writing"
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_config(config_path)
|
||||||
assert saved.agents.defaults.model_preset is None
|
assert saved.agents.defaults.model_preset == "default"
|
||||||
assert saved.model_presets["fast-writing"].label == "Fast writing"
|
assert saved.model_presets["fast-writing"].label == "Fast writing"
|
||||||
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
|
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
|
||||||
assert saved.model_presets["fast-writing"].provider == "openai"
|
assert saved.model_presets["fast-writing"].provider == "openai"
|
||||||
@@ -335,7 +333,7 @@ def test_update_model_configuration_edits_named_preset_without_selecting(
|
|||||||
assert payload["agent"]["model_preset"] == "default"
|
assert payload["agent"]["model_preset"] == "default"
|
||||||
assert payload["agent"]["model"] == "anthropic/claude-opus-4-5"
|
assert payload["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||||
saved = load_config(config_path)
|
saved = load_config(config_path)
|
||||||
assert saved.agents.defaults.model_preset is None
|
assert saved.agents.defaults.model_preset == "default"
|
||||||
assert saved.model_presets["codex"].label == "Codex"
|
assert saved.model_presets["codex"].label == "Codex"
|
||||||
assert saved.model_presets["codex"].provider == "openai_codex"
|
assert saved.model_presets["codex"].provider == "openai_codex"
|
||||||
assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5"
|
assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5"
|
||||||
@@ -348,6 +346,7 @@ def test_settings_payload_exposes_named_model_call_order(
|
|||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config = Config()
|
||||||
config.model_presets = {
|
config.model_presets = {
|
||||||
|
"default": config.resolve_default_preset(),
|
||||||
"primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"),
|
"primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"),
|
||||||
"backup": ModelPresetConfig(model="anthropic/claude-sonnet-4", provider="anthropic"),
|
"backup": ModelPresetConfig(model="anthropic/claude-sonnet-4", provider="anthropic"),
|
||||||
}
|
}
|
||||||
@@ -369,6 +368,7 @@ def test_update_model_call_order_sets_primary_and_fallbacks(
|
|||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config = Config()
|
||||||
config.model_presets = {
|
config.model_presets = {
|
||||||
|
"default": config.resolve_default_preset(),
|
||||||
"primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"),
|
"primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"),
|
||||||
"backup": ModelPresetConfig(model="anthropic/claude-sonnet-4", provider="anthropic"),
|
"backup": ModelPresetConfig(model="anthropic/claude-sonnet-4", provider="anthropic"),
|
||||||
}
|
}
|
||||||
@@ -384,7 +384,7 @@ def test_update_model_call_order_sets_primary_and_fallbacks(
|
|||||||
assert saved.agents.defaults.fallback_models == ["primary"]
|
assert saved.agents.defaults.fallback_models == ["primary"]
|
||||||
|
|
||||||
|
|
||||||
def test_update_model_call_order_requires_named_primary(
|
def test_update_model_call_order_accepts_default_as_primary(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -394,50 +394,60 @@ def test_update_model_call_order_requires_named_primary(
|
|||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as error:
|
payload = update_model_call_order({"order": [json.dumps(["backup", "default"])]})
|
||||||
update_model_call_order({"order": [json.dumps(["backup"])]})
|
|
||||||
|
|
||||||
assert error.value.status == 409
|
assert payload["model_call_order"] == ["backup", "default"]
|
||||||
assert load_config(config_path).agents.defaults.model_preset is None
|
saved = load_config(config_path)
|
||||||
|
assert saved.agents.defaults.model_preset == "backup"
|
||||||
|
assert saved.agents.defaults.fallback_models == ["default"]
|
||||||
|
|
||||||
|
|
||||||
def test_migrate_model_configurations_preserves_legacy_chain(
|
def test_loading_settings_migrates_legacy_chain_once(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config_path.write_text(
|
||||||
config.agents.defaults.model = "openai/gpt-4o"
|
json.dumps(
|
||||||
config.agents.defaults.provider = "openai"
|
{
|
||||||
config.agents.defaults.max_tokens = 4096
|
"agents": {
|
||||||
config.agents.defaults.temperature = 0.25
|
"defaults": {
|
||||||
config.agents.defaults.fallback_models = [
|
"model": "openai/gpt-4o",
|
||||||
InlineFallbackConfig(
|
"provider": "openai",
|
||||||
model="anthropic/claude-sonnet-4",
|
"maxTokens": 4096,
|
||||||
provider="anthropic",
|
"temperature": 0.25,
|
||||||
)
|
"fallbackModels": [
|
||||||
]
|
{
|
||||||
save_config(config, config_path)
|
"model": "anthropic/claude-sonnet-4",
|
||||||
|
"provider": "anthropic",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
legacy_payload = settings_payload()
|
payload = settings_payload()
|
||||||
assert legacy_payload["model_call_order"] == []
|
|
||||||
assert legacy_payload["model_call_order_editable"] is False
|
|
||||||
|
|
||||||
payload = migrate_model_configurations()
|
|
||||||
|
|
||||||
assert payload["model_call_order_editable"] is True
|
assert payload["model_call_order_editable"] is True
|
||||||
assert payload["model_call_order"] == ["gpt-4o", "claude-sonnet-4"]
|
assert payload["model_call_order"] == ["default", "claude-sonnet-4"]
|
||||||
saved = load_config(config_path)
|
saved = load_config(config_path)
|
||||||
assert saved.agents.defaults.model_preset == "gpt-4o"
|
assert saved.agents.defaults.model_preset == "default"
|
||||||
assert saved.agents.defaults.fallback_models == ["claude-sonnet-4"]
|
assert saved.agents.defaults.fallback_models == ["claude-sonnet-4"]
|
||||||
assert saved.model_presets["gpt-4o"].temperature == 0.25
|
assert saved.model_presets["default"].model == "openai/gpt-4o"
|
||||||
|
assert saved.model_presets["default"].temperature == 0.25
|
||||||
assert saved.model_presets["claude-sonnet-4"].max_tokens == 4096
|
assert saved.model_presets["claude-sonnet-4"].max_tokens == 4096
|
||||||
assert saved.model_presets["claude-sonnet-4"].temperature == 0.25
|
assert saved.model_presets["claude-sonnet-4"].temperature == 0.25
|
||||||
|
|
||||||
repeated = migrate_model_configurations()
|
repeated = migrate_model_configurations()
|
||||||
assert repeated["model_call_order"] == ["gpt-4o", "claude-sonnet-4"]
|
assert repeated["model_call_order"] == ["default", "claude-sonnet-4"]
|
||||||
assert set(load_config(config_path).model_presets) == {"gpt-4o", "claude-sonnet-4"}
|
assert set(load_config(config_path).model_presets) == {
|
||||||
|
"default",
|
||||||
|
"claude-sonnet-4",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_model_configuration_advanced_options_round_trip(
|
def test_model_configuration_advanced_options_round_trip(
|
||||||
@@ -459,6 +469,7 @@ def test_model_configuration_advanced_options_round_trip(
|
|||||||
"context_window_tokens": ["262144"],
|
"context_window_tokens": ["262144"],
|
||||||
"temperature": ["0.4"],
|
"temperature": ["0.4"],
|
||||||
"reasoning_effort": ["high"],
|
"reasoning_effort": ["high"],
|
||||||
|
"supports_image_input": ["true"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
row = next(row for row in created["model_presets"] if row["name"] == "reasoning")
|
row = next(row for row in created["model_presets"] if row["name"] == "reasoning")
|
||||||
@@ -466,6 +477,7 @@ def test_model_configuration_advanced_options_round_trip(
|
|||||||
assert row["context_window_tokens"] == 262144
|
assert row["context_window_tokens"] == 262144
|
||||||
assert row["temperature"] == 0.4
|
assert row["temperature"] == 0.4
|
||||||
assert row["reasoning_effort"] == "high"
|
assert row["reasoning_effort"] == "high"
|
||||||
|
assert row["supports_image_input"] is True
|
||||||
|
|
||||||
updated = update_model_configuration(
|
updated = update_model_configuration(
|
||||||
{
|
{
|
||||||
@@ -473,12 +485,14 @@ def test_model_configuration_advanced_options_round_trip(
|
|||||||
"max_tokens": ["8192"],
|
"max_tokens": ["8192"],
|
||||||
"temperature": ["0"],
|
"temperature": ["0"],
|
||||||
"reasoning_effort": [""],
|
"reasoning_effort": [""],
|
||||||
|
"supports_image_input": ["false"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
row = next(row for row in updated["model_presets"] if row["name"] == "reasoning")
|
row = next(row for row in updated["model_presets"] if row["name"] == "reasoning")
|
||||||
assert row["max_tokens"] == 8192
|
assert row["max_tokens"] == 8192
|
||||||
assert row["temperature"] == 0
|
assert row["temperature"] == 0
|
||||||
assert row["reasoning_effort"] is None
|
assert row["reasoning_effort"] is None
|
||||||
|
assert row["supports_image_input"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_delete_model_configuration_requires_removing_it_from_call_order(
|
def test_delete_model_configuration_requires_removing_it_from_call_order(
|
||||||
@@ -488,6 +502,7 @@ def test_delete_model_configuration_requires_removing_it_from_call_order(
|
|||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config()
|
config = Config()
|
||||||
config.model_presets = {
|
config.model_presets = {
|
||||||
|
"default": config.resolve_default_preset(),
|
||||||
"primary": ModelPresetConfig(model="openai/gpt-4.1"),
|
"primary": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
"spare": ModelPresetConfig(model="openai/gpt-4.1-mini"),
|
"spare": ModelPresetConfig(model="openai/gpt-4.1-mini"),
|
||||||
}
|
}
|
||||||
@@ -754,7 +769,7 @@ def test_update_agent_settings_accepts_context_window_options(
|
|||||||
|
|
||||||
assert payload["agent"]["context_window_tokens"] == 200000
|
assert payload["agent"]["context_window_tokens"] == 200000
|
||||||
saved = load_config(config_path)
|
saved = load_config(config_path)
|
||||||
assert saved.agents.defaults.context_window_tokens == 200000
|
assert saved.resolve_default_preset().context_window_tokens == 200000
|
||||||
|
|
||||||
|
|
||||||
def test_update_model_configuration_preserves_custom_context_windows(
|
def test_update_model_configuration_preserves_custom_context_windows(
|
||||||
@@ -799,7 +814,7 @@ def test_update_context_window_rejects_unknown_values(
|
|||||||
update_agent_settings({"context_window_tokens": ["128000"]})
|
update_agent_settings({"context_window_tokens": ["128000"]})
|
||||||
|
|
||||||
|
|
||||||
def test_update_model_configuration_rejects_default_preset(
|
def test_update_model_configuration_edits_default_preset(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -807,8 +822,16 @@ def test_update_model_configuration_rejects_default_preset(
|
|||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError, match="model configuration is required"):
|
payload = update_model_configuration({
|
||||||
update_model_configuration({"name": ["default"], "model": ["openai/gpt-4.1"]})
|
"name": ["default"],
|
||||||
|
"model": ["openai/gpt-4.1"],
|
||||||
|
"supports_image_input": ["true"],
|
||||||
|
})
|
||||||
|
|
||||||
|
assert payload["agent"]["model"] == "openai/gpt-4.1"
|
||||||
|
saved = load_config(config_path)
|
||||||
|
assert saved.resolve_default_preset().model == "openai/gpt-4.1"
|
||||||
|
assert saved.resolve_default_preset().supports_image_input is True
|
||||||
|
|
||||||
|
|
||||||
def test_settings_payload_includes_oauth_provider_status(
|
def test_settings_payload_includes_oauth_provider_status(
|
||||||
@@ -900,8 +923,8 @@ def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monke
|
|||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
|
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
|
||||||
"agents": {
|
"modelPresets": {
|
||||||
"defaults": {
|
"default": {
|
||||||
"provider": "opencode_zen",
|
"provider": "opencode_zen",
|
||||||
"model": "opencode/deepseek-v4-pro",
|
"model": "opencode/deepseek-v4-pro",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ interface AgentSettingsDraft {
|
|||||||
contextWindowTokens: number;
|
contextWindowTokens: number;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
reasoningEffort: string;
|
reasoningEffort: string;
|
||||||
|
imageInputSupport: "auto" | "supported" | "text_only";
|
||||||
timezone: string;
|
timezone: string;
|
||||||
botName: string;
|
botName: string;
|
||||||
botIcon: string;
|
botIcon: string;
|
||||||
@@ -426,12 +427,29 @@ interface SettingsViewProps {
|
|||||||
|
|
||||||
function modelPresetValue(payload: SettingsPayload): string {
|
function modelPresetValue(payload: SettingsPayload): string {
|
||||||
return (
|
return (
|
||||||
|
payload.agent.model_preset ??
|
||||||
payload.model_call_order?.[0] ??
|
payload.model_call_order?.[0] ??
|
||||||
payload.model_presets.find((preset) => !preset.is_default)?.name ??
|
payload.model_presets.find((preset) => preset.is_default)?.name ??
|
||||||
""
|
"default"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function imageInputSupportMode(
|
||||||
|
value: boolean | null | undefined,
|
||||||
|
): AgentSettingsDraft["imageInputSupport"] {
|
||||||
|
if (value === true) return "supported";
|
||||||
|
if (value === false) return "text_only";
|
||||||
|
return "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageInputSupportValue(
|
||||||
|
value: AgentSettingsDraft["imageInputSupport"],
|
||||||
|
): boolean | null {
|
||||||
|
if (value === "supported") return true;
|
||||||
|
if (value === "text_only") return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeContextWindowTokens(value: number | null | undefined): number {
|
function normalizeContextWindowTokens(value: number | null | undefined): number {
|
||||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 200_000;
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 200_000;
|
||||||
}
|
}
|
||||||
@@ -470,6 +488,7 @@ const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = {
|
|||||||
contextWindowTokens: 200_000,
|
contextWindowTokens: 200_000,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
reasoningEffort: "",
|
reasoningEffort: "",
|
||||||
|
imageInputSupport: "auto",
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
botName: "nanobot",
|
botName: "nanobot",
|
||||||
botIcon: "",
|
botIcon: "",
|
||||||
@@ -526,7 +545,7 @@ function agentDraftFromPayload(
|
|||||||
const activePresetName = preferredPresetName ?? modelPresetValue(payload);
|
const activePresetName = preferredPresetName ?? modelPresetValue(payload);
|
||||||
const activePreset =
|
const activePreset =
|
||||||
payload.model_presets.find(
|
payload.model_presets.find(
|
||||||
(preset) => !preset.is_default && preset.name === activePresetName,
|
(preset) => preset.name === activePresetName,
|
||||||
) ?? null;
|
) ?? null;
|
||||||
return {
|
return {
|
||||||
model: activePreset?.model ?? payload.agent.model,
|
model: activePreset?.model ?? payload.agent.model,
|
||||||
@@ -539,6 +558,7 @@ function agentDraftFromPayload(
|
|||||||
),
|
),
|
||||||
temperature: activePreset?.temperature ?? payload.agent.temperature,
|
temperature: activePreset?.temperature ?? payload.agent.temperature,
|
||||||
reasoningEffort: activePreset?.reasoning_effort ?? "",
|
reasoningEffort: activePreset?.reasoning_effort ?? "",
|
||||||
|
imageInputSupport: imageInputSupportMode(activePreset?.supports_image_input),
|
||||||
timezone: payload.agent.timezone,
|
timezone: payload.agent.timezone,
|
||||||
botName: payload.agent.bot_name,
|
botName: payload.agent.bot_name,
|
||||||
botIcon: payload.agent.bot_icon,
|
botIcon: payload.agent.bot_icon,
|
||||||
@@ -1034,7 +1054,7 @@ export function SettingsView({
|
|||||||
const modelDirty = useMemo(() => {
|
const modelDirty = useMemo(() => {
|
||||||
if (!settings) return false;
|
if (!settings) return false;
|
||||||
const selectedPreset = settings.model_presets.find(
|
const selectedPreset = settings.model_presets.find(
|
||||||
(preset) => !preset.is_default && preset.name === form.modelPreset,
|
(preset) => preset.name === form.modelPreset,
|
||||||
);
|
);
|
||||||
if (!selectedPreset) return false;
|
if (!selectedPreset) return false;
|
||||||
return (
|
return (
|
||||||
@@ -1044,6 +1064,7 @@ export function SettingsView({
|
|||||||
form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) ||
|
form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) ||
|
||||||
form.temperature !== selectedPreset.temperature ||
|
form.temperature !== selectedPreset.temperature ||
|
||||||
form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "") ||
|
form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "") ||
|
||||||
|
form.imageInputSupport !== imageInputSupportMode(selectedPreset.supports_image_input) ||
|
||||||
form.presetLabel.trim() !== selectedPreset.label
|
form.presetLabel.trim() !== selectedPreset.label
|
||||||
);
|
);
|
||||||
}, [form, settings]);
|
}, [form, settings]);
|
||||||
@@ -1198,6 +1219,7 @@ export function SettingsView({
|
|||||||
contextWindowTokens: form.contextWindowTokens,
|
contextWindowTokens: form.contextWindowTokens,
|
||||||
temperature: form.temperature,
|
temperature: form.temperature,
|
||||||
reasoningEffort: form.reasoningEffort || null,
|
reasoningEffort: form.reasoningEffort || null,
|
||||||
|
supportsImageInput: imageInputSupportValue(form.imageInputSupport),
|
||||||
});
|
});
|
||||||
const createdPreset = payload.created_model_preset;
|
const createdPreset = payload.created_model_preset;
|
||||||
const nextOrder = createdPreset ? [...modelCallOrder, createdPreset] : null;
|
const nextOrder = createdPreset ? [...modelCallOrder, createdPreset] : null;
|
||||||
@@ -1228,7 +1250,7 @@ export function SettingsView({
|
|||||||
|
|
||||||
if (!modelDirty) return;
|
if (!modelDirty) return;
|
||||||
const selectedPreset = settings.model_presets.find(
|
const selectedPreset = settings.model_presets.find(
|
||||||
(preset) => !preset.is_default && preset.name === form.modelPreset,
|
(preset) => preset.name === form.modelPreset,
|
||||||
);
|
);
|
||||||
if (!selectedPreset) return;
|
if (!selectedPreset) return;
|
||||||
const reasoningEffort = form.reasoningEffort || null;
|
const reasoningEffort = form.reasoningEffort || null;
|
||||||
@@ -1253,6 +1275,10 @@ export function SettingsView({
|
|||||||
form.temperature !== selectedPreset.temperature ? form.temperature : undefined,
|
form.temperature !== selectedPreset.temperature ? form.temperature : undefined,
|
||||||
reasoningEffort:
|
reasoningEffort:
|
||||||
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
|
reasoningEffort !== selectedPreset.reasoning_effort ? reasoningEffort : undefined,
|
||||||
|
supportsImageInput:
|
||||||
|
form.imageInputSupport !== imageInputSupportMode(selectedPreset.supports_image_input)
|
||||||
|
? imageInputSupportValue(form.imageInputSupport)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
setForm(agentDraftFromPayload(payload, selectedPreset.name));
|
setForm(agentDraftFromPayload(payload, selectedPreset.name));
|
||||||
@@ -1268,7 +1294,7 @@ export function SettingsView({
|
|||||||
const beginModelPresetCreation = () => {
|
const beginModelPresetCreation = () => {
|
||||||
if (!settings || saving || modelCallOrderSaving || modelConfigurationSaving) return;
|
if (!settings || saving || modelCallOrderSaving || modelConfigurationSaving) return;
|
||||||
const primaryPreset = settings.model_presets.find(
|
const primaryPreset = settings.model_presets.find(
|
||||||
(preset) => !preset.is_default && preset.name === settings.model_call_order?.[0],
|
(preset) => preset.name === settings.model_call_order?.[0],
|
||||||
);
|
);
|
||||||
const currentProvider = primaryPreset?.provider === "auto"
|
const currentProvider = primaryPreset?.provider === "auto"
|
||||||
? primaryPreset.resolved_provider ?? settings.agent.resolved_provider
|
? primaryPreset.resolved_provider ?? settings.agent.resolved_provider
|
||||||
@@ -1290,6 +1316,7 @@ export function SettingsView({
|
|||||||
),
|
),
|
||||||
temperature: primaryPreset?.temperature ?? settings.agent.temperature,
|
temperature: primaryPreset?.temperature ?? settings.agent.temperature,
|
||||||
reasoningEffort: primaryPreset?.reasoning_effort ?? settings.agent.reasoning_effort ?? "",
|
reasoningEffort: primaryPreset?.reasoning_effort ?? settings.agent.reasoning_effort ?? "",
|
||||||
|
imageInputSupport: imageInputSupportMode(primaryPreset?.supports_image_input),
|
||||||
}));
|
}));
|
||||||
setModelPresetCreating(true);
|
setModelPresetCreating(true);
|
||||||
};
|
};
|
||||||
@@ -3168,7 +3195,7 @@ function ModelsSettings({
|
|||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
|
const [draggedCallOrderIndex, setDraggedCallOrderIndex] = useState<number | null>(null);
|
||||||
const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState<number | null>(null);
|
const [dragOverCallOrderIndex, setDragOverCallOrderIndex] = useState<number | null>(null);
|
||||||
const namedPresets = settings.model_presets.filter((preset) => !preset.is_default);
|
const namedPresets = settings.model_presets;
|
||||||
const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset]));
|
const namedPresetsByName = new Map(namedPresets.map((preset) => [preset.name, preset]));
|
||||||
const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name));
|
const unorderedPresets = namedPresets.filter((preset) => !callOrder.includes(preset.name));
|
||||||
const callOrderOccurrences = new Map<string, number>();
|
const callOrderOccurrences = new Map<string, number>();
|
||||||
@@ -3244,6 +3271,7 @@ function ModelsSettings({
|
|||||||
contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens),
|
contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens),
|
||||||
temperature: preset.temperature,
|
temperature: preset.temperature,
|
||||||
reasoningEffort: preset.reasoning_effort ?? "",
|
reasoningEffort: preset.reasoning_effort ?? "",
|
||||||
|
imageInputSupport: imageInputSupportMode(preset.supports_image_input),
|
||||||
}));
|
}));
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
};
|
};
|
||||||
@@ -3655,6 +3683,7 @@ function ModelsSettings({
|
|||||||
contextWindowTokens={form.contextWindowTokens}
|
contextWindowTokens={form.contextWindowTokens}
|
||||||
temperature={form.temperature}
|
temperature={form.temperature}
|
||||||
reasoningEffort={form.reasoningEffort}
|
reasoningEffort={form.reasoningEffort}
|
||||||
|
imageInputSupport={form.imageInputSupport}
|
||||||
onChange={(value) => setForm((prev) => ({ ...prev, ...value }))}
|
onChange={(value) => setForm((prev) => ({ ...prev, ...value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -3673,7 +3702,7 @@ function ModelsSettings({
|
|||||||
>
|
>
|
||||||
{tx("settings.actions.cancel", "Cancel")}
|
{tx("settings.actions.cancel", "Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
) : selectedPreset ? (
|
) : selectedPreset && !selectedPreset.is_default ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -3726,17 +3755,23 @@ function ModelAdvancedFields({
|
|||||||
contextWindowTokens,
|
contextWindowTokens,
|
||||||
temperature,
|
temperature,
|
||||||
reasoningEffort,
|
reasoningEffort,
|
||||||
|
imageInputSupport,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
contextWindowTokens: number;
|
contextWindowTokens: number;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
reasoningEffort: string;
|
reasoningEffort: string;
|
||||||
|
imageInputSupport: AgentSettingsDraft["imageInputSupport"];
|
||||||
onChange: (
|
onChange: (
|
||||||
value: Partial<
|
value: Partial<
|
||||||
Pick<
|
Pick<
|
||||||
AgentSettingsDraft,
|
AgentSettingsDraft,
|
||||||
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
| "maxTokens"
|
||||||
|
| "contextWindowTokens"
|
||||||
|
| "temperature"
|
||||||
|
| "reasoningEffort"
|
||||||
|
| "imageInputSupport"
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
) => void;
|
) => void;
|
||||||
@@ -3811,6 +3846,33 @@ function ModelAdvancedFields({
|
|||||||
className="h-9 rounded-[12px] text-[13px]"
|
className="h-9 rounded-[12px] text-[13px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<div>
|
||||||
|
<span className="mb-2 block text-[12px] font-medium text-muted-foreground">
|
||||||
|
{tx("settings.models.imageInput", "Image input")}
|
||||||
|
</span>
|
||||||
|
<SegmentedControl
|
||||||
|
value={imageInputSupport}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: "auto",
|
||||||
|
label: tx("settings.values.auto", "Auto"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "supported",
|
||||||
|
label: tx("settings.models.imageInputSupported", "Supported"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "text_only",
|
||||||
|
label: tx("settings.models.imageInputTextOnly", "Text only"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(value) =>
|
||||||
|
onChange({
|
||||||
|
imageInputSupport: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9761,14 +9823,14 @@ function StatusPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SegmentedControl({
|
function SegmentedControl<T extends string>({
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: T;
|
||||||
options: Array<{ value: string; label: string }>;
|
options: Array<{ value: T; label: string }>;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: T) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground">
|
<div className="inline-flex h-8 items-center rounded-full bg-muted p-0.5 text-[12px] font-medium text-muted-foreground">
|
||||||
|
|||||||
+13
-1
@@ -774,7 +774,11 @@ function appendModelGenerationSettings(
|
|||||||
query: URLSearchParams,
|
query: URLSearchParams,
|
||||||
configuration: Pick<
|
configuration: Pick<
|
||||||
ModelConfigurationCreate,
|
ModelConfigurationCreate,
|
||||||
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
|
| "maxTokens"
|
||||||
|
| "contextWindowTokens"
|
||||||
|
| "temperature"
|
||||||
|
| "reasoningEffort"
|
||||||
|
| "supportsImageInput"
|
||||||
>,
|
>,
|
||||||
): void {
|
): void {
|
||||||
if (configuration.maxTokens !== undefined) {
|
if (configuration.maxTokens !== undefined) {
|
||||||
@@ -789,6 +793,14 @@ function appendModelGenerationSettings(
|
|||||||
if (configuration.reasoningEffort !== undefined) {
|
if (configuration.reasoningEffort !== undefined) {
|
||||||
query.set("reasoning_effort", configuration.reasoningEffort ?? "");
|
query.set("reasoning_effort", configuration.reasoningEffort ?? "");
|
||||||
}
|
}
|
||||||
|
if (configuration.supportsImageInput !== undefined) {
|
||||||
|
query.set(
|
||||||
|
"supports_image_input",
|
||||||
|
configuration.supportsImageInput === null
|
||||||
|
? "auto"
|
||||||
|
: String(configuration.supportsImageInput),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createModelConfiguration(
|
export async function createModelConfiguration(
|
||||||
|
|||||||
@@ -432,6 +432,7 @@ export interface SettingsPayload {
|
|||||||
context_window_tokens: number;
|
context_window_tokens: number;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
reasoning_effort: string | null;
|
reasoning_effort: string | null;
|
||||||
|
supports_image_input: boolean | null;
|
||||||
reasoning_effort_values?: string[];
|
reasoning_effort_values?: string[];
|
||||||
}>;
|
}>;
|
||||||
model_call_order: string[];
|
model_call_order: string[];
|
||||||
@@ -968,6 +969,7 @@ export interface ModelConfigurationCreate {
|
|||||||
contextWindowTokens?: number;
|
contextWindowTokens?: number;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
reasoningEffort?: string | null;
|
reasoningEffort?: string | null;
|
||||||
|
supportsImageInput?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelConfigurationUpdate {
|
export interface ModelConfigurationUpdate {
|
||||||
@@ -979,6 +981,7 @@ export interface ModelConfigurationUpdate {
|
|||||||
contextWindowTokens?: number;
|
contextWindowTokens?: number;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
reasoningEffort?: string | null;
|
reasoningEffort?: string | null;
|
||||||
|
supportsImageInput?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderSettingsUpdate {
|
export interface ProviderSettingsUpdate {
|
||||||
|
|||||||
@@ -3004,7 +3004,7 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
expect(await screen.findByRole("button", { name: "private/image-v2" })).toBeInTheDocument();
|
expect(await screen.findByRole("button", { name: "private/image-v2" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not expose the synthetic default configuration as a WebUI preset", async () => {
|
it("exposes the concrete default configuration as an editable preset", async () => {
|
||||||
const base = settingsPayload();
|
const base = settingsPayload();
|
||||||
const payload: SettingsPayload = {
|
const payload: SettingsPayload = {
|
||||||
...base,
|
...base,
|
||||||
@@ -3070,11 +3070,11 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
|
|
||||||
expect((await screen.findAllByText("MiniMax-M3")).length).toBeGreaterThan(0);
|
expect((await screen.findAllByText("MiniMax-M3")).length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText("fast").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("fast").length).toBeGreaterThan(0);
|
||||||
expect(screen.queryByText("Default")).not.toBeInTheDocument();
|
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("openai-codex/gpt-5.5")).not.toBeInTheDocument();
|
expect(screen.getByText("openai-codex/gpt-5.5")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not expose the synthetic default preset in the overview summary", async () => {
|
it("keeps the default preset suffix out of the overview summary", async () => {
|
||||||
const base = settingsPayload();
|
const base = settingsPayload();
|
||||||
const payload: SettingsPayload = {
|
const payload: SettingsPayload = {
|
||||||
...base,
|
...base,
|
||||||
|
|||||||
Reference in New Issue
Block a user