When the primary model (e.g. DeepSeek during peak hours) returns an empty
choices response with HTTP 200, the error carries no status code or
structured error metadata. The existing _FALLBACK_ERROR_TOKENS had no
matching token, so _should_fallback() returned False and fallback models
were never tried.
Changes:
- Add 'empty' token to _FALLBACK_ERROR_TOKENS so 'Error: API returned
empty choices.' text matches the fallback path
- Set error_kind='empty' in openai_compat_provider when returning
the empty-choices error, making the classification explicit
- Add test coverage for both text-only and error_kind matching paths
Fixes: glebov reported primary never falls back when DeepSeek returns
empty responses
maintainer edit: remove the Feishu-specific config-file guard from the shared CLI login command. Feishu now follows the Weixin pattern where channel.login owns its setup and persistence.
maintainer edit: fix the lint failure, report the active config path, fail fast when the registration response lacks a login URL, and cover the new Feishu login writeback path.
Manual testing against the live API showed the Keenable REST endpoint
(/v1/search) returns 401 without a key — the keyless "free tier" applies
only to the CLI, not the HTTP API. Treat Keenable like every other
key-based provider: fall back to DuckDuckGo when no key is configured,
and drop the now-inaccurate free-tier wording from the docs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the bespoke nanobot/<version> User-Agent in favor of self.user_agent
for consistency with every other search provider, and add an
X-Keenable-Title: nanobot header so Keenable can attribute traffic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep the Keenable search test consistent with the surrounding mocks
(test_tavily/test_brave) rather than introducing a multi-line outlier.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the Keenable search provider in line with the established
multi-file provider pattern so it surfaces everywhere the others do:
- settings_api.py: register in the web-search provider options so it
appears in the WebUI settings dropdown (credential: api_key, optional).
- WebUI provider-brand: add keenable brand entry + brand test.
- docs/configuration.md: provider table row + config example.
- Harden _search_keenable: honor config.timeout and return explicit
messages on HTTP status errors (429 / other), matching peer providers.
- Add env-key and HTTP-error tests; add the websocket settings whitelist
assertion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keenable is a first-party API, so identify as nanobot/<version> instead
of the shared spoofed-browser User-Agent used by scraping providers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Keenable (https://keenable.ai) as a web_search backend, modeled on
the existing httpx-based providers. Unlike key-gated providers, Keenable
has a no-login free tier, so it resolves to itself even without an API
key instead of falling back to DuckDuckGo; the X-API-Key header is only
sent when a key is configured (config api_key or KEENABLE_API_KEY env).
Maps result snippet (falling back to description) into the shared
content field. Covered by tests for keyed/anonymous search and the
no-fallback concurrency behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mistral's API constrains reasoning_effort to "high"/"none", rejects the
kwarg entirely for Magistral (reasoning is implicit), returns assistant
content as a mixed array of {type:"thinking",...}/{type:"text",...}
blocks, and 400s on the reasoning_content key in history.
- Remap user-supplied reasoning_effort (low/medium/minimal) onto Mistral's
two-tier vocabulary; strip the kwarg for Magistral models
- Lift thinking blocks into reasoning_content for both batch and streaming
responses; pass only text through on_content_delta callbacks
- Drop reasoning_content from outbound history when the spec asks for it
- Expose per-preset reasoning_effort_values so the UI can render the
provider-specific option set
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
maintainer edit: compare exact-file allowlist entries using logical paths and require the resolved target to stay at that canonical path, so symlinks or junctions cannot redirect canonical memory files to an external write target.
Maintainer edit: keep capability-specific allowed_dir boundaries active even when the outer workspace scope is full access, and cover Dream plus ordinary full-access filesystem behavior.
maintainer edit: Dream write tools used file paths as directory roots, so a missing canonical memory file could be treated as a parent directory. Add exact-file allowlist support and keep skills/ as the only Dream write directory.
Treat the current live user message as the replay boundary for normal user turns, while keeping user-turn extension for history and consolidation paths that need it. Add regression coverage for the user-triggered long tool-turn case.
chengyongru reviewed #4367 and identified that the cloud branch
created a bare httpx.AsyncClient that lacked the SDK's default settings
(follow_redirects, connection pool limits). Since the SDK's
DefaultAsyncHttpxClient already has trust_env=True and proper defaults,
the simplest fix is to let http_client stay None for cloud endpoints.
Also updated the test to match the new behavior (http_client is None).
When the host has HTTP_PROXY / HTTPS_PROXY / ALL_PROXY set, httpx routes
all traffic through the proxy — including requests to localhost or LAN
addresses that the proxy typically cannot reach. This breaks local model
servers (Ollama, llama.cpp, vLLM) silently.
- Local endpoints: pass transport=httpx.AsyncHTTPTransport(proxy=None)
so proxy env vars are ignored for local traffic.
- Cloud endpoints: pass trust_env=True so corporate/VPN proxies work
without explicit configuration.
Fixes#4366
The shell safety guard in _guard_command() checked extracted absolute
paths only against cwd_path (the command's working directory). When
cwd was a subdirectory like ~/.nanobot/workspace/obsidian_notes, any
absolute path referencing the broader workspace (e.g. a sibling
directory or the workspace root itself) was incorrectly blocked with
"path outside working dir".
Fix: pass the workspace_root from _prepare_command() into
_guard_command() and check absolute paths against it as a fallback
when they are outside cwd_path. Paths truly outside the workspace
root are still blocked.
Added tests:
- test_exec_allows_workspace_paths_from_subdirectory
- test_exec_blocks_outside_paths_from_subdirectory
Feishu CardKit content updates can fail without raising, leaving a blank Generating card while the final streamed response skips normal send. Reopen streaming mode and retry once, close blank cards when the first update fails, and fall back to a regular card when final updates still fail.
Maintainer edit: make token truncation include the suffix within the budget and route the consolidator through the shared helper so recent-history and archive truncation keep the same semantics.
The recent-history section injected into the system prompt was capped by character count (_MAX_HISTORY_CHARS = 32_000). Characters are a poor proxy for tokens: ~32k chars of English is ~8k tokens, but the same char count of CJK text or code can be far more, so the cap could let the section blow well past its intended size on non English/code-heavy histories.
Add a reusable truncate_text_to_tokens() helper (reusing the tiktoken
cl100k_base encoder already used elsewhere, with a char-based fallback) and
switch the digest cap to a token budget (_MAX_HISTORY_TOKENS = 8_000),
matching the previous English-text size while holding regardless of
content.
The non-streaming retry called process_direct again with the same
content, persisting a duplicate user turn. Pass persist_user_message=False
so the retry recovers a response without re-recording the user message.