diff --git a/docs/configuration.md b/docs/configuration.md index 34921d040..28bd9bb84 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -729,6 +729,11 @@ Then run: nanobot agent -m "Hello!" ``` +The WebUI model selector loads the models available to the signed-in account +from Codex's online catalog. Context-window and reasoning-effort metadata come +from that response; if discovery is unavailable, nanobot keeps a small built-in +fallback instead of emptying the selector. + Codex Fast mode can be enabled from the WebUI provider settings, or with: ```json @@ -808,6 +813,10 @@ a nanobot update. GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config. +After login, the WebUI loads the account-specific Copilot model catalog online. +Only models compatible with nanobot's current Copilot chat-completions transport +are shown; Responses-only entries are intentionally omitted. + For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login: ```bash export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id" diff --git a/docs/providers.md b/docs/providers.md index 404070187..ef012d88c 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -572,6 +572,10 @@ For OpenAI Codex: nanobot provider login openai-codex --set-main ``` +The WebUI reads the account's Codex model catalog online, including current +context-window and reasoning-effort metadata. A small compatible catalog remains +available when the service cannot be reached. + For an eligible X Premium / Grok subscription: ```bash @@ -603,6 +607,11 @@ For GitHub Copilot: nanobot provider login github-copilot --set-main ``` +The WebUI reads the models enabled for the signed-in Copilot account. nanobot +currently lists only entries that support Copilot's chat-completions endpoint; +models exposed solely through the Responses endpoint stay hidden until that +wire protocol is supported by the Copilot provider. + Each command authenticates the selected provider and makes its current default model active. OpenAI Codex and eligible GitHub Copilot models participate in [Responses state retention](./configuration.md#responses-state-and-compaction), while native compaction remains provider-capability-specific. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors. ## Provider Resolution diff --git a/nanobot/providers/oauth_model_catalog.py b/nanobot/providers/oauth_model_catalog.py index eb29b8745..f02fa33fa 100644 --- a/nanobot/providers/oauth_model_catalog.py +++ b/nanobot/providers/oauth_model_catalog.py @@ -1,9 +1,14 @@ """Online model discovery for OAuth providers with bounded local fallback.""" +# oauth-cli-kit does not publish type stubs. +# pyright: reportMissingTypeStubs=false + from __future__ import annotations import base64 +import hashlib import json +import os import threading import time from collections.abc import Callable, Sequence @@ -18,6 +23,8 @@ from nanobot import __version__ DEFAULT_XAI_GROK_MODEL = "xai-grok/grok-4.6" DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models" +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"] @@ -98,49 +105,61 @@ class OAuthModelCatalog: self._entries: dict[str, _CacheEntry] = {} self._failures: dict[str, float] = {} self._inflight: set[str] = set() + self._generation = 0 def get(self, *, cache_key: str, proxy: str | None = None) -> OAuthModelCatalogSnapshot: """Return a fresh catalog, sharing concurrent work and failing to a usable list.""" - with self._condition: - cached = self._cached_result(cache_key) - if cached is not None: - return cached - while cache_key in self._inflight: - self._condition.wait() + while True: + with self._condition: cached = self._cached_result(cache_key) if cached is not None: return cached - self._inflight.add(cache_key) + while cache_key in self._inflight: + self._condition.wait() + cached = self._cached_result(cache_key) + if cached is not None: + return cached + generation = self._generation + self._inflight.add(cache_key) - try: - models = tuple(self._fetch(proxy)) - if not models: - raise ValueError("provider returned an empty model catalog") - except Exception as exc: - logger.warning( - "OAuth model catalog refresh failed: type={}", - type(exc).__name__, - ) - result = self._failure_result(cache_key) - else: - now = self._monotonic() - result = OAuthModelCatalogSnapshot( - models=models, - source="remote", - fetched_at=self._wall_clock(), - ) - with self._condition: - self._store(cache_key, _CacheEntry(snapshot=result, stored_at=now)) - self._failures.pop(cache_key, None) - finally: - with self._condition: - self._inflight.discard(cache_key) - self._condition.notify_all() - return result + try: + models = tuple(self._fetch(proxy)) + if not models: + raise ValueError("provider returned an empty model catalog") + except Exception as exc: + logger.warning( + "OAuth model catalog refresh failed: type={}", + type(exc).__name__, + ) + with self._condition: + invalidated = generation != self._generation + result = self._failure_result(cache_key) if not invalidated else None + else: + now = self._monotonic() + result = OAuthModelCatalogSnapshot( + models=models, + source="remote", + fetched_at=self._wall_clock(), + ) + with self._condition: + invalidated = generation != self._generation + if not invalidated: + self._store(cache_key, _CacheEntry(snapshot=result, stored_at=now)) + self._failures.pop(cache_key, None) + finally: + with self._condition: + self._inflight.discard(cache_key) + self._condition.notify_all() + + if invalidated: + continue + assert result is not None + return result def invalidate(self) -> None: - """Drop cached and negative results, for example after account changes.""" + """Drop cached work and prevent an older account refresh from being stored.""" with self._condition: + self._generation += 1 self._entries.clear() self._failures.clear() @@ -155,10 +174,9 @@ class OAuthModelCatalog: return None def _failure_result(self, cache_key: str) -> OAuthModelCatalogSnapshot: - with self._condition: - now = self._monotonic() - self._failures[cache_key] = now + self._failure_ttl_s - return self._stale_or_fallback(self._entries.get(cache_key), now) + now = self._monotonic() + self._failures[cache_key] = now + self._failure_ttl_s + return self._stale_or_fallback(self._entries.get(cache_key), now) def _stale_or_fallback( self, @@ -200,11 +218,83 @@ _CURATED_XAI_GROK_MODELS = ( ), ) +_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 () @@ -214,16 +304,203 @@ def get_oauth_model_catalog( proxy: str | None = None, ) -> OAuthModelCatalogSnapshot: """Discover models for a supported OAuth provider.""" - if provider_name != "xai_grok": - raise ValueError(f"OAuth model discovery is not available for {provider_name}") - cache_key = f"{_xai_oauth_storage_path()}\0{proxy or ''}" - return _XAI_GROK_CATALOG.get(cache_key=cache_key, proxy=proxy) + 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": + cache_key = ( + f"{_openai_codex_storage_path()}\0{_openai_codex_account_key()}\0{proxy or ''}" + ) + return _OPENAI_CODEX_CATALOG.get(cache_key=cache_key, proxy=proxy) + if provider_name == "github_copilot": + cache_key = ( + f"{_github_copilot_storage_path()}\0{_github_copilot_account_key()}\0" + f"{_github_copilot_models_url()}" + ) + return _GITHUB_COPILOT_CATALOG.get(cache_key=cache_key, proxy=proxy) + raise ValueError(f"OAuth model discovery is not available for {provider_name}") def invalidate_oauth_model_catalog(provider_name: str) -> None: """Invalidate provider discovery after OAuth identity changes.""" - if provider_name == "xai_grok": - _XAI_GROK_CATALOG.invalidate() + catalog = _OAUTH_CATALOGS.get(provider_name) + if catalog is not None: + catalog.invalidate() + + +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()) + + +def _parse_openai_codex_models(payload: Any) -> tuple[OAuthModelInfo, ...]: + rows = cast(dict[str, Any], payload).get("models") if isinstance(payload, dict) else None + 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 ( + DEFAULT_COPILOT_TOKEN_URL, + EDITOR_PLUGIN_VERSION, + EDITOR_VERSION, + USER_AGENT, + get_storage, + ) + + 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( + 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, ...]: @@ -334,6 +611,10 @@ def _first_text(row: dict[str, Any], *keys: str) -> str: 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) @@ -361,7 +642,7 @@ def _reasoning_efforts(value: Any) -> tuple[str, ...]: if isinstance(item, str): effort = item.strip() elif isinstance(item, dict): - effort = _first_text(cast(dict[str, Any], item), "value", "id") + effort = _first_text(cast(dict[str, Any], item), "effort", "value", "id") else: effort = "" if effort and effort not in efforts: @@ -375,6 +656,56 @@ def _xai_oauth_storage_path() -> 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 @@ -423,3 +754,16 @@ _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, +} diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 5865c0d76..41d7c1261 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -409,46 +409,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("openai-codex",), env_key="", display_name="OpenAI Codex", - model_catalog="builtin", - builtin_models=( + model_catalog="hybrid", + builtin_models=tuple( ProviderModelSpec( - id="openai-codex/gpt-5.6-sol", - label="GPT-5.6-Sol", - description="Latest frontier agentic coding model.", - context_window=372000, - ), - ProviderModelSpec( - id="openai-codex/gpt-5.6-terra", - label="GPT-5.6-Terra", - description="Balanced agentic coding model for everyday work.", - context_window=372000, - ), - ProviderModelSpec( - id="openai-codex/gpt-5.6-luna", - label="GPT-5.6-Luna", - description="Fast and affordable agentic coding model.", - context_window=372000, - ), - ProviderModelSpec( - id="openai-codex/gpt-5.5", - label="GPT-5.5", - description="Frontier model for complex coding, research, and real-world work.", - ), - ProviderModelSpec( - id="openai-codex/gpt-5.4", - label="GPT-5.4", - description="Strong model for everyday coding.", - ), - ProviderModelSpec( - id="openai-codex/gpt-5.4-mini", - label="GPT-5.4-Mini", - description="Small, fast, and cost-efficient model for simpler coding tasks.", - ), - ProviderModelSpec( - id="openai-codex/gpt-5.3-codex-spark", - label="GPT-5.3-Codex-Spark", - description="Ultra-fast coding model.", - ), + id=model.id, + label=model.label, + description=model.description, + context_window=model.context_window, + ) + for model in curated_oauth_models("openai_codex") ), backend="openai_codex", detect_by_base_keyword="codex", @@ -481,6 +450,16 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("github_copilot", "copilot"), env_key="", display_name="Github Copilot", + model_catalog="hybrid", + builtin_models=tuple( + ProviderModelSpec( + id=model.id, + label=model.label, + description=model.description, + context_window=model.context_window, + ) + for model in curated_oauth_models("github_copilot") + ), backend="github_copilot", default_api_base="https://api.githubcopilot.com", strip_model_prefix=True, diff --git a/nanobot/webui/settings_models.py b/nanobot/webui/settings_models.py index 4b85717d5..42b44882f 100644 --- a/nanobot/webui/settings_models.py +++ b/nanobot/webui/settings_models.py @@ -1534,6 +1534,7 @@ def login_oauth_provider( token = login_github_copilot(print_fn=lambda _message: None) if not (token and token.access): raise WebUISettingsError("OAuth login failed", status=401) + invalidate_oauth_model_catalog(spec.name) return settings_payload(config_path=config_path) if spec.name == "xai_grok": @@ -1666,6 +1667,7 @@ def logout_oauth_provider( for path in (token_path, token_path.with_suffix(".lock")): with suppress(FileNotFoundError): path.unlink() + invalidate_oauth_model_catalog(spec.name) return settings_payload(config_path=config_path) diff --git a/tests/providers/test_oauth_model_catalog.py b/tests/providers/test_oauth_model_catalog.py index f7c3d435c..23541d71e 100644 --- a/tests/providers/test_oauth_model_catalog.py +++ b/tests/providers/test_oauth_model_catalog.py @@ -6,12 +6,15 @@ import threading import time from concurrent.futures import ThreadPoolExecutor from pathlib import Path +from types import SimpleNamespace import httpx import pytest from nanobot.providers.oauth_model_catalog import ( + DEFAULT_OPENAI_CODEX_MODELS_URL, DEFAULT_XAI_GROK_MODELS_URL, + OPENAI_CODEX_CATALOG_CLIENT_VERSION, OAuthModelCatalog, OAuthModelInfo, get_oauth_model_catalog, @@ -21,10 +24,12 @@ from nanobot.providers.xai_oauth import XAIToken @pytest.fixture(autouse=True) -def _clear_xai_catalog() -> None: - invalidate_oauth_model_catalog("xai_grok") +def _clear_oauth_catalogs() -> None: + for provider in ("openai_codex", "xai_grok", "github_copilot"): + invalidate_oauth_model_catalog(provider) yield - invalidate_oauth_model_catalog("xai_grok") + for provider in ("openai_codex", "xai_grok", "github_copilot"): + invalidate_oauth_model_catalog(provider) def _fallback_model() -> OAuthModelInfo: @@ -127,6 +132,180 @@ def test_xai_catalog_fetches_remote_models_and_reuses_capability_metadata( assert get_oauth_model_catalog("xai_grok").source == "cache" +def test_openai_codex_catalog_uses_account_catalog_and_filters_hidden_models( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + original_client = httpx.Client + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response( + 200, + json={ + "models": [ + { + "slug": "gpt-new", + "display_name": "GPT New", + "description": "New model", + "context_window": 300_000, + "priority": 2, + "visibility": "list", + "supported_reasoning_levels": [ + {"effort": "low"}, + {"effort": "high"}, + ], + }, + { + "slug": "gpt-first", + "display_name": "GPT First", + "priority": 1, + }, + { + "slug": "internal-model", + "display_name": "Internal", + "visibility": "hide", + "priority": 0, + }, + ] + }, + request=request, + ) + + def fake_client(**kwargs: object) -> httpx.Client: + captured["kwargs"] = kwargs + return original_client( + transport=httpx.MockTransport(handler), + timeout=kwargs["timeout"], + follow_redirects=kwargs["follow_redirects"], + ) + + monkeypatch.setattr( + "nanobot.providers.oauth_model_catalog._openai_codex_storage_path", + lambda: tmp_path / "auth" / "openai-codex.json", + ) + monkeypatch.setattr( + "nanobot.providers.oauth_model_catalog._openai_codex_account_key", + lambda: "account-key", + ) + monkeypatch.setattr( + "oauth_cli_kit.get_token", + lambda **_kwargs: SimpleNamespace(access="secret", account_id="account-42"), + ) + monkeypatch.setattr("nanobot.providers.oauth_model_catalog.httpx.Client", fake_client) + + catalog = get_oauth_model_catalog("openai_codex") + + assert catalog.source == "remote" + assert [model.id for model in catalog.models] == [ + "openai-codex/gpt-first", + "openai-codex/gpt-new", + ] + assert catalog.models[1].context_window == 300_000 + assert catalog.models[1].reasoning_efforts == ("low", "high") + request = captured["request"] + assert isinstance(request, httpx.Request) + assert request.url.copy_with(query=None) == httpx.URL(DEFAULT_OPENAI_CODEX_MODELS_URL) + assert request.url.params["client_version"] == OPENAI_CODEX_CATALOG_CLIENT_VERSION + assert request.headers["Authorization"] == "Bearer secret" + assert request.headers["chatgpt-account-id"] == "account-42" + + +def test_github_copilot_catalog_only_lists_compatible_chat_models( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + original_client = httpx.Client + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + if request.url.path.endswith("/copilot_internal/v2/token"): + return httpx.Response( + 200, + json={ + "token": "copilot-secret", + "endpoints": {"api": "https://api.individual.githubcopilot.com"}, + }, + request=request, + ) + return httpx.Response( + 200, + json={ + "data": [ + { + "id": "claude-sonnet", + "name": "Claude Sonnet", + "model_picker_enabled": True, + "policy": {"state": "enabled"}, + "supported_endpoints": ["/chat/completions"], + "capabilities": { + "supports": {"reasoning_effort": ["low", "high"]}, + "limits": {"max_context_window_tokens": 200_000}, + }, + }, + { + "id": "responses-only", + "name": "Responses only", + "model_picker_enabled": True, + "supported_endpoints": ["/responses"], + }, + { + "id": "disabled", + "model_picker_enabled": True, + "policy": {"state": "disabled"}, + "supported_endpoints": ["/chat/completions"], + }, + ] + }, + request=request, + ) + + def fake_client(**kwargs: object) -> httpx.Client: + return original_client( + transport=httpx.MockTransport(handler), + timeout=kwargs["timeout"], + follow_redirects=kwargs["follow_redirects"], + ) + + class Storage: + def load(self) -> SimpleNamespace: + return SimpleNamespace(access="github-secret", account_id="octocat") + + def get_token_path(self) -> Path: + 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( + "nanobot.providers.github_copilot_provider.get_storage", + lambda: Storage(), + ) + monkeypatch.setattr("nanobot.providers.oauth_model_catalog.httpx.Client", fake_client) + + catalog = get_oauth_model_catalog("github_copilot") + + assert catalog.source == "remote" + assert [model.id for model in catalog.models] == ["github-copilot/claude-sonnet"] + assert catalog.models[0].context_window == 200_000 + assert catalog.models[0].reasoning_efforts == ("low", "high") + assert len(captured) == 2 + assert captured[0].headers["Authorization"] == "token github-secret" + assert captured[1].headers["Authorization"] == "Bearer copilot-secret" + assert str(captured[1].url) == "https://api.individual.githubcopilot.com/models" + + def test_catalog_single_flights_concurrent_refreshes() -> None: calls = 0 calls_lock = threading.Lock() @@ -154,6 +333,33 @@ def test_catalog_single_flights_concurrent_refreshes() -> None: assert [result.source for result in results].count("cache") == 7 +def test_catalog_invalidation_discards_an_inflight_account_refresh() -> None: + started = threading.Event() + release = threading.Event() + calls = 0 + + def fetch(_proxy: str | None) -> tuple[OAuthModelInfo, ...]: + nonlocal calls + calls += 1 + if calls == 1: + started.set() + assert release.wait(timeout=2) + return (OAuthModelInfo(id="provider/old-account", label="Old"),) + return (OAuthModelInfo(id="provider/new-account", label="New"),) + + catalog = OAuthModelCatalog(fallback_models=(_fallback_model(),), fetch=fetch) + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(catalog.get, cache_key="shared") + assert started.wait(timeout=2) + catalog.invalidate() + release.set() + result = future.result(timeout=2) + + assert calls == 2 + assert result.models[0].id == "provider/new-account" + assert catalog.get(cache_key="shared").models[0].id == "provider/new-account" + + def test_catalog_returns_stale_then_negative_caches_refresh_failure() -> None: now = [0.0] calls = 0 diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index edfbdfc6c..5714adab8 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -1996,24 +1996,69 @@ def test_provider_models_payload_fetches_openai_compatible_models( assert payload["models"][1]["context_window"] == 65536 -def test_provider_models_payload_returns_curated_openai_codex_models() -> None: +def test_provider_models_payload_returns_online_openai_codex_models( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "nanobot.webui.settings_models.get_oauth_model_catalog", + lambda *_args, **_kwargs: OAuthModelCatalogSnapshot( + 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"), + ), + ), + source="remote", + fetched_at=123, + ), + ) + payload = provider_models_payload({"provider": ["openai_codex"]}) assert payload["status"] == "available" - assert payload["catalog_kind"] == "builtin" - assert payload["model_count"] == 7 + assert payload["catalog_kind"] == "hybrid" + assert payload["source"] == "remote" + assert payload["model_count"] == 1 assert payload["models"][0] == { "id": "openai-codex/gpt-5.6-sol", "label": "GPT-5.6-Sol", "description": "Latest frontier agentic coding model.", "owned_by": "OpenAI Codex", - "context_window": 372000, + "context_window": 272000, + "reasoning_efforts": ["low", "medium", "high", "xhigh", "max", "ultra"], + "supports_backend_search": False, } - assert [model["id"] for model in payload["models"][:3]] == [ - "openai-codex/gpt-5.6-sol", - "openai-codex/gpt-5.6-terra", - "openai-codex/gpt-5.6-luna", - ] + + +def test_provider_models_payload_returns_online_github_copilot_models( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "nanobot.webui.settings_models.get_oauth_model_catalog", + lambda *_args, **_kwargs: OAuthModelCatalogSnapshot( + models=( + OAuthModelInfo( + id="github-copilot/claude-sonnet", + label="Claude Sonnet", + owned_by="GitHub Copilot", + context_window=200_000, + ), + ), + source="remote", + fetched_at=123, + ), + ) + + payload = provider_models_payload({"provider": ["github_copilot"]}) + + assert payload["status"] == "available" + assert payload["catalog_kind"] == "hybrid" + assert payload["source"] == "remote" + assert payload["models"][0]["id"] == "github-copilot/claude-sonnet" def test_provider_models_payload_returns_online_xai_grok_models( @@ -2203,8 +2248,9 @@ def test_model_catalog_kind_uses_provider_spec_metadata() -> None: assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported" assert _model_catalog_kind(find_by_name("openrouter")) == "catalog" assert _model_catalog_kind(find_by_name("orcarouter")) == "catalog" - assert _model_catalog_kind(find_by_name("openai_codex")) == "builtin" + assert _model_catalog_kind(find_by_name("openai_codex")) == "hybrid" assert _model_catalog_kind(find_by_name("xai_grok")) == "hybrid" + assert _model_catalog_kind(find_by_name("github_copilot")) == "hybrid" def test_create_model_configuration_accepts_configured_oauth_provider(