From bc4de246a49cfe1ef3b65fb052c12a70dedad441 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:06:07 +0800 Subject: [PATCH] feat(providers): discover OAuth model catalogs online --- docs/configuration.md | 11 +- docs/providers.md | 10 +- nanobot/providers/oauth_model_catalog.py | 425 ++++++++++++++++++ nanobot/providers/registry.py | 27 +- nanobot/providers/xai_grok_provider.py | 154 +------ nanobot/webui/settings_models.py | 30 ++ tests/providers/test_oauth_model_catalog.py | 257 +++++++++++ tests/providers/test_xai_grok_provider.py | 140 +----- tests/webui/test_settings_api.py | 49 +- .../settings/shared/ModelControls.tsx | 6 +- webui/src/lib/types.ts | 12 +- webui/src/tests/settings-models.test.tsx | 82 ++++ 12 files changed, 920 insertions(+), 283 deletions(-) create mode 100644 nanobot/providers/oauth_model_catalog.py create mode 100644 tests/providers/test_oauth_model_catalog.py diff --git a/docs/configuration.md b/docs/configuration.md index 0b6393eb2..34921d040 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -765,10 +765,13 @@ nanobot agent -m "Hello from Grok." ``` 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` -tool only when the selected model advertises `supportsBackendSearch`. 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. +The provider reads and caches xAI's online model catalog for both WebUI model +selection and runtime capabilities. Newly available models appear automatically; +when discovery fails, the last successful catalog or built-in fallback remains +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 WebUI provider settings or with `providers.xaiGrok.extraBody.tools: []`. diff --git a/docs/providers.md b/docs/providers.md index c52438e9a..404070187 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -578,9 +578,13 @@ For an eligible X Premium / Grok subscription: nanobot provider login xai-grok --set-main ``` -This selects `xai-grok/grok-4.6`. The provider reads xAI's model catalog and -exposes the hosted `x_search` tool only when the selected model advertises -`supportsBackendSearch`; otherwise the model runs without hosted X Search. +This selects `xai-grok/grok-4.6`. The WebUI model selector reads xAI's online +model catalog, so newly available subscription models appear without a nanobot +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 without invoking a local nanobot tool. Credentials are stored under the active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in diff --git a/nanobot/providers/oauth_model_catalog.py b/nanobot/providers/oauth_model_catalog.py new file mode 100644 index 000000000..eb29b8745 --- /dev/null +++ b/nanobot/providers/oauth_model_catalog.py @@ -0,0 +1,425 @@ +"""Online model discovery for OAuth providers with bounded local fallback.""" + +from __future__ import annotations + +import base64 +import json +import threading +import time +from collections.abc import Callable, Sequence +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any, Literal, Protocol, cast + +import httpx +from loguru import logger + +from nanobot import __version__ + +DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.6" +DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models" + +CatalogSource = Literal["remote", "cache", "stale", "fallback"] + + +class _XAIToken(Protocol): + @property + def access(self) -> str: ... + + @property + def account_id(self) -> str | None: ... + + +@dataclass(frozen=True) +class OAuthModelInfo: + """Normalized provider model metadata used by settings and runtimes.""" + + id: str + label: str + description: str = "" + owned_by: str = "" + context_window: int | None = None + reasoning_efforts: tuple[str, ...] = () + supports_backend_search: bool = False + + @property + def wire_id(self) -> str: + return self.id.split("/", 1)[-1] + + +@dataclass(frozen=True) +class OAuthModelCatalogSnapshot: + """One usable catalog view, including where it came from.""" + + models: tuple[OAuthModelInfo, ...] + source: CatalogSource + fetched_at: float + message: str | None = None + + def find(self, model: str) -> OAuthModelInfo | None: + wire_id = model.split("/", 1)[-1] + return next((item for item in self.models if item.wire_id == wire_id), None) + + +@dataclass(frozen=True) +class _CacheEntry: + snapshot: OAuthModelCatalogSnapshot + stored_at: float + + +class OAuthModelCatalog: + """Cache remote discovery behind one thread-safe, failure-tolerant interface.""" + + def __init__( + self, + *, + fallback_models: Sequence[OAuthModelInfo], + fetch: Callable[[str | None], Sequence[OAuthModelInfo]], + 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() + + def get(self, *, cache_key: str, proxy: str | None = None) -> OAuthModelCatalogSnapshot: + """Return a fresh catalog, sharing concurrent work and failing to a usable list.""" + with self._condition: + cached = self._cached_result(cache_key) + if cached is not None: + return cached + while cache_key in self._inflight: + self._condition.wait() + 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__, + ) + result = self._failure_result(cache_key) + else: + now = self._monotonic() + result = OAuthModelCatalogSnapshot( + models=models, + source="remote", + fetched_at=self._wall_clock(), + ) + with self._condition: + 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 and negative results, for example after account changes.""" + with self._condition: + self._entries.clear() + self._failures.clear() + + 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, 0) + if failure_until > now: + return self._stale_or_fallback(entry, now) + return None + + def _failure_result(self, cache_key: str) -> OAuthModelCatalogSnapshot: + with self._condition: + now = self._monotonic() + 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: + message = "Could not refresh the online model list; showing cached models." + if entry is not None and now - entry.stored_at < self._stale_ttl_s: + return replace(entry.snapshot, source="stale", message=message) + 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: + if cache_key not in self._entries and len(self._entries) >= self._max_entries: + oldest = min(self._entries, key=lambda key: self._entries[key].stored_at) + self._entries.pop(oldest, None) + self._failures.pop(oldest, None) + self._entries[cache_key] = entry + + +_CURATED_XAI_GROK_MODELS = ( + OAuthModelInfo( + id="xai-grok/grok-4.6", + label="Grok 4.6", + description="Grok via xAI subscription; X Search is enabled when supported.", + owned_by="xAI Grok", + context_window=500_000, + ), + OAuthModelInfo( + id="xai-grok/grok-4.5", + label="Grok 4.5", + description="Grok via xAI subscription; X Search is enabled when supported.", + owned_by="xAI Grok", + context_window=500_000, + ), +) + + +def curated_oauth_models(provider_name: str) -> tuple[OAuthModelInfo, ...]: + """Return stable metadata used only to enrich or backstop online discovery.""" + if provider_name == "xai_grok": + return _CURATED_XAI_GROK_MODELS + return () + + +def get_oauth_model_catalog( + provider_name: str, + *, + proxy: str | None = None, +) -> OAuthModelCatalogSnapshot: + """Discover models for a supported OAuth provider.""" + if provider_name != "xai_grok": + raise ValueError(f"OAuth model discovery is not available for {provider_name}") + cache_key = f"{_xai_oauth_storage_path()}\0{proxy or ''}" + return _XAI_GROK_CATALOG.get(cache_key=cache_key, proxy=proxy) + + +def invalidate_oauth_model_catalog(provider_name: str) -> None: + """Invalidate provider discovery after OAuth identity changes.""" + if provider_name == "xai_grok": + _XAI_GROK_CATALOG.invalidate() + + +def _fetch_xai_grok_models(proxy: str | None) -> tuple[OAuthModelInfo, ...]: + token = _xai_oauth_token(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), + ) + response.raise_for_status() + return _parse_xai_grok_models(response.json()) + + +def _parse_xai_grok_models(payload: Any) -> tuple[OAuthModelInfo, ...]: + 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 () + + curated = {model.wire_id: model for model in _CURATED_XAI_GROK_MODELS} + models: list[OAuthModelInfo] = [] + seen: set[str] = set() + for value in cast(list[object], rows): + if not isinstance(value, dict): + continue + row = cast(dict[str, Any], value) + meta_value = row.get("_meta") + meta = cast(dict[str, Any], meta_value) if isinstance(meta_value, dict) else {} + 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 = curated.get(wire_id) + model_id = f"xai-grok/{wire_id}" + label = _first_text(row, "display_name", "label", "name") or _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 + description = _first_text(row, "description") or _first_text(meta, "description") + owner = _first_text(row, "owned_by", "owner", "organization") or _first_text( + meta, + "owned_by", + "owner", + "organization", + ) + models.append( + OAuthModelInfo( + id=model_id, + label=label, + description=( + description + or (fallback.description if fallback is not None else "") + ), + owned_by=owner or (fallback.owned_by if fallback is not None else "xAI"), + context_window=( + _positive_int(row, "context_window", "context_length") + or _positive_int(meta, "context_window", "context_length") + or (fallback.context_window if fallback is not None else None) + ), + reasoning_efforts=_reasoning_efforts( + row.get("reasoning_efforts", meta.get("reasoning_efforts")) + ), + supports_backend_search=_bool_field( + row, + "supports_backend_search", + "supportsBackendSearch", + ), + ) + ) + return tuple(models) + + +def _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 _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 _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") + if isinstance(meta, dict): + return _bool_field(cast(dict[str, Any], meta), *keys) + return False + + +def _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 = _first_text(cast(dict[str, Any], item), "value", "id") + else: + effort = "" + if effort and effort not in efforts: + efforts.append(effort) + return tuple(efforts) + + +def _xai_oauth_storage_path() -> Path: + from nanobot.providers.xai_oauth import get_xai_oauth_storage_path + + return get_xai_oauth_storage_path() + + +def _xai_oauth_token(proxy: str | None) -> _XAIToken: + from nanobot.providers.xai_oauth import get_xai_oauth_token + + return get_xai_oauth_token(proxy=proxy) + + +def _build_xai_model_headers(token: _XAIToken) -> dict[str, str]: + from nanobot.providers.xai_oauth import XAI_CLIENT_VERSION + + 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]: + 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 {} + + +_XAI_GROK_CATALOG = OAuthModelCatalog( + fallback_models=_CURATED_XAI_GROK_MODELS, + fetch=_fetch_xai_grok_models, +) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index be6d83364..5865c0d76 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -17,10 +17,12 @@ from typing import Any from pydantic.alias_generators import to_snake +from nanobot.providers.oauth_model_catalog import curated_oauth_models + @dataclass(frozen=True) 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 label: str = "" @@ -42,7 +44,7 @@ class ProviderSpec: keywords: tuple[str, ...] # model-name keywords for matching (lowercase) env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY" 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, ...] = () settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings @@ -459,20 +461,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("xai-grok", "xai_grok"), env_key="", display_name="xAI Grok", - model_catalog="builtin", - builtin_models=( + model_catalog="hybrid", + builtin_models=tuple( ProviderModelSpec( - id="xai-grok/grok-4.6", - label="Grok 4.6", - description="Grok via xAI subscription; X Search is enabled when supported.", - context_window=500000, - ), - ProviderModelSpec( - id="xai-grok/grok-4.5", - label="Grok 4.5", - description="Grok via xAI subscription; X Search is enabled when supported.", - context_window=500000, - ), + id=model.id, + label=model.label, + description=model.description, + context_window=model.context_window, + ) + for model in curated_oauth_models("xai_grok") ), backend="xai_grok", default_api_base="https://cli-chat-proxy.grok.com/v1", diff --git a/nanobot/providers/xai_grok_provider.py b/nanobot/providers/xai_grok_provider.py index 88c401e14..504e9eceb 100644 --- a/nanobot/providers/xai_grok_provider.py +++ b/nanobot/providers/xai_grok_provider.py @@ -3,10 +3,8 @@ from __future__ import annotations import asyncio -import base64 import json import re -import time import uuid from collections.abc import Awaitable, Callable from typing import Any, cast @@ -22,6 +20,10 @@ from nanobot.providers.base import ( ToolCallRequest, resolve_stream_idle_timeout_s, ) +from nanobot.providers.oauth_model_catalog import ( + DEFAULT_XAI_GROK_MODEL, + get_oauth_model_catalog, +) from nanobot.providers.openai_responses import ( consume_sse_with_reasoning, convert_messages, @@ -29,14 +31,10 @@ from nanobot.providers.openai_responses import ( ) from nanobot.providers.xai_oauth import ( XAI_CLIENT_VERSION, - XAIToken, get_xai_oauth_token, ) 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_MODEL = "xai-grok/grok-4.6" -_MODEL_CAPABILITIES_TTL_S = 5 * 60 _MAX_ERROR_BODY_CHARS = 1000 _SENSITIVE_ERROR_KEYS = { "accesstoken", @@ -75,37 +73,20 @@ class XAIGrokProvider(LLMProvider): self.default_model = default_model self.proxy = proxy or None 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: - now = time.monotonic() - capabilities = self._model_capabilities - if ( - capabilities is None - or now - self._model_capabilities_fetched_at >= _MODEL_CAPABILITIES_TTL_S - ): - try: - capabilities = await _fetch_xai_model_capabilities( - DEFAULT_XAI_GROK_MODELS_URL, - _build_model_headers(token), - proxy=self.proxy, - ) - 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 _supports_backend_search(self, model: str) -> bool: + catalog = await asyncio.to_thread( + get_oauth_model_catalog, + "xai_grok", + proxy=self.proxy, + ) + if catalog.message: + logger.warning( + "xAI model catalog unavailable; hosted X Search disabled unless cached: {}", + catalog.message, + ) + info = catalog.find(model) + return bool(info and info.supports_backend_search) async def _call_xai( self, @@ -138,7 +119,7 @@ class XAIGrokProvider(LLMProvider): supports_backend_search = False if not tools_are_explicit: 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 []) if isinstance(configured_tools, list): converted_tools.extend(cast(list[dict[str, Any]], configured_tools)) @@ -308,44 +289,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): def __init__( self, @@ -367,67 +310,6 @@ class _XAIHTTPError(RuntimeError): self.response_body = response_body -async def _fetch_xai_model_capabilities( - url: str, - 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) - - -def _parse_xai_model_capabilities(payload: Any) -> dict[str, bool]: - if isinstance(payload, dict): - payload = cast(dict[str, Any], payload) - rows: object = payload.get("data") - if not isinstance(rows, list): - rows = payload.get("models") - else: - rows = payload - if not isinstance(rows, list): - return {} - - 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: - if isinstance(identifier, str) and identifier.strip(): - capabilities[_strip_model_prefix(identifier.strip())] = supports_backend_search - return capabilities - - async def _request_xai( url: str, headers: dict[str, str], diff --git a/nanobot/webui/settings_models.py b/nanobot/webui/settings_models.py index a5cb4f9b2..4b85717d5 100644 --- a/nanobot/webui/settings_models.py +++ b/nanobot/webui/settings_models.py @@ -28,6 +28,10 @@ from nanobot.config.loader import resolve_config_env_vars from nanobot.config.schema import Config, FallbackCandidate, ModelPresetConfig, ProviderConfig 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_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.webui.settings_contracts import ( QueryParams, @@ -661,6 +665,30 @@ def provider_models_payload( "models": 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 if spec.name == "openai" and not api_base: @@ -1591,6 +1619,7 @@ def complete_oauth_provider( oauth_flows.remove(spec.name, flow_id, flow, cancel=False) if not token.access: raise WebUISettingsError("OAuth login failed", status=401) + invalidate_oauth_model_catalog(spec.name) return settings_payload(config_path=config_path) @@ -1629,6 +1658,7 @@ def logout_oauth_provider( oauth_flows.clear(spec.name) logout_xai_oauth() + invalidate_oauth_model_catalog(spec.name) return settings_payload(config_path=config_path) else: raise WebUISettingsError("OAuth logout is not supported for this provider") diff --git a/tests/providers/test_oauth_model_catalog.py b/tests/providers/test_oauth_model_catalog.py new file mode 100644 index 000000000..f7c3d435c --- /dev/null +++ b/tests/providers/test_oauth_model_catalog.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import base64 +import json +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import httpx +import pytest + +from nanobot.providers.oauth_model_catalog import ( + DEFAULT_XAI_GROK_MODELS_URL, + OAuthModelCatalog, + OAuthModelInfo, + get_oauth_model_catalog, + invalidate_oauth_model_catalog, +) +from nanobot.providers.xai_oauth import XAIToken + + +@pytest.fixture(autouse=True) +def _clear_xai_catalog() -> None: + invalidate_oauth_model_catalog("xai_grok") + yield + invalidate_oauth_model_catalog("xai_grok") + + +def _fallback_model() -> OAuthModelInfo: + return OAuthModelInfo(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.oauth_model_catalog._xai_oauth_storage_path", + lambda: tmp_path / "auth" / "xai.json", + ) + monkeypatch.setattr( + "nanobot.providers.oauth_model_catalog._xai_oauth_token", + lambda _proxy: token, + ) + monkeypatch.setattr("nanobot.providers.oauth_model_catalog.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_catalog_single_flights_concurrent_refreshes() -> None: + calls = 0 + calls_lock = threading.Lock() + barrier = threading.Barrier(8) + + def fetch(_proxy: str | None) -> tuple[OAuthModelInfo, ...]: + nonlocal calls + with calls_lock: + calls += 1 + time.sleep(0.05) + return (OAuthModelInfo(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_returns_stale_then_negative_caches_refresh_failure() -> None: + now = [0.0] + calls = 0 + + def fetch(_proxy: str | None) -> tuple[OAuthModelInfo, ...]: + nonlocal calls + calls += 1 + if calls > 1: + raise httpx.ConnectError("offline") + return (OAuthModelInfo(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[OAuthModelInfo, ...]: + 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[OAuthModelInfo, ...]: + nonlocal calls + calls += 1 + return () if calls == 1 else (OAuthModelInfo(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 diff --git a/tests/providers/test_xai_grok_provider.py b/tests/providers/test_xai_grok_provider.py index ce8eb005e..abae49320 100644 --- a/tests/providers/test_xai_grok_provider.py +++ b/tests/providers/test_xai_grok_provider.py @@ -1,6 +1,5 @@ from __future__ import annotations -import base64 import json import time from types import SimpleNamespace @@ -12,18 +11,15 @@ import pytest from nanobot.config.schema import Config from nanobot.providers.base import LLMUsage from nanobot.providers.factory import make_provider +from nanobot.providers.oauth_model_catalog import OAuthModelCatalogSnapshot, OAuthModelInfo from nanobot.providers.registry import find_by_name from nanobot.providers.xai_grok_provider import ( DEFAULT_XAI_GROK_MODEL, - DEFAULT_XAI_GROK_MODELS_URL, XAIGrokProvider, _bounded_error_body, _build_headers, - _build_model_headers, _build_reasoning_options, _build_xai_http_error, - _fetch_xai_model_capabilities, - _parse_xai_model_capabilities, _request_xai, _xai_error_response, _XAIHTTPError, @@ -51,15 +47,27 @@ def _mock_model_capabilities( *, supports_backend_search: bool, ) -> None: - async def fake_fetch(*_args, **_kwargs): - return { - "grok-4.5": supports_backend_search, - "grok-4.6": supports_backend_search, - } + def fake_catalog(*_args, **_kwargs): + return OAuthModelCatalogSnapshot( + models=( + OAuthModelInfo( + id="xai-grok/grok-4.5", + label="Grok 4.5", + supports_backend_search=supports_backend_search, + ), + OAuthModelInfo( + id="xai-grok/grok-4.6", + label="Grok 4.6", + supports_backend_search=supports_backend_search, + ), + ), + source="remote", + fetched_at=1, + ) monkeypatch.setattr( - "nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities", - fake_fetch, + "nanobot.providers.xai_grok_provider.get_oauth_model_catalog", + fake_catalog, ) @@ -154,7 +162,7 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look _mock_token(monkeypatch) 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") async def fake_request(_url, _headers, body, **_kwargs): @@ -162,7 +170,7 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look return "ok", [], "stop", {}, None monkeypatch.setattr( - "nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities", + "nanobot.providers.xai_grok_provider.get_oauth_model_catalog", unexpected_catalog_lookup, ) monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request) @@ -217,7 +225,7 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk _mock_token(monkeypatch) 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") async def fake_request(_url, _headers, body, **_kwargs): @@ -225,7 +233,7 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk return "ok", [], "stop", {}, None monkeypatch.setattr( - "nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities", + "nanobot.providers.xai_grok_provider.get_oauth_model_catalog", unexpected_catalog_lookup, ) monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request) @@ -290,35 +298,6 @@ async def test_provider_keeps_local_x_search_when_model_does_not_support_hosted_ ] -@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 async def test_provider_refreshes_and_retries_exactly_once_after_401(monkeypatch) -> None: _mock_model_capabilities(monkeypatch, supports_backend_search=False) @@ -534,77 +513,6 @@ async def test_raw_response_request_streams_hosted_x_search_lifecycle(monkeypatc 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 -async def test_model_capability_request_uses_subscription_headers(monkeypatch) -> None: - original_client = httpx.AsyncClient - captured: dict[str, Any] = {} - - def handler(request: httpx.Request) -> httpx.Response: - captured["request"] = request - return httpx.Response( - 200, - json={"data": [{"id": "grok-search", "supportsBackendSearch": True}]}, - request=request, - ) - - def fake_client(**kwargs) -> httpx.AsyncClient: - 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.httpx.AsyncClient", fake_client) - payload = base64.urlsafe_b64encode( - 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( - DEFAULT_XAI_GROK_MODELS_URL, - headers, - ) - - request = captured["request"] - assert isinstance(request, httpx.Request) - assert request.method == "GET" - assert str(request.url) == DEFAULT_XAI_GROK_MODELS_URL - assert request.headers["Authorization"] == f"Bearer {access_token}" - 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 capabilities == {"grok-search": True} - - @pytest.mark.asyncio async def test_raw_response_error_preserves_bounded_redacted_body(monkeypatch) -> None: original_client = httpx.AsyncClient diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 562ac1e84..edfbdfc6c 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -13,6 +13,7 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi from nanobot.llm_usage import get_llm_usage_store from nanobot.llm_usage.models import LLMCallRecord from nanobot.providers.base import LLMUsage +from nanobot.providers.oauth_model_catalog import OAuthModelCatalogSnapshot, OAuthModelInfo from nanobot.providers.registry import find_by_name from nanobot.session.manager import SessionManager from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY @@ -2015,25 +2016,60 @@ def test_provider_models_payload_returns_curated_openai_codex_models() -> None: ] -def test_provider_models_payload_returns_xai_grok_models() -> None: +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=( + OAuthModelInfo( + 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, + ), + OAuthModelInfo( + 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"]}) 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"] == [ { "id": "xai-grok/grok-4.6", "label": "Grok 4.6", - "description": "Grok via xAI subscription; X Search is enabled when supported.", - "owned_by": "xAI Grok", + "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", "label": "Grok 4.5", - "description": "Grok via xAI subscription; X Search is enabled when supported.", - "owned_by": "xAI Grok", + "description": None, + "owned_by": "xAI", "context_window": 500000, + "reasoning_efforts": ["high", "medium", "low"], + "supports_backend_search": True, } ] @@ -2168,6 +2204,7 @@ def test_model_catalog_kind_uses_provider_spec_metadata() -> None: 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("openai_codex")) == "builtin" + assert _model_catalog_kind(find_by_name("xai_grok")) == "hybrid" def test_create_model_configuration_accepts_configured_oauth_provider( diff --git a/webui/src/components/settings/shared/ModelControls.tsx b/webui/src/components/settings/shared/ModelControls.tsx index 2704a0ba4..f42f7018c 100644 --- a/webui/src/components/settings/shared/ModelControls.tsx +++ b/webui/src/components/settings/shared/ModelControls.tsx @@ -204,13 +204,15 @@ export function ModelIdPicker({ const providerConfigured = settingsProviderConfigured(settings, effectiveProvider); const providerRequiresConfiguration = !hasStaticModels && hasConcreteProvider && !providerConfigured; - const providerHasBuiltinModels = providerRow?.model_catalog === "builtin"; + const providerHasManagedModels = ["builtin", "hybrid"].includes( + providerRow?.model_catalog ?? "", + ); const providerUsesManualModelIds = !hasStaticModels && hasConcreteProvider && providerConfigured && providerRow?.auth_type === "oauth" && - !providerHasBuiltinModels; + !providerHasManagedModels; const canFetchModels = !hasStaticModels && hasConcreteProvider && providerConfigured && !providerUsesManualModelIds; diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index c69c85367..d7dd1c212 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -510,6 +510,8 @@ interface ProviderModelInfo { description?: string | null; owned_by?: string | null; context_window?: number | null; + reasoning_efforts?: string[]; + supports_backend_search?: boolean; } export interface ProviderModelsPayload { @@ -521,7 +523,15 @@ export interface ProviderModelsPayload { | "not_configured" | "missing_api_base" | "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[]; model_count: number; message?: string | null; diff --git a/webui/src/tests/settings-models.test.tsx b/webui/src/tests/settings-models.test.tsx index ec54b6a23..50746739f 100644 --- a/webui/src/tests/settings-models.test.tsx +++ b/webui/src/tests/settings-models.test.tsx @@ -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 () => { vi.stubGlobal( "fetch",