220 lines
8.3 KiB
Python
220 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Discover account-visible provider models without leaking credentials."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import select
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from typing import Any
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlencode
|
|
from urllib.request import Request, urlopen
|
|
|
|
from provider_model_catalog import Catalog, model_records, unique_models
|
|
|
|
|
|
def codex_cli_authenticated() -> bool:
|
|
"""Return whether the installed Codex CLI has a usable local login."""
|
|
codex = shutil.which("codex")
|
|
if not codex:
|
|
return False
|
|
try:
|
|
status = subprocess.run(
|
|
[codex, "login", "status"], capture_output=True, check=False,
|
|
text=True, timeout=10,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
detail = f"{status.stdout}\n{status.stderr}".lower()
|
|
return status.returncode == 0 and (
|
|
"logged in" in detail or "authenticated" in detail
|
|
)
|
|
|
|
def _codex_app_server_records(codex: str) -> tuple[list[str], dict[str, dict[str, Any]]] | None:
|
|
"""Read the authenticated app-server ``model/list`` response when available."""
|
|
try:
|
|
process = subprocess.Popen(
|
|
[codex, "app-server"],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=False,
|
|
)
|
|
except OSError:
|
|
return None
|
|
if process.stdin is None or process.stdout is None:
|
|
process.terminate()
|
|
return None
|
|
buffer = b""
|
|
|
|
def send(message: dict[str, Any]) -> None:
|
|
assert process.stdin is not None
|
|
process.stdin.write(json.dumps(message, separators=(",", ":")).encode() + b"\n")
|
|
process.stdin.flush()
|
|
|
|
def response(request_id: int, deadline: float) -> dict[str, Any] | None:
|
|
nonlocal buffer
|
|
assert process.stdout is not None
|
|
while time.monotonic() < deadline:
|
|
readable, _, _ = select.select([process.stdout.fileno()], [], [], 0.25)
|
|
if not readable:
|
|
continue
|
|
chunk = os.read(process.stdout.fileno(), 65536)
|
|
if not chunk:
|
|
return None
|
|
buffer += chunk
|
|
while b"\n" in buffer:
|
|
line, buffer = buffer.split(b"\n", 1)
|
|
try:
|
|
document = json.loads(line)
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
continue
|
|
if document.get("id") == request_id and isinstance(document.get("result"), dict):
|
|
return document["result"]
|
|
return None
|
|
|
|
try:
|
|
send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"clientInfo": {"name": "hermes-catalog", "version": "1"}, "capabilities": {}}})
|
|
if response(1, time.monotonic() + 12) is None:
|
|
return None
|
|
send({"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
|
values: list[Any] = []
|
|
cursor: str | None = None
|
|
request_id = 2
|
|
while True:
|
|
params: dict[str, Any] = {"includeHidden": False, "limit": 100}
|
|
if cursor:
|
|
params["cursor"] = cursor
|
|
send({"jsonrpc": "2.0", "id": request_id, "method": "model/list", "params": params})
|
|
result = response(request_id, time.monotonic() + 12)
|
|
if result is None:
|
|
return None
|
|
page = result.get("models", result.get("data", []))
|
|
if not isinstance(page, list):
|
|
return None
|
|
values.extend(page)
|
|
cursor = result.get("nextCursor") or result.get("next_cursor")
|
|
if not isinstance(cursor, str) or not cursor:
|
|
return model_records(values)
|
|
request_id += 1
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
pass
|
|
finally:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
return None
|
|
|
|
|
|
def _anthropic_api_records() -> tuple[list[str], dict[str, dict[str, Any]]] | None:
|
|
"""List current Anthropic account models, including documented capabilities."""
|
|
token = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
|
if not token:
|
|
return None
|
|
request = Request(
|
|
"https://api.anthropic.com/v1/models?" + urlencode({"limit": 100}),
|
|
headers={"x-api-key": token, "anthropic-version": "2023-06-01"},
|
|
)
|
|
try:
|
|
with urlopen(request, timeout=15) as response:
|
|
document = json.load(response)
|
|
except (HTTPError, URLError, OSError, TimeoutError, ValueError):
|
|
return None
|
|
values = document.get("data", []) if isinstance(document, dict) else []
|
|
return model_records(values if isinstance(values, list) else [])
|
|
|
|
|
|
def discover_codex_models() -> Catalog:
|
|
"""Use the authenticated Codex endpoint; catalogs are status-only fallback."""
|
|
token = ""
|
|
try:
|
|
from hermes_cli.auth import resolve_codex_runtime_credentials
|
|
|
|
credentials = resolve_codex_runtime_credentials(refresh_if_expiring=True) or {}
|
|
token = str(credentials.get("api_key") or "").strip()
|
|
except Exception:
|
|
token = ""
|
|
api_records: tuple[list[str], dict[str, dict[str, Any]]] | None = None
|
|
if token:
|
|
try:
|
|
from hermes_cli.codex_models import _fetch_models_from_api
|
|
|
|
api_records = model_records(_fetch_models_from_api(token))
|
|
except Exception:
|
|
api_records = None
|
|
if api_records is not None:
|
|
live, live_metadata = api_records
|
|
return Catalog("openai-codex", live, True, True, "connected-api", live_metadata)
|
|
codex = shutil.which("codex")
|
|
if codex:
|
|
app_records = _codex_app_server_records(codex)
|
|
if app_records is not None:
|
|
app_models, app_metadata = app_records
|
|
return Catalog("openai-codex", app_models, True, True, "connected-app-server", app_metadata)
|
|
try:
|
|
from hermes_cli.models import provider_model_ids
|
|
|
|
known, known_metadata = model_records(
|
|
provider_model_ids("openai-codex", force_refresh=True)
|
|
)
|
|
except Exception:
|
|
known, known_metadata = [], {}
|
|
# The owner agent deliberately uses the local Codex app-server and the
|
|
# Codex CLI's ChatGPT login. That credential is not exported into Hermes'
|
|
# bearer-token store, so API discovery above may be empty even though the
|
|
# runtime is fully authenticated. Treat a successful CLI status check as a
|
|
# connected, degraded catalog: routing may use Codex while retaining the
|
|
# last-known working model names until app-server discovery is available.
|
|
if codex_cli_authenticated():
|
|
return Catalog(
|
|
"openai-codex",
|
|
known,
|
|
False,
|
|
True,
|
|
"connected-cli",
|
|
known_metadata,
|
|
)
|
|
state = "degraded" if token else "not-configured"
|
|
return Catalog("openai-codex", known, False, bool(token), state, known_metadata)
|
|
|
|
|
|
def discover_claude_models() -> Catalog:
|
|
"""Discover subscription models first, retaining API discovery as fallback."""
|
|
try:
|
|
from claude_model_discovery import discover_claude_subscription_models
|
|
|
|
subscription = discover_claude_subscription_models()
|
|
except Exception:
|
|
subscription = None
|
|
if subscription is not None and subscription.live:
|
|
records = [model.as_dict() for model in subscription.models]
|
|
models, metadata = model_records(records)
|
|
return Catalog(
|
|
"anthropic", models, True, subscription.connected,
|
|
subscription.state, metadata,
|
|
)
|
|
api_records = _anthropic_api_records()
|
|
if api_records is not None:
|
|
live_models, live_metadata = api_records
|
|
return Catalog("anthropic", live_models, True, True, "connected-api", live_metadata)
|
|
try:
|
|
from hermes_cli.models import provider_model_ids
|
|
|
|
known, known_metadata = model_records(
|
|
provider_model_ids("anthropic", force_refresh=True)
|
|
)
|
|
except Exception:
|
|
known, known_metadata = [], {}
|
|
if subscription is not None:
|
|
return Catalog(
|
|
"anthropic", known, False, subscription.connected,
|
|
subscription.state, known_metadata,
|
|
)
|
|
return Catalog("anthropic", known, False, False, "not-configured", known_metadata)
|