mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
fix(providers): complete OAuth model discovery
This commit is contained in:
@@ -729,6 +729,11 @@ Then run:
|
|||||||
nanobot agent -m "Hello!"
|
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:
|
Codex Fast mode can be enabled from the WebUI provider settings, or with:
|
||||||
|
|
||||||
```json
|
```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.
|
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:
|
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
|
||||||
```bash
|
```bash
|
||||||
export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id"
|
export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id"
|
||||||
|
|||||||
@@ -572,6 +572,10 @@ For OpenAI Codex:
|
|||||||
nanobot provider login openai-codex --set-main
|
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:
|
For an eligible X Premium / Grok subscription:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -603,6 +607,11 @@ For GitHub Copilot:
|
|||||||
nanobot provider login github-copilot --set-main
|
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.
|
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
|
## Provider Resolution
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"""Online model discovery for OAuth providers with bounded local fallback."""
|
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable, Sequence
|
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_MODEL = "xai-grok/grok-4.6"
|
||||||
DEFAULT_XAI_GROK_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models"
|
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"]
|
||||||
|
|
||||||
@@ -98,49 +105,61 @@ class OAuthModelCatalog:
|
|||||||
self._entries: dict[str, _CacheEntry] = {}
|
self._entries: dict[str, _CacheEntry] = {}
|
||||||
self._failures: dict[str, float] = {}
|
self._failures: dict[str, float] = {}
|
||||||
self._inflight: set[str] = set()
|
self._inflight: set[str] = set()
|
||||||
|
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 failing to a usable list."""
|
||||||
with self._condition:
|
while True:
|
||||||
cached = self._cached_result(cache_key)
|
with self._condition:
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
while cache_key in self._inflight:
|
|
||||||
self._condition.wait()
|
|
||||||
cached = self._cached_result(cache_key)
|
cached = self._cached_result(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
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:
|
try:
|
||||||
models = tuple(self._fetch(proxy))
|
models = tuple(self._fetch(proxy))
|
||||||
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={}",
|
"OAuth model catalog refresh failed: type={}",
|
||||||
type(exc).__name__,
|
type(exc).__name__,
|
||||||
)
|
)
|
||||||
result = self._failure_result(cache_key)
|
with self._condition:
|
||||||
else:
|
invalidated = generation != self._generation
|
||||||
now = self._monotonic()
|
result = self._failure_result(cache_key) if not invalidated else None
|
||||||
result = OAuthModelCatalogSnapshot(
|
else:
|
||||||
models=models,
|
now = self._monotonic()
|
||||||
source="remote",
|
result = OAuthModelCatalogSnapshot(
|
||||||
fetched_at=self._wall_clock(),
|
models=models,
|
||||||
)
|
source="remote",
|
||||||
with self._condition:
|
fetched_at=self._wall_clock(),
|
||||||
self._store(cache_key, _CacheEntry(snapshot=result, stored_at=now))
|
)
|
||||||
self._failures.pop(cache_key, None)
|
with self._condition:
|
||||||
finally:
|
invalidated = generation != self._generation
|
||||||
with self._condition:
|
if not invalidated:
|
||||||
self._inflight.discard(cache_key)
|
self._store(cache_key, _CacheEntry(snapshot=result, stored_at=now))
|
||||||
self._condition.notify_all()
|
self._failures.pop(cache_key, None)
|
||||||
return result
|
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:
|
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:
|
with self._condition:
|
||||||
|
self._generation += 1
|
||||||
self._entries.clear()
|
self._entries.clear()
|
||||||
self._failures.clear()
|
self._failures.clear()
|
||||||
|
|
||||||
@@ -155,10 +174,9 @@ class OAuthModelCatalog:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _failure_result(self, cache_key: str) -> OAuthModelCatalogSnapshot:
|
def _failure_result(self, cache_key: str) -> OAuthModelCatalogSnapshot:
|
||||||
with self._condition:
|
now = self._monotonic()
|
||||||
now = self._monotonic()
|
self._failures[cache_key] = now + self._failure_ttl_s
|
||||||
self._failures[cache_key] = now + self._failure_ttl_s
|
return self._stale_or_fallback(self._entries.get(cache_key), now)
|
||||||
return self._stale_or_fallback(self._entries.get(cache_key), now)
|
|
||||||
|
|
||||||
def _stale_or_fallback(
|
def _stale_or_fallback(
|
||||||
self,
|
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, ...]:
|
def curated_oauth_models(provider_name: str) -> tuple[OAuthModelInfo, ...]:
|
||||||
"""Return stable metadata used only to enrich or backstop online discovery."""
|
"""Return stable metadata used only to enrich or backstop online discovery."""
|
||||||
if provider_name == "xai_grok":
|
if provider_name == "xai_grok":
|
||||||
return _CURATED_XAI_GROK_MODELS
|
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 ()
|
return ()
|
||||||
|
|
||||||
|
|
||||||
@@ -214,16 +304,203 @@ def get_oauth_model_catalog(
|
|||||||
proxy: str | None = None,
|
proxy: str | None = None,
|
||||||
) -> OAuthModelCatalogSnapshot:
|
) -> OAuthModelCatalogSnapshot:
|
||||||
"""Discover models for a supported OAuth provider."""
|
"""Discover models for a supported OAuth provider."""
|
||||||
if provider_name != "xai_grok":
|
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{_xai_account_key()}\0{proxy or ''}"
|
||||||
cache_key = f"{_xai_oauth_storage_path()}\0{proxy or ''}"
|
return _XAI_GROK_CATALOG.get(cache_key=cache_key, proxy=proxy)
|
||||||
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:
|
def invalidate_oauth_model_catalog(provider_name: str) -> None:
|
||||||
"""Invalidate provider discovery after OAuth identity changes."""
|
"""Invalidate provider discovery after OAuth identity changes."""
|
||||||
if provider_name == "xai_grok":
|
catalog = _OAUTH_CATALOGS.get(provider_name)
|
||||||
_XAI_GROK_CATALOG.invalidate()
|
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, ...]:
|
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 ""
|
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:
|
def _positive_int(row: dict[str, Any], *keys: str) -> int | None:
|
||||||
for key in keys:
|
for key in keys:
|
||||||
value = row.get(key)
|
value = row.get(key)
|
||||||
@@ -361,7 +642,7 @@ def _reasoning_efforts(value: Any) -> tuple[str, ...]:
|
|||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
effort = item.strip()
|
effort = item.strip()
|
||||||
elif isinstance(item, dict):
|
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:
|
else:
|
||||||
effort = ""
|
effort = ""
|
||||||
if effort and effort not in efforts:
|
if effort and effort not in efforts:
|
||||||
@@ -375,6 +656,56 @@ def _xai_oauth_storage_path() -> Path:
|
|||||||
return 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:
|
def _xai_oauth_token(proxy: str | None) -> _XAIToken:
|
||||||
from nanobot.providers.xai_oauth import get_xai_oauth_token
|
from nanobot.providers.xai_oauth import get_xai_oauth_token
|
||||||
|
|
||||||
@@ -423,3 +754,16 @@ _XAI_GROK_CATALOG = OAuthModelCatalog(
|
|||||||
fallback_models=_CURATED_XAI_GROK_MODELS,
|
fallback_models=_CURATED_XAI_GROK_MODELS,
|
||||||
fetch=_fetch_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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -409,46 +409,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
keywords=("openai-codex",),
|
keywords=("openai-codex",),
|
||||||
env_key="",
|
env_key="",
|
||||||
display_name="OpenAI Codex",
|
display_name="OpenAI Codex",
|
||||||
model_catalog="builtin",
|
model_catalog="hybrid",
|
||||||
builtin_models=(
|
builtin_models=tuple(
|
||||||
ProviderModelSpec(
|
ProviderModelSpec(
|
||||||
id="openai-codex/gpt-5.6-sol",
|
id=model.id,
|
||||||
label="GPT-5.6-Sol",
|
label=model.label,
|
||||||
description="Latest frontier agentic coding model.",
|
description=model.description,
|
||||||
context_window=372000,
|
context_window=model.context_window,
|
||||||
),
|
)
|
||||||
ProviderModelSpec(
|
for model in curated_oauth_models("openai_codex")
|
||||||
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.",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
backend="openai_codex",
|
backend="openai_codex",
|
||||||
detect_by_base_keyword="codex",
|
detect_by_base_keyword="codex",
|
||||||
@@ -481,6 +450,16 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
keywords=("github_copilot", "copilot"),
|
keywords=("github_copilot", "copilot"),
|
||||||
env_key="",
|
env_key="",
|
||||||
display_name="Github Copilot",
|
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",
|
backend="github_copilot",
|
||||||
default_api_base="https://api.githubcopilot.com",
|
default_api_base="https://api.githubcopilot.com",
|
||||||
strip_model_prefix=True,
|
strip_model_prefix=True,
|
||||||
|
|||||||
@@ -1534,6 +1534,7 @@ def login_oauth_provider(
|
|||||||
token = login_github_copilot(print_fn=lambda _message: None)
|
token = login_github_copilot(print_fn=lambda _message: None)
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError("OAuth login failed", status=401)
|
||||||
|
invalidate_oauth_model_catalog(spec.name)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
if spec.name == "xai_grok":
|
if spec.name == "xai_grok":
|
||||||
@@ -1666,6 +1667,7 @@ def logout_oauth_provider(
|
|||||||
for path in (token_path, token_path.with_suffix(".lock")):
|
for path in (token_path, token_path.with_suffix(".lock")):
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
path.unlink()
|
path.unlink()
|
||||||
|
invalidate_oauth_model_catalog(spec.name)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import httpx
|
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,
|
DEFAULT_XAI_GROK_MODELS_URL,
|
||||||
|
OPENAI_CODEX_CATALOG_CLIENT_VERSION,
|
||||||
OAuthModelCatalog,
|
OAuthModelCatalog,
|
||||||
OAuthModelInfo,
|
OAuthModelInfo,
|
||||||
get_oauth_model_catalog,
|
get_oauth_model_catalog,
|
||||||
@@ -21,10 +24,12 @@ from nanobot.providers.xai_oauth import XAIToken
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _clear_xai_catalog() -> None:
|
def _clear_oauth_catalogs() -> None:
|
||||||
invalidate_oauth_model_catalog("xai_grok")
|
for provider in ("openai_codex", "xai_grok", "github_copilot"):
|
||||||
|
invalidate_oauth_model_catalog(provider)
|
||||||
yield
|
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:
|
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"
|
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:
|
def test_catalog_single_flights_concurrent_refreshes() -> None:
|
||||||
calls = 0
|
calls = 0
|
||||||
calls_lock = threading.Lock()
|
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
|
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:
|
def test_catalog_returns_stale_then_negative_caches_refresh_failure() -> None:
|
||||||
now = [0.0]
|
now = [0.0]
|
||||||
calls = 0
|
calls = 0
|
||||||
|
|||||||
@@ -1996,24 +1996,69 @@ def test_provider_models_payload_fetches_openai_compatible_models(
|
|||||||
assert payload["models"][1]["context_window"] == 65536
|
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"]})
|
payload = provider_models_payload({"provider": ["openai_codex"]})
|
||||||
|
|
||||||
assert payload["status"] == "available"
|
assert payload["status"] == "available"
|
||||||
assert payload["catalog_kind"] == "builtin"
|
assert payload["catalog_kind"] == "hybrid"
|
||||||
assert payload["model_count"] == 7
|
assert payload["source"] == "remote"
|
||||||
|
assert payload["model_count"] == 1
|
||||||
assert payload["models"][0] == {
|
assert payload["models"][0] == {
|
||||||
"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.",
|
||||||
"owned_by": "OpenAI Codex",
|
"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",
|
def test_provider_models_payload_returns_online_github_copilot_models(
|
||||||
"openai-codex/gpt-5.6-luna",
|
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(
|
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("anthropic")) == "unsupported"
|
||||||
assert _model_catalog_kind(find_by_name("openrouter")) == "catalog"
|
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("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("xai_grok")) == "hybrid"
|
||||||
|
assert _model_catalog_kind(find_by_name("github_copilot")) == "hybrid"
|
||||||
|
|
||||||
|
|
||||||
def test_create_model_configuration_accepts_configured_oauth_provider(
|
def test_create_model_configuration_accepts_configured_oauth_provider(
|
||||||
|
|||||||
Reference in New Issue
Block a user