# Conflicts: # AGENTS.md # acp_adapter/edit_approval.py # acp_adapter/server.py # agent/agent_init.py # agent/anthropic_adapter.py # agent/anthropic_credentials.py # agent/auxiliary_client.py # agent/azure_identity_adapter.py # agent/bedrock_adapter.py # agent/browser_registry.py # agent/chat_completion_helpers.py # agent/coding_context.py # agent/context_references.py # agent/conversation_loop.py # agent/copilot_acp_client.py # agent/credits_tracker.py # agent/curator.py # agent/curator_backup.py # agent/deadline.py # agent/display.py # agent/errors.py # agent/estop.py # agent/i18n.py # agent/image_gen_registry.py # agent/image_routing.py # agent/learning_graph.py # agent/learning_mutations.py # agent/lsp/servers.py # agent/model_metadata.py # agent/models_dev.py # agent/monitoring/gateway_health_export.py # agent/monitoring/otlp_exporter.py # agent/pet/store.py # agent/process_bootstrap.py # agent/prompt_builder.py # agent/proxy_sources/iron_proxy.py # agent/secret_sources/_cache.py # agent/secret_sources/bitwarden.py # agent/secret_sources/registry.py # agent/shell_hooks.py # agent/skill_bundles.py # agent/skill_commands.py # agent/skill_utils.py # agent/ssl_guard.py # agent/ssl_verify.py # agent/system_prompt.py # agent/terminal_env_registry.py # agent/trace_upload.py # agent/transcription_registry.py # agent/tts_registry.py # agent/verify/environment.py # agent/vertex_adapter.py # agent/video_gen_registry.py # agent/web_search_registry.py # cli.py # cron/jobs.py # cron/scheduler.py # gateway/agent_cache_pressure.py # gateway/cgroup_cleanup.py # gateway/channel_directory.py # gateway/config.py # gateway/control_socket.py # gateway/dead_targets.py # gateway/drain_control.py # gateway/hooks.py # gateway/kanban_watchers.py # gateway/lifecycle_ledger.py # gateway/mirror.py # gateway/pairing.py # gateway/platform_registry.py # gateway/platforms/helpers.py # gateway/platforms/weixin.py # gateway/readiness.py # gateway/restart_loop_guard.py # gateway/rich_sent_store.py # gateway/run.py # gateway/session.py # gateway/shutdown_flush.py # gateway/shutdown_forensics.py # gateway/slash_commands.py # gateway/status.py # gateway/sticker_cache.py # gateway/whatsapp_identity.py # hermes_bootstrap.py # hermes_cli/_early_recovery.py # hermes_cli/_install_repair.py # hermes_cli/_startup_fast.py # hermes_cli/_subprocess_compat.py # hermes_cli/agent_plugins.py # hermes_cli/auth.py # hermes_cli/backup.py # hermes_cli/banner.py # hermes_cli/browser_connect.py # hermes_cli/build_info.py # hermes_cli/cli_agent_setup_mixin.py # hermes_cli/cli_commands_mixin.py # hermes_cli/codex_models.py # hermes_cli/config.py # hermes_cli/config_defaults.py # hermes_cli/config_migrations.py # hermes_cli/container_boot.py # hermes_cli/dashboard_auth/registry.py # hermes_cli/debug.py # hermes_cli/dep_ensure.py # hermes_cli/doctor.py # hermes_cli/doctor_live.py # hermes_cli/dump.py # hermes_cli/env_loader.py # hermes_cli/foreign_sessions.py # hermes_cli/gateway.py # hermes_cli/gateway_windows.py # hermes_cli/gui_uninstall.py # hermes_cli/image_provenance.py # hermes_cli/install_identity.py # hermes_cli/kanban.py # hermes_cli/kanban_db.py # hermes_cli/linux_desktop_entry.py # hermes_cli/local_runtime/binaries.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/local_runtime/growth.py # hermes_cli/local_runtime/supervisor.py # hermes_cli/logs.py # hermes_cli/macos_tcc_anchor.py # hermes_cli/main.py # hermes_cli/memory_setup.py # hermes_cli/model_catalog.py # hermes_cli/models.py # hermes_cli/nous_subscription.py # hermes_cli/npm_engine.py # hermes_cli/plugin_index.py # hermes_cli/plugins.py # hermes_cli/plugins_cmd.py # hermes_cli/profile_distribution.py # hermes_cli/profiles.py # hermes_cli/prompt_size.py # hermes_cli/psutil_android.py # hermes_cli/runtime_repair.py # hermes_cli/security_advisories.py # hermes_cli/security_audit.py # hermes_cli/security_audit_startup.py # hermes_cli/service_manager.py # hermes_cli/session_export_md.py # hermes_cli/setup.py # hermes_cli/skills_hub.py # hermes_cli/slack_cli.py # hermes_cli/status.py # hermes_cli/subcommands/gateway.py # hermes_cli/subcommands/uninstall.py # hermes_cli/tools_config.py # hermes_cli/uninstall.py # hermes_cli/update_cmd.py # hermes_cli/update_contract.py # hermes_cli/update_inventory.py # hermes_cli/update_lock.py # hermes_cli/update_receipt.py # hermes_cli/urllib_security.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_routers/profiles.py # hermes_cli/web_routers/skills.py # hermes_cli/web_server.py # hermes_constants.py # hermes_state.py # plugins/disk-cleanup/__init__.py # plugins/disk-cleanup/disk_cleanup.py # plugins/google_meet/node/registry.py # plugins/google_meet/node/server.py # plugins/google_meet/process_manager.py # plugins/google_meet/realtime/openai_client.py # plugins/hermes-achievements/dashboard/plugin_api.py # plugins/memory/hindsight/__init__.py # plugins/memory/honcho/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/honcho/client.py # plugins/memory/honcho/oauth.py # plugins/memory/honcho/session.py # plugins/memory/mem0/__init__.py # plugins/memory/mem0/_setup.py # plugins/memory/openviking/__init__.py # plugins/memory/retaindb/__init__.py # plugins/memory/supermemory/__init__.py # plugins/platforms/a2a/protocol.py # plugins/platforms/dingtalk/adapter.py # plugins/platforms/discord/adapter.py # plugins/platforms/feishu/adapter.py # plugins/platforms/google_chat/adapter.py # plugins/platforms/matrix/adapter.py # plugins/platforms/photon/adapter.py # plugins/platforms/photon/auth.py # plugins/platforms/photon/cli.py # plugins/platforms/slack/adapter.py # plugins/platforms/teams/adapter.py # plugins/platforms/telegram/adapter.py # plugins/platforms/wecom/callback_adapter.py # plugins/platforms/whatsapp/adapter.py # plugins/teams_pipeline/store.py # plugins/video_gen/fal/__init__.py # plugins/web/ddgs/provider.py # plugins/web/exa/provider.py # plugins/web/firecrawl/provider.py # plugins/web/parallel/provider.py # tests/agent/test_ssl_ca_guard.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_cmd_update_apt.py # tests/hermes_cli/test_dashboard_unified_launch.py # tests/hermes_cli/test_dep_ensure.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_live.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_kanban_boards.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_memory_setup_provider_arg.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_pip_install_detection.py # tests/hermes_cli/test_profile_export_credentials.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_tui_npm_install.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_ui_build.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/tools/test_browser_chromium_autoinstall.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_lightpanda.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_open_timeout.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_suspect_recycle.py # tests/tools/test_find_shell.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_macos_protected_search.py # tests/tui_gateway/test_compute_host.py # tools/approval.py # tools/blueprints.py # tools/bot_mode_dm.py # tools/bot_mode_probe.py # tools/bot_relay.py # tools/browser_tool.py # tools/browser_use_cli.py # tools/checkpoint_manager.py # tools/code_execution_tool.py # tools/code_kernel.py # tools/computer_use/cua_backend.py # tools/cronjob_tools.py # tools/discord_tool.py # tools/environments/base.py # tools/environments/daytona.py # tools/environments/local.py # tools/environments/modal.py # tools/environments/vercel_sandbox.py # tools/fal_common.py # tools/file_operations.py # tools/lazy_deps.py # tools/mcp_tool.py # tools/neutts_synth.py # tools/process_registry.py # tools/read_extract.py # tools/registry.py # tools/skill_ledger.py # tools/skill_linter.py # tools/skill_manager_tool.py # tools/skill_usage.py # tools/skills_ast_audit.py # tools/skills_guard.py # tools/skills_hub.py # tools/skills_sync.py # tools/skills_sync_client.py # tools/skills_tool.py # tools/terminal_scope.py # tools/terminal_tool.py # tools/tirith_security.py # tools/transcription_tools.py # tools/tts_tool.py # tools/vision_tools.py # tools/voice_mode.py # tools/wake_word.py # tools/web_result_cache.py # tools/website_policy.py # tools/working_diff.py # tools/write_approval.py # tui_gateway/entry.py # tui_gateway/methods_tools.py # tui_gateway/server.py
203 lines
8.5 KiB
Python
203 lines
8.5 KiB
Python
"""Codex model discovery from API, local cache, and config."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Curated offline fallback (first-run, transient API failure). Only slugs the ChatGPT Codex
|
|
# OAuth backend actually accepts: the public API's "-pro" variants and the retired
|
|
# gpt-5.2-codex / gpt-5.1-codex-max / gpt-5.1-codex-mini return HTTP 400 there ("not supported
|
|
# when using Codex with a ChatGPT account"), so listing them leaked dead picker choices. If
|
|
# OpenAI re-enables any, live discovery (_fetch_models_from_api) picks them up automatically.
|
|
DEFAULT_CODEX_MODELS: List[str] = [
|
|
"gpt-5.6-sol",
|
|
"gpt-5.6-terra",
|
|
"gpt-5.6-luna",
|
|
"gpt-5.5",
|
|
"gpt-5.4-mini",
|
|
"gpt-5.4",
|
|
"gpt-5.3-codex",
|
|
# Research preview exposed ONLY via the Codex OAuth backend for ChatGPT Pro subscribers —
|
|
# not in the public API, so it stays out of the "openai" catalog in hermes_cli/models.py.
|
|
# The backend reports ``supported_in_api: false`` for it; that flag describes API
|
|
# availability, not Codex availability, so fetch/cache paths must not filter on it.
|
|
"gpt-5.3-codex-spark"]
|
|
|
|
# gpt-5.3-codex-spark is in research preview and is exposed *only* via the Codex CLI / OAuth backend
|
|
# (chatgpt.com/backend-api/codex/models) for ChatGPT Pro subscribers. It is NOT available in the public
|
|
# OpenAI API, so it intentionally stays out of the "openai" provider catalog in hermes_cli/models.py — only
|
|
# the openai-codex (OAuth) provider surfaces it. The Codex backend reports ``supported_in_api: false`` for
|
|
# this slug; that flag describes API availability, not Codex backend availability, so the fetch/cache code
|
|
# paths below intentionally do not filter on it. PR #12994 removed this entry on the assumption it was
|
|
# unsupported — that was wrong; restored here. Keep it in the curated fallback so Pro users still see Spark
|
|
# in `/model` when live discovery is unavailable (offline first run, transient API failure).
|
|
_FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
|
|
("gpt-5.6-sol", ("gpt-5.5", "gpt-5.4")),
|
|
("gpt-5.6-terra", ("gpt-5.5", "gpt-5.4")),
|
|
("gpt-5.6-luna", ("gpt-5.5", "gpt-5.4")),
|
|
("gpt-5.5", ("gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex")),
|
|
("gpt-5.4-mini", ("gpt-5.3-codex",)),
|
|
("gpt-5.4", ("gpt-5.3-codex",)),
|
|
# Spark surfaces whenever a compatible template is present; the backend (not Hermes)
|
|
# gates real availability by ChatGPT Pro entitlement.
|
|
("gpt-5.3-codex-spark", ("gpt-5.3-codex",))]
|
|
|
|
|
|
def _dedupe(model_ids) -> List[str]:
|
|
"""Order-preserving dedupe."""
|
|
return list(dict.fromkeys(model_ids))
|
|
|
|
|
|
def _add_forward_compat_models(model_ids: List[str]) -> List[str]:
|
|
"""Surface newer Codex slugs missing from live discovery when an older compatible template is
|
|
present (Clawdbot-style synthetic forward-compat catalog)."""
|
|
ordered = _dedupe(model_ids)
|
|
seen = set(ordered)
|
|
for synthetic_model, template_models in _FORWARD_COMPAT_TEMPLATE_MODELS:
|
|
if synthetic_model not in seen and any(template in seen for template in template_models):
|
|
ordered.append(synthetic_model)
|
|
seen.add(synthetic_model)
|
|
return ordered
|
|
|
|
|
|
def _add_context_variants(model_ids: List[str]) -> List[str]:
|
|
"""Insert ``<slug>-900k`` large-context picker variants after eligible base slugs.
|
|
|
|
Base slugs keep the cheaper advertised 272K limit; the variant opts into the large window.
|
|
The suffix is Hermes-side only — stripped before the id hits the wire (agent/transports/codex.py,
|
|
agent/auxiliary_client.py).
|
|
"""
|
|
from agent.model_metadata import CODEX_CONTEXT_VARIANT_SUFFIX, has_codex_context_variant
|
|
|
|
out: List[str] = []
|
|
present = set(model_ids)
|
|
for model_id in model_ids:
|
|
out.append(model_id)
|
|
variant = model_id + CODEX_CONTEXT_VARIANT_SUFFIX
|
|
if variant in present or variant in out:
|
|
continue
|
|
if has_codex_context_variant(model_id):
|
|
out.append(variant)
|
|
return out
|
|
|
|
|
|
def _finalize_codex_models(model_ids: List[str]) -> List[str]:
|
|
"""Forward-compat synthesis + large-context variant synthesis."""
|
|
return _add_context_variants(_add_forward_compat_models(model_ids))
|
|
|
|
|
|
def _extract_chatgpt_account_id(access_token: str) -> Optional[str]:
|
|
"""Best-effort ``chatgpt_account_id`` from the OAuth JWT; None on any parse error.
|
|
|
|
The Codex backend requires the ``ChatGPT-Account-Id`` header for the per-account catalog;
|
|
without it ``GET /backend-api/codex/models`` returns ``{"models":[]}`` with HTTP 200, which
|
|
masquerades as "no models" and silently degrades the picker to the curated fallback.
|
|
"""
|
|
try:
|
|
parts = access_token.split(".")
|
|
if len(parts) < 2:
|
|
return None
|
|
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
claims = json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
acct_id = (
|
|
claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id")
|
|
if isinstance(claims, dict)
|
|
else None)
|
|
return acct_id if isinstance(acct_id, str) and acct_id else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _ranked_slugs(entries: object) -> List[str]:
|
|
"""Visible slugs from a Codex catalog ``models`` list, sorted by (priority, slug), deduped.
|
|
|
|
Does not filter on ``supported_in_api``: that flag describes the public OpenAI API, while the
|
|
OAuth-backed Codex backend still accepts slugs marked false there (gpt-5.3-codex-spark).
|
|
"""
|
|
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
|
|
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.strip()))
|
|
|
|
sortable.sort()
|
|
return _dedupe(slug for _, slug in sortable)
|
|
|
|
|
|
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
|
|
headers = {"Authorization": f"Bearer {access_token}"}
|
|
acct_id = _extract_chatgpt_account_id(access_token)
|
|
if acct_id:
|
|
headers["ChatGPT-Account-Id"] = acct_id
|
|
resp = httpx.get(
|
|
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
|
|
headers=headers,
|
|
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 []
|
|
|
|
return _finalize_codex_models(_ranked_slugs(entries))
|
|
|
|
|
|
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
|
|
import tomllib
|
|
payload = tomllib.loads(config_path.read_text(encoding="utf-8-sig"))
|
|
except Exception:
|
|
return None
|
|
model = payload.get("model") if isinstance(payload, dict) else None
|
|
return model.strip() if isinstance(model, str) and model.strip() else 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-sig"))
|
|
except Exception:
|
|
return []
|
|
|
|
entries = raw.get("models") if isinstance(raw, dict) else None
|
|
return _ranked_slugs(entries if isinstance(entries, list) else [])
|
|
|
|
|
|
def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]:
|
|
"""Available Codex model IDs: live API (if token) > config.toml default > local cache > defaults."""
|
|
codex_home = Path(os.getenv("CODEX_HOME", "").strip() or str(Path.home() / ".codex")).expanduser()
|
|
if access_token:
|
|
api_models = _fetch_models_from_api(access_token)
|
|
if api_models:
|
|
return _finalize_codex_models(api_models)
|
|
default_model = _read_default_model(codex_home)
|
|
return _finalize_codex_models(_dedupe([
|
|
*([default_model] if default_model else []), *_read_cache_models(codex_home),
|
|
*DEFAULT_CODEX_MODELS]))
|