mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
feat(providers): discover OAuth model catalogs online
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user