fix(providers): complete OAuth model discovery

This commit is contained in:
Xubin Ren
2026-08-29 21:22:20 +08:00
parent bc4de246a4
commit 97fb9aaf72
7 changed files with 691 additions and 96 deletions
+9
View File
@@ -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"
+9
View File
@@ -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
+388 -44
View File
@@ -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,
}
+18 -39
View File
@@ -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,
+2
View File
@@ -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)
+209 -3
View File
@@ -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
+56 -10
View File
@@ -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(