Compare commits

...
31 changed files with 2276 additions and 462 deletions
+2 -2
View File
@@ -139,7 +139,7 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` | | `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, open `http://127.0.0.1:8765`, and follow new gateway logs |
| `nanobot webui --background` | Deprecated; prints the equivalent explicit `nanobot gateway --background` command and exits | | `nanobot webui --background` | Deprecated; prints the equivalent explicit `nanobot gateway --background` command and exits |
| `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates | | `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates |
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser | | `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
@@ -344,7 +344,7 @@ remain accepted as no-op compatibility aliases.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model | | `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model |
| `nanobot provider login xai-grok --set-main` | Authenticate an eligible X Premium / Grok subscription and select Grok 4.5; hosted X Search is enabled for models that advertise support | | `nanobot provider login xai-grok --set-main` | Authenticate an eligible X Premium / Grok subscription and select Grok 4.6; hosted X Search is enabled for models that advertise support |
| `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model | | `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model |
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state | | `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
| `nanobot provider logout xai-grok --config <path>` | Remove the selected nanobot instance's xAI OAuth state | | `nanobot provider logout xai-grok --config <path>` | Remove the selected nanobot instance's xAI OAuth state |
+17 -5
View File
@@ -729,6 +729,11 @@ Then run:
nanobot agent -m "Hello!" nanobot agent -m "Hello!"
``` ```
The WebUI model selector loads the models available to the signed-in account
from Codex's online catalog. Context-window and reasoning-effort metadata come
from that response; if discovery is unavailable, nanobot keeps a small built-in
fallback instead of emptying the selector.
Codex Fast mode can be enabled from the WebUI provider settings, or with: Codex Fast mode can be enabled from the WebUI provider settings, or with:
```json ```json
@@ -764,11 +769,14 @@ nanobot provider login xai-grok --set-main
nanobot agent -m "Hello from Grok." nanobot agent -m "Hello from Grok."
``` ```
The default model is `xai-grok/grok-4.5` with a 500,000-token context window. The default model is `xai-grok/grok-4.6` with a 500,000-token context window.
The provider reads xAI's model catalog and includes the server-hosted `x_search` The provider reads and caches xAI's online model catalog for both WebUI model
tool only when the selected model advertises `supportsBackendSearch`. Models selection and runtime capabilities. Newly available models appear automatically;
without that capability continue normally without hosted X Search. When enabled, when discovery fails, the last successful catalog or built-in fallback remains
searches run inside xAI's Responses API and citations arrive as inline links. available. The server-hosted `x_search` tool is included only when the selected
model advertises support. Models without that capability continue normally
without hosted X Search. When enabled, searches run inside xAI's Responses API
and citations arrive as inline links.
Hosted X Search is on by default to preserve this behavior. It can be turned off in the Hosted X Search is on by default to preserve this behavior. It can be turned off in the
WebUI provider settings or with `providers.xaiGrok.extraBody.tools: []`. WebUI provider settings or with `providers.xaiGrok.extraBody.tools: []`.
@@ -805,6 +813,10 @@ a nanobot update.
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config. GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
After login, the WebUI loads the account-specific Copilot model catalog online.
Only models compatible with nanobot's current chat-completions or Responses
transport are shown.
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login: For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
```bash ```bash
export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id" export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id"
+15 -3
View File
@@ -572,15 +572,23 @@ For OpenAI Codex:
nanobot provider login openai-codex --set-main nanobot provider login openai-codex --set-main
``` ```
The WebUI reads the account's Codex model catalog online, including current
context-window and reasoning-effort metadata. A small compatible catalog remains
available when the service cannot be reached.
For an eligible X Premium / Grok subscription: For an eligible X Premium / Grok subscription:
```bash ```bash
nanobot provider login xai-grok --set-main nanobot provider login xai-grok --set-main
``` ```
This selects `xai-grok/grok-4.5`. The provider reads xAI's model catalog and This selects `xai-grok/grok-4.6`. The WebUI model selector reads xAI's online
exposes the hosted `x_search` tool only when the selected model advertises model catalog, so newly available subscription models appear without a nanobot
`supportsBackendSearch`; otherwise the model runs without hosted X Search. release. Online metadata is cached and enriched with nanobot's curated labels;
if xAI is temporarily unavailable, nanobot uses the last successful catalog or
a small built-in fallback instead of emptying the selector. The same catalog
controls whether the provider exposes the hosted `x_search` tool; models that do
not advertise support continue without hosted X Search.
When enabled, Grok can search current X posts and return inline source links When enabled, Grok can search current X posts and return inline source links
without invoking a local nanobot tool. Credentials are stored under the without invoking a local nanobot tool. Credentials are stored under the
active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in
@@ -599,6 +607,10 @@ For GitHub Copilot:
nanobot provider login github-copilot --set-main nanobot provider login github-copilot --set-main
``` ```
The WebUI reads the models enabled for the signed-in Copilot account. nanobot
lists entries that support its current Copilot chat-completions or Responses
transport and hides models that it cannot route safely.
Each command authenticates the selected provider and makes its current default model active. OpenAI Codex and eligible GitHub Copilot models participate in [Responses state retention](./configuration.md#responses-state-and-compaction), while native compaction remains provider-capability-specific. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors. Each command authenticates the selected provider and makes its current default model active. OpenAI Codex and eligible GitHub Copilot models participate in [Responses state retention](./configuration.md#responses-state-and-compaction), while native compaction remains provider-capability-specific. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors.
## Provider Resolution ## Provider Resolution
+3 -1
View File
@@ -23,7 +23,9 @@ one is missing, starts or joins the same on-demand gateway used by the native
TUI, and opens the browser. With a fresh config, TUI, and opens the browser. With a fresh config,
it can open before a model is configured so you can finish setup in **Settings it can open before a model is configured so you can finish setup in **Settings
→ Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so → Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so
it is not available from other devices on your LAN. it is not available from other devices on your LAN. While the launcher remains
attached, it mirrors new log output from that exact gateway instance in the
terminal without replaying older logs.
After model setup, explicitly promote the shared gateway when you do not want to keep a client open: After model setup, explicitly promote the shared gateway when you do not want to keep a client open:
+5 -2
View File
@@ -29,7 +29,7 @@ _PROVIDER_DISPLAY: dict[str, str] = {
_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = { _OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
"openai_codex": "openai-codex/gpt-5.6-sol", "openai_codex": "openai-codex/gpt-5.6-sol",
"xai_grok": "xai-grok/grok-4.5", "xai_grok": "xai-grok/grok-4.6",
"github_copilot": "github-copilot/gpt-5.4-mini", "github_copilot": "github-copilot/gpt-5.4-mini",
} }
@@ -134,7 +134,10 @@ def _set_oauth_provider_as_main(
config.agents.defaults.model_preset = None config.agents.defaults.model_preset = None
config.agents.defaults.provider = provider_name config.agents.defaults.provider = provider_name
config.agents.defaults.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 in {
"xai-grok/grok-4.5",
"xai-grok/grok-4.6",
}:
config.agents.defaults.context_window_tokens = 500_000 config.agents.defaults.context_window_tokens = 500_000
save_config(config, resolved_config_path) save_config(config, resolved_config_path)
+83 -4
View File
@@ -1,12 +1,14 @@
"""Shared WebUI setup, URL, health, and browser helpers.""" """Shared WebUI setup, URL, health, and browser helpers."""
import os
import subprocess import subprocess
import sys import sys
import time import time
import webbrowser import webbrowser
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, BinaryIO
import typer import typer
from pydantic import ValidationError from pydantic import ValidationError
@@ -457,27 +459,104 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
console.print("[green]WebUI is attached to the shared gateway.[/green]") console.print("[green]WebUI is attached to the shared gateway.[/green]")
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]") console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
console.print( console.print(
"[dim]Press Ctrl+C to detach; the gateway stops only when the last local client exits.[/dim]" "[dim]Following live gateway logs. Press Ctrl+C to detach; the gateway stops "
"only when the last local client exits.[/dim]"
) )
_LOG_ANCHOR_BYTES = 64
@dataclass
class _GatewayLogCursor:
offset: int = 0
identity: tuple[int, int] | None = None
anchor: bytes = b""
pending: bytes = b""
def _log_anchor(handle: BinaryIO, offset: int) -> bytes:
size = min(offset, _LOG_ANCHOR_BYTES)
handle.seek(offset - size)
return handle.read(size)
def _start_gateway_log_cursor(log_path: Path) -> _GatewayLogCursor:
"""Start following at the current end of *log_path*."""
try:
with log_path.open("rb") as handle:
stat = os.fstat(handle.fileno())
offset = stat.st_size
return _GatewayLogCursor(
offset=offset,
identity=(stat.st_dev, stat.st_ino),
anchor=_log_anchor(handle, offset),
)
except OSError:
return _GatewayLogCursor()
def _read_new_gateway_logs(
log_path: Path,
cursor: _GatewayLogCursor,
*,
flush: bool = False,
) -> list[str]:
"""Read complete gateway log lines appended after *cursor*."""
try:
with log_path.open("rb") as handle:
stat = os.fstat(handle.fileno())
identity = (stat.st_dev, stat.st_ino)
reset = cursor.identity != identity or stat.st_size < cursor.offset
if not reset and cursor.offset:
reset = _log_anchor(handle, cursor.offset) != cursor.anchor
if reset:
cursor.offset = 0
cursor.pending = b""
handle.seek(cursor.offset)
chunk = handle.read()
cursor.offset = handle.tell()
cursor.identity = identity
cursor.anchor = _log_anchor(handle, cursor.offset)
except OSError:
return []
parts = (cursor.pending + chunk).split(b"\n")
cursor.pending = parts.pop()
if flush and cursor.pending:
parts.append(cursor.pending)
cursor.pending = b""
return [part.removesuffix(b"\r").decode("utf-8", errors="replace") for part in parts]
def _attach_to_background_gateway( def _attach_to_background_gateway(
runtime: "GatewayRuntime", runtime: "GatewayRuntime",
*, *,
poll_hook: Callable[[], None] | None = None, poll_hook: Callable[[], None] | None = None,
sleep: Callable[[float], None] = time.sleep, sleep: Callable[[float], None] = time.sleep,
) -> None: ) -> None:
"""Keep a WebUI launcher attached without taking ownership of the gateway.""" """Keep the launcher attached and mirror this gateway's new log output."""
status = runtime.status()
log_path = status.log_path
cursor = _start_gateway_log_cursor(log_path)
_print_webui_foreground_lifecycle(attached=True) _print_webui_foreground_lifecycle(attached=True)
try: try:
while runtime.status().running: while status.running:
for line in _read_new_gateway_logs(log_path, cursor):
console.print(line, markup=False, highlight=False)
if poll_hook is not None: if poll_hook is not None:
poll_hook() poll_hook()
sleep(0.5) sleep(0.5)
status = runtime.status()
except KeyboardInterrupt: except KeyboardInterrupt:
for line in _read_new_gateway_logs(log_path, cursor, flush=True):
console.print(line, markup=False, highlight=False)
console.print("\n[yellow]WebUI launcher detached.[/yellow]") console.print("\n[yellow]WebUI launcher detached.[/yellow]")
return return
for line in _read_new_gateway_logs(log_path, cursor, flush=True):
console.print(line, markup=False, highlight=False)
console.print("[yellow]Gateway stopped.[/yellow]") console.print("[yellow]Gateway stopped.[/yellow]")
+183 -4
View File
@@ -5,6 +5,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import os import os
import time import time
import webbrowser import webbrowser
@@ -17,7 +18,12 @@ from oauth_cli_kit.models import OAuthToken
from oauth_cli_kit.storage import FileTokenStorage from oauth_cli_kit.storage import FileTokenStorage
from nanobot.providers.base import LLMResponse, ProviderCallContext from nanobot.providers.base import LLMResponse, ProviderCallContext
from nanobot.providers.oauth_model_catalog import (
OAuthModelCatalog,
OAuthModelCatalogSnapshot,
)
from nanobot.providers.openai_compat_provider import OpenAICompatProvider from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import ProviderModelSpec, find_by_name
DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code" DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
DEFAULT_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" DEFAULT_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
@@ -96,7 +102,9 @@ def login_github_copilot(
device_code = str(payload["device_code"]) device_code = str(payload["device_code"])
user_code = str(payload["user_code"]) user_code = str(payload["user_code"])
verify_url = str(payload.get("verification_uri") or payload.get("verification_uri_complete") or "") verify_url = str(
payload.get("verification_uri") or payload.get("verification_uri_complete") or ""
)
verify_complete = str(payload.get("verification_uri_complete") or verify_url) verify_complete = str(payload.get("verification_uri_complete") or verify_url)
interval = max(1, int(payload.get("interval") or 5)) interval = max(1, int(payload.get("interval") or 5))
expires_in = int(payload.get("expires_in") or 900) expires_in = int(payload.get("expires_in") or 900)
@@ -180,8 +188,6 @@ class GitHubCopilotProvider(OpenAICompatProvider):
*, *,
provider_name: str = "github_copilot", provider_name: str = "github_copilot",
): ):
from nanobot.providers.registry import find_by_name
self._copilot_access_token: str | None = None self._copilot_access_token: str | None = None
self._copilot_expires_at: float = 0.0 self._copilot_expires_at: float = 0.0
self._copilot_token_lock: asyncio.Lock = asyncio.Lock() self._copilot_token_lock: asyncio.Lock = asyncio.Lock()
@@ -217,7 +223,9 @@ class GitHubCopilotProvider(OpenAICompatProvider):
) )
timeout = httpx.Timeout(20.0, connect=20.0) timeout = httpx.Timeout(20.0, connect=20.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client: async with httpx.AsyncClient(
timeout=timeout, follow_redirects=True, trust_env=True
) as client:
response = await client.get( response = await client.get(
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL), _resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
headers=_copilot_headers(github_token.access), headers=_copilot_headers(github_token.access),
@@ -296,3 +304,174 @@ class GitHubCopilotProvider(OpenAICompatProvider):
on_tool_call_delta=on_tool_call_delta, on_tool_call_delta=on_tool_call_delta,
provider_context=provider_context, provider_context=provider_context,
) )
def get_github_copilot_model_catalog(
proxy: str | None = None,
) -> OAuthModelCatalogSnapshot:
storage = get_storage()
token = storage.load()
account_key = _catalog_account_key(getattr(token, "account_id", None))
cache_key = (
f"{storage.get_token_path()}\0{account_key}\0"
f"{_resolve('NANOBOT_COPILOT_BASE_URL', DEFAULT_COPILOT_BASE_URL)}\0{proxy or ''}"
)
return _GITHUB_COPILOT_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
def invalidate_github_copilot_model_catalog() -> None:
_GITHUB_COPILOT_MODEL_CATALOG.invalidate()
def _fetch_github_copilot_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
github_token = get_storage().load()
if not github_token or not github_token.access:
raise RuntimeError("GitHub Copilot is not logged in")
common_headers = {
"Accept": "application/json",
"User-Agent": USER_AGENT,
"Editor-Version": EDITOR_VERSION,
"Editor-Plugin-Version": EDITOR_PLUGIN_VERSION,
}
client_kwargs: dict[str, Any] = {"timeout": 20.0, "follow_redirects": True}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
with httpx.Client(**client_kwargs) as client:
exchange = client.get(
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
headers={**common_headers, "Authorization": f"token {github_token.access}"},
)
exchange.raise_for_status()
exchange_mapping = _catalog_mapping(exchange.json())
copilot_token = exchange_mapping.get("token")
if not isinstance(copilot_token, str) or not copilot_token:
raise RuntimeError("GitHub Copilot token exchange returned no token")
endpoint_base = _catalog_first_text(
_catalog_mapping(exchange_mapping.get("endpoints")),
"api",
)
base_url = endpoint_base or _resolve(
"NANOBOT_COPILOT_BASE_URL",
DEFAULT_COPILOT_BASE_URL,
)
models_url = (
base_url
if base_url.rstrip("/").endswith("/models")
else f"{base_url.rstrip('/')}/models"
)
response = client.get(
models_url,
headers={**common_headers, "Authorization": f"Bearer {copilot_token}"},
)
response.raise_for_status()
return _parse_github_copilot_models(response.json())
def _parse_github_copilot_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
rows = cast(dict[str, Any], payload).get("data") if isinstance(payload, dict) else None
if not isinstance(rows, list):
return ()
fallback_models = _oauth_fallback_models("github_copilot")
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
models: list[ProviderModelSpec] = []
seen: set[str] = set()
for value in cast(list[object], rows):
if not isinstance(value, dict):
continue
row = cast(dict[str, Any], value)
wire_id = _catalog_first_text(row, "id")
policy = _catalog_mapping(row.get("policy"))
endpoints = row.get("supported_endpoints")
if (
not wire_id
or wire_id in seen
or row.get("model_picker_enabled") is not True
or policy.get("state") == "disabled"
or not _copilot_transport_supported(wire_id, endpoints)
):
continue
seen.add(wire_id)
capabilities = _catalog_mapping(row.get("capabilities"))
supports = _catalog_mapping(capabilities.get("supports"))
limits = _catalog_mapping(capabilities.get("limits"))
fallback = fallback_by_id.get(wire_id)
models.append(
ProviderModelSpec(
id=f"github-copilot/{wire_id}",
label=(
_catalog_first_text(row, "name")
or (fallback.label if fallback is not None else wire_id)
),
description=(fallback.description if fallback is not None else ""),
owned_by="GitHub Copilot",
context_window=(
_catalog_positive_int(limits, "max_context_window_tokens")
or (fallback.context_window if fallback is not None else None)
),
reasoning_efforts=_catalog_reasoning_efforts(supports.get("reasoning_effort")),
)
)
return tuple(models)
def _copilot_transport_supported(wire_id: str, endpoints: object) -> bool:
if not isinstance(endpoints, list):
return True
supported = cast(list[object], endpoints)
if "/chat/completions" in supported:
return True
model = wire_id.lower()
return "/responses" in supported and any(
token in model for token in ("gpt-5", "o1", "o3", "o4")
)
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
spec = find_by_name(provider_name)
assert spec is not None
return spec.builtin_models
def _catalog_account_key(account_id: object) -> str:
value = account_id if isinstance(account_id, str) else ""
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
def _catalog_mapping(value: Any) -> dict[str, Any]:
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
for key in keys:
value = row.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
for key in keys:
value = row.get(key)
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
return int(value)
return None
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
return tuple(
dict.fromkeys(
item.strip()
for item in cast(list[object], value)
if isinstance(item, str) and item.strip()
)
)
_GITHUB_COPILOT_MODEL_CATALOG = OAuthModelCatalog(
fallback_models=_oauth_fallback_models("github_copilot"),
fetch=_fetch_github_copilot_models,
)
+224
View File
@@ -0,0 +1,224 @@
"""Shared cache seam for OAuth provider model discovery."""
from __future__ import annotations
import threading
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass, replace
from typing import Literal
from loguru import logger
from nanobot.providers.registry import ProviderModelSpec
CatalogSource = Literal["remote", "cache", "stale", "fallback"]
@dataclass(frozen=True, slots=True)
class OAuthModelCatalogSnapshot:
"""One usable catalog view, including where it came from."""
models: tuple[ProviderModelSpec, ...]
source: CatalogSource
fetched_at: float
message: str | None = None
def find(self, model: str) -> ProviderModelSpec | None:
wire_id = model.split("/", 1)[-1]
return next(
(item for item in self.models if item.id.split("/", 1)[-1] == wire_id),
None,
)
@dataclass(frozen=True, slots=True)
class _CacheEntry:
snapshot: OAuthModelCatalogSnapshot
stored_at: float
class OAuthModelCatalog:
"""Cache one provider's discovery behind a small failure-tolerant interface."""
def __init__(
self,
*,
fallback_models: Sequence[ProviderModelSpec],
fetch: Callable[[str | None], Sequence[ProviderModelSpec]],
fresh_ttl_s: float = 5 * 60,
stale_ttl_s: float = 24 * 60 * 60,
failure_ttl_s: float = 30,
max_entries: int = 8,
monotonic: Callable[[], float] = time.monotonic,
wall_clock: Callable[[], float] = time.time,
) -> None:
if fresh_ttl_s < 0 or stale_ttl_s < fresh_ttl_s or failure_ttl_s < 0:
raise ValueError("catalog cache TTLs are invalid")
if max_entries < 1:
raise ValueError("catalog cache must allow at least one entry")
self._fallback_models = tuple(fallback_models)
self._fetch = fetch
self._fresh_ttl_s = fresh_ttl_s
self._stale_ttl_s = stale_ttl_s
self._failure_ttl_s = failure_ttl_s
self._max_entries = max_entries
self._monotonic = monotonic
self._wall_clock = wall_clock
self._condition = threading.Condition()
self._entries: dict[str, _CacheEntry] = {}
self._failures: dict[str, float] = {}
self._inflight: set[str] = set()
self._generation = 0
def get(self, *, cache_key: str, proxy: str | None = None) -> OAuthModelCatalogSnapshot:
"""Return a fresh catalog, sharing concurrent work and retaining a fallback."""
with self._condition:
generation = self._generation
cached = self._cached_result(cache_key)
if cached is not None:
return cached
while cache_key in self._inflight:
self._condition.wait()
if generation != self._generation:
return self._stale_or_fallback(None, self._monotonic())
cached = self._cached_result(cache_key)
if cached is not None:
return cached
self._inflight.add(cache_key)
try:
models = tuple(self._fetch(proxy))
if not models:
raise ValueError("provider returned an empty model catalog")
except Exception as exc:
logger.warning("OAuth model catalog refresh failed: type={}", type(exc).__name__)
with self._condition:
result = (
self._stale_or_fallback(None, self._monotonic())
if generation != self._generation
else self._failure_result(cache_key)
)
else:
now = self._monotonic()
result = OAuthModelCatalogSnapshot(
models=models,
source="remote",
fetched_at=self._wall_clock(),
)
with self._condition:
if generation != self._generation:
result = self._stale_or_fallback(None, now)
else:
self._store(cache_key, _CacheEntry(snapshot=result, stored_at=now))
self._failures.pop(cache_key, None)
finally:
with self._condition:
self._inflight.discard(cache_key)
self._condition.notify_all()
return result
def invalidate(self) -> None:
"""Drop cached work and prevent an older identity refresh from being stored."""
with self._condition:
self._generation += 1
self._entries.clear()
self._failures.clear()
self._condition.notify_all()
def _cached_result(self, cache_key: str) -> OAuthModelCatalogSnapshot | None:
now = self._monotonic()
entry = self._entries.get(cache_key)
if entry is not None and now - entry.stored_at < self._fresh_ttl_s:
return replace(entry.snapshot, source="cache")
failure_until = self._failures.get(cache_key)
if failure_until is not None and failure_until <= now:
self._failures.pop(cache_key, None)
elif failure_until is not None:
return self._stale_or_fallback(entry, now)
return None
def _failure_result(self, cache_key: str) -> OAuthModelCatalogSnapshot:
now = self._monotonic()
self._reserve(cache_key)
self._failures[cache_key] = now + self._failure_ttl_s
return self._stale_or_fallback(self._entries.get(cache_key), now)
def _stale_or_fallback(
self,
entry: _CacheEntry | None,
now: float,
) -> OAuthModelCatalogSnapshot:
if entry is not None and now - entry.stored_at < self._stale_ttl_s:
return replace(
entry.snapshot,
source="stale",
message="Could not refresh the online model list; showing cached models.",
)
return OAuthModelCatalogSnapshot(
models=self._fallback_models,
source="fallback",
fetched_at=self._wall_clock(),
message="Could not load the online model list; showing built-in fallback models.",
)
def _store(self, cache_key: str, entry: _CacheEntry) -> None:
self._reserve(cache_key)
self._entries[cache_key] = entry
def _reserve(self, cache_key: str) -> None:
known = set(self._entries) | set(self._failures)
if cache_key in known or len(known) < self._max_entries:
return
oldest = min(
known,
key=lambda key: (
self._entries[key].stored_at
if key in self._entries
else self._failures[key] - self._failure_ttl_s
),
)
self._entries.pop(oldest, None)
self._failures.pop(oldest, None)
def get_oauth_model_catalog(
provider_name: str,
*,
proxy: str | None = None,
) -> OAuthModelCatalogSnapshot:
"""Discover models through the owning provider module."""
if provider_name == "openai_codex":
from nanobot.providers.openai_codex_provider import get_openai_codex_model_catalog
return get_openai_codex_model_catalog(proxy)
if provider_name == "xai_grok":
from nanobot.providers.xai_grok_provider import get_xai_grok_model_catalog
return get_xai_grok_model_catalog(proxy)
if provider_name == "github_copilot":
from nanobot.providers.github_copilot_provider import get_github_copilot_model_catalog
return get_github_copilot_model_catalog(proxy)
raise ValueError(f"OAuth model discovery is not available for {provider_name}")
def invalidate_oauth_model_catalog(provider_name: str) -> None:
"""Invalidate provider discovery after its OAuth identity changes."""
if provider_name == "openai_codex":
from nanobot.providers.openai_codex_provider import (
invalidate_openai_codex_model_catalog,
)
invalidate_openai_codex_model_catalog()
elif provider_name == "xai_grok":
from nanobot.providers.xai_grok_provider import invalidate_xai_grok_model_catalog
invalidate_xai_grok_model_catalog()
elif provider_name == "github_copilot":
from nanobot.providers.github_copilot_provider import (
invalidate_github_copilot_model_catalog,
)
invalidate_github_copilot_model_catalog()
+173 -28
View File
@@ -14,7 +14,10 @@ from typing import Any, cast
import httpx import httpx
from loguru import logger from loguru import logger
from oauth_cli_kit import get_token as get_codex_token from oauth_cli_kit import get_token as get_codex_token
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
from oauth_cli_kit.storage import FileTokenStorage
from nanobot import __version__
from nanobot.providers.base import ( from nanobot.providers.base import (
LLMProvider, LLMProvider,
LLMResponse, LLMResponse,
@@ -22,6 +25,10 @@ from nanobot.providers.base import (
ProviderConversationState, ProviderConversationState,
resolve_stream_idle_timeout_s, resolve_stream_idle_timeout_s,
) )
from nanobot.providers.oauth_model_catalog import (
OAuthModelCatalog,
OAuthModelCatalogSnapshot,
)
from nanobot.providers.openai_responses import ( from nanobot.providers.openai_responses import (
ResponsesStreamCapture, ResponsesStreamCapture,
build_responses_state, build_responses_state,
@@ -35,8 +42,11 @@ from nanobot.providers.openai_responses import (
responses_state_items, responses_state_items,
responses_state_matches, responses_state_matches,
) )
from nanobot.providers.registry import ProviderModelSpec, find_by_name
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses" DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
DEFAULT_OPENAI_CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models"
OPENAI_CODEX_CATALOG_CLIENT_VERSION = "0.144.0"
DEFAULT_ORIGINATOR = "nanobot" DEFAULT_ORIGINATOR = "nanobot"
_COMPACTION_RETAINED_CHAR_BUDGET = 256_000 _COMPACTION_RETAINED_CHAR_BUDGET = 256_000
@@ -87,9 +97,7 @@ class OpenAICodexProvider(LLMProvider):
model = model or self.default_model model = model or self.default_model
sanitized_messages = self._sanitize_empty_content(messages) sanitized_messages = self._sanitize_empty_content(messages)
sanitized_state = ( sanitized_state = (
provider_context.conversation_state provider_context.conversation_state if provider_context is not None else None
if provider_context is not None
else None
) )
if sanitized_state is not None: if sanitized_state is not None:
sanitized_state = sanitized_state.with_pending_messages( sanitized_state = sanitized_state.with_pending_messages(
@@ -168,11 +176,7 @@ class OpenAICodexProvider(LLMProvider):
) )
compact_threshold = resolve_compact_threshold( compact_threshold = resolve_compact_threshold(
( (provider_context.context_window_tokens if provider_context is not None else None),
provider_context.context_window_tokens
if provider_context is not None
else None
),
max_tokens, max_tokens,
) )
if ( if (
@@ -236,8 +240,12 @@ class OpenAICodexProvider(LLMProvider):
return response return response
async def chat( async def chat(
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, self,
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None, reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
provider_context: ProviderCallContext | None = None, provider_context: ProviderCallContext | None = None,
@@ -264,8 +272,12 @@ class OpenAICodexProvider(LLMProvider):
) )
async def chat_stream( async def chat_stream(
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, self,
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None, reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
@@ -344,11 +356,7 @@ def _without_response_item_ids(
sanitized_input.append(raw_item) sanitized_input.append(raw_item)
continue continue
item = cast(dict[str, Any], raw_item) item = cast(dict[str, Any], raw_item)
sanitized_input.append({ sanitized_input.append({key: value for key, value in item.items() if key != "id"})
key: value
for key, value in item.items()
if key != "id"
})
body = dict(request_body) body = dict(request_body)
body["input"] = sanitized_input body["input"] = sanitized_input
@@ -444,15 +452,12 @@ async def _request_codex(
raw = text.decode("utf-8", "ignore") raw = text.decode("utf-8", "ignore")
retry_after = LLMProvider._extract_retry_after_from_headers(response.headers) retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
error_type, error_code = LLMProvider._extract_error_type_code(raw) error_type, error_code = LLMProvider._extract_error_type_code(raw)
compaction_unsupported = ( compaction_unsupported = response.status_code in {400, 404, 422} and any(
response.status_code in {400, 404, 422} marker in raw.lower()
and any( for marker in (
marker in raw.lower() "context_management",
for marker in ( "compact_threshold",
"context_management", "compaction_trigger",
"compact_threshold",
"compaction_trigger",
)
) )
) )
raise _CodexHTTPError( raise _CodexHTTPError(
@@ -461,7 +466,9 @@ async def _request_codex(
retry_after=retry_after, retry_after=retry_after,
error_type=error_type, error_type=error_type,
error_code=error_code, error_code=error_code,
should_retry=_should_retry_status(response.status_code, error_type, error_code, raw), should_retry=_should_retry_status(
response.status_code, error_type, error_code, raw
),
compaction_unsupported=compaction_unsupported, compaction_unsupported=compaction_unsupported,
) )
capture = ResponsesStreamCapture() capture = ResponsesStreamCapture()
@@ -534,7 +541,9 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
default_detail = "HTTP request failed" default_detail = "HTTP request failed"
if status_code is not None and should_retry is None: if status_code is not None and should_retry is None:
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail retry_content = (
None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
)
should_retry = _should_retry_status( should_retry = _should_retry_status(
int(status_code), int(status_code),
getattr(exc, "error_type", None), getattr(exc, "error_type", None),
@@ -592,3 +601,139 @@ def _should_retry_status(
) )
) )
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500 return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500
def get_openai_codex_model_catalog(
proxy: str | None = None,
) -> OAuthModelCatalogSnapshot:
storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename)
token = storage.load()
account_id = getattr(token, "account_id", None)
account_key = _catalog_account_key(account_id)
cache_key = f"{storage.get_token_path()}\0{account_key}\0{proxy or ''}"
return _OPENAI_CODEX_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
def invalidate_openai_codex_model_catalog() -> None:
_OPENAI_CODEX_MODEL_CATALOG.invalidate()
def _fetch_openai_codex_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
token = get_codex_token(proxy=proxy)
account_id = getattr(token, "account_id", None)
if not isinstance(account_id, str) or not account_id:
raise RuntimeError("OpenAI Codex OAuth token has no account ID")
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
with httpx.Client(**client_kwargs) as client:
response = client.get(
DEFAULT_OPENAI_CODEX_MODELS_URL,
params={"client_version": OPENAI_CODEX_CATALOG_CLIENT_VERSION},
headers={
"Authorization": f"Bearer {token.access}",
"chatgpt-account-id": account_id,
"originator": DEFAULT_ORIGINATOR,
"User-Agent": f"nanobot/{__version__} (python)",
"accept": "application/json",
},
)
response.raise_for_status()
return _parse_openai_codex_models(response.json())
def _parse_openai_codex_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
rows = cast(dict[str, Any], payload).get("models") if isinstance(payload, dict) else None
if not isinstance(rows, list):
return ()
fallback_models = _oauth_fallback_models("openai_codex")
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
parsed: list[tuple[int, ProviderModelSpec]] = []
seen: set[str] = set()
for value in cast(list[object], rows):
if not isinstance(value, dict):
continue
row = cast(dict[str, Any], value)
wire_id = _catalog_first_text(row, "slug", "id")
if not wire_id or wire_id in seen or row.get("visibility") in {"hide", "none"}:
continue
seen.add(wire_id)
fallback = fallback_by_id.get(wire_id)
priority = row.get("priority")
parsed.append(
(
priority if isinstance(priority, int) and not isinstance(priority, bool) else 2**31,
ProviderModelSpec(
id=f"openai-codex/{wire_id}",
label=(
_catalog_first_text(row, "display_name", "name")
or (fallback.label if fallback is not None else wire_id)
),
description=(
_catalog_first_text(row, "description")
or (fallback.description if fallback is not None else "")
),
owned_by="OpenAI Codex",
context_window=(
_catalog_positive_int(row, "context_window")
or (fallback.context_window if fallback is not None else None)
),
reasoning_efforts=(
_catalog_reasoning_efforts(row.get("supported_reasoning_levels"))
or (fallback.reasoning_efforts if fallback is not None else ())
),
),
)
)
parsed.sort(key=lambda item: item[0])
return tuple(model for _, model in parsed)
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
spec = find_by_name(provider_name)
assert spec is not None
return spec.builtin_models
def _catalog_account_key(account_id: object) -> str:
value = account_id if isinstance(account_id, str) else ""
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
for key in keys:
value = row.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
for key in keys:
value = row.get(key)
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
return int(value)
return None
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
efforts: list[str] = []
for item in cast(list[object], value):
if isinstance(item, str):
effort = item.strip()
elif isinstance(item, dict):
effort = _catalog_first_text(cast(dict[str, Any], item), "effort", "value", "id")
else:
effort = ""
if effort and effort not in efforts:
efforts.append(effort)
return tuple(efforts)
_OPENAI_CODEX_MODEL_CATALOG = OAuthModelCatalog(
fallback_models=_oauth_fallback_models("openai_codex"),
fetch=_fetch_openai_codex_models,
)
+41 -8
View File
@@ -20,12 +20,15 @@ from pydantic.alias_generators import to_snake
@dataclass(frozen=True) @dataclass(frozen=True)
class ProviderModelSpec: class ProviderModelSpec:
"""A curated model exposed by providers without a model-list endpoint.""" """Curated model metadata used for fixed catalogs or online fallback."""
id: str id: str
label: str = "" label: str = ""
description: str = "" description: str = ""
owned_by: str = ""
context_window: int | None = None context_window: int | None = None
reasoning_efforts: tuple[str, ...] = ()
supports_backend_search: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -42,7 +45,7 @@ class ProviderSpec:
keywords: tuple[str, ...] # model-name keywords for matching (lowercase) keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY" env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
display_name: str = "" # shown in `nanobot status` display_name: str = "" # shown in `nanobot status`
model_catalog: str = "auto" # WebUI model-list source model_catalog: str = "auto" # WebUI model-list source, including builtin/hybrid
builtin_models: tuple[ProviderModelSpec, ...] = () builtin_models: tuple[ProviderModelSpec, ...] = ()
settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings
@@ -407,45 +410,56 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("openai-codex",), keywords=("openai-codex",),
env_key="", env_key="",
display_name="OpenAI Codex", display_name="OpenAI Codex",
model_catalog="builtin", model_catalog="hybrid",
builtin_models=( builtin_models=(
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.6-sol", id="openai-codex/gpt-5.6-sol",
label="GPT-5.6-Sol", label="GPT-5.6-Sol",
description="Latest frontier agentic coding model.", description="Latest frontier agentic coding model.",
context_window=372000, context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
), ),
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.6-terra", id="openai-codex/gpt-5.6-terra",
label="GPT-5.6-Terra", label="GPT-5.6-Terra",
description="Balanced agentic coding model for everyday work.", description="Balanced agentic coding model for everyday work.",
context_window=372000, context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
), ),
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.6-luna", id="openai-codex/gpt-5.6-luna",
label="GPT-5.6-Luna", label="GPT-5.6-Luna",
description="Fast and affordable agentic coding model.", description="Fast and affordable agentic coding model.",
context_window=372000, context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh", "max"),
), ),
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.5", id="openai-codex/gpt-5.5",
label="GPT-5.5", label="GPT-5.5",
description="Frontier model for complex coding, research, and real-world work.", description="Frontier model for complex coding, research, and real-world work.",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
), ),
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.4", id="openai-codex/gpt-5.4",
label="GPT-5.4", label="GPT-5.4",
description="Strong model for everyday coding.", description="Strong model for everyday coding.",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
), ),
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.4-mini", id="openai-codex/gpt-5.4-mini",
label="GPT-5.4-Mini", label="GPT-5.4-Mini",
description="Small, fast, and cost-efficient model for simpler coding tasks.", description="Small, fast, and cost-efficient model for simpler coding tasks.",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
), ),
ProviderModelSpec( ProviderModelSpec(
id="openai-codex/gpt-5.3-codex-spark", id="openai-codex/gpt-5.3-codex-spark",
label="GPT-5.3-Codex-Spark", label="GPT-5.3-Codex-Spark",
description="Ultra-fast coding model.", description="Ultra-fast coding model.",
context_window=128_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
), ),
), ),
backend="openai_codex", backend="openai_codex",
@@ -459,13 +473,19 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("xai-grok", "xai_grok"), keywords=("xai-grok", "xai_grok"),
env_key="", env_key="",
display_name="xAI Grok", display_name="xAI Grok",
model_catalog="builtin", model_catalog="hybrid",
builtin_models=( builtin_models=(
ProviderModelSpec(
id="xai-grok/grok-4.6",
label="Grok 4.6",
description="Grok via xAI subscription; X Search is enabled when supported.",
context_window=500_000,
),
ProviderModelSpec( ProviderModelSpec(
id="xai-grok/grok-4.5", id="xai-grok/grok-4.5",
label="Grok 4.5", label="Grok 4.5",
description="Grok via xAI subscription; X Search is enabled when supported.", description="Grok via xAI subscription; X Search is enabled when supported.",
context_window=500000, context_window=500_000,
), ),
), ),
backend="xai_grok", backend="xai_grok",
@@ -478,6 +498,19 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("github_copilot", "copilot"), keywords=("github_copilot", "copilot"),
env_key="", env_key="",
display_name="Github Copilot", display_name="Github Copilot",
model_catalog="hybrid",
builtin_models=(
ProviderModelSpec(
id="github-copilot/gpt-5.4-mini",
label="GPT-5.4 Mini",
description="GitHub Copilot Responses model.",
),
ProviderModelSpec(
id="github-copilot/gpt-4.1",
label="GPT-4.1",
description="GitHub Copilot chat model.",
),
),
backend="github_copilot", backend="github_copilot",
default_api_base="https://api.githubcopilot.com", default_api_base="https://api.githubcopilot.com",
strip_model_prefix=True, strip_model_prefix=True,
+393 -187
View File
@@ -4,9 +4,9 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import hashlib
import json import json
import re import re
import time
import uuid import uuid
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any, cast from typing import Any, cast
@@ -22,21 +22,24 @@ from nanobot.providers.base import (
ToolCallRequest, ToolCallRequest,
resolve_stream_idle_timeout_s, resolve_stream_idle_timeout_s,
) )
from nanobot.providers.oauth_model_catalog import OAuthModelCatalog, OAuthModelCatalogSnapshot
from nanobot.providers.openai_responses import ( from nanobot.providers.openai_responses import (
consume_sse_with_reasoning, consume_sse_with_reasoning,
convert_messages, convert_messages,
convert_tools, convert_tools,
) )
from nanobot.providers.registry import ProviderModelSpec, find_by_name
from nanobot.providers.xai_oauth import ( from nanobot.providers.xai_oauth import (
XAI_CLIENT_VERSION, XAI_CLIENT_VERSION,
XAIToken, get_xai_oauth_login_status,
get_xai_oauth_storage_path,
get_xai_oauth_token, get_xai_oauth_token,
) )
DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.6"
DEFAULT_XAI_GROK_URL = "https://cli-chat-proxy.grok.com/v1/responses" DEFAULT_XAI_GROK_URL = "https://cli-chat-proxy.grok.com/v1/responses"
DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models" DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models"
DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.5" _HOSTED_SEARCH_MAX_TURNS = 5
_MODEL_CAPABILITIES_TTL_S = 5 * 60
_MAX_ERROR_BODY_CHARS = 1000 _MAX_ERROR_BODY_CHARS = 1000
_SENSITIVE_ERROR_KEYS = { _SENSITIVE_ERROR_KEYS = {
"accesstoken", "accesstoken",
@@ -63,6 +66,10 @@ def _is_named_x_search_tool(value: object) -> bool:
class XAIGrokProvider(LLMProvider): class XAIGrokProvider(LLMProvider):
"""Call xAI's subscription proxy and expose supported hosted tools.""" """Call xAI's subscription proxy and expose supported hosted tools."""
# An incomplete hosted-tool stream can already have emitted answer text. Let the
# provider close that stream segment before its one bounded recovery attempt.
supports_stream_recover_callback = True
def __init__( def __init__(
self, self,
default_model: str = DEFAULT_XAI_GROK_MODEL, default_model: str = DEFAULT_XAI_GROK_MODEL,
@@ -75,37 +82,19 @@ class XAIGrokProvider(LLMProvider):
self.default_model = default_model self.default_model = default_model
self.proxy = proxy or None self.proxy = proxy or None
self._extra_body = dict(extra_body or {}) self._extra_body = dict(extra_body or {})
self._model_capabilities: dict[str, bool] | None = None
self._model_capabilities_fetched_at = 0.0
async def _supports_backend_search(self, token: XAIToken, model: str) -> bool: async def _supports_backend_search(self, model: str) -> bool:
now = time.monotonic() catalog = await asyncio.to_thread(
capabilities = self._model_capabilities get_xai_grok_model_catalog,
if ( self.proxy,
capabilities is None )
or now - self._model_capabilities_fetched_at >= _MODEL_CAPABILITIES_TTL_S if catalog.message:
): logger.warning(
try: "xAI model catalog unavailable; hosted X Search disabled unless cached: {}",
capabilities = await _fetch_xai_model_capabilities( catalog.message,
DEFAULT_XAI_GROK_MODELS_URL, )
_build_model_headers(token), info = catalog.find(model)
proxy=self.proxy, return bool(info and info.supports_backend_search)
)
except Exception as exc:
logger.warning(
"xAI model capability lookup failed; hosted X Search disabled for model {}: "
"type={} error={}",
model,
type(exc).__name__,
str(exc).strip() or "unexpected error",
)
capabilities = {}
self._model_capabilities = capabilities
self._model_capabilities_fetched_at = now
else:
self._model_capabilities = capabilities
self._model_capabilities_fetched_at = now
return capabilities.get(model, False)
async def _call_xai( async def _call_xai(
self, self,
@@ -119,6 +108,7 @@ class XAIGrokProvider(LLMProvider):
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None, on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
wire_model = _strip_model_prefix(model or self.default_model) wire_model = _strip_model_prefix(model or self.default_model)
system_prompt, input_items = convert_messages(messages) system_prompt, input_items = convert_messages(messages)
@@ -128,17 +118,13 @@ class XAIGrokProvider(LLMProvider):
token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy) token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy)
configured_tools = self._extra_body.get("tools") configured_tools = self._extra_body.get("tools")
tools_are_explicit = "tools" in self._extra_body tools_are_explicit = "tools" in self._extra_body
configured_hosted_search = ( configured_hosted_search = isinstance(configured_tools, list) and any(
isinstance(configured_tools, list) _is_hosted_x_search_tool(tool) for tool in cast(list[object], configured_tools)
and any(
_is_hosted_x_search_tool(tool)
for tool in cast(list[object], configured_tools)
)
) )
supports_backend_search = False supports_backend_search = False
if not tools_are_explicit: if not tools_are_explicit:
stage = "model_capabilities" stage = "model_capabilities"
supports_backend_search = await self._supports_backend_search(token, wire_model) supports_backend_search = await self._supports_backend_search(wire_model)
converted_tools = convert_tools(tools or []) converted_tools = convert_tools(tools or [])
if isinstance(configured_tools, list): if isinstance(configured_tools, list):
converted_tools.extend(cast(list[dict[str, Any]], configured_tools)) converted_tools.extend(cast(list[dict[str, Any]], configured_tools))
@@ -149,6 +135,8 @@ class XAIGrokProvider(LLMProvider):
if supports_backend_search: if supports_backend_search:
converted_tools.append({"type": "x_search"}) converted_tools.append({"type": "x_search"})
hosted_search_enabled = supports_backend_search or configured_hosted_search
body: dict[str, Any] = { body: dict[str, Any] = {
"model": wire_model, "model": wire_model,
"store": False, "store": False,
@@ -164,51 +152,65 @@ class XAIGrokProvider(LLMProvider):
"temperature": temperature, "temperature": temperature,
"reasoning": _build_reasoning_options(reasoning_effort), "reasoning": _build_reasoning_options(reasoning_effort),
} }
if hosted_search_enabled:
# xAI's global default is intentionally unspecified. Five turns is
# their documented balanced setting and prevents a search from
# stopping after a single unsuccessful lookup.
body["max_turns"] = _HOSTED_SEARCH_MAX_TURNS
if self._extra_body: if self._extra_body:
body.update({ body.update(
key: value {key: value for key, value in self._extra_body.items() if key != "tools"}
for key, value in self._extra_body.items() )
if key != "tools"
})
if tools_are_explicit and not isinstance(configured_tools, list): if tools_are_explicit and not isinstance(configured_tools, list):
body["tools"] = configured_tools body["tools"] = configured_tools
headers = _build_headers(token.access, wire_model) headers = _build_headers(token.access, wire_model)
stage = "xai_request" stage = "xai_request"
try: auth_retried = False
result = await _request_xai( hosted_tool_retried = False
DEFAULT_XAI_GROK_URL, retry_usage: LLMUsage | None = None
headers, while True:
body, try:
proxy=self.proxy, result = await _request_xai(
on_content_delta=on_content_delta, DEFAULT_XAI_GROK_URL,
on_thinking_delta=on_thinking_delta, headers,
on_tool_call_delta=on_tool_call_delta, body,
) proxy=self.proxy,
except _XAIHTTPError as exc: on_content_delta=on_content_delta,
if exc.status_code != 401: on_thinking_delta=on_thinking_delta,
raise on_tool_call_delta=on_tool_call_delta,
stage = "oauth_refresh" )
token = await asyncio.to_thread( break
get_xai_oauth_token, except _XAIHTTPError as exc:
proxy=self.proxy, if exc.status_code != 401 or auth_retried:
force_refresh=True, raise
) auth_retried = True
self._model_capabilities = None stage = "oauth_refresh"
self._model_capabilities_fetched_at = 0.0 token = await asyncio.to_thread(
headers = _build_headers(token.access, wire_model) get_xai_oauth_token,
stage = "xai_request_retry" proxy=self.proxy,
result = await _request_xai( force_refresh=True,
DEFAULT_XAI_GROK_URL, )
headers, headers = _build_headers(token.access, wire_model)
body, stage = "xai_request_after_oauth_refresh"
proxy=self.proxy, except _XAIIncompleteHostedToolError as exc:
on_content_delta=on_content_delta, retry_usage = _combine_usage(retry_usage, exc.usage)
on_thinking_delta=on_thinking_delta, cannot_recover_stream = exc.stream_output_emitted and on_stream_recover is None
on_tool_call_delta=on_tool_call_delta, if hosted_tool_retried or cannot_recover_stream:
) exc.usage = retry_usage
raise
hosted_tool_retried = True
stage = "hosted_tool_recovery"
logger.warning(
"xAI response ended with unfinished hosted tool(s): {}; retrying once",
", ".join(exc.tool_names),
)
if on_stream_recover is not None:
await on_stream_recover()
headers = _build_headers(token.access, wire_model)
content, tool_calls, finish_reason, usage, reasoning_content = result content, tool_calls, finish_reason, usage, reasoning_content = result
usage = _combine_usage(retry_usage, usage)
return LLMResponse( return LLMResponse(
content=content, content=content,
tool_calls=tool_calls, tool_calls=tool_calls,
@@ -257,6 +259,7 @@ class XAIGrokProvider(LLMProvider):
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None, on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
) -> LLMResponse: ) -> LLMResponse:
return await self._call_xai( return await self._call_xai(
messages, messages,
@@ -269,6 +272,7 @@ class XAIGrokProvider(LLMProvider):
on_content_delta, on_content_delta,
on_thinking_delta, on_thinking_delta,
on_tool_call_delta, on_tool_call_delta,
on_stream_recover,
) )
def get_default_model(self) -> str: def get_default_model(self) -> str:
@@ -288,6 +292,14 @@ def _build_reasoning_options(reasoning_effort: str | None) -> dict[str, str]:
return options return options
def _combine_usage(left: LLMUsage | None, right: LLMUsage | None) -> LLMUsage | None:
if left is None:
return right
if right is None:
return left
return left + right
def _build_headers(token: str, model: str) -> dict[str, str]: def _build_headers(token: str, model: str) -> dict[str, str]:
conversation_id = str(uuid.uuid4()) conversation_id = str(uuid.uuid4())
return { return {
@@ -308,44 +320,6 @@ def _build_headers(token: str, model: str) -> dict[str, str]:
} }
def _build_model_headers(token: XAIToken) -> dict[str, str]:
headers = {
"Authorization": f"Bearer {token.access}",
"X-XAI-Token-Auth": "xai-grok-cli",
"x-grok-client-version": XAI_CLIENT_VERSION,
"x-grok-client-identifier": "nanobot",
"x-grok-client-mode": "headless",
"User-Agent": f"nanobot/{__version__} (python)",
"accept": "application/json",
}
claims = _decode_access_token_claims(token.access)
user_id = claims.get("sub")
if claims.get("principal_type") == "Team":
user_id = claims.get("principal_id") or user_id
if isinstance(user_id, str) and user_id:
headers["x-userid"] = user_id
email = claims.get("email")
if not isinstance(email, str) or "@" not in email:
email = token.account_id if token.account_id and "@" in token.account_id else None
if email:
headers["x-email"] = email
return headers
def _decode_access_token_claims(token: str) -> dict[str, Any]:
"""Read identity hints from the signed token; the server still authenticates it."""
parts = token.split(".")
if len(parts) < 2 or not parts[1]:
return {}
payload = parts[1]
try:
decoded = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
claims = json.loads(decoded)
except (ValueError, TypeError):
return {}
return cast(dict[str, Any], claims) if isinstance(claims, dict) else {}
class _XAIHTTPError(RuntimeError): class _XAIHTTPError(RuntimeError):
def __init__( def __init__(
self, self,
@@ -367,65 +341,25 @@ class _XAIHTTPError(RuntimeError):
self.response_body = response_body self.response_body = response_body
async def _fetch_xai_model_capabilities( class _XAIIncompleteHostedToolError(RuntimeError):
url: str, """A nominally successful xAI stream ended before a hosted tool did."""
headers: dict[str, str],
*,
proxy: str | None = None,
) -> dict[str, bool]:
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
async with httpx.AsyncClient(**client_kwargs) as client:
response = await client.get(url, headers=headers)
if response.status_code != 200:
raw = response.content.decode("utf-8", "ignore")
raise _build_xai_http_error(response.status_code, response.headers, raw)
try:
payload = response.json()
except ValueError as exc:
raise RuntimeError("xAI model catalog returned invalid JSON.") from exc
return _parse_xai_model_capabilities(payload)
should_retry = False # _call_xai already performs the one safe recovery attempt.
def _parse_xai_model_capabilities(payload: Any) -> dict[str, bool]: def __init__(
if isinstance(payload, dict): self,
payload = cast(dict[str, Any], payload) active_tools: list[dict[str, Any]],
rows: object = payload.get("data") *,
if not isinstance(rows, list): usage: LLMUsage | None,
rows = payload.get("models") stream_output_emitted: bool = False,
else: ) -> None:
rows = payload names = [str(event.get("name") or "hosted_tool") for event in active_tools]
if not isinstance(rows, list): super().__init__(
return {} "xAI ended the response before its hosted tool completed: " + ", ".join(names)
capabilities: dict[str, bool] = {}
for row_value in cast(list[object], rows):
if not isinstance(row_value, dict):
continue
row = cast(dict[str, Any], row_value)
meta_value = row.get("_meta")
meta = cast(dict[str, Any], meta_value) if isinstance(meta_value, dict) else {}
support_value = row.get("supportsBackendSearch")
if not isinstance(support_value, bool):
support_value = row.get("supports_backend_search")
if not isinstance(support_value, bool):
support_value = meta.get("supportsBackendSearch")
if not isinstance(support_value, bool):
support_value = meta.get("supports_backend_search")
supports_backend_search = support_value if isinstance(support_value, bool) else False
identifiers = (
row.get("model"),
row.get("modelId"),
row.get("id"),
meta.get("model"),
meta.get("modelId"),
) )
for identifier in identifiers: self.tool_names = tuple(names)
if isinstance(identifier, str) and identifier.strip(): self.usage = usage
capabilities[_strip_model_prefix(identifier.strip())] = supports_backend_search self.stream_output_emitted = stream_output_emitted
return capabilities
async def _request_xai( async def _request_xai(
@@ -438,10 +372,39 @@ async def _request_xai(
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None, on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, LLMUsage | None, str | None]: ) -> tuple[str, list[ToolCallRequest], str, LLMUsage | None, str | None]:
active_hosted_tools: dict[str, dict[str, Any]] = {}
stream_output_emitted = False
async def _forward_content_delta(delta: str) -> None:
nonlocal stream_output_emitted
if delta:
stream_output_emitted = True
if on_content_delta is not None:
await on_content_delta(delta)
async def _forward_thinking_delta(delta: str) -> None:
nonlocal stream_output_emitted
if delta:
stream_output_emitted = True
if on_thinking_delta is not None:
await on_thinking_delta(delta)
async def _track_and_forward_tool_event(event: dict[str, Any]) -> None:
if event.get("kind") == "hosted_tool":
call_id = event.get("call_id")
if call_id:
call_id = str(call_id)
if event.get("phase") == "start":
active_hosted_tools[call_id] = dict(event)
elif event.get("phase") in {"end", "error"}:
active_hosted_tools.pop(call_id, None)
if on_tool_call_delta is not None:
await on_tool_call_delta(event)
async def _on_response_event(event: dict[str, Any]) -> None: async def _on_response_event(event: dict[str, Any]) -> None:
hosted_event = _xai_hosted_tool_event(event) hosted_event = _xai_hosted_tool_event(event)
if hosted_event is not None and on_tool_call_delta is not None: if hosted_event is not None:
await on_tool_call_delta(hosted_event) await _track_and_forward_tool_event(hosted_event)
client_kwargs: dict[str, Any] = {"timeout": resolve_stream_idle_timeout_s()} client_kwargs: dict[str, Any] = {"timeout": resolve_stream_idle_timeout_s()}
if proxy: if proxy:
@@ -452,13 +415,34 @@ async def _request_xai(
content = await response.aread() content = await response.aread()
raw = content.decode("utf-8", "ignore") raw = content.decode("utf-8", "ignore")
raise _build_xai_http_error(response.status_code, response.headers, raw) raise _build_xai_http_error(response.status_code, response.headers, raw)
return await consume_sse_with_reasoning( result = await consume_sse_with_reasoning(
response, response,
on_content_delta=on_content_delta, on_content_delta=(_forward_content_delta if on_content_delta is not None else None),
on_tool_call_delta=on_tool_call_delta, # Always observe tool events so protocol validation also works for
on_reasoning_delta=on_thinking_delta, # non-streaming callers that did not request UI progress callbacks.
on_response_event=_on_response_event if on_tool_call_delta else None, on_tool_call_delta=_track_and_forward_tool_event,
on_reasoning_delta=(
_forward_thinking_delta if on_thinking_delta is not None else None
),
on_response_event=_on_response_event,
) )
if result[2] != "error" and active_hosted_tools:
active = list(active_hosted_tools.values())
for event in active:
await _track_and_forward_tool_event(
{
**event,
"phase": "error",
"result": None,
"error": "xAI ended the response before this hosted tool completed.",
}
)
raise _XAIIncompleteHostedToolError(
active,
usage=result[3],
stream_output_emitted=stream_output_emitted,
)
return result
def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None: def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
@@ -472,19 +456,33 @@ def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
"phase": "start", "phase": "start",
"call_id": str(call_id), "call_id": str(call_id),
"name": "x_search", "name": "x_search",
"arguments": _xai_hosted_tool_arguments( "arguments": _xai_hosted_tool_arguments(event.get("input", event.get("arguments"))),
event.get("input", event.get("arguments"))
),
"result": None, "result": None,
} }
if event_type != "response.output_item.done": if event_type not in {"response.output_item.added", "response.output_item.done"}:
return None return None
item = event.get("item") item = event.get("item")
if not isinstance(item, dict): if not isinstance(item, dict):
return None return None
item = cast(dict[str, Any], item) item = cast(dict[str, Any], item)
if item.get("type") != "custom_tool_call": item_type = item.get("type")
if item_type == "x_search_call":
call_id = item.get("id") or item.get("call_id") or event.get("item_id")
if not call_id:
return None
phase = "start" if event_type == "response.output_item.added" else "end"
return {
"kind": "hosted_tool",
"phase": phase,
"call_id": str(call_id),
"name": "x_search",
"arguments": _xai_hosted_tool_arguments(item.get("action")),
"result": (
{"status": str(item.get("status") or "completed")} if phase == "end" else None
),
}
if event_type != "response.output_item.done" or item_type != "custom_tool_call":
return None return None
tool_name = item.get("name") tool_name = item.get("name")
if not isinstance(tool_name, str) or not tool_name.startswith("x_"): if not isinstance(tool_name, str) or not tool_name.startswith("x_"):
@@ -497,9 +495,7 @@ def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
"phase": "end", "phase": "end",
"call_id": str(call_id), "call_id": str(call_id),
"name": "x_search", "name": "x_search",
"arguments": _xai_hosted_tool_arguments( "arguments": _xai_hosted_tool_arguments(item.get("input", item.get("arguments"))),
item.get("input", item.get("arguments"))
),
# Keep the useful search subtype, but do not persist large hosted results # Keep the useful search subtype, but do not persist large hosted results
# in WebUI activity messages. The model answer already carries citations. # in WebUI activity messages. The model answer already carries citations.
"result": {"name": tool_name}, "result": {"name": tool_name},
@@ -608,6 +604,8 @@ def _xai_error_response(exc: Exception) -> LLMResponse:
should_retry = True if should_retry is None else should_retry should_retry = True if should_retry is None else should_retry
elif isinstance(exc, _XAIHTTPError): elif isinstance(exc, _XAIHTTPError):
error_kind = "http" error_kind = "http"
elif isinstance(exc, _XAIIncompleteHostedToolError):
error_kind = "provider"
if status_code is not None and should_retry is None: if status_code is not None and should_retry is None:
should_retry = _should_retry_status( should_retry = _should_retry_status(
int(status_code), int(status_code),
@@ -617,9 +615,11 @@ def _xai_error_response(exc: Exception) -> LLMResponse:
) )
message = str(exc).strip() or "unexpected error" message = str(exc).strip() or "unexpected error"
retry_after = getattr(exc, "retry_after", None) retry_after = getattr(exc, "retry_after", None)
usage = getattr(exc, "usage", None)
return LLMResponse( return LLMResponse(
content=f"Error calling xAI ({type(exc).__name__}): {message}", content=f"Error calling xAI ({type(exc).__name__}): {message}",
finish_reason="error", finish_reason="error",
usage=usage if isinstance(usage, LLMUsage) else None,
retry_after=retry_after, retry_after=retry_after,
error_status_code=int(status_code) if status_code is not None else None, error_status_code=int(status_code) if status_code is not None else None,
error_kind=error_kind, error_kind=error_kind,
@@ -647,3 +647,209 @@ def _should_retry_status(
) )
) )
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500 # pyright: ignore[reportPrivateUsage] return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500 # pyright: ignore[reportPrivateUsage]
def get_xai_grok_model_catalog(proxy: str | None = None) -> OAuthModelCatalogSnapshot:
token = get_xai_oauth_login_status()
account_key = _catalog_account_key(getattr(token, "account_id", None))
cache_key = f"{get_xai_oauth_storage_path()}\0{account_key}\0{proxy or ''}"
return _XAI_GROK_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
def invalidate_xai_grok_model_catalog() -> None:
_XAI_GROK_MODEL_CATALOG.invalidate()
def _fetch_xai_grok_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
token = get_xai_oauth_token(proxy=proxy)
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
with httpx.Client(**client_kwargs) as client:
response = client.get(
DEFAULT_XAI_GROK_MODELS_URL,
headers=_build_xai_model_headers(token.access, token.account_id),
)
response.raise_for_status()
return _parse_xai_grok_models(response.json())
def _parse_xai_grok_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
if isinstance(payload, dict):
payload_mapping = cast(dict[str, Any], payload)
rows: object = payload_mapping.get("data")
if not isinstance(rows, list):
rows = payload_mapping.get("models")
else:
rows = payload
if not isinstance(rows, list):
return ()
fallback_models = _oauth_fallback_models("xai_grok")
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
models: list[ProviderModelSpec] = []
seen: set[str] = set()
for value in cast(list[object], rows):
if not isinstance(value, dict):
continue
row = cast(dict[str, Any], value)
meta = _catalog_mapping(row.get("_meta"))
raw_id = next(
(
candidate.strip()
for candidate in (
row.get("id"),
row.get("model"),
row.get("modelId"),
row.get("name"),
meta.get("id"),
meta.get("model"),
meta.get("modelId"),
)
if isinstance(candidate, str) and candidate.strip()
),
None,
)
if raw_id is None:
continue
wire_id = raw_id.split("/", 1)[-1]
if wire_id in seen:
continue
seen.add(wire_id)
fallback = fallback_by_id.get(wire_id)
label = _catalog_first_text(row, "display_name", "label", "name") or _catalog_first_text(
meta,
"display_name",
"label",
"name",
)
if not label or label == raw_id:
label = fallback.label if fallback is not None else wire_id
models.append(
ProviderModelSpec(
id=f"xai-grok/{wire_id}",
label=label,
description=(
_catalog_first_text(row, "description")
or _catalog_first_text(meta, "description")
or (fallback.description if fallback is not None else "")
),
owned_by=(
_catalog_first_text(row, "owned_by", "owner", "organization")
or _catalog_first_text(meta, "owned_by", "owner", "organization")
or (fallback.owned_by if fallback is not None else "xAI")
),
context_window=(
_catalog_positive_int(row, "context_window", "context_length")
or _catalog_positive_int(meta, "context_window", "context_length")
or (fallback.context_window if fallback is not None else None)
),
reasoning_efforts=_catalog_reasoning_efforts(
row.get("reasoning_efforts", meta.get("reasoning_efforts"))
),
supports_backend_search=_catalog_bool_field(
row,
"supports_backend_search",
"supportsBackendSearch",
),
)
)
return tuple(models)
def _build_xai_model_headers(access_token: str, account_id: str | None) -> dict[str, str]:
headers = {
"Authorization": f"Bearer {access_token}",
"X-XAI-Token-Auth": "xai-grok-cli",
"x-grok-client-version": XAI_CLIENT_VERSION,
"x-grok-client-identifier": "nanobot",
"x-grok-client-mode": "headless",
"User-Agent": f"nanobot/{__version__} (python)",
"accept": "application/json",
}
claims = _decode_access_token_claims(access_token)
user_id = claims.get("sub")
if claims.get("principal_type") == "Team":
user_id = claims.get("principal_id") or user_id
if isinstance(user_id, str) and user_id:
headers["x-userid"] = user_id
email = claims.get("email")
if not isinstance(email, str) or "@" not in email:
email = account_id if account_id and "@" in account_id else None
if email:
headers["x-email"] = email
return headers
def _decode_access_token_claims(token: str) -> dict[str, Any]:
parts = token.split(".")
if len(parts) < 2 or not parts[1]:
return {}
try:
decoded = base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4))
claims = json.loads(decoded)
except (ValueError, TypeError):
return {}
return cast(dict[str, Any], claims) if isinstance(claims, dict) else {}
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
spec = find_by_name(provider_name)
assert spec is not None
return spec.builtin_models
def _catalog_account_key(account_id: object) -> str:
value = account_id if isinstance(account_id, str) else ""
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
def _catalog_mapping(value: Any) -> dict[str, Any]:
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
for key in keys:
value = row.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
for key in keys:
value = row.get(key)
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
return int(value)
return None
def _catalog_bool_field(row: dict[str, Any], *keys: str) -> bool:
for key in keys:
value = row.get(key)
if isinstance(value, bool):
return value
meta = row.get("_meta")
return _catalog_bool_field(_catalog_mapping(meta), *keys) if isinstance(meta, dict) else False
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
efforts: list[str] = []
for item in cast(list[object], value):
if isinstance(item, str):
effort = item.strip()
elif isinstance(item, dict):
effort = _catalog_first_text(cast(dict[str, Any], item), "effort", "value", "id")
else:
effort = ""
if effort and effort not in efforts:
efforts.append(effort)
return tuple(efforts)
_XAI_GROK_MODEL_CATALOG = OAuthModelCatalog(
fallback_models=_oauth_fallback_models("xai_grok"),
fetch=_fetch_xai_grok_models,
)
+32
View File
@@ -28,6 +28,10 @@ from nanobot.config.loader import resolve_config_env_vars
from nanobot.config.schema import Config, FallbackCandidate, ModelPresetConfig, ProviderConfig from nanobot.config.schema import Config, FallbackCandidate, ModelPresetConfig, ProviderConfig
from nanobot.providers.image_generation import get_image_gen_provider from nanobot.providers.image_generation import get_image_gen_provider
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
from nanobot.providers.oauth_model_catalog import (
get_oauth_model_catalog,
invalidate_oauth_model_catalog,
)
from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
from nanobot.webui.settings_contracts import ( from nanobot.webui.settings_contracts import (
QueryParams, QueryParams,
@@ -661,6 +665,30 @@ def provider_models_payload(
"models": rows, "models": rows,
"model_count": len(rows), "model_count": len(rows),
} }
if catalog_kind == "hybrid":
proxy = _resolve_env_placeholders(provider_config.proxy)
catalog = get_oauth_model_catalog(spec.name, proxy=proxy)
rows = [
{
"id": model.id,
"label": model.label or None,
"description": model.description or None,
"owned_by": model.owned_by or spec.label,
"context_window": model.context_window,
"reasoning_efforts": list(model.reasoning_efforts),
"supports_backend_search": model.supports_backend_search,
}
for model in catalog.models
]
return {
**base_payload,
"status": "available",
"source": catalog.source,
"models": rows,
"model_count": len(rows),
"message": catalog.message,
"fetched_at": catalog.fetched_at,
}
api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base
if spec.name == "openai" and not api_base: if spec.name == "openai" and not api_base:
@@ -1506,6 +1534,7 @@ def login_oauth_provider(
token = login_github_copilot(print_fn=lambda _message: None) token = login_github_copilot(print_fn=lambda _message: None)
if not (token and token.access): if not (token and token.access):
raise WebUISettingsError("OAuth login failed", status=401) raise WebUISettingsError("OAuth login failed", status=401)
invalidate_oauth_model_catalog(spec.name)
return settings_payload(config_path=config_path) return settings_payload(config_path=config_path)
if spec.name == "xai_grok": if spec.name == "xai_grok":
@@ -1591,6 +1620,7 @@ def complete_oauth_provider(
oauth_flows.remove(spec.name, flow_id, flow, cancel=False) oauth_flows.remove(spec.name, flow_id, flow, cancel=False)
if not token.access: if not token.access:
raise WebUISettingsError("OAuth login failed", status=401) raise WebUISettingsError("OAuth login failed", status=401)
invalidate_oauth_model_catalog(spec.name)
return settings_payload(config_path=config_path) return settings_payload(config_path=config_path)
@@ -1629,6 +1659,7 @@ def logout_oauth_provider(
oauth_flows.clear(spec.name) oauth_flows.clear(spec.name)
logout_xai_oauth() logout_xai_oauth()
invalidate_oauth_model_catalog(spec.name)
return settings_payload(config_path=config_path) return settings_payload(config_path=config_path)
else: else:
raise WebUISettingsError("OAuth logout is not supported for this provider") raise WebUISettingsError("OAuth logout is not supported for this provider")
@@ -1636,6 +1667,7 @@ def logout_oauth_provider(
for path in (token_path, token_path.with_suffix(".lock")): for path in (token_path, token_path.with_suffix(".lock")):
with suppress(FileNotFoundError): with suppress(FileNotFoundError):
path.unlink() path.unlink()
invalidate_oauth_model_catalog(spec.name)
return settings_payload(config_path=config_path) return settings_payload(config_path=config_path)
+85 -5
View File
@@ -799,7 +799,7 @@ def test_provider_login_can_set_xai_grok_as_main_provider(tmp_path):
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.agents.defaults.provider == "xai_grok"
assert saved.agents.defaults.model == "xai-grok/grok-4.5" assert saved.agents.defaults.model == "xai-grok/grok-4.6"
assert saved.agents.defaults.context_window_tokens == 500_000 assert saved.agents.defaults.context_window_tokens == 500_000
assert saved.agents.defaults.model_preset is None assert saved.agents.defaults.model_preset is None
assert make_provider(saved).__class__.__name__ == "XAIGrokProvider" assert make_provider(saved).__class__.__name__ == "XAIGrokProvider"
@@ -2654,12 +2654,14 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
assert seen["lease_release_wait_for_stop"] is False assert seen["lease_release_wait_for_stop"] is False
def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys) -> None: def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys, tmp_path: Path) -> None:
stopped = False stopped = False
log_path = tmp_path / "gateway.log"
log_path.touch()
class _FakeRuntime: class _FakeRuntime:
def status(self): def status(self):
return SimpleNamespace(running=True) return SimpleNamespace(running=True, log_path=log_path)
def stop(self): def stop(self):
nonlocal stopped nonlocal stopped
@@ -2679,10 +2681,88 @@ def test_attach_to_background_gateway_detaches_on_ctrl_c(capsys) -> None:
assert "WebUI launcher detached" in rendered assert "WebUI launcher detached" in rendered
def test_attach_to_background_gateway_checks_owned_sidecar() -> None: def test_attach_to_background_gateway_follows_only_new_logs(capsys, tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.write_text("historical log\n", encoding="utf-8")
polls = 0
class _FakeRuntime: class _FakeRuntime:
def status(self): def status(self):
return SimpleNamespace(running=True) return SimpleNamespace(running=True, log_path=log_path)
def _append_then_interrupt(_seconds: float) -> None:
nonlocal polls
if polls == 0:
with log_path.open("a", encoding="utf-8") as handle:
handle.write("[websocket] live log\n")
polls += 1
return
raise KeyboardInterrupt
cli_webui_support._attach_to_background_gateway(
_FakeRuntime(),
sleep=_append_then_interrupt,
)
output = capsys.readouterr().out
assert "[websocket] live log" in output
assert "historical log" not in output
def test_read_new_gateway_logs_recovers_after_truncation(tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.write_text("a much longer historical log line\n", encoding="utf-8")
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
log_path.write_text("fresh log\n", encoding="utf-8")
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
assert lines == ["fresh log"]
assert cursor.offset == log_path.stat().st_size
def test_read_new_gateway_logs_detects_fast_rewrite_past_offset(tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.write_text("historical log\n", encoding="utf-8")
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
log_path.write_text("first fresh log\nsecond fresh log\n", encoding="utf-8")
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
assert lines == ["first fresh log", "second fresh log"]
def test_read_new_gateway_logs_waits_for_complete_utf8_line(tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.touch()
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
encoded = "模型 ready\n".encode()
log_path.write_bytes(encoded[:2])
assert cli_webui_support._read_new_gateway_logs(log_path, cursor) == []
with log_path.open("ab") as handle:
handle.write(encoded[2:])
assert cli_webui_support._read_new_gateway_logs(log_path, cursor) == ["模型 ready"]
def test_read_new_gateway_logs_tolerates_missing_file(tmp_path: Path) -> None:
log_path = tmp_path / "missing.log"
cursor = cli_webui_support._start_gateway_log_cursor(log_path)
lines = cli_webui_support._read_new_gateway_logs(log_path, cursor)
assert lines == []
assert cursor.offset == 0
def test_attach_to_background_gateway_checks_owned_sidecar(tmp_path: Path) -> None:
log_path = tmp_path / "gateway.log"
log_path.touch()
class _FakeRuntime:
def status(self):
return SimpleNamespace(running=True, log_path=log_path)
def sidecar_exited() -> None: def sidecar_exited() -> None:
raise WebUIDevError("WebUI development server exited unexpectedly (code 23)") raise WebUIDevError("WebUI development server exited unexpectedly (code 23)")
+504
View File
@@ -0,0 +1,504 @@
from __future__ import annotations
import base64
import json
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import SimpleNamespace
import httpx
import pytest
from nanobot.providers.oauth_model_catalog import (
OAuthModelCatalog,
get_oauth_model_catalog,
invalidate_oauth_model_catalog,
)
from nanobot.providers.openai_codex_provider import (
DEFAULT_OPENAI_CODEX_MODELS_URL,
OPENAI_CODEX_CATALOG_CLIENT_VERSION,
)
from nanobot.providers.registry import ProviderModelSpec
from nanobot.providers.xai_grok_provider import DEFAULT_XAI_GROK_MODELS_URL
from nanobot.providers.xai_oauth import XAIToken
@pytest.fixture(autouse=True)
def _clear_oauth_catalogs() -> None:
for provider in ("openai_codex", "xai_grok", "github_copilot"):
invalidate_oauth_model_catalog(provider)
yield
for provider in ("openai_codex", "xai_grok", "github_copilot"):
invalidate_oauth_model_catalog(provider)
def _fallback_model() -> ProviderModelSpec:
return ProviderModelSpec(id="provider/fallback", label="Fallback")
def test_xai_catalog_fetches_remote_models_and_reuses_capability_metadata(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
original_client = httpx.Client
captured: dict[str, object] = {}
payload = (
base64.urlsafe_b64encode(
json.dumps({"sub": "user-42", "email": "user@example.com"}).encode()
)
.decode()
.rstrip("=")
)
token = XAIToken(
access=f"header.{payload}.signature",
refresh="refresh-token",
expires=int(time.time() * 1000) + 3_600_000,
account_id="user@example.com",
)
def handler(request: httpx.Request) -> httpx.Response:
captured["request"] = request
return httpx.Response(
200,
json={
"data": [
{
"id": "grok-4.6",
"name": "Grok 4.6",
"description": "Latest frontier model",
"owned_by": "xAI",
"context_window": 500_000,
"supports_backend_search": True,
"reasoning_efforts": [
{"value": "xhigh"},
{"value": "high"},
{"value": "low"},
],
},
{
"id": "grok-next",
"_meta": {
"name": "Grok Next",
"context_window": 750_000,
"reasoning_efforts": ["high", "low"],
},
},
]
},
request=request,
)
def fake_client(**kwargs: object) -> httpx.Client:
captured["kwargs"] = kwargs
return original_client(
transport=httpx.MockTransport(handler),
timeout=kwargs["timeout"],
follow_redirects=kwargs["follow_redirects"],
)
monkeypatch.setattr(
"nanobot.providers.xai_grok_provider.get_xai_oauth_storage_path",
lambda: tmp_path / "auth" / "xai.json",
)
monkeypatch.setattr(
"nanobot.providers.xai_grok_provider.get_xai_oauth_login_status",
lambda: token,
)
monkeypatch.setattr(
"nanobot.providers.xai_grok_provider.get_xai_oauth_token",
lambda **_kwargs: token,
)
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.Client", fake_client)
catalog = get_oauth_model_catalog("xai_grok")
assert catalog.source == "remote"
assert [model.id for model in catalog.models] == [
"xai-grok/grok-4.6",
"xai-grok/grok-next",
]
grok = catalog.find("grok-4.6")
assert grok is not None
assert grok.description == "Latest frontier model"
assert grok.context_window == 500_000
assert grok.reasoning_efforts == ("xhigh", "high", "low")
assert grok.supports_backend_search is True
next_model = catalog.find("xai-grok/grok-next")
assert next_model is not None
assert next_model.label == "Grok Next"
assert next_model.context_window == 750_000
assert next_model.reasoning_efforts == ("high", "low")
request = captured["request"]
assert isinstance(request, httpx.Request)
assert str(request.url) == DEFAULT_XAI_GROK_MODELS_URL
assert request.headers["Authorization"] == f"Bearer {token.access}"
assert request.headers["X-XAI-Token-Auth"] == "xai-grok-cli"
assert request.headers["x-userid"] == "user-42"
assert request.headers["x-email"] == "user@example.com"
assert captured["kwargs"] == {"timeout": 10.0, "follow_redirects": False}
assert get_oauth_model_catalog("xai_grok").source == "cache"
def test_openai_codex_catalog_uses_account_catalog_and_filters_hidden_models(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
original_client = httpx.Client
captured: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["request"] = request
return httpx.Response(
200,
json={
"models": [
{
"slug": "gpt-new",
"display_name": "GPT New",
"description": "New model",
"context_window": 300_000,
"priority": 2,
"visibility": "list",
"supported_reasoning_levels": [
{"effort": "low"},
{"effort": "high"},
],
},
{
"slug": "gpt-first",
"display_name": "GPT First",
"priority": 1,
},
{
"slug": "internal-model",
"display_name": "Internal",
"visibility": "hide",
"priority": 0,
},
]
},
request=request,
)
def fake_client(**kwargs: object) -> httpx.Client:
captured["kwargs"] = kwargs
return original_client(
transport=httpx.MockTransport(handler),
timeout=kwargs["timeout"],
follow_redirects=kwargs["follow_redirects"],
)
class Storage:
def load(self) -> SimpleNamespace:
return SimpleNamespace(access="secret", account_id="account-42")
def get_token_path(self) -> Path:
return tmp_path / "auth" / "openai-codex.json"
monkeypatch.setattr(
"nanobot.providers.openai_codex_provider.FileTokenStorage",
lambda **_kwargs: Storage(),
)
monkeypatch.setattr(
"nanobot.providers.openai_codex_provider.get_codex_token",
lambda **_kwargs: SimpleNamespace(access="secret", account_id="account-42"),
)
monkeypatch.setattr("nanobot.providers.openai_codex_provider.httpx.Client", fake_client)
catalog = get_oauth_model_catalog("openai_codex")
assert catalog.source == "remote"
assert [model.id for model in catalog.models] == [
"openai-codex/gpt-first",
"openai-codex/gpt-new",
]
assert catalog.models[1].context_window == 300_000
assert catalog.models[1].reasoning_efforts == ("low", "high")
request = captured["request"]
assert isinstance(request, httpx.Request)
assert request.url.copy_with(query=None) == httpx.URL(DEFAULT_OPENAI_CODEX_MODELS_URL)
assert request.url.params["client_version"] == OPENAI_CODEX_CATALOG_CLIENT_VERSION
assert request.headers["Authorization"] == "Bearer secret"
assert request.headers["chatgpt-account-id"] == "account-42"
def test_github_copilot_catalog_only_lists_compatible_chat_models(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
original_client = httpx.Client
captured: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(request)
if request.url.path.endswith("/copilot_internal/v2/token"):
return httpx.Response(
200,
json={
"token": "copilot-secret",
"endpoints": {"api": "https://api.individual.githubcopilot.com"},
},
request=request,
)
return httpx.Response(
200,
json={
"data": [
{
"id": "claude-sonnet",
"name": "Claude Sonnet",
"model_picker_enabled": True,
"policy": {"state": "enabled"},
"supported_endpoints": ["/chat/completions"],
"capabilities": {
"supports": {"reasoning_effort": ["low", "high"]},
"limits": {"max_context_window_tokens": 200_000},
},
},
{
"id": "gpt-5.4-mini",
"name": "GPT-5.4 Mini",
"model_picker_enabled": True,
"supported_endpoints": ["/responses"],
},
{
"id": "unknown-responses-only",
"name": "Unknown Responses only",
"model_picker_enabled": True,
"supported_endpoints": ["/responses"],
},
{
"id": "disabled",
"model_picker_enabled": True,
"policy": {"state": "disabled"},
"supported_endpoints": ["/chat/completions"],
},
]
},
request=request,
)
def fake_client(**kwargs: object) -> httpx.Client:
return original_client(
transport=httpx.MockTransport(handler),
timeout=kwargs["timeout"],
follow_redirects=kwargs["follow_redirects"],
)
class Storage:
def load(self) -> SimpleNamespace:
return SimpleNamespace(access="github-secret", account_id="octocat")
def get_token_path(self) -> Path:
return tmp_path / "auth" / "github-copilot.json"
monkeypatch.setattr(
"nanobot.providers.github_copilot_provider.get_storage",
lambda: Storage(),
)
monkeypatch.setattr("nanobot.providers.github_copilot_provider.httpx.Client", fake_client)
catalog = get_oauth_model_catalog("github_copilot")
assert catalog.source == "remote"
assert [model.id for model in catalog.models] == [
"github-copilot/claude-sonnet",
"github-copilot/gpt-5.4-mini",
]
assert catalog.models[0].context_window == 200_000
assert catalog.models[0].reasoning_efforts == ("low", "high")
assert len(captured) == 2
assert captured[0].headers["Authorization"] == "token github-secret"
assert captured[1].headers["Authorization"] == "Bearer copilot-secret"
assert str(captured[1].url) == "https://api.individual.githubcopilot.com/models"
assert get_oauth_model_catalog("github_copilot").source == "cache"
assert get_oauth_model_catalog(
"github_copilot",
proxy="http://proxy.example:8080",
).source == "remote"
assert len(captured) == 4
def test_catalog_single_flights_concurrent_refreshes() -> None:
calls = 0
calls_lock = threading.Lock()
barrier = threading.Barrier(8)
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls
with calls_lock:
calls += 1
time.sleep(0.05)
return (ProviderModelSpec(id="provider/remote", label="Remote"),)
catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch)
def get_catalog(_index: int):
barrier.wait()
return catalog.get(cache_key="shared")
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(get_catalog, range(8)))
assert calls == 1
assert {result.models[0].id for result in results} == {"provider/remote"}
assert [result.source for result in results].count("remote") == 1
assert [result.source for result in results].count("cache") == 7
def test_catalog_invalidation_discards_an_inflight_account_refresh() -> None:
started = threading.Event()
release = threading.Event()
identity = ["old-account"]
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
current = identity[0]
if current == "old-account":
started.set()
assert release.wait(timeout=2)
return (ProviderModelSpec(id=f"provider/{current}", label=current),)
catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch)
with ThreadPoolExecutor(max_workers=2) as pool:
old_future = pool.submit(catalog.get, cache_key="old-key")
assert started.wait(timeout=2)
identity[0] = "new-account"
catalog.invalidate()
new_future = pool.submit(catalog.get, cache_key="new-key")
new_result = new_future.result(timeout=2)
release.set()
old_result = old_future.result(timeout=2)
assert old_result.source == "fallback"
assert new_result.models[0].id == "provider/new-account"
identity[0] = "old-account"
assert catalog.get(cache_key="old-key").models[0].id == "provider/old-account"
def test_catalog_bounds_failure_only_keys() -> None:
calls = 0
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls
calls += 1
raise httpx.ConnectError("offline")
catalog = OAuthModelCatalog(
fallback_models=(_fallback_model(),),
fetch=fetch,
max_entries=2,
)
for key in ("one", "two", "three"):
assert catalog.get(cache_key=key).source == "fallback"
assert calls == 3
assert catalog.get(cache_key="one").source == "fallback"
assert calls == 4
def test_catalog_returns_stale_then_negative_caches_refresh_failure() -> None:
now = [0.0]
calls = 0
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls
calls += 1
if calls > 1:
raise httpx.ConnectError("offline")
return (ProviderModelSpec(id="provider/remote", label="Remote"),)
catalog = OAuthModelCatalog(
fallback_models=(_fallback_model(),),
fetch=fetch,
fresh_ttl_s=10,
stale_ttl_s=100,
failure_ttl_s=30,
monotonic=lambda: now[0],
wall_clock=lambda: 123.0,
)
assert catalog.get(cache_key="one").source == "remote"
now[0] = 11
stale = catalog.get(cache_key="one")
assert stale.source == "stale"
assert stale.models[0].id == "provider/remote"
assert catalog.get(cache_key="one").source == "stale"
assert calls == 2
now[0] = 101
fallback = catalog.get(cache_key="one")
assert fallback.source == "fallback"
assert fallback.models[0].id == "provider/fallback"
assert calls == 3
@pytest.mark.parametrize(
"failure",
[
httpx.ConnectError("offline"),
ValueError("invalid JSON"),
httpx.HTTPStatusError(
"unauthorized",
request=httpx.Request("GET", DEFAULT_XAI_GROK_MODELS_URL),
response=httpx.Response(401),
),
httpx.HTTPStatusError(
"rate limited",
request=httpx.Request("GET", DEFAULT_XAI_GROK_MODELS_URL),
response=httpx.Response(429),
),
httpx.HTTPStatusError(
"upstream failure",
request=httpx.Request("GET", DEFAULT_XAI_GROK_MODELS_URL),
response=httpx.Response(503),
),
],
)
def test_catalog_falls_back_for_remote_failures(failure: Exception) -> None:
calls = 0
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls
calls += 1
raise failure
catalog = OAuthModelCatalog(
fallback_models=(_fallback_model(),),
fetch=fetch,
failure_ttl_s=30,
)
first = catalog.get(cache_key="one")
second = catalog.get(cache_key="one")
assert first.source == "fallback"
assert second.source == "fallback"
assert first.models == (_fallback_model(),)
assert calls == 1
def test_catalog_treats_empty_remote_list_as_failure_and_can_be_invalidated() -> None:
calls = 0
def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls
calls += 1
return () if calls == 1 else (ProviderModelSpec(id="provider/new", label="New"),)
catalog = OAuthModelCatalog(
fallback_models=(_fallback_model(),),
fetch=fetch,
failure_ttl_s=30,
)
assert catalog.get(cache_key="one").source == "fallback"
catalog.invalidate()
refreshed = catalog.get(cache_key="one")
assert refreshed.source == "remote"
assert refreshed.models[0].id == "provider/new"
assert calls == 2
+228 -119
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import base64
import json import json
import time import time
from types import SimpleNamespace from types import SimpleNamespace
@@ -12,21 +11,19 @@ import pytest
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.providers.base import LLMUsage from nanobot.providers.base import LLMUsage
from nanobot.providers.factory import make_provider from nanobot.providers.factory import make_provider
from nanobot.providers.registry import find_by_name from nanobot.providers.oauth_model_catalog import OAuthModelCatalogSnapshot
from nanobot.providers.registry import ProviderModelSpec, find_by_name
from nanobot.providers.xai_grok_provider import ( from nanobot.providers.xai_grok_provider import (
DEFAULT_XAI_GROK_MODEL, DEFAULT_XAI_GROK_MODEL,
DEFAULT_XAI_GROK_MODELS_URL,
XAIGrokProvider, XAIGrokProvider,
_bounded_error_body, _bounded_error_body,
_build_headers, _build_headers,
_build_model_headers,
_build_reasoning_options, _build_reasoning_options,
_build_xai_http_error, _build_xai_http_error,
_fetch_xai_model_capabilities,
_parse_xai_model_capabilities,
_request_xai, _request_xai,
_xai_error_response, _xai_error_response,
_XAIHTTPError, _XAIHTTPError,
_XAIIncompleteHostedToolError,
) )
@@ -51,22 +48,41 @@ def _mock_model_capabilities(
*, *,
supports_backend_search: bool, supports_backend_search: bool,
) -> None: ) -> None:
async def fake_fetch(*_args, **_kwargs): def fake_catalog(*_args, **_kwargs):
return {"grok-4.5": supports_backend_search} return OAuthModelCatalogSnapshot(
models=(
ProviderModelSpec(
id="xai-grok/grok-4.5",
label="Grok 4.5",
supports_backend_search=supports_backend_search,
),
ProviderModelSpec(
id="xai-grok/grok-4.6",
label="Grok 4.6",
supports_backend_search=supports_backend_search,
),
),
source="remote",
fetched_at=1,
)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities", "nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
fake_fetch, fake_catalog,
) )
def test_xai_grok_registry_exposes_curated_x_search_model() -> None: def test_xai_grok_registry_exposes_curated_x_search_models() -> None:
spec = find_by_name("xai_grok") spec = find_by_name("xai_grok")
assert spec is not None assert spec is not None
assert spec.is_oauth is True assert spec.is_oauth is True
assert spec.backend == "xai_grok" assert spec.backend == "xai_grok"
assert spec.builtin_models[0].id == DEFAULT_XAI_GROK_MODEL assert spec.builtin_models[0].id == DEFAULT_XAI_GROK_MODEL
assert [model.id for model in spec.builtin_models] == [
"xai-grok/grok-4.6",
"xai-grok/grok-4.5",
]
assert spec.builtin_models[0].context_window == 500000 assert spec.builtin_models[0].context_window == 500000
assert "when supported" in spec.builtin_models[0].description assert "when supported" in spec.builtin_models[0].description
@@ -117,7 +133,7 @@ async def test_provider_injects_hosted_x_search_and_required_proxy_headers(monke
assert response.content == "answer [[1]](https://x.com/example/status/1)" assert response.content == "answer [[1]](https://x.com/example/status/1)"
url, headers, body = calls[0] url, headers, body = calls[0]
assert url == "https://cli-chat-proxy.grok.com/v1/responses" assert url == "https://cli-chat-proxy.grok.com/v1/responses"
assert body["model"] == "grok-4.5" assert body["model"] == "grok-4.6"
assert body["tools"] == [ assert body["tools"] == [
{ {
"type": "function", "type": "function",
@@ -132,12 +148,13 @@ async def test_provider_injects_hosted_x_search_and_required_proxy_headers(monke
assert body["stream_tool_calls"] is True assert body["stream_tool_calls"] is True
assert body["reasoning"] == {"summary": "concise", "effort": "high"} assert body["reasoning"] == {"summary": "concise", "effort": "high"}
assert body["store"] is False assert body["store"] is False
assert body["max_turns"] == 5
assert headers["Authorization"] == "Bearer subscription-token" assert headers["Authorization"] == "Bearer subscription-token"
assert headers["X-XAI-Token-Auth"] == "xai-grok-cli" assert headers["X-XAI-Token-Auth"] == "xai-grok-cli"
assert headers["x-authenticateresponse"] == "authenticate-response" assert headers["x-authenticateresponse"] == "authenticate-response"
assert headers["x-grok-client-identifier"] == "nanobot" assert headers["x-grok-client-identifier"] == "nanobot"
assert headers["x-grok-client-mode"] == "headless" assert headers["x-grok-client-mode"] == "headless"
assert headers["x-grok-model-override"] == "grok-4.5" assert headers["x-grok-model-override"] == "grok-4.6"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -147,7 +164,7 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
_mock_token(monkeypatch) _mock_token(monkeypatch)
bodies: list[dict[str, Any]] = [] bodies: list[dict[str, Any]] = []
async def unexpected_catalog_lookup(*_args, **_kwargs): def unexpected_catalog_lookup(*_args, **_kwargs):
raise AssertionError("explicit raw tools must not depend on model catalog metadata") raise AssertionError("explicit raw tools must not depend on model catalog metadata")
async def fake_request(_url, _headers, body, **_kwargs): async def fake_request(_url, _headers, body, **_kwargs):
@@ -155,7 +172,7 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
return "ok", [], "stop", {}, None return "ok", [], "stop", {}, None
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities", "nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
unexpected_catalog_lookup, unexpected_catalog_lookup,
) )
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request) monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
@@ -164,10 +181,12 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
"allowed_x_handles": ["nanobot_ai"], "allowed_x_handles": ["nanobot_ai"],
"from_date": "2026-01-01", "from_date": "2026-01-01",
} }
provider = XAIGrokProvider(extra_body={ provider = XAIGrokProvider(
"parallel_tool_calls": False, extra_body={
"tools": [hosted_tool, {"type": "code_interpreter", "container": "auto"}], "parallel_tool_calls": False,
}) "tools": [hosted_tool, {"type": "code_interpreter", "container": "auto"}],
}
)
response = await provider.chat( response = await provider.chat(
[{"role": "user", "content": "search"}], [{"role": "user", "content": "search"}],
@@ -210,7 +229,7 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
_mock_token(monkeypatch) _mock_token(monkeypatch)
bodies: list[dict[str, Any]] = [] bodies: list[dict[str, Any]] = []
async def unexpected_catalog_lookup(*_args, **_kwargs): def unexpected_catalog_lookup(*_args, **_kwargs):
raise AssertionError("explicitly disabled X Search must not fetch model capabilities") raise AssertionError("explicitly disabled X Search must not fetch model capabilities")
async def fake_request(_url, _headers, body, **_kwargs): async def fake_request(_url, _headers, body, **_kwargs):
@@ -218,7 +237,7 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
return "ok", [], "stop", {}, None return "ok", [], "stop", {}, None
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities", "nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
unexpected_catalog_lookup, unexpected_catalog_lookup,
) )
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request) monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
@@ -226,23 +245,28 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
response = await provider.chat( response = await provider.chat(
[{"role": "user", "content": "hello"}], [{"role": "user", "content": "hello"}],
tools=[{ tools=[
"type": "function", {
"function": { "type": "function",
"name": "read_file", "function": {
"description": "Read a file", "name": "read_file",
"parameters": {"type": "object"}, "description": "Read a file",
}, "parameters": {"type": "object"},
}], },
}
],
) )
assert response.content == "ok" assert response.content == "ok"
assert bodies[0]["tools"] == [{ assert bodies[0]["tools"] == [
"type": "function", {
"name": "read_file", "type": "function",
"description": "Read a file", "name": "read_file",
"parameters": {"type": "object"}, "description": "Read a file",
}] "parameters": {"type": "object"},
}
]
assert "max_turns" not in bodies[0]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -281,35 +305,8 @@ async def test_provider_keeps_local_x_search_when_model_does_not_support_hosted_
"parameters": {"type": "object"}, "parameters": {"type": "object"},
} }
] ]
assert "max_turns" not in bodies[0]
assert bodies[0]["instructions"] == ""
@pytest.mark.asyncio
async def test_provider_fails_closed_and_caches_model_catalog_failure(monkeypatch) -> None:
_mock_token(monkeypatch)
fetch_calls = 0
bodies: list[dict[str, Any]] = []
async def failing_fetch(*_args, **_kwargs):
nonlocal fetch_calls
fetch_calls += 1
raise httpx.ConnectError("catalog unavailable")
async def fake_request(_url, _headers, body, **_kwargs):
bodies.append(body)
return "ok", [], "stop", {}, None
monkeypatch.setattr(
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities",
failing_fetch,
)
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
provider = XAIGrokProvider()
await provider.chat([{"role": "user", "content": "first"}])
await provider.chat([{"role": "user", "content": "second"}])
assert fetch_calls == 1
assert all({"type": "x_search"} not in body["tools"] for body in bodies)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -395,7 +392,10 @@ async def test_factory_builds_xai_provider_and_applies_explicit_body_overrides(m
"providers": { "providers": {
"xaiGrok": { "xaiGrok": {
"proxy": "http://127.0.0.1:7890", "proxy": "http://127.0.0.1:7890",
"extraBody": {"parallel_tool_calls": False}, "extraBody": {
"parallel_tool_calls": False,
"max_turns": 2,
},
} }
}, },
} }
@@ -408,6 +408,7 @@ async def test_factory_builds_xai_provider_and_applies_explicit_body_overrides(m
assert provider.proxy == "http://127.0.0.1:7890" assert provider.proxy == "http://127.0.0.1:7890"
assert response.content == "ok" assert response.content == "ok"
assert bodies[0]["parallel_tool_calls"] is False assert bodies[0]["parallel_tool_calls"] is False
assert bodies[0]["max_turns"] == 2
assert {"type": "x_search"} in bodies[0]["tools"] assert {"type": "x_search"} in bodies[0]["tools"]
@@ -527,75 +528,183 @@ async def test_raw_response_request_streams_hosted_x_search_lifecycle(monkeypatc
assert "large hosted result" not in json.dumps(tool_events) assert "large hosted result" not in json.dumps(tool_events)
def test_model_capabilities_follow_upstream_aliases_and_default_to_disabled() -> None:
capabilities = _parse_xai_model_capabilities(
{
"data": [
{"id": "grok-4.5", "supportsBackendSearch": False},
{
"model": "grok-search",
"supports_backend_search": True,
},
{
"modelId": "grok-meta",
"_meta": {"supportsBackendSearch": True},
},
{"id": "grok-unknown"},
]
}
)
assert capabilities == {
"grok-4.5": False,
"grok-search": True,
"grok-meta": True,
"grok-unknown": False,
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_model_capability_request_uses_subscription_headers(monkeypatch) -> None: async def test_raw_response_request_streams_official_x_search_lifecycle(monkeypatch) -> None:
original_client = httpx.AsyncClient original_client = httpx.AsyncClient
captured: dict[str, Any] = {} events = [
{
"type": "response.output_item.added",
"item": {
"type": "x_search_call",
"id": "x-search-1",
"status": "in_progress",
"action": {"query": "nanobot oauth"},
},
},
{
"type": "response.output_item.done",
"item": {
"type": "x_search_call",
"id": "x-search-1",
"status": "completed",
"action": {"query": "nanobot oauth"},
},
},
{
"type": "response.completed",
"response": {"status": "completed", "usage": {}},
},
]
content = "".join(f"data: {json.dumps(event)}\n\n" for event in events)
def handler(request: httpx.Request) -> httpx.Response: def handler(request: httpx.Request) -> httpx.Response:
captured["request"] = request return httpx.Response(200, content=content, request=request)
return httpx.Response(
200,
json={"data": [{"id": "grok-search", "supportsBackendSearch": True}]},
request=request,
)
def fake_client(**kwargs) -> httpx.AsyncClient: def fake_client(**kwargs) -> httpx.AsyncClient:
captured["kwargs"] = kwargs
return original_client( return original_client(
transport=httpx.MockTransport(handler), transport=httpx.MockTransport(handler),
timeout=kwargs["timeout"], timeout=kwargs["timeout"],
follow_redirects=kwargs["follow_redirects"],
) )
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.AsyncClient", fake_client) monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.AsyncClient", fake_client)
payload = base64.urlsafe_b64encode( tool_events: list[dict[str, Any]] = []
json.dumps({"sub": "user-42", "email": "user@example.com"}).encode()
).decode().rstrip("=")
access_token = f"header.{payload}.signature"
headers = _build_model_headers(_token(access_token))
capabilities = await _fetch_xai_model_capabilities( await _request_xai(
DEFAULT_XAI_GROK_MODELS_URL, "https://cli-chat-proxy.grok.com/v1/responses",
headers, _build_headers("secret", "grok-4.6"),
{"model": "grok-4.6", "tools": [{"type": "x_search"}]},
on_tool_call_delta=lambda event: _append(tool_events, event),
) )
request = captured["request"] assert [(event["phase"], event["name"]) for event in tool_events] == [
assert isinstance(request, httpx.Request) ("start", "x_search"),
assert request.method == "GET" ("end", "x_search"),
assert str(request.url) == DEFAULT_XAI_GROK_MODELS_URL ]
assert request.headers["Authorization"] == f"Bearer {access_token}" assert tool_events[-1]["result"] == {"status": "completed"}
assert request.headers["X-XAI-Token-Auth"] == "xai-grok-cli"
assert request.headers["x-userid"] == "user-42"
assert request.headers["x-email"] == "user@example.com" @pytest.mark.asyncio
assert captured["kwargs"] == {"timeout": 10.0, "follow_redirects": False} async def test_raw_response_rejects_unfinished_hosted_tool_and_closes_progress(
assert capabilities == {"grok-search": True} monkeypatch,
) -> None:
original_client = httpx.AsyncClient
events = [
{
"type": "response.custom_tool_call_input.done",
"item_id": "x-search-1",
"input": '{"query":"nanobot oauth"}',
},
{"type": "response.output_text.delta", "delta": "I will keep searching."},
{
"type": "response.completed",
"response": {
"status": "completed",
"usage": {"input_tokens": 8, "output_tokens": 4, "total_tokens": 12},
},
},
]
content = "".join(f"data: {json.dumps(event)}\n\n" for event in events)
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, content=content, request=request)
def fake_client(**kwargs) -> httpx.AsyncClient:
return original_client(
transport=httpx.MockTransport(handler),
timeout=kwargs["timeout"],
)
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.AsyncClient", fake_client)
tool_events: list[dict[str, Any]] = []
with pytest.raises(_XAIIncompleteHostedToolError) as caught:
await _request_xai(
"https://cli-chat-proxy.grok.com/v1/responses",
_build_headers("secret", "grok-4.6"),
{"model": "grok-4.6", "tools": [{"type": "x_search"}]},
on_tool_call_delta=lambda event: _append(tool_events, event),
)
assert caught.value.usage == LLMUsage.reported(input_tokens=8, output_tokens=4)
assert [event["phase"] for event in tool_events] == ["start", "error"]
assert "before this hosted tool completed" in tool_events[-1]["error"]
@pytest.mark.asyncio
async def test_provider_recovers_unfinished_hosted_tool_once_and_preserves_usage(
monkeypatch,
) -> None:
_mock_token(monkeypatch)
_mock_model_capabilities(monkeypatch, supports_backend_search=True)
attempts = 0
request_ids: list[str] = []
streamed: list[str] = []
recovered: list[bool] = []
first_usage = LLMUsage.reported(input_tokens=10, output_tokens=2)
second_usage = LLMUsage.reported(input_tokens=11, output_tokens=4)
async def fake_request(_url, headers, body, **kwargs):
nonlocal attempts
attempts += 1
request_ids.append(headers["x-grok-req-id"])
assert body["max_turns"] == 5
if attempts == 1:
await kwargs["on_content_delta"]("I will keep searching.")
raise _XAIIncompleteHostedToolError(
[{"name": "x_search", "call_id": "search-1"}],
usage=first_usage,
)
await kwargs["on_content_delta"]("Final researched answer.")
return "Final researched answer.", [], "stop", second_usage, None
async def on_recover() -> None:
recovered.append(True)
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
provider = XAIGrokProvider()
response = await provider.chat_stream_with_retry(
[{"role": "user", "content": "Search X"}],
on_content_delta=lambda delta: _append(streamed, delta),
on_stream_recover=on_recover,
)
assert attempts == 2
assert len(set(request_ids)) == 2
assert recovered == [True]
assert streamed == ["I will keep searching.", "Final researched answer."]
assert response.content == "Final researched answer."
assert response.usage == first_usage + second_usage
@pytest.mark.asyncio
async def test_provider_preserves_usage_when_hosted_tool_recovery_also_fails(
monkeypatch,
) -> None:
_mock_token(monkeypatch)
_mock_model_capabilities(monkeypatch, supports_backend_search=True)
attempts = 0
usage = LLMUsage.reported(input_tokens=10, output_tokens=2)
async def fake_request(*_args, **_kwargs):
nonlocal attempts
attempts += 1
raise _XAIIncompleteHostedToolError(
[{"name": "x_search", "call_id": f"search-{attempts}"}],
usage=usage,
)
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
provider = XAIGrokProvider()
response = await provider.chat_stream_with_retry(
[{"role": "user", "content": "Search X"}],
on_stream_recover=lambda: _append([], True),
)
assert attempts == 2
assert response.finish_reason == "error"
assert response.usage == usage + usage
@pytest.mark.asyncio @pytest.mark.asyncio
+171 -71
View File
@@ -13,7 +13,8 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi
from nanobot.llm_usage import get_llm_usage_store from nanobot.llm_usage import get_llm_usage_store
from nanobot.llm_usage.models import LLMCallRecord from nanobot.llm_usage.models import LLMCallRecord
from nanobot.providers.base import LLMUsage from nanobot.providers.base import LLMUsage
from nanobot.providers.registry import find_by_name from nanobot.providers.oauth_model_catalog import OAuthModelCatalogSnapshot
from nanobot.providers.registry import ProviderModelSpec, find_by_name
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
from nanobot.webui.settings_api import ( from nanobot.webui.settings_api import (
@@ -183,11 +184,13 @@ def test_update_api_settings_requires_key_for_network_access(
with pytest.raises(WebUISettingsError, match="API key"): with pytest.raises(WebUISettingsError, match="API key"):
update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]}) update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]})
payload = update_api_settings({ payload = update_api_settings(
"host": ["0.0.0.0"], {
"port": ["9900"], "host": ["0.0.0.0"],
"api_key": ["secret-token"], "port": ["9900"],
}) "api_key": ["secret-token"],
}
)
saved = load_config(config_path) saved = load_config(config_path)
assert saved.api.host == "0.0.0.0" assert saved.api.host == "0.0.0.0"
assert saved.api.port == 9900 assert saved.api.port == 9900
@@ -346,13 +349,15 @@ def test_create_model_configuration_rejects_dynamic_custom_provider_without_api_
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config = Config.model_validate({ config = Config.model_validate(
"providers": { {
DYNAMIC_PROVIDER_NAME: { "providers": {
"apiKey": "sk-test", DYNAMIC_PROVIDER_NAME: {
"apiKey": "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)
@@ -497,9 +502,7 @@ def test_update_model_configuration_rolls_back_sessions_when_config_save_fails(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config = Config( config = Config(model_presets={"openai": ModelPresetConfig(model="openai/gpt-4.1")})
model_presets={"openai": ModelPresetConfig(model="openai/gpt-4.1")}
)
save_config(config, config_path) save_config(config, config_path)
calls: list[tuple[str, str]] = [] calls: list[tuple[str, str]] = []
@@ -890,11 +893,13 @@ def test_update_provider_settings_updates_and_clears_oauth_proxy(
}, },
) )
payload = update_provider_settings({ payload = update_provider_settings(
"provider": [provider_name], {
"proxy": [" http://127.0.0.1:7890 "], "provider": [provider_name],
"extraBody": [json.dumps({"tools": []})], "proxy": [" http://127.0.0.1:7890 "],
}) "extraBody": [json.dumps({"tools": []})],
}
)
providers = {row["name"]: row for row in payload["providers"]} providers = {row["name"]: row for row in payload["providers"]}
assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890" assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890"
@@ -1099,15 +1104,17 @@ def test_settings_payload_groups_opencode_compatibility_alias(tmp_path, monkeypa
def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None: def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None:
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"}}, {
"agents": { "providers": {"opencodeZen": {"apiKey": "legacy-key"}},
"defaults": { "agents": {
"provider": "opencode_zen", "defaults": {
"model": "opencode/deepseek-v4-pro", "provider": "opencode_zen",
} "model": "opencode/deepseek-v4-pro",
}, }
}) },
}
)
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)
@@ -1124,13 +1131,15 @@ def test_settings_payload_marks_dynamic_custom_provider_without_api_base_unconfi
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config = Config.model_validate({ config = Config.model_validate(
"providers": { {
DYNAMIC_PROVIDER_NAME: { "providers": {
"apiKey": "sk-test", DYNAMIC_PROVIDER_NAME: {
"apiKey": "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)
@@ -1466,16 +1475,18 @@ def test_settings_payload_includes_token_usage_summary(
config = Config() config = Config()
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)
get_llm_usage_store().record(LLMCallRecord( get_llm_usage_store().record(
started_at_ms=int(time.time() * 1000), LLMCallRecord(
duration_ms=1, started_at_ms=int(time.time() * 1000),
provider="openai", duration_ms=1,
model="gpt-5", provider="openai",
source="user", model="gpt-5",
stream=False, source="user",
finish_reason="stop", stream=False,
usage=LLMUsage.reported(input_tokens=10, output_tokens=5), finish_reason="stop",
)) usage=LLMUsage.reported(input_tokens=10, output_tokens=5),
)
)
payload = settings_payload() payload = settings_payload()
@@ -1496,16 +1507,18 @@ def test_settings_usage_payload_returns_lightweight_token_usage(
config = Config() config = Config()
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)
get_llm_usage_store().record(LLMCallRecord( get_llm_usage_store().record(
started_at_ms=int(time.time() * 1000), LLMCallRecord(
duration_ms=1, started_at_ms=int(time.time() * 1000),
provider="openai", duration_ms=1,
model="gpt-5", provider="openai",
source="user", model="gpt-5",
stream=False, source="user",
finish_reason="stop", stream=False,
usage=LLMUsage.reported(input_tokens=20, output_tokens=2), finish_reason="stop",
)) usage=LLMUsage.reported(input_tokens=20, output_tokens=2),
)
)
payload = settings_usage_payload() payload = settings_usage_payload()
@@ -1929,9 +1942,7 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
) )
assert exc.value.status == 502 assert exc.value.status == 502
assert str(exc.value) == ( assert str(exc.value) == ("xAI OAuth login failed: Could not reach xAI sign-in: ConnectError.")
"xAI OAuth login failed: Could not reach xAI sign-in: ConnectError."
)
assert exc.value.__cause__ is failure assert exc.value.__cause__ is failure
@@ -1995,39 +2006,126 @@ def test_provider_models_payload_fetches_openai_compatible_models(
assert payload["models"][1]["context_window"] == 65536 assert payload["models"][1]["context_window"] == 65536
def test_provider_models_payload_returns_curated_openai_codex_models() -> None: def test_provider_models_payload_returns_online_openai_codex_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"nanobot.webui.settings_models.get_oauth_model_catalog",
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
models=(
ProviderModelSpec(
id="openai-codex/gpt-5.6-sol",
label="GPT-5.6-Sol",
description="Latest frontier agentic coding model.",
owned_by="OpenAI Codex",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
),
),
source="remote",
fetched_at=123,
),
)
payload = provider_models_payload({"provider": ["openai_codex"]}) payload = provider_models_payload({"provider": ["openai_codex"]})
assert payload["status"] == "available" assert payload["status"] == "available"
assert payload["catalog_kind"] == "builtin" assert payload["catalog_kind"] == "hybrid"
assert payload["model_count"] == 7 assert payload["source"] == "remote"
assert payload["model_count"] == 1
assert payload["models"][0] == { assert payload["models"][0] == {
"id": "openai-codex/gpt-5.6-sol", "id": "openai-codex/gpt-5.6-sol",
"label": "GPT-5.6-Sol", "label": "GPT-5.6-Sol",
"description": "Latest frontier agentic coding model.", "description": "Latest frontier agentic coding model.",
"owned_by": "OpenAI Codex", "owned_by": "OpenAI Codex",
"context_window": 372000, "context_window": 272000,
"reasoning_efforts": ["low", "medium", "high", "xhigh", "max", "ultra"],
"supports_backend_search": False,
} }
assert [model["id"] for model in payload["models"][:3]] == [
"openai-codex/gpt-5.6-sol",
"openai-codex/gpt-5.6-terra",
"openai-codex/gpt-5.6-luna",
]
def test_provider_models_payload_returns_xai_grok_model() -> None: def test_provider_models_payload_returns_online_github_copilot_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"nanobot.webui.settings_models.get_oauth_model_catalog",
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
models=(
ProviderModelSpec(
id="github-copilot/claude-sonnet",
label="Claude Sonnet",
owned_by="GitHub Copilot",
context_window=200_000,
),
),
source="remote",
fetched_at=123,
),
)
payload = provider_models_payload({"provider": ["github_copilot"]})
assert payload["status"] == "available"
assert payload["catalog_kind"] == "hybrid"
assert payload["source"] == "remote"
assert payload["models"][0]["id"] == "github-copilot/claude-sonnet"
def test_provider_models_payload_returns_online_xai_grok_models(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"nanobot.webui.settings_models.get_oauth_model_catalog",
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
models=(
ProviderModelSpec(
id="xai-grok/grok-4.6",
label="Grok 4.6",
description="Latest frontier model",
owned_by="xAI",
context_window=500_000,
reasoning_efforts=("xhigh", "high", "medium", "low"),
supports_backend_search=True,
),
ProviderModelSpec(
id="xai-grok/grok-4.5",
label="Grok 4.5",
owned_by="xAI",
context_window=500_000,
reasoning_efforts=("high", "medium", "low"),
supports_backend_search=True,
),
),
source="remote",
fetched_at=123,
),
)
payload = provider_models_payload({"provider": ["xai_grok"]}) payload = provider_models_payload({"provider": ["xai_grok"]})
assert payload["status"] == "available" assert payload["status"] == "available"
assert payload["catalog_kind"] == "builtin" assert payload["catalog_kind"] == "hybrid"
assert payload["source"] == "remote"
assert payload["fetched_at"] == 123
assert payload["models"] == [ assert payload["models"] == [
{
"id": "xai-grok/grok-4.6",
"label": "Grok 4.6",
"description": "Latest frontier model",
"owned_by": "xAI",
"context_window": 500000,
"reasoning_efforts": ["xhigh", "high", "medium", "low"],
"supports_backend_search": True,
},
{ {
"id": "xai-grok/grok-4.5", "id": "xai-grok/grok-4.5",
"label": "Grok 4.5", "label": "Grok 4.5",
"description": "Grok via xAI subscription; X Search is enabled when supported.", "description": None,
"owned_by": "xAI Grok", "owned_by": "xAI",
"context_window": 500000, "context_window": 500000,
} "reasoning_efforts": ["high", "medium", "low"],
"supports_backend_search": True,
},
] ]
@@ -2160,7 +2258,9 @@ def test_model_catalog_kind_uses_provider_spec_metadata() -> None:
assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported" assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported"
assert _model_catalog_kind(find_by_name("openrouter")) == "catalog" assert _model_catalog_kind(find_by_name("openrouter")) == "catalog"
assert _model_catalog_kind(find_by_name("orcarouter")) == "catalog" assert _model_catalog_kind(find_by_name("orcarouter")) == "catalog"
assert _model_catalog_kind(find_by_name("openai_codex")) == "builtin" assert _model_catalog_kind(find_by_name("openai_codex")) == "hybrid"
assert _model_catalog_kind(find_by_name("xai_grok")) == "hybrid"
assert _model_catalog_kind(find_by_name("github_copilot")) == "hybrid"
def test_create_model_configuration_accepts_configured_oauth_provider( def test_create_model_configuration_accepts_configured_oauth_provider(
@@ -546,7 +546,7 @@ export function ModelsSettings({
> >
{saving || creatingSaving {saving || creatingSaving
? tx("settings.actions.saving", "Saving...") ? tx("settings.actions.saving", "Saving...")
: tx("settings.actions.savePreset", "Save preset")} : tx("settings.actions.savePreset", "Save")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -204,13 +204,15 @@ export function ModelIdPicker({
const providerConfigured = settingsProviderConfigured(settings, effectiveProvider); const providerConfigured = settingsProviderConfigured(settings, effectiveProvider);
const providerRequiresConfiguration = const providerRequiresConfiguration =
!hasStaticModels && hasConcreteProvider && !providerConfigured; !hasStaticModels && hasConcreteProvider && !providerConfigured;
const providerHasBuiltinModels = providerRow?.model_catalog === "builtin"; const providerHasManagedModels = ["builtin", "hybrid"].includes(
providerRow?.model_catalog ?? "",
);
const providerUsesManualModelIds = const providerUsesManualModelIds =
!hasStaticModels && !hasStaticModels &&
hasConcreteProvider && hasConcreteProvider &&
providerConfigured && providerConfigured &&
providerRow?.auth_type === "oauth" && providerRow?.auth_type === "oauth" &&
!providerHasBuiltinModels; !providerHasManagedModels;
const canFetchModels = const canFetchModels =
!hasStaticModels && !hasStaticModels &&
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds; hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
+1 -1
View File
@@ -483,7 +483,7 @@
"save": "Save", "save": "Save",
"saving": "Saving", "saving": "Saving",
"saveOrder": "Save order", "saveOrder": "Save order",
"savePreset": "Save preset", "savePreset": "Save",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"deleting": "Deleting...", "deleting": "Deleting...",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "Guardar", "save": "Guardar",
"saving": "Guardando", "saving": "Guardando",
"saveOrder": "Guardar orden", "saveOrder": "Guardar orden",
"savePreset": "Guardar preajuste", "savePreset": "Guardar",
"delete": "Eliminar", "delete": "Eliminar",
"deleting": "Eliminando...", "deleting": "Eliminando...",
"edit": "Editar", "edit": "Editar",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "Enregistrer", "save": "Enregistrer",
"saving": "Enregistrement", "saving": "Enregistrement",
"saveOrder": "Enregistrer lordre", "saveOrder": "Enregistrer lordre",
"savePreset": "Enregistrer le préréglage", "savePreset": "Enregistrer",
"delete": "Supprimer", "delete": "Supprimer",
"deleting": "Suppression...", "deleting": "Suppression...",
"edit": "Modifier", "edit": "Modifier",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "Simpan", "save": "Simpan",
"saving": "Menyimpan", "saving": "Menyimpan",
"saveOrder": "Simpan urutan", "saveOrder": "Simpan urutan",
"savePreset": "Simpan prasetel", "savePreset": "Simpan",
"delete": "Hapus", "delete": "Hapus",
"deleting": "Menghapus...", "deleting": "Menghapus...",
"edit": "Ubah", "edit": "Ubah",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "保存", "save": "保存",
"saving": "保存中", "saving": "保存中",
"saveOrder": "順序を保存", "saveOrder": "順序を保存",
"savePreset": "プリセットを保存", "savePreset": "保存",
"delete": "削除", "delete": "削除",
"deleting": "削除中...", "deleting": "削除中...",
"edit": "編集", "edit": "編集",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "저장", "save": "저장",
"saving": "저장 중", "saving": "저장 중",
"saveOrder": "순서 저장", "saveOrder": "순서 저장",
"savePreset": "프리셋 저장", "savePreset": "저장",
"delete": "삭제", "delete": "삭제",
"deleting": "삭제 중...", "deleting": "삭제 중...",
"edit": "편집", "edit": "편집",
+1 -1
View File
@@ -483,7 +483,7 @@
"save": "Salvar", "save": "Salvar",
"saving": "Salvando", "saving": "Salvando",
"saveOrder": "Salvar ordem", "saveOrder": "Salvar ordem",
"savePreset": "Salvar predefinição", "savePreset": "Salvar",
"delete": "Excluir", "delete": "Excluir",
"deleting": "Excluindo...", "deleting": "Excluindo...",
"edit": "Editar", "edit": "Editar",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "Lưu", "save": "Lưu",
"saving": "Đang lưu", "saving": "Đang lưu",
"saveOrder": "Lưu thứ tự", "saveOrder": "Lưu thứ tự",
"savePreset": "Lưu cấu hình đặt trước", "savePreset": "Lưu",
"delete": "Xóa", "delete": "Xóa",
"deleting": "Đang xóa...", "deleting": "Đang xóa...",
"edit": "Sửa", "edit": "Sửa",
+1 -1
View File
@@ -483,7 +483,7 @@
"save": "保存", "save": "保存",
"saving": "正在保存", "saving": "正在保存",
"saveOrder": "保存顺序", "saveOrder": "保存顺序",
"savePreset": "保存预设", "savePreset": "保存",
"edit": "编辑", "edit": "编辑",
"delete": "删除", "delete": "删除",
"deleting": "正在删除...", "deleting": "正在删除...",
+1 -1
View File
@@ -271,7 +271,7 @@
"save": "儲存", "save": "儲存",
"saving": "正在儲存", "saving": "正在儲存",
"saveOrder": "儲存順序", "saveOrder": "儲存順序",
"savePreset": "儲存預設", "savePreset": "儲存",
"delete": "刪除", "delete": "刪除",
"deleting": "正在刪除…", "deleting": "正在刪除…",
"edit": "編輯", "edit": "編輯",
+11 -1
View File
@@ -510,6 +510,8 @@ interface ProviderModelInfo {
description?: string | null; description?: string | null;
owned_by?: string | null; owned_by?: string | null;
context_window?: number | null; context_window?: number | null;
reasoning_efforts?: string[];
supports_backend_search?: boolean;
} }
export interface ProviderModelsPayload { export interface ProviderModelsPayload {
@@ -521,7 +523,15 @@ export interface ProviderModelsPayload {
| "not_configured" | "not_configured"
| "missing_api_base" | "missing_api_base"
| "error"; | "error";
catalog_kind: "builtin" | "official" | "catalog" | "local" | "custom" | "unsupported"; catalog_kind:
| "builtin"
| "hybrid"
| "official"
| "catalog"
| "local"
| "custom"
| "unsupported";
source?: "remote" | "cache" | "stale" | "fallback";
models: ProviderModelInfo[]; models: ProviderModelInfo[];
model_count: number; model_count: number;
message?: string | null; message?: string | null;
+1 -1
View File
@@ -2531,7 +2531,7 @@ describe("App layout", () => {
).toBe(true); ).toBe(true);
await user.click(screen.getByRole("button", { name: "Select model" })); await user.click(screen.getByRole("button", { name: "Select model" }));
await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ })); await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" })); fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByText("Up to date.")).not.toBeInTheDocument(); expect(screen.queryByText("Up to date.")).not.toBeInTheDocument();
fireEvent.click( fireEvent.click(
+90 -8
View File
@@ -141,7 +141,7 @@ describe("Settings models", () => {
fireEvent.change(screen.getByLabelText("Temperature"), { fireEvent.change(screen.getByLabelText("Temperature"), {
target: { value: "0.4" }, target: { value: "0.4" },
}); });
fireEvent.click(screen.getByRole("button", { name: "Save preset" })); fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => { await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith( expect(requestMutationMock).toHaveBeenCalledWith(
@@ -173,7 +173,7 @@ describe("Settings models", () => {
const nameInput = screen.getByRole("textbox", { name: "Preset name" }); const nameInput = screen.getByRole("textbox", { name: "Preset name" });
fireEvent.change(nameInput, { target: { value: "Codex" } }); fireEvent.change(nameInput, { target: { value: "Codex" } });
fireEvent.click(screen.getByRole("button", { name: "Save preset" })); fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => { await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith( expect(requestMutationMock).toHaveBeenCalledWith(
@@ -196,7 +196,7 @@ describe("Settings models", () => {
const nameInput = screen.getByRole("textbox", { name: "Preset name" }); const nameInput = screen.getByRole("textbox", { name: "Preset name" });
fireEvent.change(nameInput, { target: { value: "Codex" } }); fireEvent.change(nameInput, { target: { value: "Codex" } });
fireEvent.click(screen.getByRole("button", { name: "Save preset" })); fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent( expect(await screen.findByRole("alert")).toHaveTextContent(
"A preset with this name already exists.", "A preset with this name already exists.",
@@ -368,7 +368,7 @@ describe("Settings models", () => {
expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument();
expect(screen.getByLabelText("Temperature")).toHaveValue(0.4); expect(screen.getByLabelText("Temperature")).toHaveValue(0.4);
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
}); });
it("keeps repeated fallback preset rows stable when changing the primary preset", async () => { it("keeps repeated fallback preset rows stable when changing the primary preset", async () => {
@@ -604,7 +604,7 @@ describe("Settings models", () => {
); );
fireEvent.click(screen.getByRole("button", { name: "New model preset" })); fireEvent.click(screen.getByRole("button", { name: "New model preset" }));
expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: "New model preset" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save preset" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
expect( expect(
screen.queryByText("Complete the preset before saving."), screen.queryByText("Complete the preset before saving."),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -619,7 +619,7 @@ describe("Settings models", () => {
target: { value: "openai/gpt-4o-mini" }, target: { value: "openai/gpt-4o-mini" },
}); });
fireEvent.keyDown(modelSearch, { key: "Enter" }); fireEvent.keyDown(modelSearch, { key: "Enter" });
const saveButton = screen.getByRole("button", { name: "Save preset" }); const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled(); expect(saveButton).toBeEnabled();
fireEvent.click(saveButton); fireEvent.click(saveButton);
@@ -656,7 +656,7 @@ describe("Settings models", () => {
}); });
fireEvent.change(modelSearch, { target: { value: "openai/gpt-4o-mini" } }); fireEvent.change(modelSearch, { target: { value: "openai/gpt-4o-mini" } });
fireEvent.keyDown(modelSearch, { key: "Enter" }); fireEvent.keyDown(modelSearch, { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "Save preset" })); fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(requestMutationMock).not.toHaveBeenCalled(); expect(requestMutationMock).not.toHaveBeenCalled();
expect(nameInput).toHaveAttribute("aria-invalid", "true"); expect(nameInput).toHaveAttribute("aria-invalid", "true");
@@ -1295,6 +1295,88 @@ describe("Settings models", () => {
); );
}); });
it("loads hybrid online models for configured OAuth providers", async () => {
const base = settingsPayload();
const payload: SettingsPayload = {
...base,
agent: {
...base.agent,
model: "xai-grok/grok-4.5",
provider: "xai_grok",
resolved_provider: "xai_grok",
},
model_presets: [
{
...base.model_presets[0],
model: "xai-grok/grok-4.5",
provider: "xai_grok",
},
],
providers: [
{
name: "xai_grok",
label: "xAI Grok",
configured: true,
auth_type: "oauth",
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://cli-chat-proxy.grok.com/v1",
model_catalog: "hybrid",
oauth_account: "acct-test",
oauth_expires_at: null,
oauth_login_supported: true,
},
],
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings/provider-models?provider=xai_grok") {
return jsonResponse({
provider: "xai_grok",
label: "xAI Grok",
status: "available",
catalog_kind: "hybrid",
source: "remote",
models: [
{
id: "xai-grok/grok-4.6",
label: "Grok 4.6",
description: "Latest frontier model",
owned_by: "xAI",
context_window: 500_000,
},
{
id: "xai-grok/grok-4.5",
label: "Grok 4.5",
owned_by: "xAI",
context_window: 500_000,
},
],
model_count: 2,
fetched_at: 1,
});
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await togglePresetEditor();
const modelButtons = await screen.findAllByRole("button", {
name: /xai-grok\/grok-4\.5/i,
});
await openPopover(modelButtons[modelButtons.length - 1]);
expect(await screen.findByText("Grok 4.6")).toBeInTheDocument();
expect(screen.getByText(/Latest frontier model/)).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider-models?provider=xai_grok",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
});
it("creates presets in the inline editor and can cancel without opening a dialog", async () => { it("creates presets in the inline editor and can cancel without opening a dialog", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
@@ -1417,7 +1499,7 @@ describe("Settings models", () => {
fireEvent.change(screen.getByLabelText("Reasoning effort"), { fireEvent.change(screen.getByLabelText("Reasoning effort"), {
target: { value: "provider-native-mode" }, target: { value: "provider-native-mode" },
}); });
fireEvent.click(screen.getByRole("button", { name: "Save preset" })); fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(