mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-04 02:01:48 +03:00
feat(providers): discover OAuth model catalogs online
This commit is contained 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 nanobot.providers.oauth_model_catalog import curated_oauth_models
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderModelSpec:
|
||||
"""A curated model exposed by providers without a model-list endpoint."""
|
||||
"""Curated model metadata used for fixed catalogs or online fallback."""
|
||||
|
||||
id: str
|
||||
label: str = ""
|
||||
@@ -42,7 +44,7 @@ class ProviderSpec:
|
||||
keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
|
||||
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
|
||||
display_name: str = "" # shown in `nanobot status`
|
||||
model_catalog: str = "auto" # WebUI model-list source
|
||||
model_catalog: str = "auto" # WebUI model-list source, including builtin/hybrid
|
||||
builtin_models: tuple[ProviderModelSpec, ...] = ()
|
||||
settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings
|
||||
|
||||
@@ -459,20 +461,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
keywords=("xai-grok", "xai_grok"),
|
||||
env_key="",
|
||||
display_name="xAI Grok",
|
||||
model_catalog="builtin",
|
||||
builtin_models=(
|
||||
model_catalog="hybrid",
|
||||
builtin_models=tuple(
|
||||
ProviderModelSpec(
|
||||
id="xai-grok/grok-4.6",
|
||||
label="Grok 4.6",
|
||||
description="Grok via xAI subscription; X Search is enabled when supported.",
|
||||
context_window=500000,
|
||||
),
|
||||
ProviderModelSpec(
|
||||
id="xai-grok/grok-4.5",
|
||||
label="Grok 4.5",
|
||||
description="Grok via xAI subscription; X Search is enabled when supported.",
|
||||
context_window=500000,
|
||||
),
|
||||
id=model.id,
|
||||
label=model.label,
|
||||
description=model.description,
|
||||
context_window=model.context_window,
|
||||
)
|
||||
for model in curated_oauth_models("xai_grok")
|
||||
),
|
||||
backend="xai_grok",
|
||||
default_api_base="https://cli-chat-proxy.grok.com/v1",
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
@@ -22,6 +20,10 @@ from nanobot.providers.base import (
|
||||
ToolCallRequest,
|
||||
resolve_stream_idle_timeout_s,
|
||||
)
|
||||
from nanobot.providers.oauth_model_catalog import (
|
||||
DEFAULT_XAI_GROK_MODEL,
|
||||
get_oauth_model_catalog,
|
||||
)
|
||||
from nanobot.providers.openai_responses import (
|
||||
consume_sse_with_reasoning,
|
||||
convert_messages,
|
||||
@@ -29,14 +31,10 @@ from nanobot.providers.openai_responses import (
|
||||
)
|
||||
from nanobot.providers.xai_oauth import (
|
||||
XAI_CLIENT_VERSION,
|
||||
XAIToken,
|
||||
get_xai_oauth_token,
|
||||
)
|
||||
|
||||
DEFAULT_XAI_GROK_URL = "https://cli-chat-proxy.grok.com/v1/responses"
|
||||
DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models"
|
||||
DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.6"
|
||||
_MODEL_CAPABILITIES_TTL_S = 5 * 60
|
||||
_MAX_ERROR_BODY_CHARS = 1000
|
||||
_SENSITIVE_ERROR_KEYS = {
|
||||
"accesstoken",
|
||||
@@ -75,37 +73,20 @@ class XAIGrokProvider(LLMProvider):
|
||||
self.default_model = default_model
|
||||
self.proxy = proxy or None
|
||||
self._extra_body = dict(extra_body or {})
|
||||
self._model_capabilities: dict[str, bool] | None = None
|
||||
self._model_capabilities_fetched_at = 0.0
|
||||
|
||||
async def _supports_backend_search(self, token: XAIToken, model: str) -> bool:
|
||||
now = time.monotonic()
|
||||
capabilities = self._model_capabilities
|
||||
if (
|
||||
capabilities is None
|
||||
or now - self._model_capabilities_fetched_at >= _MODEL_CAPABILITIES_TTL_S
|
||||
):
|
||||
try:
|
||||
capabilities = await _fetch_xai_model_capabilities(
|
||||
DEFAULT_XAI_GROK_MODELS_URL,
|
||||
_build_model_headers(token),
|
||||
proxy=self.proxy,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"xAI model capability lookup failed; hosted X Search disabled for model {}: "
|
||||
"type={} error={}",
|
||||
model,
|
||||
type(exc).__name__,
|
||||
str(exc).strip() or "unexpected error",
|
||||
)
|
||||
capabilities = {}
|
||||
self._model_capabilities = capabilities
|
||||
self._model_capabilities_fetched_at = now
|
||||
else:
|
||||
self._model_capabilities = capabilities
|
||||
self._model_capabilities_fetched_at = now
|
||||
return capabilities.get(model, False)
|
||||
async def _supports_backend_search(self, model: str) -> bool:
|
||||
catalog = await asyncio.to_thread(
|
||||
get_oauth_model_catalog,
|
||||
"xai_grok",
|
||||
proxy=self.proxy,
|
||||
)
|
||||
if catalog.message:
|
||||
logger.warning(
|
||||
"xAI model catalog unavailable; hosted X Search disabled unless cached: {}",
|
||||
catalog.message,
|
||||
)
|
||||
info = catalog.find(model)
|
||||
return bool(info and info.supports_backend_search)
|
||||
|
||||
async def _call_xai(
|
||||
self,
|
||||
@@ -138,7 +119,7 @@ class XAIGrokProvider(LLMProvider):
|
||||
supports_backend_search = False
|
||||
if not tools_are_explicit:
|
||||
stage = "model_capabilities"
|
||||
supports_backend_search = await self._supports_backend_search(token, wire_model)
|
||||
supports_backend_search = await self._supports_backend_search(wire_model)
|
||||
converted_tools = convert_tools(tools or [])
|
||||
if isinstance(configured_tools, list):
|
||||
converted_tools.extend(cast(list[dict[str, Any]], configured_tools))
|
||||
@@ -308,44 +289,6 @@ def _build_headers(token: str, model: str) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _build_model_headers(token: XAIToken) -> dict[str, str]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token.access}",
|
||||
"X-XAI-Token-Auth": "xai-grok-cli",
|
||||
"x-grok-client-version": XAI_CLIENT_VERSION,
|
||||
"x-grok-client-identifier": "nanobot",
|
||||
"x-grok-client-mode": "headless",
|
||||
"User-Agent": f"nanobot/{__version__} (python)",
|
||||
"accept": "application/json",
|
||||
}
|
||||
claims = _decode_access_token_claims(token.access)
|
||||
user_id = claims.get("sub")
|
||||
if claims.get("principal_type") == "Team":
|
||||
user_id = claims.get("principal_id") or user_id
|
||||
if isinstance(user_id, str) and user_id:
|
||||
headers["x-userid"] = user_id
|
||||
email = claims.get("email")
|
||||
if not isinstance(email, str) or "@" not in email:
|
||||
email = token.account_id if token.account_id and "@" in token.account_id else None
|
||||
if email:
|
||||
headers["x-email"] = email
|
||||
return headers
|
||||
|
||||
|
||||
def _decode_access_token_claims(token: str) -> dict[str, Any]:
|
||||
"""Read identity hints from the signed token; the server still authenticates it."""
|
||||
parts = token.split(".")
|
||||
if len(parts) < 2 or not parts[1]:
|
||||
return {}
|
||||
payload = parts[1]
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
|
||||
claims = json.loads(decoded)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return cast(dict[str, Any], claims) if isinstance(claims, dict) else {}
|
||||
|
||||
|
||||
class _XAIHTTPError(RuntimeError):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -367,67 +310,6 @@ class _XAIHTTPError(RuntimeError):
|
||||
self.response_body = response_body
|
||||
|
||||
|
||||
async def _fetch_xai_model_capabilities(
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
*,
|
||||
proxy: str | None = None,
|
||||
) -> dict[str, bool]:
|
||||
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
|
||||
if proxy:
|
||||
client_kwargs.update(proxy=proxy, trust_env=False)
|
||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code != 200:
|
||||
raw = response.content.decode("utf-8", "ignore")
|
||||
raise _build_xai_http_error(response.status_code, response.headers, raw)
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise RuntimeError("xAI model catalog returned invalid JSON.") from exc
|
||||
return _parse_xai_model_capabilities(payload)
|
||||
|
||||
|
||||
def _parse_xai_model_capabilities(payload: Any) -> dict[str, bool]:
|
||||
if isinstance(payload, dict):
|
||||
payload = cast(dict[str, Any], payload)
|
||||
rows: object = payload.get("data")
|
||||
if not isinstance(rows, list):
|
||||
rows = payload.get("models")
|
||||
else:
|
||||
rows = payload
|
||||
if not isinstance(rows, list):
|
||||
return {}
|
||||
|
||||
capabilities: dict[str, bool] = {}
|
||||
for row_value in cast(list[object], rows):
|
||||
if not isinstance(row_value, dict):
|
||||
continue
|
||||
row = cast(dict[str, Any], row_value)
|
||||
meta_value = row.get("_meta")
|
||||
meta = cast(dict[str, Any], meta_value) if isinstance(meta_value, dict) else {}
|
||||
support_value = row.get("supportsBackendSearch")
|
||||
if not isinstance(support_value, bool):
|
||||
support_value = row.get("supports_backend_search")
|
||||
if not isinstance(support_value, bool):
|
||||
support_value = meta.get("supportsBackendSearch")
|
||||
if not isinstance(support_value, bool):
|
||||
support_value = meta.get("supports_backend_search")
|
||||
supports_backend_search = support_value if isinstance(support_value, bool) else False
|
||||
|
||||
identifiers = (
|
||||
row.get("model"),
|
||||
row.get("modelId"),
|
||||
row.get("id"),
|
||||
meta.get("model"),
|
||||
meta.get("modelId"),
|
||||
)
|
||||
for identifier in identifiers:
|
||||
if isinstance(identifier, str) and identifier.strip():
|
||||
capabilities[_strip_model_prefix(identifier.strip())] = supports_backend_search
|
||||
return capabilities
|
||||
|
||||
|
||||
async def _request_xai(
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
|
||||
@@ -28,6 +28,10 @@ from nanobot.config.loader import resolve_config_env_vars
|
||||
from nanobot.config.schema import Config, FallbackCandidate, ModelPresetConfig, ProviderConfig
|
||||
from nanobot.providers.image_generation import get_image_gen_provider
|
||||
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
|
||||
from nanobot.providers.oauth_model_catalog import (
|
||||
get_oauth_model_catalog,
|
||||
invalidate_oauth_model_catalog,
|
||||
)
|
||||
from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
|
||||
from nanobot.webui.settings_contracts import (
|
||||
QueryParams,
|
||||
@@ -661,6 +665,30 @@ def provider_models_payload(
|
||||
"models": rows,
|
||||
"model_count": len(rows),
|
||||
}
|
||||
if catalog_kind == "hybrid":
|
||||
proxy = _resolve_env_placeholders(provider_config.proxy)
|
||||
catalog = get_oauth_model_catalog(spec.name, proxy=proxy)
|
||||
rows = [
|
||||
{
|
||||
"id": model.id,
|
||||
"label": model.label or None,
|
||||
"description": model.description or None,
|
||||
"owned_by": model.owned_by or spec.label,
|
||||
"context_window": model.context_window,
|
||||
"reasoning_efforts": list(model.reasoning_efforts),
|
||||
"supports_backend_search": model.supports_backend_search,
|
||||
}
|
||||
for model in catalog.models
|
||||
]
|
||||
return {
|
||||
**base_payload,
|
||||
"status": "available",
|
||||
"source": catalog.source,
|
||||
"models": rows,
|
||||
"model_count": len(rows),
|
||||
"message": catalog.message,
|
||||
"fetched_at": catalog.fetched_at,
|
||||
}
|
||||
|
||||
api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base
|
||||
if spec.name == "openai" and not api_base:
|
||||
@@ -1591,6 +1619,7 @@ def complete_oauth_provider(
|
||||
oauth_flows.remove(spec.name, flow_id, flow, cancel=False)
|
||||
if not token.access:
|
||||
raise WebUISettingsError("OAuth login failed", status=401)
|
||||
invalidate_oauth_model_catalog(spec.name)
|
||||
return settings_payload(config_path=config_path)
|
||||
|
||||
|
||||
@@ -1629,6 +1658,7 @@ def logout_oauth_provider(
|
||||
|
||||
oauth_flows.clear(spec.name)
|
||||
logout_xai_oauth()
|
||||
invalidate_oauth_model_catalog(spec.name)
|
||||
return settings_payload(config_path=config_path)
|
||||
else:
|
||||
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
||||
|
||||
Reference in New Issue
Block a user