177 lines
5.7 KiB
Python
177 lines
5.7 KiB
Python
"""Codex model discovery from API, local cache, and config."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
import os
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_CODEX_MODELS: List[str] = [
|
|
"gpt-5.4-mini",
|
|
"gpt-5.4",
|
|
"gpt-5.3-codex",
|
|
"gpt-5.2-codex",
|
|
"gpt-5.1-codex-max",
|
|
"gpt-5.1-codex-mini",
|
|
]
|
|
|
|
_FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
|
|
("gpt-5.4-mini", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
|
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
|
("gpt-5.3-codex", ("gpt-5.2-codex",)),
|
|
("gpt-5.3-codex-spark", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
|
]
|
|
|
|
|
|
def _add_forward_compat_models(model_ids: List[str]) -> List[str]:
|
|
"""Add Clawdbot-style synthetic forward-compat Codex models.
|
|
|
|
If a newer Codex slug isn't returned by live discovery, surface it when an
|
|
older compatible template model is present. This mirrors Clawdbot's
|
|
synthetic catalog / forward-compat behavior for GPT-5 Codex variants.
|
|
"""
|
|
ordered: List[str] = []
|
|
seen: set[str] = set()
|
|
for model_id in model_ids:
|
|
if model_id not in seen:
|
|
ordered.append(model_id)
|
|
seen.add(model_id)
|
|
|
|
for synthetic_model, template_models in _FORWARD_COMPAT_TEMPLATE_MODELS:
|
|
if synthetic_model in seen:
|
|
continue
|
|
if any(template in seen for template in template_models):
|
|
ordered.append(synthetic_model)
|
|
seen.add(synthetic_model)
|
|
|
|
return ordered
|
|
|
|
|
|
def _fetch_models_from_api(access_token: str) -> List[str]:
|
|
"""Fetch available models from the Codex API. Returns visible models sorted by priority."""
|
|
try:
|
|
import httpx
|
|
resp = httpx.get(
|
|
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
|
|
headers={"Authorization": f"Bearer {access_token}"},
|
|
timeout=10,
|
|
)
|
|
if resp.status_code != 200:
|
|
return []
|
|
data = resp.json()
|
|
entries = data.get("models", []) if isinstance(data, dict) else []
|
|
except Exception as exc:
|
|
logger.debug("Failed to fetch Codex models from API: %s", exc)
|
|
return []
|
|
|
|
sortable = []
|
|
for item in entries:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
slug = item.get("slug")
|
|
if not isinstance(slug, str) or not slug.strip():
|
|
continue
|
|
slug = slug.strip()
|
|
if item.get("supported_in_api") is False:
|
|
continue
|
|
visibility = item.get("visibility", "")
|
|
if isinstance(visibility, str) and visibility.strip().lower() in ("hide", "hidden"):
|
|
continue
|
|
priority = item.get("priority")
|
|
rank = int(priority) if isinstance(priority, (int, float)) else 10_000
|
|
sortable.append((rank, slug))
|
|
|
|
sortable.sort(key=lambda x: (x[0], x[1]))
|
|
return _add_forward_compat_models([slug for _, slug in sortable])
|
|
|
|
|
|
def _read_default_model(codex_home: Path) -> Optional[str]:
|
|
config_path = codex_home / "config.toml"
|
|
if not config_path.exists():
|
|
return None
|
|
try:
|
|
import tomllib
|
|
except Exception:
|
|
return None
|
|
try:
|
|
payload = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return None
|
|
model = payload.get("model") if isinstance(payload, dict) else None
|
|
if isinstance(model, str) and model.strip():
|
|
return model.strip()
|
|
return None
|
|
|
|
|
|
def _read_cache_models(codex_home: Path) -> List[str]:
|
|
cache_path = codex_home / "models_cache.json"
|
|
if not cache_path.exists():
|
|
return []
|
|
try:
|
|
raw = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return []
|
|
|
|
entries = raw.get("models") if isinstance(raw, dict) else None
|
|
sortable = []
|
|
if isinstance(entries, list):
|
|
for item in entries:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
slug = item.get("slug")
|
|
if not isinstance(slug, str) or not slug.strip():
|
|
continue
|
|
slug = slug.strip()
|
|
if item.get("supported_in_api") is False:
|
|
continue
|
|
visibility = item.get("visibility")
|
|
if isinstance(visibility, str) and visibility.strip().lower() in ("hide", "hidden"):
|
|
continue
|
|
priority = item.get("priority")
|
|
rank = int(priority) if isinstance(priority, (int, float)) else 10_000
|
|
sortable.append((rank, slug))
|
|
|
|
sortable.sort(key=lambda item: (item[0], item[1]))
|
|
deduped: List[str] = []
|
|
for _, slug in sortable:
|
|
if slug not in deduped:
|
|
deduped.append(slug)
|
|
return deduped
|
|
|
|
|
|
def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]:
|
|
"""Return available Codex model IDs, trying API first, then local sources.
|
|
|
|
Resolution order: API (live, if token provided) > config.toml default >
|
|
local cache > hardcoded defaults.
|
|
"""
|
|
codex_home_str = os.getenv("CODEX_HOME", "").strip() or str(Path.home() / ".codex")
|
|
codex_home = Path(codex_home_str).expanduser()
|
|
ordered: List[str] = []
|
|
|
|
# Try live API if we have a token
|
|
if access_token:
|
|
api_models = _fetch_models_from_api(access_token)
|
|
if api_models:
|
|
return _add_forward_compat_models(api_models)
|
|
|
|
# Fall back to local sources
|
|
default_model = _read_default_model(codex_home)
|
|
if default_model:
|
|
ordered.append(default_model)
|
|
|
|
for model_id in _read_cache_models(codex_home):
|
|
if model_id not in ordered:
|
|
ordered.append(model_id)
|
|
|
|
for model_id in DEFAULT_CODEX_MODELS:
|
|
if model_id not in ordered:
|
|
ordered.append(model_id)
|
|
|
|
return _add_forward_compat_models(ordered)
|