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