feat(providers): discover OAuth model catalogs online

This commit is contained in:
Xubin Ren
2026-08-29 21:22:20 +08:00
parent 2389ab1f5a
commit bc4de246a4
12 changed files with 920 additions and 283 deletions
+7 -4
View File
@@ -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 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: []`.
+7 -3
View File
@@ -578,9 +578,13 @@ For an eligible X Premium / Grok subscription:
nanobot provider login xai-grok --set-main nanobot provider login xai-grok --set-main
``` ```
This selects `xai-grok/grok-4.6`. 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
+425
View File
@@ -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,
)
+12 -15
View File
@@ -17,10 +17,12 @@ from typing import Any
from pydantic.alias_generators import to_snake from pydantic.alias_generators import to_snake
from nanobot.providers.oauth_model_catalog import curated_oauth_models
@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 = ""
@@ -42,7 +44,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
@@ -459,20 +461,15 @@ 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=tuple(
ProviderModelSpec( ProviderModelSpec(
id="xai-grok/grok-4.6", id=model.id,
label="Grok 4.6", label=model.label,
description="Grok via xAI subscription; X Search is enabled when supported.", description=model.description,
context_window=500000, context_window=model.context_window,
), )
ProviderModelSpec( for model in curated_oauth_models("xai_grok")
id="xai-grok/grok-4.5",
label="Grok 4.5",
description="Grok via xAI subscription; X Search is enabled when supported.",
context_window=500000,
),
), ),
backend="xai_grok", backend="xai_grok",
default_api_base="https://cli-chat-proxy.grok.com/v1", default_api_base="https://cli-chat-proxy.grok.com/v1",
+18 -136
View File
@@ -3,10 +3,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import base64
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,6 +20,10 @@ from nanobot.providers.base import (
ToolCallRequest, ToolCallRequest,
resolve_stream_idle_timeout_s, 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 ( from nanobot.providers.openai_responses import (
consume_sse_with_reasoning, consume_sse_with_reasoning,
convert_messages, convert_messages,
@@ -29,14 +31,10 @@ from nanobot.providers.openai_responses import (
) )
from nanobot.providers.xai_oauth import ( from nanobot.providers.xai_oauth import (
XAI_CLIENT_VERSION, XAI_CLIENT_VERSION,
XAIToken,
get_xai_oauth_token, get_xai_oauth_token,
) )
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_MODEL = "xai-grok/grok-4.6"
_MODEL_CAPABILITIES_TTL_S = 5 * 60
_MAX_ERROR_BODY_CHARS = 1000 _MAX_ERROR_BODY_CHARS = 1000
_SENSITIVE_ERROR_KEYS = { _SENSITIVE_ERROR_KEYS = {
"accesstoken", "accesstoken",
@@ -75,37 +73,20 @@ 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_oauth_model_catalog,
if ( "xai_grok",
capabilities is None proxy=self.proxy,
or now - self._model_capabilities_fetched_at >= _MODEL_CAPABILITIES_TTL_S )
): if catalog.message:
try: logger.warning(
capabilities = await _fetch_xai_model_capabilities( "xAI model catalog unavailable; hosted X Search disabled unless cached: {}",
DEFAULT_XAI_GROK_MODELS_URL, catalog.message,
_build_model_headers(token), )
proxy=self.proxy, info = catalog.find(model)
) 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,
@@ -138,7 +119,7 @@ class XAIGrokProvider(LLMProvider):
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))
@@ -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): class _XAIHTTPError(RuntimeError):
def __init__( def __init__(
self, self,
@@ -367,67 +310,6 @@ class _XAIHTTPError(RuntimeError):
self.response_body = response_body 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( async def _request_xai(
url: str, url: str,
headers: dict[str, str], headers: dict[str, str],
+30
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:
@@ -1591,6 +1619,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 +1658,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")
+257
View File
@@ -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
+24 -116
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,18 +11,15 @@ 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.oauth_model_catalog import OAuthModelCatalogSnapshot, OAuthModelInfo
from nanobot.providers.registry import find_by_name from nanobot.providers.registry import 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,
@@ -51,15 +47,27 @@ 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 { return OAuthModelCatalogSnapshot(
"grok-4.5": supports_backend_search, models=(
"grok-4.6": supports_backend_search, 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( monkeypatch.setattr(
"nanobot.providers.xai_grok_provider._fetch_xai_model_capabilities", "nanobot.providers.xai_grok_provider.get_oauth_model_catalog",
fake_fetch, fake_catalog,
) )
@@ -154,7 +162,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):
@@ -162,7 +170,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_oauth_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)
@@ -217,7 +225,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):
@@ -225,7 +233,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_oauth_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)
@@ -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 @pytest.mark.asyncio
async def test_provider_refreshes_and_retries_exactly_once_after_401(monkeypatch) -> None: async def test_provider_refreshes_and_retries_exactly_once_after_401(monkeypatch) -> None:
_mock_model_capabilities(monkeypatch, supports_backend_search=False) _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) 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 @pytest.mark.asyncio
async def test_raw_response_error_preserves_bounded_redacted_body(monkeypatch) -> None: async def test_raw_response_error_preserves_bounded_redacted_body(monkeypatch) -> None:
original_client = httpx.AsyncClient original_client = httpx.AsyncClient
+43 -6
View File
@@ -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 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.oauth_model_catalog import OAuthModelCatalogSnapshot, OAuthModelInfo
from nanobot.providers.registry import find_by_name from nanobot.providers.registry import 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
@@ -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"]}) 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", "id": "xai-grok/grok-4.6",
"label": "Grok 4.6", "label": "Grok 4.6",
"description": "Grok via xAI subscription; X Search is enabled when supported.", "description": "Latest frontier model",
"owned_by": "xAI Grok", "owned_by": "xAI",
"context_window": 500000, "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,
} }
] ]
@@ -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("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")) == "builtin"
assert _model_catalog_kind(find_by_name("xai_grok")) == "hybrid"
def test_create_model_configuration_accepts_configured_oauth_provider( def test_create_model_configuration_accepts_configured_oauth_provider(
@@ -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;
+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;
+82
View File
@@ -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",