refactor(providers): localize OAuth model discovery

This commit is contained in:
Xubin Ren
2026-08-29 17:29:22 +08:00
parent 23cb897f55
commit f3df64154b
8 changed files with 848 additions and 853 deletions
+174 -4
View File
@@ -5,6 +5,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import os import os
import time import time
import webbrowser import webbrowser
@@ -17,7 +18,12 @@ from oauth_cli_kit.models import OAuthToken
from oauth_cli_kit.storage import FileTokenStorage from oauth_cli_kit.storage import FileTokenStorage
from nanobot.providers.base import LLMResponse, ProviderCallContext from nanobot.providers.base import LLMResponse, ProviderCallContext
from nanobot.providers.oauth_model_catalog import (
OAuthModelCatalog,
OAuthModelCatalogSnapshot,
)
from nanobot.providers.openai_compat_provider import OpenAICompatProvider from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import ProviderModelSpec, find_by_name
DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code" DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
DEFAULT_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token" DEFAULT_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
@@ -96,7 +102,9 @@ def login_github_copilot(
device_code = str(payload["device_code"]) device_code = str(payload["device_code"])
user_code = str(payload["user_code"]) user_code = str(payload["user_code"])
verify_url = str(payload.get("verification_uri") or payload.get("verification_uri_complete") or "") verify_url = str(
payload.get("verification_uri") or payload.get("verification_uri_complete") or ""
)
verify_complete = str(payload.get("verification_uri_complete") or verify_url) verify_complete = str(payload.get("verification_uri_complete") or verify_url)
interval = max(1, int(payload.get("interval") or 5)) interval = max(1, int(payload.get("interval") or 5))
expires_in = int(payload.get("expires_in") or 900) expires_in = int(payload.get("expires_in") or 900)
@@ -180,8 +188,6 @@ class GitHubCopilotProvider(OpenAICompatProvider):
*, *,
provider_name: str = "github_copilot", provider_name: str = "github_copilot",
): ):
from nanobot.providers.registry import find_by_name
self._copilot_access_token: str | None = None self._copilot_access_token: str | None = None
self._copilot_expires_at: float = 0.0 self._copilot_expires_at: float = 0.0
self._copilot_token_lock: asyncio.Lock = asyncio.Lock() self._copilot_token_lock: asyncio.Lock = asyncio.Lock()
@@ -217,7 +223,9 @@ class GitHubCopilotProvider(OpenAICompatProvider):
) )
timeout = httpx.Timeout(20.0, connect=20.0) timeout = httpx.Timeout(20.0, connect=20.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client: async with httpx.AsyncClient(
timeout=timeout, follow_redirects=True, trust_env=True
) as client:
response = await client.get( response = await client.get(
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL), _resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
headers=_copilot_headers(github_token.access), headers=_copilot_headers(github_token.access),
@@ -296,3 +304,165 @@ class GitHubCopilotProvider(OpenAICompatProvider):
on_tool_call_delta=on_tool_call_delta, on_tool_call_delta=on_tool_call_delta,
provider_context=provider_context, provider_context=provider_context,
) )
def get_github_copilot_model_catalog(
proxy: str | None = None,
) -> OAuthModelCatalogSnapshot:
storage = get_storage()
token = storage.load()
account_key = _catalog_account_key(getattr(token, "account_id", None))
cache_key = (
f"{storage.get_token_path()}\0{account_key}\0"
f"{_resolve('NANOBOT_COPILOT_BASE_URL', DEFAULT_COPILOT_BASE_URL)}"
)
return _GITHUB_COPILOT_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
def invalidate_github_copilot_model_catalog() -> None:
_GITHUB_COPILOT_MODEL_CATALOG.invalidate()
def _fetch_github_copilot_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
github_token = get_storage().load()
if not github_token or not github_token.access:
raise RuntimeError("GitHub Copilot is not logged in")
common_headers = {
"Accept": "application/json",
"User-Agent": USER_AGENT,
"Editor-Version": EDITOR_VERSION,
"Editor-Plugin-Version": EDITOR_PLUGIN_VERSION,
}
client_kwargs: dict[str, Any] = {"timeout": 20.0, "follow_redirects": True}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
with httpx.Client(**client_kwargs) as client:
exchange = client.get(
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
headers={**common_headers, "Authorization": f"token {github_token.access}"},
)
exchange.raise_for_status()
exchange_mapping = _catalog_mapping(exchange.json())
copilot_token = exchange_mapping.get("token")
if not isinstance(copilot_token, str) or not copilot_token:
raise RuntimeError("GitHub Copilot token exchange returned no token")
endpoint_base = _catalog_first_text(
_catalog_mapping(exchange_mapping.get("endpoints")),
"api",
)
base_url = endpoint_base or _resolve(
"NANOBOT_COPILOT_BASE_URL",
DEFAULT_COPILOT_BASE_URL,
)
models_url = (
base_url
if base_url.rstrip("/").endswith("/models")
else f"{base_url.rstrip('/')}/models"
)
response = client.get(
models_url,
headers={**common_headers, "Authorization": f"Bearer {copilot_token}"},
)
response.raise_for_status()
return _parse_github_copilot_models(response.json())
def _parse_github_copilot_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
rows = cast(dict[str, Any], payload).get("data") if isinstance(payload, dict) else None
if not isinstance(rows, list):
return ()
fallback_models = _oauth_fallback_models("github_copilot")
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
models: list[ProviderModelSpec] = []
seen: set[str] = set()
for value in cast(list[object], rows):
if not isinstance(value, dict):
continue
row = cast(dict[str, Any], value)
wire_id = _catalog_first_text(row, "id")
policy = _catalog_mapping(row.get("policy"))
endpoints = row.get("supported_endpoints")
if (
not wire_id
or wire_id in seen
or row.get("model_picker_enabled") is not True
or policy.get("state") == "disabled"
or (
isinstance(endpoints, list)
and "/chat/completions" not in cast(list[object], endpoints)
)
):
continue
seen.add(wire_id)
capabilities = _catalog_mapping(row.get("capabilities"))
supports = _catalog_mapping(capabilities.get("supports"))
limits = _catalog_mapping(capabilities.get("limits"))
fallback = fallback_by_id.get(wire_id)
models.append(
ProviderModelSpec(
id=f"github-copilot/{wire_id}",
label=(
_catalog_first_text(row, "name")
or (fallback.label if fallback is not None else wire_id)
),
description=(fallback.description if fallback is not None else ""),
owned_by="GitHub Copilot",
context_window=(
_catalog_positive_int(limits, "max_context_window_tokens")
or (fallback.context_window if fallback is not None else None)
),
reasoning_efforts=_catalog_reasoning_efforts(supports.get("reasoning_effort")),
)
)
return tuple(models)
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
spec = find_by_name(provider_name)
assert spec is not None
return spec.builtin_models
def _catalog_account_key(account_id: object) -> str:
value = account_id if isinstance(account_id, str) else ""
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
def _catalog_mapping(value: Any) -> dict[str, Any]:
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
for key in keys:
value = row.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
for key in keys:
value = row.get(key)
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
return int(value)
return None
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
return tuple(
dict.fromkeys(
item.strip()
for item in cast(list[object], value)
if isinstance(item, str) and item.strip()
)
)
_GITHUB_COPILOT_MODEL_CATALOG = OAuthModelCatalog(
fallback_models=_oauth_fallback_models("github_copilot"),
fetch=_fetch_github_copilot_models,
)
+45 -609
View File
@@ -1,87 +1,51 @@
"""Online model discovery for OAuth providers with bounded local fallback.""" """Shared cache seam for OAuth provider model discovery."""
# oauth-cli-kit does not publish type stubs.
# pyright: reportMissingTypeStubs=false
from __future__ import annotations from __future__ import annotations
import base64
import hashlib
import json
import os
import threading import threading
import time import time
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from pathlib import Path from typing import Literal
from typing import Any, Literal, Protocol, cast
import httpx
from loguru import logger from loguru import logger
from nanobot import __version__ from nanobot.providers.registry import ProviderModelSpec
DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.6"
DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models"
DEFAULT_OPENAI_CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models"
OPENAI_CODEX_CATALOG_CLIENT_VERSION = "0.144.0"
CatalogSource = Literal["remote", "cache", "stale", "fallback"] CatalogSource = Literal["remote", "cache", "stale", "fallback"]
class _XAIToken(Protocol): @dataclass(frozen=True, slots=True)
@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: class OAuthModelCatalogSnapshot:
"""One usable catalog view, including where it came from.""" """One usable catalog view, including where it came from."""
models: tuple[OAuthModelInfo, ...] models: tuple[ProviderModelSpec, ...]
source: CatalogSource source: CatalogSource
fetched_at: float fetched_at: float
message: str | None = None message: str | None = None
def find(self, model: str) -> OAuthModelInfo | None: def find(self, model: str) -> ProviderModelSpec | None:
wire_id = model.split("/", 1)[-1] wire_id = model.split("/", 1)[-1]
return next((item for item in self.models if item.wire_id == wire_id), None) return next(
(item for item in self.models if item.id.split("/", 1)[-1] == wire_id),
None,
)
@dataclass(frozen=True) @dataclass(frozen=True, slots=True)
class _CacheEntry: class _CacheEntry:
snapshot: OAuthModelCatalogSnapshot snapshot: OAuthModelCatalogSnapshot
stored_at: float stored_at: float
class OAuthModelCatalog: class OAuthModelCatalog:
"""Cache remote discovery behind one thread-safe, failure-tolerant interface.""" """Cache one provider's discovery behind a small failure-tolerant interface."""
def __init__( def __init__(
self, self,
*, *,
fallback_models: Sequence[OAuthModelInfo], fallback_models: Sequence[ProviderModelSpec],
fetch: Callable[[str | None], Sequence[OAuthModelInfo]], fetch: Callable[[str | None], Sequence[ProviderModelSpec]],
fresh_ttl_s: float = 5 * 60, fresh_ttl_s: float = 5 * 60,
stale_ttl_s: float = 24 * 60 * 60, stale_ttl_s: float = 24 * 60 * 60,
failure_ttl_s: float = 30, failure_ttl_s: float = 30,
@@ -108,7 +72,7 @@ class OAuthModelCatalog:
self._generation = 0 self._generation = 0
def get(self, *, cache_key: str, proxy: str | None = None) -> OAuthModelCatalogSnapshot: def get(self, *, cache_key: str, proxy: str | None = None) -> OAuthModelCatalogSnapshot:
"""Return a fresh catalog, sharing concurrent work and failing to a usable list.""" """Return a fresh catalog, sharing concurrent work and retaining a fallback."""
while True: while True:
with self._condition: with self._condition:
cached = self._cached_result(cache_key) cached = self._cached_result(cache_key)
@@ -127,10 +91,7 @@ class OAuthModelCatalog:
if not models: if not models:
raise ValueError("provider returned an empty model catalog") raise ValueError("provider returned an empty model catalog")
except Exception as exc: except Exception as exc:
logger.warning( logger.warning("OAuth model catalog refresh failed: type={}", type(exc).__name__)
"OAuth model catalog refresh failed: type={}",
type(exc).__name__,
)
with self._condition: with self._condition:
invalidated = generation != self._generation invalidated = generation != self._generation
result = self._failure_result(cache_key) if not invalidated else None result = self._failure_result(cache_key) if not invalidated else None
@@ -157,7 +118,7 @@ class OAuthModelCatalog:
return result return result
def invalidate(self) -> None: def invalidate(self) -> None:
"""Drop cached work and prevent an older account refresh from being stored.""" """Drop cached work and prevent an older identity refresh from being stored."""
with self._condition: with self._condition:
self._generation += 1 self._generation += 1
self._entries.clear() self._entries.clear()
@@ -168,8 +129,7 @@ class OAuthModelCatalog:
entry = self._entries.get(cache_key) entry = self._entries.get(cache_key)
if entry is not None and now - entry.stored_at < self._fresh_ttl_s: if entry is not None and now - entry.stored_at < self._fresh_ttl_s:
return replace(entry.snapshot, source="cache") return replace(entry.snapshot, source="cache")
failure_until = self._failures.get(cache_key, 0) if self._failures.get(cache_key, 0) > now:
if failure_until > now:
return self._stale_or_fallback(entry, now) return self._stale_or_fallback(entry, now)
return None return None
@@ -183,9 +143,12 @@ class OAuthModelCatalog:
entry: _CacheEntry | None, entry: _CacheEntry | None,
now: float, now: float,
) -> OAuthModelCatalogSnapshot: ) -> 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: if entry is not None and now - entry.stored_at < self._stale_ttl_s:
return replace(entry.snapshot, source="stale", message=message) return replace(
entry.snapshot,
source="stale",
message="Could not refresh the online model list; showing cached models.",
)
return OAuthModelCatalogSnapshot( return OAuthModelCatalogSnapshot(
models=self._fallback_models, models=self._fallback_models,
source="fallback", source="fallback",
@@ -201,569 +164,42 @@ class OAuthModelCatalog:
self._entries[cache_key] = entry 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,
),
)
_CURATED_OPENAI_CODEX_MODELS = (
OAuthModelInfo(
id="openai-codex/gpt-5.6-sol",
label="GPT-5.6-Sol",
description="Latest frontier agentic coding model.",
owned_by="OpenAI Codex",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
),
OAuthModelInfo(
id="openai-codex/gpt-5.6-terra",
label="GPT-5.6-Terra",
description="Balanced agentic coding model for everyday work.",
owned_by="OpenAI Codex",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
),
OAuthModelInfo(
id="openai-codex/gpt-5.6-luna",
label="GPT-5.6-Luna",
description="Fast and affordable agentic coding model.",
owned_by="OpenAI Codex",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh", "max"),
),
OAuthModelInfo(
id="openai-codex/gpt-5.5",
label="GPT-5.5",
description="Frontier model for complex coding, research, and real-world work.",
owned_by="OpenAI Codex",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
),
OAuthModelInfo(
id="openai-codex/gpt-5.4",
label="GPT-5.4",
description="Strong model for everyday coding.",
owned_by="OpenAI Codex",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
),
OAuthModelInfo(
id="openai-codex/gpt-5.4-mini",
label="GPT-5.4-Mini",
description="Small, fast, and cost-efficient model for simpler coding tasks.",
owned_by="OpenAI Codex",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
),
OAuthModelInfo(
id="openai-codex/gpt-5.3-codex-spark",
label="GPT-5.3-Codex-Spark",
description="Ultra-fast coding model.",
owned_by="OpenAI Codex",
context_window=128_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
),
)
_CURATED_GITHUB_COPILOT_MODELS = (
OAuthModelInfo(
id="github-copilot/gpt-4.1",
label="GPT-4.1",
description="GitHub Copilot chat model.",
owned_by="GitHub Copilot",
),
)
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
if provider_name == "openai_codex":
return _CURATED_OPENAI_CODEX_MODELS
if provider_name == "github_copilot":
return _CURATED_GITHUB_COPILOT_MODELS
return ()
def get_oauth_model_catalog( def get_oauth_model_catalog(
provider_name: str, provider_name: str,
*, *,
proxy: str | None = None, proxy: str | None = None,
) -> OAuthModelCatalogSnapshot: ) -> OAuthModelCatalogSnapshot:
"""Discover models for a supported OAuth provider.""" """Discover models through the owning provider module."""
if provider_name == "xai_grok":
cache_key = f"{_xai_oauth_storage_path()}\0{_xai_account_key()}\0{proxy or ''}"
return _XAI_GROK_CATALOG.get(cache_key=cache_key, proxy=proxy)
if provider_name == "openai_codex": if provider_name == "openai_codex":
cache_key = ( from nanobot.providers.openai_codex_provider import get_openai_codex_model_catalog
f"{_openai_codex_storage_path()}\0{_openai_codex_account_key()}\0{proxy or ''}"
) return get_openai_codex_model_catalog(proxy)
return _OPENAI_CODEX_CATALOG.get(cache_key=cache_key, proxy=proxy) if provider_name == "xai_grok":
from nanobot.providers.xai_grok_provider import get_xai_grok_model_catalog
return get_xai_grok_model_catalog(proxy)
if provider_name == "github_copilot": if provider_name == "github_copilot":
cache_key = ( from nanobot.providers.github_copilot_provider import get_github_copilot_model_catalog
f"{_github_copilot_storage_path()}\0{_github_copilot_account_key()}\0"
f"{_github_copilot_models_url()}" return get_github_copilot_model_catalog(proxy)
)
return _GITHUB_COPILOT_CATALOG.get(cache_key=cache_key, proxy=proxy)
raise ValueError(f"OAuth model discovery is not available for {provider_name}") raise ValueError(f"OAuth model discovery is not available for {provider_name}")
def invalidate_oauth_model_catalog(provider_name: str) -> None: def invalidate_oauth_model_catalog(provider_name: str) -> None:
"""Invalidate provider discovery after OAuth identity changes.""" """Invalidate provider discovery after its OAuth identity changes."""
catalog = _OAUTH_CATALOGS.get(provider_name) if provider_name == "openai_codex":
if catalog is not None: from nanobot.providers.openai_codex_provider import (
catalog.invalidate() invalidate_openai_codex_model_catalog,
def _fetch_openai_codex_models(proxy: str | None) -> tuple[OAuthModelInfo, ...]:
from oauth_cli_kit import get_token as get_codex_token
token = get_codex_token(proxy=proxy)
account_id = getattr(token, "account_id", None)
if not isinstance(account_id, str) or not account_id:
raise RuntimeError("OpenAI Codex OAuth token has no account ID")
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
with httpx.Client(**client_kwargs) as client:
response = client.get(
DEFAULT_OPENAI_CODEX_MODELS_URL,
params={"client_version": OPENAI_CODEX_CATALOG_CLIENT_VERSION},
headers={
"Authorization": f"Bearer {token.access}",
"chatgpt-account-id": account_id,
"originator": "nanobot",
"User-Agent": f"nanobot/{__version__} (python)",
"accept": "application/json",
},
) )
response.raise_for_status()
return _parse_openai_codex_models(response.json())
invalidate_openai_codex_model_catalog()
elif provider_name == "xai_grok":
from nanobot.providers.xai_grok_provider import invalidate_xai_grok_model_catalog
def _parse_openai_codex_models(payload: Any) -> tuple[OAuthModelInfo, ...]: invalidate_xai_grok_model_catalog()
rows = cast(dict[str, Any], payload).get("models") if isinstance(payload, dict) else None elif provider_name == "github_copilot":
if not isinstance(rows, list):
return ()
curated = {model.wire_id: model for model in _CURATED_OPENAI_CODEX_MODELS}
parsed: list[tuple[int, OAuthModelInfo]] = []
seen: set[str] = set()
for value in cast(list[object], rows):
if not isinstance(value, dict):
continue
row = cast(dict[str, Any], value)
wire_id = _first_text(row, "slug", "id")
if not wire_id or wire_id in seen or row.get("visibility") in {"hide", "none"}:
continue
seen.add(wire_id)
fallback = curated.get(wire_id)
label = _first_text(row, "display_name", "name")
description = _first_text(row, "description")
priority = row.get("priority")
parsed.append(
(
priority if isinstance(priority, int) and not isinstance(priority, bool) else 2**31,
OAuthModelInfo(
id=f"openai-codex/{wire_id}",
label=label or (fallback.label if fallback is not None else wire_id),
description=(
description
or (fallback.description if fallback is not None else "")
),
owned_by="OpenAI Codex",
context_window=(
_positive_int(row, "context_window")
or (fallback.context_window if fallback is not None else None)
),
reasoning_efforts=(
_reasoning_efforts(row.get("supported_reasoning_levels"))
or (fallback.reasoning_efforts if fallback is not None else ())
),
),
)
)
parsed.sort(key=lambda item: item[0])
return tuple(model for _, model in parsed)
def _fetch_github_copilot_models(proxy: str | None) -> tuple[OAuthModelInfo, ...]:
from nanobot.providers.github_copilot_provider import ( from nanobot.providers.github_copilot_provider import (
DEFAULT_COPILOT_TOKEN_URL, invalidate_github_copilot_model_catalog,
EDITOR_PLUGIN_VERSION,
EDITOR_VERSION,
USER_AGENT,
get_storage,
) )
github_token = get_storage().load() invalidate_github_copilot_model_catalog()
if not github_token or not github_token.access:
raise RuntimeError("GitHub Copilot is not logged in")
common_headers = {
"Accept": "application/json",
"User-Agent": USER_AGENT,
"Editor-Version": EDITOR_VERSION,
"Editor-Plugin-Version": EDITOR_PLUGIN_VERSION,
}
client_kwargs: dict[str, Any] = {"timeout": 20.0, "follow_redirects": True}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
with httpx.Client(**client_kwargs) as client:
exchange = client.get(
os.environ.get("NANOBOT_COPILOT_TOKEN_URL", "").strip()
or DEFAULT_COPILOT_TOKEN_URL,
headers={**common_headers, "Authorization": f"token {github_token.access}"},
)
exchange.raise_for_status()
exchange_payload = exchange.json()
exchange_mapping = _mapping(exchange_payload)
copilot_token = (
exchange_mapping.get("token") if exchange_mapping else None
)
if not isinstance(copilot_token, str) or not copilot_token:
raise RuntimeError("GitHub Copilot token exchange returned no token")
endpoint_base = _first_text(_mapping(exchange_mapping.get("endpoints")), "api")
if endpoint_base:
models_url = (
endpoint_base
if endpoint_base.rstrip("/").endswith("/models")
else f"{endpoint_base.rstrip('/')}/models"
)
else:
models_url = _github_copilot_models_url()
response = client.get(
models_url,
headers={**common_headers, "Authorization": f"Bearer {copilot_token}"},
)
response.raise_for_status()
return _parse_github_copilot_models(response.json())
def _parse_github_copilot_models(payload: Any) -> tuple[OAuthModelInfo, ...]:
rows = cast(dict[str, Any], payload).get("data") if isinstance(payload, dict) else None
if not isinstance(rows, list):
return ()
curated = {model.wire_id: model for model in _CURATED_GITHUB_COPILOT_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)
wire_id = _first_text(row, "id")
policy = _mapping(row.get("policy"))
endpoints = row.get("supported_endpoints")
if (
not wire_id
or wire_id in seen
or row.get("model_picker_enabled") is not True
or policy.get("state") == "disabled"
or (
isinstance(endpoints, list)
and "/chat/completions" not in cast(list[object], endpoints)
)
):
continue
seen.add(wire_id)
capabilities = _mapping(row.get("capabilities"))
supports = _mapping(capabilities.get("supports"))
limits = _mapping(capabilities.get("limits"))
fallback = curated.get(wire_id)
models.append(
OAuthModelInfo(
id=f"github-copilot/{wire_id}",
label=(
_first_text(row, "name")
or (fallback.label if fallback is not None else wire_id)
),
description=(fallback.description if fallback is not None else ""),
owned_by="GitHub Copilot",
context_window=(
_positive_int(limits, "max_context_window_tokens")
or (fallback.context_window if fallback is not None else None)
),
reasoning_efforts=_reasoning_efforts(supports.get("reasoning_effort")),
)
)
return tuple(models)
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 _mapping(value: Any) -> dict[str, Any]:
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
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), "effort", "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 _account_key(account_id: object) -> str:
value = account_id if isinstance(account_id, str) else ""
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
def _xai_account_key() -> str:
from nanobot.providers.xai_oauth import get_xai_oauth_login_status
token = get_xai_oauth_login_status()
return _account_key(getattr(token, "account_id", None))
def _openai_codex_storage_path() -> Path:
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
from oauth_cli_kit.storage import FileTokenStorage
return FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
def _openai_codex_account_key() -> str:
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
from oauth_cli_kit.storage import FileTokenStorage
token = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).load()
return _account_key(getattr(token, "account_id", None))
def _github_copilot_storage_path() -> Path:
from nanobot.providers.github_copilot_provider import get_storage
return get_storage().get_token_path()
def _github_copilot_account_key() -> str:
from nanobot.providers.github_copilot_provider import get_storage
token = get_storage().load()
return _account_key(getattr(token, "account_id", None))
def _github_copilot_models_url() -> str:
from nanobot.providers.github_copilot_provider import DEFAULT_COPILOT_BASE_URL
base_url = (
os.environ.get("NANOBOT_COPILOT_BASE_URL", "").strip()
or DEFAULT_COPILOT_BASE_URL
)
return f"{base_url.rstrip('/')}/models"
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,
)
_OPENAI_CODEX_CATALOG = OAuthModelCatalog(
fallback_models=_CURATED_OPENAI_CODEX_MODELS,
fetch=_fetch_openai_codex_models,
)
_GITHUB_COPILOT_CATALOG = OAuthModelCatalog(
fallback_models=_CURATED_GITHUB_COPILOT_MODELS,
fetch=_fetch_github_copilot_models,
)
_OAUTH_CATALOGS = {
"xai_grok": _XAI_GROK_CATALOG,
"openai_codex": _OPENAI_CODEX_CATALOG,
"github_copilot": _GITHUB_COPILOT_CATALOG,
}
+168 -23
View File
@@ -14,7 +14,10 @@ from typing import Any, cast
import httpx import httpx
from loguru import logger from loguru import logger
from oauth_cli_kit import get_token as get_codex_token from oauth_cli_kit import get_token as get_codex_token
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
from oauth_cli_kit.storage import FileTokenStorage
from nanobot import __version__
from nanobot.providers.base import ( from nanobot.providers.base import (
LLMProvider, LLMProvider,
LLMResponse, LLMResponse,
@@ -22,6 +25,10 @@ from nanobot.providers.base import (
ProviderConversationState, ProviderConversationState,
resolve_stream_idle_timeout_s, resolve_stream_idle_timeout_s,
) )
from nanobot.providers.oauth_model_catalog import (
OAuthModelCatalog,
OAuthModelCatalogSnapshot,
)
from nanobot.providers.openai_responses import ( from nanobot.providers.openai_responses import (
ResponsesStreamCapture, ResponsesStreamCapture,
build_responses_state, build_responses_state,
@@ -35,8 +42,11 @@ from nanobot.providers.openai_responses import (
responses_state_items, responses_state_items,
responses_state_matches, responses_state_matches,
) )
from nanobot.providers.registry import ProviderModelSpec, find_by_name
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses" DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
DEFAULT_OPENAI_CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models"
OPENAI_CODEX_CATALOG_CLIENT_VERSION = "0.144.0"
DEFAULT_ORIGINATOR = "nanobot" DEFAULT_ORIGINATOR = "nanobot"
_COMPACTION_RETAINED_CHAR_BUDGET = 256_000 _COMPACTION_RETAINED_CHAR_BUDGET = 256_000
@@ -87,9 +97,7 @@ class OpenAICodexProvider(LLMProvider):
model = model or self.default_model model = model or self.default_model
sanitized_messages = self._sanitize_empty_content(messages) sanitized_messages = self._sanitize_empty_content(messages)
sanitized_state = ( sanitized_state = (
provider_context.conversation_state provider_context.conversation_state if provider_context is not None else None
if provider_context is not None
else None
) )
if sanitized_state is not None: if sanitized_state is not None:
sanitized_state = sanitized_state.with_pending_messages( sanitized_state = sanitized_state.with_pending_messages(
@@ -168,11 +176,7 @@ class OpenAICodexProvider(LLMProvider):
) )
compact_threshold = resolve_compact_threshold( compact_threshold = resolve_compact_threshold(
( (provider_context.context_window_tokens if provider_context is not None else None),
provider_context.context_window_tokens
if provider_context is not None
else None
),
max_tokens, max_tokens,
) )
if ( if (
@@ -236,8 +240,12 @@ class OpenAICodexProvider(LLMProvider):
return response return response
async def chat( async def chat(
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, self,
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None, reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
provider_context: ProviderCallContext | None = None, provider_context: ProviderCallContext | None = None,
@@ -264,8 +272,12 @@ class OpenAICodexProvider(LLMProvider):
) )
async def chat_stream( async def chat_stream(
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, self,
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None, reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None, tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
@@ -344,11 +356,7 @@ def _without_response_item_ids(
sanitized_input.append(raw_item) sanitized_input.append(raw_item)
continue continue
item = cast(dict[str, Any], raw_item) item = cast(dict[str, Any], raw_item)
sanitized_input.append({ sanitized_input.append({key: value for key, value in item.items() if key != "id"})
key: value
for key, value in item.items()
if key != "id"
})
body = dict(request_body) body = dict(request_body)
body["input"] = sanitized_input body["input"] = sanitized_input
@@ -444,9 +452,7 @@ async def _request_codex(
raw = text.decode("utf-8", "ignore") raw = text.decode("utf-8", "ignore")
retry_after = LLMProvider._extract_retry_after_from_headers(response.headers) retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
error_type, error_code = LLMProvider._extract_error_type_code(raw) error_type, error_code = LLMProvider._extract_error_type_code(raw)
compaction_unsupported = ( compaction_unsupported = response.status_code in {400, 404, 422} and any(
response.status_code in {400, 404, 422}
and any(
marker in raw.lower() marker in raw.lower()
for marker in ( for marker in (
"context_management", "context_management",
@@ -454,14 +460,15 @@ async def _request_codex(
"compaction_trigger", "compaction_trigger",
) )
) )
)
raise _CodexHTTPError( raise _CodexHTTPError(
_friendly_error(response.status_code, raw), _friendly_error(response.status_code, raw),
status_code=response.status_code, status_code=response.status_code,
retry_after=retry_after, retry_after=retry_after,
error_type=error_type, error_type=error_type,
error_code=error_code, error_code=error_code,
should_retry=_should_retry_status(response.status_code, error_type, error_code, raw), should_retry=_should_retry_status(
response.status_code, error_type, error_code, raw
),
compaction_unsupported=compaction_unsupported, compaction_unsupported=compaction_unsupported,
) )
capture = ResponsesStreamCapture() capture = ResponsesStreamCapture()
@@ -534,7 +541,9 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
default_detail = "HTTP request failed" default_detail = "HTTP request failed"
if status_code is not None and should_retry is None: if status_code is not None and should_retry is None:
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail retry_content = (
None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
)
should_retry = _should_retry_status( should_retry = _should_retry_status(
int(status_code), int(status_code),
getattr(exc, "error_type", None), getattr(exc, "error_type", None),
@@ -592,3 +601,139 @@ def _should_retry_status(
) )
) )
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500 return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500
def get_openai_codex_model_catalog(
proxy: str | None = None,
) -> OAuthModelCatalogSnapshot:
storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename)
token = storage.load()
account_id = getattr(token, "account_id", None)
account_key = _catalog_account_key(account_id)
cache_key = f"{storage.get_token_path()}\0{account_key}\0{proxy or ''}"
return _OPENAI_CODEX_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
def invalidate_openai_codex_model_catalog() -> None:
_OPENAI_CODEX_MODEL_CATALOG.invalidate()
def _fetch_openai_codex_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
token = get_codex_token(proxy=proxy)
account_id = getattr(token, "account_id", None)
if not isinstance(account_id, str) or not account_id:
raise RuntimeError("OpenAI Codex OAuth token has no account ID")
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
with httpx.Client(**client_kwargs) as client:
response = client.get(
DEFAULT_OPENAI_CODEX_MODELS_URL,
params={"client_version": OPENAI_CODEX_CATALOG_CLIENT_VERSION},
headers={
"Authorization": f"Bearer {token.access}",
"chatgpt-account-id": account_id,
"originator": DEFAULT_ORIGINATOR,
"User-Agent": f"nanobot/{__version__} (python)",
"accept": "application/json",
},
)
response.raise_for_status()
return _parse_openai_codex_models(response.json())
def _parse_openai_codex_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
rows = cast(dict[str, Any], payload).get("models") if isinstance(payload, dict) else None
if not isinstance(rows, list):
return ()
fallback_models = _oauth_fallback_models("openai_codex")
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
parsed: list[tuple[int, ProviderModelSpec]] = []
seen: set[str] = set()
for value in cast(list[object], rows):
if not isinstance(value, dict):
continue
row = cast(dict[str, Any], value)
wire_id = _catalog_first_text(row, "slug", "id")
if not wire_id or wire_id in seen or row.get("visibility") in {"hide", "none"}:
continue
seen.add(wire_id)
fallback = fallback_by_id.get(wire_id)
priority = row.get("priority")
parsed.append(
(
priority if isinstance(priority, int) and not isinstance(priority, bool) else 2**31,
ProviderModelSpec(
id=f"openai-codex/{wire_id}",
label=(
_catalog_first_text(row, "display_name", "name")
or (fallback.label if fallback is not None else wire_id)
),
description=(
_catalog_first_text(row, "description")
or (fallback.description if fallback is not None else "")
),
owned_by="OpenAI Codex",
context_window=(
_catalog_positive_int(row, "context_window")
or (fallback.context_window if fallback is not None else None)
),
reasoning_efforts=(
_catalog_reasoning_efforts(row.get("supported_reasoning_levels"))
or (fallback.reasoning_efforts if fallback is not None else ())
),
),
)
)
parsed.sort(key=lambda item: item[0])
return tuple(model for _, model in parsed)
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
spec = find_by_name(provider_name)
assert spec is not None
return spec.builtin_models
def _catalog_account_key(account_id: object) -> str:
value = account_id if isinstance(account_id, str) else ""
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
for key in keys:
value = row.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
for key in keys:
value = row.get(key)
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
return int(value)
return None
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
efforts: list[str] = []
for item in cast(list[object], value):
if isinstance(item, str):
effort = item.strip()
elif isinstance(item, dict):
effort = _catalog_first_text(cast(dict[str, Any], item), "effort", "value", "id")
else:
effort = ""
if effort and effort not in efforts:
efforts.append(effort)
return tuple(efforts)
_OPENAI_CODEX_MODEL_CATALOG = OAuthModelCatalog(
fallback_models=_oauth_fallback_models("openai_codex"),
fetch=_fetch_openai_codex_models,
)
+70 -32
View File
@@ -17,8 +17,6 @@ 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:
@@ -27,7 +25,10 @@ class ProviderModelSpec:
id: str id: str
label: str = "" label: str = ""
description: str = "" description: str = ""
owned_by: str = ""
context_window: int | None = None context_window: int | None = None
reasoning_efforts: tuple[str, ...] = ()
supports_backend_search: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -152,7 +153,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat", backend="openai_compat",
is_direct=True, is_direct=True,
), ),
# === Azure OpenAI (direct API calls with API version 2024-10-21) ===== # === Azure OpenAI (direct API calls with API version 2024-10-21) =====
ProviderSpec( ProviderSpec(
name="azure_openai", name="azure_openai",
@@ -315,7 +315,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
detect_by_base_keyword="siliconflow", detect_by_base_keyword="siliconflow",
default_api_base="https://api.siliconflow.cn/v1", default_api_base="https://api.siliconflow.cn/v1",
), ),
# Novita AI: OpenAI-compatible gateway for hosted model APIs. # Novita AI: OpenAI-compatible gateway for hosted model APIs.
ProviderSpec( ProviderSpec(
name="novita", name="novita",
@@ -327,7 +326,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
detect_by_base_keyword="novita", detect_by_base_keyword="novita",
default_api_base="https://api.novita.ai/openai", default_api_base="https://api.novita.ai/openai",
), ),
# VolcEngine (火山引擎): OpenAI-compatible gateway, pay-per-use models # VolcEngine (火山引擎): OpenAI-compatible gateway, pay-per-use models
ProviderSpec( ProviderSpec(
name="volcengine", name="volcengine",
@@ -341,7 +339,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
thinking_style="thinking_type", thinking_style="thinking_type",
supports_max_completion_tokens=True, supports_max_completion_tokens=True,
), ),
# VolcEngine Coding Plan (火山引擎 Coding Plan): same key as volcengine # VolcEngine Coding Plan (火山引擎 Coding Plan): same key as volcengine
ProviderSpec( ProviderSpec(
name="volcengine_coding_plan", name="volcengine_coding_plan",
@@ -355,7 +352,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
thinking_style="thinking_type", thinking_style="thinking_type",
supports_max_completion_tokens=True, supports_max_completion_tokens=True,
), ),
# BytePlus: VolcEngine international, pay-per-use models # BytePlus: VolcEngine international, pay-per-use models
ProviderSpec( ProviderSpec(
name="byteplus", name="byteplus",
@@ -369,7 +365,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
strip_model_prefix=True, strip_model_prefix=True,
thinking_style="thinking_type", thinking_style="thinking_type",
), ),
# BytePlus Coding Plan: same key as byteplus # BytePlus Coding Plan: same key as byteplus
ProviderSpec( ProviderSpec(
name="byteplus_coding_plan", name="byteplus_coding_plan",
@@ -382,8 +377,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
strip_model_prefix=True, strip_model_prefix=True,
thinking_style="thinking_type", thinking_style="thinking_type",
), ),
# === Standard providers (matched by model-name keywords) =============== # === Standard providers (matched by model-name keywords) ===============
# Anthropic: native Anthropic SDK # Anthropic: native Anthropic SDK
ProviderSpec( ProviderSpec(
@@ -410,14 +403,56 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
env_key="", env_key="",
display_name="OpenAI Codex", display_name="OpenAI Codex",
model_catalog="hybrid", model_catalog="hybrid",
builtin_models=tuple( builtin_models=(
ProviderModelSpec( ProviderModelSpec(
id=model.id, id="openai-codex/gpt-5.6-sol",
label=model.label, label="GPT-5.6-Sol",
description=model.description, description="Latest frontier agentic coding model.",
context_window=model.context_window, context_window=272_000,
) reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
for model in curated_oauth_models("openai_codex") ),
ProviderModelSpec(
id="openai-codex/gpt-5.6-terra",
label="GPT-5.6-Terra",
description="Balanced agentic coding model for everyday work.",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh", "max", "ultra"),
),
ProviderModelSpec(
id="openai-codex/gpt-5.6-luna",
label="GPT-5.6-Luna",
description="Fast and affordable agentic coding model.",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh", "max"),
),
ProviderModelSpec(
id="openai-codex/gpt-5.5",
label="GPT-5.5",
description="Frontier model for complex coding, research, and real-world work.",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
),
ProviderModelSpec(
id="openai-codex/gpt-5.4",
label="GPT-5.4",
description="Strong model for everyday coding.",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
),
ProviderModelSpec(
id="openai-codex/gpt-5.4-mini",
label="GPT-5.4-Mini",
description="Small, fast, and cost-efficient model for simpler coding tasks.",
context_window=272_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
),
ProviderModelSpec(
id="openai-codex/gpt-5.3-codex-spark",
label="GPT-5.3-Codex-Spark",
description="Ultra-fast coding model.",
context_window=128_000,
reasoning_efforts=("low", "medium", "high", "xhigh"),
),
), ),
backend="openai_codex", backend="openai_codex",
detect_by_base_keyword="codex", detect_by_base_keyword="codex",
@@ -431,14 +466,19 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
env_key="", env_key="",
display_name="xAI Grok", display_name="xAI Grok",
model_catalog="hybrid", model_catalog="hybrid",
builtin_models=tuple( builtin_models=(
ProviderModelSpec( ProviderModelSpec(
id=model.id, id="xai-grok/grok-4.6",
label=model.label, label="Grok 4.6",
description=model.description, description="Grok via xAI subscription; X Search is enabled when supported.",
context_window=model.context_window, context_window=500_000,
) ),
for model in curated_oauth_models("xai_grok") ProviderModelSpec(
id="xai-grok/grok-4.5",
label="Grok 4.5",
description="Grok via xAI subscription; X Search is enabled when supported.",
context_window=500_000,
),
), ),
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",
@@ -451,14 +491,12 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
env_key="", env_key="",
display_name="Github Copilot", display_name="Github Copilot",
model_catalog="hybrid", model_catalog="hybrid",
builtin_models=tuple( builtin_models=(
ProviderModelSpec( ProviderModelSpec(
id=model.id, id="github-copilot/gpt-4.1",
label=model.label, label="GPT-4.1",
description=model.description, description="GitHub Copilot chat model.",
context_window=model.context_window, ),
)
for model in curated_oauth_models("github_copilot")
), ),
backend="github_copilot", backend="github_copilot",
default_api_base="https://api.githubcopilot.com", default_api_base="https://api.githubcopilot.com",
@@ -730,7 +768,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
env_key="QIANFAN_API_KEY", env_key="QIANFAN_API_KEY",
display_name="Qianfan", display_name="Qianfan",
backend="openai_compat", backend="openai_compat",
default_api_base="https://qianfan.baidubce.com/v2" default_api_base="https://qianfan.baidubce.com/v2",
), ),
) )
+233 -44
View File
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import base64
import hashlib
import json import json
import re import re
import uuid import uuid
@@ -20,21 +22,23 @@ from nanobot.providers.base import (
ToolCallRequest, ToolCallRequest,
resolve_stream_idle_timeout_s, resolve_stream_idle_timeout_s,
) )
from nanobot.providers.oauth_model_catalog import ( from nanobot.providers.oauth_model_catalog import OAuthModelCatalog, OAuthModelCatalogSnapshot
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,
convert_tools, convert_tools,
) )
from nanobot.providers.registry import ProviderModelSpec, find_by_name
from nanobot.providers.xai_oauth import ( from nanobot.providers.xai_oauth import (
XAI_CLIENT_VERSION, XAI_CLIENT_VERSION,
get_xai_oauth_login_status,
get_xai_oauth_storage_path,
get_xai_oauth_token, get_xai_oauth_token,
) )
DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.6"
DEFAULT_XAI_GROK_URL = "https://cli-chat-proxy.grok.com/v1/responses" DEFAULT_XAI_GROK_URL = "https://cli-chat-proxy.grok.com/v1/responses"
DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models"
_HOSTED_SEARCH_MAX_TURNS = 5 _HOSTED_SEARCH_MAX_TURNS = 5
_MAX_ERROR_BODY_CHARS = 1000 _MAX_ERROR_BODY_CHARS = 1000
_SENSITIVE_ERROR_KEYS = { _SENSITIVE_ERROR_KEYS = {
@@ -81,9 +85,8 @@ class XAIGrokProvider(LLMProvider):
async def _supports_backend_search(self, model: str) -> bool: async def _supports_backend_search(self, model: str) -> bool:
catalog = await asyncio.to_thread( catalog = await asyncio.to_thread(
get_oauth_model_catalog, get_xai_grok_model_catalog,
"xai_grok", self.proxy,
proxy=self.proxy,
) )
if catalog.message: if catalog.message:
logger.warning( logger.warning(
@@ -115,12 +118,8 @@ class XAIGrokProvider(LLMProvider):
token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy) token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy)
configured_tools = self._extra_body.get("tools") configured_tools = self._extra_body.get("tools")
tools_are_explicit = "tools" in self._extra_body tools_are_explicit = "tools" in self._extra_body
configured_hosted_search = ( configured_hosted_search = isinstance(configured_tools, list) and any(
isinstance(configured_tools, list) _is_hosted_x_search_tool(tool) for tool in cast(list[object], configured_tools)
and any(
_is_hosted_x_search_tool(tool)
for tool in cast(list[object], configured_tools)
)
) )
supports_backend_search = False supports_backend_search = False
if not tools_are_explicit: if not tools_are_explicit:
@@ -159,11 +158,9 @@ class XAIGrokProvider(LLMProvider):
# stopping after a single unsuccessful lookup. # stopping after a single unsuccessful lookup.
body["max_turns"] = _HOSTED_SEARCH_MAX_TURNS body["max_turns"] = _HOSTED_SEARCH_MAX_TURNS
if self._extra_body: if self._extra_body:
body.update({ body.update(
key: value {key: value for key, value in self._extra_body.items() if key != "tools"}
for key, value in self._extra_body.items() )
if key != "tools"
})
if tools_are_explicit and not isinstance(configured_tools, list): if tools_are_explicit and not isinstance(configured_tools, list):
body["tools"] = configured_tools body["tools"] = configured_tools
@@ -198,18 +195,14 @@ class XAIGrokProvider(LLMProvider):
stage = "xai_request_after_oauth_refresh" stage = "xai_request_after_oauth_refresh"
except _XAIIncompleteHostedToolError as exc: except _XAIIncompleteHostedToolError as exc:
retry_usage = _combine_usage(retry_usage, exc.usage) retry_usage = _combine_usage(retry_usage, exc.usage)
cannot_recover_stream = ( cannot_recover_stream = exc.stream_output_emitted and on_stream_recover is None
exc.stream_output_emitted
and on_stream_recover is None
)
if hosted_tool_retried or cannot_recover_stream: if hosted_tool_retried or cannot_recover_stream:
exc.usage = retry_usage exc.usage = retry_usage
raise raise
hosted_tool_retried = True hosted_tool_retried = True
stage = "hosted_tool_recovery" stage = "hosted_tool_recovery"
logger.warning( logger.warning(
"xAI response ended with unfinished hosted tool(s): {}; " "xAI response ended with unfinished hosted tool(s): {}; retrying once",
"retrying once",
", ".join(exc.tool_names), ", ".join(exc.tool_names),
) )
if on_stream_recover is not None: if on_stream_recover is not None:
@@ -359,13 +352,9 @@ class _XAIIncompleteHostedToolError(RuntimeError):
usage: LLMUsage | None, usage: LLMUsage | None,
stream_output_emitted: bool = False, stream_output_emitted: bool = False,
) -> None: ) -> None:
names = [ names = [str(event.get("name") or "hosted_tool") for event in active_tools]
str(event.get("name") or "hosted_tool")
for event in active_tools
]
super().__init__( super().__init__(
"xAI ended the response before its hosted tool completed: " "xAI ended the response before its hosted tool completed: " + ", ".join(names)
+ ", ".join(names)
) )
self.tool_names = tuple(names) self.tool_names = tuple(names)
self.usage = usage self.usage = usage
@@ -427,9 +416,7 @@ async def _request_xai(
raise _build_xai_http_error(response.status_code, response.headers, raw) raise _build_xai_http_error(response.status_code, response.headers, raw)
result = await consume_sse_with_reasoning( result = await consume_sse_with_reasoning(
response, response,
on_content_delta=( on_content_delta=(_forward_content_delta if on_content_delta is not None else None),
_forward_content_delta if on_content_delta is not None else None
),
# Always observe tool events so protocol validation also works for # Always observe tool events so protocol validation also works for
# non-streaming callers that did not request UI progress callbacks. # non-streaming callers that did not request UI progress callbacks.
on_tool_call_delta=_track_and_forward_tool_event, on_tool_call_delta=_track_and_forward_tool_event,
@@ -441,12 +428,14 @@ async def _request_xai(
if result[2] != "error" and active_hosted_tools: if result[2] != "error" and active_hosted_tools:
active = list(active_hosted_tools.values()) active = list(active_hosted_tools.values())
for event in active: for event in active:
await _track_and_forward_tool_event({ await _track_and_forward_tool_event(
{
**event, **event,
"phase": "error", "phase": "error",
"result": None, "result": None,
"error": "xAI ended the response before this hosted tool completed.", "error": "xAI ended the response before this hosted tool completed.",
}) }
)
raise _XAIIncompleteHostedToolError( raise _XAIIncompleteHostedToolError(
active, active,
usage=result[3], usage=result[3],
@@ -466,9 +455,7 @@ def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
"phase": "start", "phase": "start",
"call_id": str(call_id), "call_id": str(call_id),
"name": "x_search", "name": "x_search",
"arguments": _xai_hosted_tool_arguments( "arguments": _xai_hosted_tool_arguments(event.get("input", event.get("arguments"))),
event.get("input", event.get("arguments"))
),
"result": None, "result": None,
} }
@@ -491,9 +478,7 @@ def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
"name": "x_search", "name": "x_search",
"arguments": _xai_hosted_tool_arguments(item.get("action")), "arguments": _xai_hosted_tool_arguments(item.get("action")),
"result": ( "result": (
{"status": str(item.get("status") or "completed")} {"status": str(item.get("status") or "completed")} if phase == "end" else None
if phase == "end"
else None
), ),
} }
if event_type != "response.output_item.done" or item_type != "custom_tool_call": if event_type != "response.output_item.done" or item_type != "custom_tool_call":
@@ -509,9 +494,7 @@ def _xai_hosted_tool_event(event: dict[str, Any]) -> dict[str, Any] | None:
"phase": "end", "phase": "end",
"call_id": str(call_id), "call_id": str(call_id),
"name": "x_search", "name": "x_search",
"arguments": _xai_hosted_tool_arguments( "arguments": _xai_hosted_tool_arguments(item.get("input", item.get("arguments"))),
item.get("input", item.get("arguments"))
),
# Keep the useful search subtype, but do not persist large hosted results # Keep the useful search subtype, but do not persist large hosted results
# in WebUI activity messages. The model answer already carries citations. # in WebUI activity messages. The model answer already carries citations.
"result": {"name": tool_name}, "result": {"name": tool_name},
@@ -662,3 +645,209 @@ def _should_retry_status(
) )
) )
return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500 # pyright: ignore[reportPrivateUsage] return status_code in LLMProvider._RETRYABLE_STATUS_CODES or status_code >= 500 # pyright: ignore[reportPrivateUsage]
def get_xai_grok_model_catalog(proxy: str | None = None) -> OAuthModelCatalogSnapshot:
token = get_xai_oauth_login_status()
account_key = _catalog_account_key(getattr(token, "account_id", None))
cache_key = f"{get_xai_oauth_storage_path()}\0{account_key}\0{proxy or ''}"
return _XAI_GROK_MODEL_CATALOG.get(cache_key=cache_key, proxy=proxy)
def invalidate_xai_grok_model_catalog() -> None:
_XAI_GROK_MODEL_CATALOG.invalidate()
def _fetch_xai_grok_models(proxy: str | None) -> tuple[ProviderModelSpec, ...]:
token = get_xai_oauth_token(proxy=proxy)
client_kwargs: dict[str, Any] = {"timeout": 10.0, "follow_redirects": False}
if proxy:
client_kwargs.update(proxy=proxy, trust_env=False)
with httpx.Client(**client_kwargs) as client:
response = client.get(
DEFAULT_XAI_GROK_MODELS_URL,
headers=_build_xai_model_headers(token.access, token.account_id),
)
response.raise_for_status()
return _parse_xai_grok_models(response.json())
def _parse_xai_grok_models(payload: Any) -> tuple[ProviderModelSpec, ...]:
if isinstance(payload, dict):
payload_mapping = cast(dict[str, Any], payload)
rows: object = payload_mapping.get("data")
if not isinstance(rows, list):
rows = payload_mapping.get("models")
else:
rows = payload
if not isinstance(rows, list):
return ()
fallback_models = _oauth_fallback_models("xai_grok")
fallback_by_id = {model.id.split("/", 1)[-1]: model for model in fallback_models}
models: list[ProviderModelSpec] = []
seen: set[str] = set()
for value in cast(list[object], rows):
if not isinstance(value, dict):
continue
row = cast(dict[str, Any], value)
meta = _catalog_mapping(row.get("_meta"))
raw_id = next(
(
candidate.strip()
for candidate in (
row.get("id"),
row.get("model"),
row.get("modelId"),
row.get("name"),
meta.get("id"),
meta.get("model"),
meta.get("modelId"),
)
if isinstance(candidate, str) and candidate.strip()
),
None,
)
if raw_id is None:
continue
wire_id = raw_id.split("/", 1)[-1]
if wire_id in seen:
continue
seen.add(wire_id)
fallback = fallback_by_id.get(wire_id)
label = _catalog_first_text(row, "display_name", "label", "name") or _catalog_first_text(
meta,
"display_name",
"label",
"name",
)
if not label or label == raw_id:
label = fallback.label if fallback is not None else wire_id
models.append(
ProviderModelSpec(
id=f"xai-grok/{wire_id}",
label=label,
description=(
_catalog_first_text(row, "description")
or _catalog_first_text(meta, "description")
or (fallback.description if fallback is not None else "")
),
owned_by=(
_catalog_first_text(row, "owned_by", "owner", "organization")
or _catalog_first_text(meta, "owned_by", "owner", "organization")
or (fallback.owned_by if fallback is not None else "xAI")
),
context_window=(
_catalog_positive_int(row, "context_window", "context_length")
or _catalog_positive_int(meta, "context_window", "context_length")
or (fallback.context_window if fallback is not None else None)
),
reasoning_efforts=_catalog_reasoning_efforts(
row.get("reasoning_efforts", meta.get("reasoning_efforts"))
),
supports_backend_search=_catalog_bool_field(
row,
"supports_backend_search",
"supportsBackendSearch",
),
)
)
return tuple(models)
def _build_xai_model_headers(access_token: str, account_id: str | None) -> dict[str, str]:
headers = {
"Authorization": f"Bearer {access_token}",
"X-XAI-Token-Auth": "xai-grok-cli",
"x-grok-client-version": XAI_CLIENT_VERSION,
"x-grok-client-identifier": "nanobot",
"x-grok-client-mode": "headless",
"User-Agent": f"nanobot/{__version__} (python)",
"accept": "application/json",
}
claims = _decode_access_token_claims(access_token)
user_id = claims.get("sub")
if claims.get("principal_type") == "Team":
user_id = claims.get("principal_id") or user_id
if isinstance(user_id, str) and user_id:
headers["x-userid"] = user_id
email = claims.get("email")
if not isinstance(email, str) or "@" not in email:
email = account_id if account_id and "@" in account_id else None
if email:
headers["x-email"] = email
return headers
def _decode_access_token_claims(token: str) -> dict[str, Any]:
parts = token.split(".")
if len(parts) < 2 or not parts[1]:
return {}
try:
decoded = base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4))
claims = json.loads(decoded)
except (ValueError, TypeError):
return {}
return cast(dict[str, Any], claims) if isinstance(claims, dict) else {}
def _oauth_fallback_models(provider_name: str) -> tuple[ProviderModelSpec, ...]:
spec = find_by_name(provider_name)
assert spec is not None
return spec.builtin_models
def _catalog_account_key(account_id: object) -> str:
value = account_id if isinstance(account_id, str) else ""
return hashlib.sha256(value.encode()).hexdigest()[:16] if value else "anonymous"
def _catalog_mapping(value: Any) -> dict[str, Any]:
return cast(dict[str, Any], value) if isinstance(value, dict) else {}
def _catalog_first_text(row: dict[str, Any], *keys: str) -> str:
for key in keys:
value = row.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _catalog_positive_int(row: dict[str, Any], *keys: str) -> int | None:
for key in keys:
value = row.get(key)
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
return int(value)
return None
def _catalog_bool_field(row: dict[str, Any], *keys: str) -> bool:
for key in keys:
value = row.get(key)
if isinstance(value, bool):
return value
meta = row.get("_meta")
return _catalog_bool_field(_catalog_mapping(meta), *keys) if isinstance(meta, dict) else False
def _catalog_reasoning_efforts(value: Any) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
efforts: list[str] = []
for item in cast(list[object], value):
if isinstance(item, str):
effort = item.strip()
elif isinstance(item, dict):
effort = _catalog_first_text(cast(dict[str, Any], item), "effort", "value", "id")
else:
effort = ""
if effort and effort not in efforts:
efforts.append(effort)
return tuple(efforts)
_XAI_GROK_MODEL_CATALOG = OAuthModelCatalog(
fallback_models=_oauth_fallback_models("xai_grok"),
fetch=_fetch_xai_grok_models,
)
+44 -43
View File
@@ -12,14 +12,16 @@ import httpx
import pytest import pytest
from nanobot.providers.oauth_model_catalog import ( from nanobot.providers.oauth_model_catalog import (
DEFAULT_OPENAI_CODEX_MODELS_URL,
DEFAULT_XAI_GROK_MODELS_URL,
OPENAI_CODEX_CATALOG_CLIENT_VERSION,
OAuthModelCatalog, OAuthModelCatalog,
OAuthModelInfo,
get_oauth_model_catalog, get_oauth_model_catalog,
invalidate_oauth_model_catalog, invalidate_oauth_model_catalog,
) )
from nanobot.providers.openai_codex_provider import (
DEFAULT_OPENAI_CODEX_MODELS_URL,
OPENAI_CODEX_CATALOG_CLIENT_VERSION,
)
from nanobot.providers.registry import ProviderModelSpec
from nanobot.providers.xai_grok_provider import DEFAULT_XAI_GROK_MODELS_URL
from nanobot.providers.xai_oauth import XAIToken from nanobot.providers.xai_oauth import XAIToken
@@ -32,8 +34,8 @@ def _clear_oauth_catalogs() -> None:
invalidate_oauth_model_catalog(provider) invalidate_oauth_model_catalog(provider)
def _fallback_model() -> OAuthModelInfo: def _fallback_model() -> ProviderModelSpec:
return OAuthModelInfo(id="provider/fallback", label="Fallback") return ProviderModelSpec(id="provider/fallback", label="Fallback")
def test_xai_catalog_fetches_remote_models_and_reuses_capability_metadata( def test_xai_catalog_fetches_remote_models_and_reuses_capability_metadata(
@@ -42,9 +44,13 @@ def test_xai_catalog_fetches_remote_models_and_reuses_capability_metadata(
) -> None: ) -> None:
original_client = httpx.Client original_client = httpx.Client
captured: dict[str, object] = {} captured: dict[str, object] = {}
payload = base64.urlsafe_b64encode( payload = (
base64.urlsafe_b64encode(
json.dumps({"sub": "user-42", "email": "user@example.com"}).encode() json.dumps({"sub": "user-42", "email": "user@example.com"}).encode()
).decode().rstrip("=") )
.decode()
.rstrip("=")
)
token = XAIToken( token = XAIToken(
access=f"header.{payload}.signature", access=f"header.{payload}.signature",
refresh="refresh-token", refresh="refresh-token",
@@ -93,14 +99,18 @@ def test_xai_catalog_fetches_remote_models_and_reuses_capability_metadata(
) )
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.oauth_model_catalog._xai_oauth_storage_path", "nanobot.providers.xai_grok_provider.get_xai_oauth_storage_path",
lambda: tmp_path / "auth" / "xai.json", lambda: tmp_path / "auth" / "xai.json",
) )
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.oauth_model_catalog._xai_oauth_token", "nanobot.providers.xai_grok_provider.get_xai_oauth_login_status",
lambda _proxy: token, lambda: token,
) )
monkeypatch.setattr("nanobot.providers.oauth_model_catalog.httpx.Client", fake_client) monkeypatch.setattr(
"nanobot.providers.xai_grok_provider.get_xai_oauth_token",
lambda **_kwargs: token,
)
monkeypatch.setattr("nanobot.providers.xai_grok_provider.httpx.Client", fake_client)
catalog = get_oauth_model_catalog("xai_grok") catalog = get_oauth_model_catalog("xai_grok")
@@ -181,19 +191,22 @@ def test_openai_codex_catalog_uses_account_catalog_and_filters_hidden_models(
follow_redirects=kwargs["follow_redirects"], follow_redirects=kwargs["follow_redirects"],
) )
class Storage:
def load(self) -> SimpleNamespace:
return SimpleNamespace(access="secret", account_id="account-42")
def get_token_path(self) -> Path:
return tmp_path / "auth" / "openai-codex.json"
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.oauth_model_catalog._openai_codex_storage_path", "nanobot.providers.openai_codex_provider.FileTokenStorage",
lambda: tmp_path / "auth" / "openai-codex.json", lambda **_kwargs: Storage(),
) )
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.oauth_model_catalog._openai_codex_account_key", "nanobot.providers.openai_codex_provider.get_codex_token",
lambda: "account-key",
)
monkeypatch.setattr(
"oauth_cli_kit.get_token",
lambda **_kwargs: SimpleNamespace(access="secret", account_id="account-42"), lambda **_kwargs: SimpleNamespace(access="secret", account_id="account-42"),
) )
monkeypatch.setattr("nanobot.providers.oauth_model_catalog.httpx.Client", fake_client) monkeypatch.setattr("nanobot.providers.openai_codex_provider.httpx.Client", fake_client)
catalog = get_oauth_model_catalog("openai_codex") catalog = get_oauth_model_catalog("openai_codex")
@@ -276,23 +289,11 @@ def test_github_copilot_catalog_only_lists_compatible_chat_models(
def get_token_path(self) -> Path: def get_token_path(self) -> Path:
return tmp_path / "auth" / "github-copilot.json" return tmp_path / "auth" / "github-copilot.json"
monkeypatch.setattr(
"nanobot.providers.oauth_model_catalog._github_copilot_storage_path",
lambda: tmp_path / "auth" / "github-copilot.json",
)
monkeypatch.setattr(
"nanobot.providers.oauth_model_catalog._github_copilot_account_key",
lambda: "account-key",
)
monkeypatch.setattr(
"nanobot.providers.oauth_model_catalog._github_copilot_models_url",
lambda: "https://api.githubcopilot.com/models",
)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.github_copilot_provider.get_storage", "nanobot.providers.github_copilot_provider.get_storage",
lambda: Storage(), lambda: Storage(),
) )
monkeypatch.setattr("nanobot.providers.oauth_model_catalog.httpx.Client", fake_client) monkeypatch.setattr("nanobot.providers.github_copilot_provider.httpx.Client", fake_client)
catalog = get_oauth_model_catalog("github_copilot") catalog = get_oauth_model_catalog("github_copilot")
@@ -311,12 +312,12 @@ def test_catalog_single_flights_concurrent_refreshes() -> None:
calls_lock = threading.Lock() calls_lock = threading.Lock()
barrier = threading.Barrier(8) barrier = threading.Barrier(8)
def fetch(_proxy: str | None) -> tuple[OAuthModelInfo, ...]: def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls nonlocal calls
with calls_lock: with calls_lock:
calls += 1 calls += 1
time.sleep(0.05) time.sleep(0.05)
return (OAuthModelInfo(id="provider/remote", label="Remote"),) return (ProviderModelSpec(id="provider/remote", label="Remote"),)
catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch) catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch)
@@ -338,14 +339,14 @@ def test_catalog_invalidation_discards_an_inflight_account_refresh() -> None:
release = threading.Event() release = threading.Event()
calls = 0 calls = 0
def fetch(_proxy: str | None) -> tuple[OAuthModelInfo, ...]: def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls nonlocal calls
calls += 1 calls += 1
if calls == 1: if calls == 1:
started.set() started.set()
assert release.wait(timeout=2) assert release.wait(timeout=2)
return (OAuthModelInfo(id="provider/old-account", label="Old"),) return (ProviderModelSpec(id="provider/old-account", label="Old"),)
return (OAuthModelInfo(id="provider/new-account", label="New"),) return (ProviderModelSpec(id="provider/new-account", label="New"),)
catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch) catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch)
with ThreadPoolExecutor(max_workers=1) as pool: with ThreadPoolExecutor(max_workers=1) as pool:
@@ -364,12 +365,12 @@ def test_catalog_returns_stale_then_negative_caches_refresh_failure() -> None:
now = [0.0] now = [0.0]
calls = 0 calls = 0
def fetch(_proxy: str | None) -> tuple[OAuthModelInfo, ...]: def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls nonlocal calls
calls += 1 calls += 1
if calls > 1: if calls > 1:
raise httpx.ConnectError("offline") raise httpx.ConnectError("offline")
return (OAuthModelInfo(id="provider/remote", label="Remote"),) return (ProviderModelSpec(id="provider/remote", label="Remote"),)
catalog = OAuthModelCatalog( catalog = OAuthModelCatalog(
fallback_models=(_fallback_model(),), fallback_models=(_fallback_model(),),
@@ -421,7 +422,7 @@ def test_catalog_returns_stale_then_negative_caches_refresh_failure() -> None:
def test_catalog_falls_back_for_remote_failures(failure: Exception) -> None: def test_catalog_falls_back_for_remote_failures(failure: Exception) -> None:
calls = 0 calls = 0
def fetch(_proxy: str | None) -> tuple[OAuthModelInfo, ...]: def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls nonlocal calls
calls += 1 calls += 1
raise failure raise failure
@@ -444,10 +445,10 @@ def test_catalog_falls_back_for_remote_failures(failure: Exception) -> None:
def test_catalog_treats_empty_remote_list_as_failure_and_can_be_invalidated() -> None: def test_catalog_treats_empty_remote_list_as_failure_and_can_be_invalidated() -> None:
calls = 0 calls = 0
def fetch(_proxy: str | None) -> tuple[OAuthModelInfo, ...]: def fetch(_proxy: str | None) -> tuple[ProviderModelSpec, ...]:
nonlocal calls nonlocal calls
calls += 1 calls += 1
return () if calls == 1 else (OAuthModelInfo(id="provider/new", label="New"),) return () if calls == 1 else (ProviderModelSpec(id="provider/new", label="New"),)
catalog = OAuthModelCatalog( catalog = OAuthModelCatalog(
fallback_models=(_fallback_model(),), fallback_models=(_fallback_model(),),
+19 -13
View File
@@ -11,8 +11,8 @@ 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.oauth_model_catalog import OAuthModelCatalogSnapshot
from nanobot.providers.registry import find_by_name from nanobot.providers.registry import ProviderModelSpec, find_by_name
from nanobot.providers.xai_grok_provider import ( from nanobot.providers.xai_grok_provider import (
DEFAULT_XAI_GROK_MODEL, DEFAULT_XAI_GROK_MODEL,
XAIGrokProvider, XAIGrokProvider,
@@ -51,12 +51,12 @@ def _mock_model_capabilities(
def fake_catalog(*_args, **_kwargs): def fake_catalog(*_args, **_kwargs):
return OAuthModelCatalogSnapshot( return OAuthModelCatalogSnapshot(
models=( models=(
OAuthModelInfo( ProviderModelSpec(
id="xai-grok/grok-4.5", id="xai-grok/grok-4.5",
label="Grok 4.5", label="Grok 4.5",
supports_backend_search=supports_backend_search, supports_backend_search=supports_backend_search,
), ),
OAuthModelInfo( ProviderModelSpec(
id="xai-grok/grok-4.6", id="xai-grok/grok-4.6",
label="Grok 4.6", label="Grok 4.6",
supports_backend_search=supports_backend_search, supports_backend_search=supports_backend_search,
@@ -67,7 +67,7 @@ def _mock_model_capabilities(
) )
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.xai_grok_provider.get_oauth_model_catalog", "nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
fake_catalog, fake_catalog,
) )
@@ -172,7 +172,7 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
return "ok", [], "stop", {}, None return "ok", [], "stop", {}, None
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.xai_grok_provider.get_oauth_model_catalog", "nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
unexpected_catalog_lookup, unexpected_catalog_lookup,
) )
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request) monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
@@ -181,10 +181,12 @@ async def test_explicit_parameterized_x_search_is_preserved_without_catalog_look
"allowed_x_handles": ["nanobot_ai"], "allowed_x_handles": ["nanobot_ai"],
"from_date": "2026-01-01", "from_date": "2026-01-01",
} }
provider = XAIGrokProvider(extra_body={ provider = XAIGrokProvider(
extra_body={
"parallel_tool_calls": False, "parallel_tool_calls": False,
"tools": [hosted_tool, {"type": "code_interpreter", "container": "auto"}], "tools": [hosted_tool, {"type": "code_interpreter", "container": "auto"}],
}) }
)
response = await provider.chat( response = await provider.chat(
[{"role": "user", "content": "search"}], [{"role": "user", "content": "search"}],
@@ -235,7 +237,7 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
return "ok", [], "stop", {}, None return "ok", [], "stop", {}, None
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.providers.xai_grok_provider.get_oauth_model_catalog", "nanobot.providers.xai_grok_provider.get_xai_grok_model_catalog",
unexpected_catalog_lookup, unexpected_catalog_lookup,
) )
monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request) monkeypatch.setattr("nanobot.providers.xai_grok_provider._request_xai", fake_request)
@@ -243,23 +245,27 @@ async def test_explicit_empty_tools_disables_catalog_lookup_and_hosted_tool(monk
response = await provider.chat( response = await provider.chat(
[{"role": "user", "content": "hello"}], [{"role": "user", "content": "hello"}],
tools=[{ tools=[
{
"type": "function", "type": "function",
"function": { "function": {
"name": "read_file", "name": "read_file",
"description": "Read a file", "description": "Read a file",
"parameters": {"type": "object"}, "parameters": {"type": "object"},
}, },
}], }
],
) )
assert response.content == "ok" assert response.content == "ok"
assert bodies[0]["tools"] == [{ assert bodies[0]["tools"] == [
{
"type": "function", "type": "function",
"name": "read_file", "name": "read_file",
"description": "Read a file", "description": "Read a file",
"parameters": {"type": "object"}, "parameters": {"type": "object"},
}] }
]
assert "max_turns" not in bodies[0] assert "max_turns" not in bodies[0]
+37 -27
View File
@@ -13,8 +13,8 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi
from nanobot.llm_usage import get_llm_usage_store from nanobot.llm_usage import get_llm_usage_store
from nanobot.llm_usage.models import LLMCallRecord from nanobot.llm_usage.models import LLMCallRecord
from nanobot.providers.base import LLMUsage from nanobot.providers.base import LLMUsage
from nanobot.providers.oauth_model_catalog import OAuthModelCatalogSnapshot, OAuthModelInfo from nanobot.providers.oauth_model_catalog import OAuthModelCatalogSnapshot
from nanobot.providers.registry import find_by_name from nanobot.providers.registry import ProviderModelSpec, find_by_name
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
from nanobot.webui.settings_api import ( from nanobot.webui.settings_api import (
@@ -184,11 +184,13 @@ def test_update_api_settings_requires_key_for_network_access(
with pytest.raises(WebUISettingsError, match="API key"): with pytest.raises(WebUISettingsError, match="API key"):
update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]}) update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]})
payload = update_api_settings({ payload = update_api_settings(
{
"host": ["0.0.0.0"], "host": ["0.0.0.0"],
"port": ["9900"], "port": ["9900"],
"api_key": ["secret-token"], "api_key": ["secret-token"],
}) }
)
saved = load_config(config_path) saved = load_config(config_path)
assert saved.api.host == "0.0.0.0" assert saved.api.host == "0.0.0.0"
assert saved.api.port == 9900 assert saved.api.port == 9900
@@ -347,13 +349,15 @@ def test_create_model_configuration_rejects_dynamic_custom_provider_without_api_
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config = Config.model_validate({ config = Config.model_validate(
{
"providers": { "providers": {
DYNAMIC_PROVIDER_NAME: { DYNAMIC_PROVIDER_NAME: {
"apiKey": "sk-test", "apiKey": "sk-test",
} }
} }
}) }
)
save_config(config, config_path) save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
@@ -498,9 +502,7 @@ def test_update_model_configuration_rolls_back_sessions_when_config_save_fails(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config = Config( config = Config(model_presets={"openai": ModelPresetConfig(model="openai/gpt-4.1")})
model_presets={"openai": ModelPresetConfig(model="openai/gpt-4.1")}
)
save_config(config, config_path) save_config(config, config_path)
calls: list[tuple[str, str]] = [] calls: list[tuple[str, str]] = []
@@ -891,11 +893,13 @@ def test_update_provider_settings_updates_and_clears_oauth_proxy(
}, },
) )
payload = update_provider_settings({ payload = update_provider_settings(
{
"provider": [provider_name], "provider": [provider_name],
"proxy": [" http://127.0.0.1:7890 "], "proxy": [" http://127.0.0.1:7890 "],
"extraBody": [json.dumps({"tools": []})], "extraBody": [json.dumps({"tools": []})],
}) }
)
providers = {row["name"]: row for row in payload["providers"]} providers = {row["name"]: row for row in payload["providers"]}
assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890" assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890"
@@ -1100,7 +1104,8 @@ def test_settings_payload_groups_opencode_compatibility_alias(tmp_path, monkeypa
def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None: def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config = Config.model_validate({ config = Config.model_validate(
{
"providers": {"opencodeZen": {"apiKey": "legacy-key"}}, "providers": {"opencodeZen": {"apiKey": "legacy-key"}},
"agents": { "agents": {
"defaults": { "defaults": {
@@ -1108,7 +1113,8 @@ def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monke
"model": "opencode/deepseek-v4-pro", "model": "opencode/deepseek-v4-pro",
} }
}, },
}) }
)
save_config(config, config_path) save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
@@ -1125,13 +1131,15 @@ def test_settings_payload_marks_dynamic_custom_provider_without_api_base_unconfi
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config = Config.model_validate({ config = Config.model_validate(
{
"providers": { "providers": {
DYNAMIC_PROVIDER_NAME: { DYNAMIC_PROVIDER_NAME: {
"apiKey": "sk-test", "apiKey": "sk-test",
} }
} }
}) }
)
save_config(config, config_path) save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
@@ -1467,7 +1475,8 @@ def test_settings_payload_includes_token_usage_summary(
config = Config() config = Config()
save_config(config, config_path) save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
get_llm_usage_store().record(LLMCallRecord( get_llm_usage_store().record(
LLMCallRecord(
started_at_ms=int(time.time() * 1000), started_at_ms=int(time.time() * 1000),
duration_ms=1, duration_ms=1,
provider="openai", provider="openai",
@@ -1476,7 +1485,8 @@ def test_settings_payload_includes_token_usage_summary(
stream=False, stream=False,
finish_reason="stop", finish_reason="stop",
usage=LLMUsage.reported(input_tokens=10, output_tokens=5), usage=LLMUsage.reported(input_tokens=10, output_tokens=5),
)) )
)
payload = settings_payload() payload = settings_payload()
@@ -1497,7 +1507,8 @@ def test_settings_usage_payload_returns_lightweight_token_usage(
config = Config() config = Config()
save_config(config, config_path) save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
get_llm_usage_store().record(LLMCallRecord( get_llm_usage_store().record(
LLMCallRecord(
started_at_ms=int(time.time() * 1000), started_at_ms=int(time.time() * 1000),
duration_ms=1, duration_ms=1,
provider="openai", provider="openai",
@@ -1506,7 +1517,8 @@ def test_settings_usage_payload_returns_lightweight_token_usage(
stream=False, stream=False,
finish_reason="stop", finish_reason="stop",
usage=LLMUsage.reported(input_tokens=20, output_tokens=2), usage=LLMUsage.reported(input_tokens=20, output_tokens=2),
)) )
)
payload = settings_usage_payload() payload = settings_usage_payload()
@@ -1930,9 +1942,7 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
) )
assert exc.value.status == 502 assert exc.value.status == 502
assert str(exc.value) == ( assert str(exc.value) == ("xAI OAuth login failed: Could not reach xAI sign-in: ConnectError.")
"xAI OAuth login failed: Could not reach xAI sign-in: ConnectError."
)
assert exc.value.__cause__ is failure assert exc.value.__cause__ is failure
@@ -2003,7 +2013,7 @@ def test_provider_models_payload_returns_online_openai_codex_models(
"nanobot.webui.settings_models.get_oauth_model_catalog", "nanobot.webui.settings_models.get_oauth_model_catalog",
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot( lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
models=( models=(
OAuthModelInfo( ProviderModelSpec(
id="openai-codex/gpt-5.6-sol", id="openai-codex/gpt-5.6-sol",
label="GPT-5.6-Sol", label="GPT-5.6-Sol",
description="Latest frontier agentic coding model.", description="Latest frontier agentic coding model.",
@@ -2041,7 +2051,7 @@ def test_provider_models_payload_returns_online_github_copilot_models(
"nanobot.webui.settings_models.get_oauth_model_catalog", "nanobot.webui.settings_models.get_oauth_model_catalog",
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot( lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
models=( models=(
OAuthModelInfo( ProviderModelSpec(
id="github-copilot/claude-sonnet", id="github-copilot/claude-sonnet",
label="Claude Sonnet", label="Claude Sonnet",
owned_by="GitHub Copilot", owned_by="GitHub Copilot",
@@ -2068,7 +2078,7 @@ def test_provider_models_payload_returns_online_xai_grok_models(
"nanobot.webui.settings_models.get_oauth_model_catalog", "nanobot.webui.settings_models.get_oauth_model_catalog",
lambda *_args, **_kwargs: OAuthModelCatalogSnapshot( lambda *_args, **_kwargs: OAuthModelCatalogSnapshot(
models=( models=(
OAuthModelInfo( ProviderModelSpec(
id="xai-grok/grok-4.6", id="xai-grok/grok-4.6",
label="Grok 4.6", label="Grok 4.6",
description="Latest frontier model", description="Latest frontier model",
@@ -2077,7 +2087,7 @@ def test_provider_models_payload_returns_online_xai_grok_models(
reasoning_efforts=("xhigh", "high", "medium", "low"), reasoning_efforts=("xhigh", "high", "medium", "low"),
supports_backend_search=True, supports_backend_search=True,
), ),
OAuthModelInfo( ProviderModelSpec(
id="xai-grok/grok-4.5", id="xai-grok/grok-4.5",
label="Grok 4.5", label="Grok 4.5",
owned_by="xAI", owned_by="xAI",
@@ -2115,7 +2125,7 @@ def test_provider_models_payload_returns_online_xai_grok_models(
"context_window": 500000, "context_window": 500000,
"reasoning_efforts": ["high", "medium", "low"], "reasoning_efforts": ["high", "medium", "low"],
"supports_backend_search": True, "supports_backend_search": True,
} },
] ]