281 lines
9.4 KiB
Python
281 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Discover the current Claude subscription model catalog without inference.
|
|
|
|
Claude Code exposes the account-visible ``ModelInfo[]`` through its Agent SDK
|
|
``initialize`` control response. This module speaks that documented stream-JSON
|
|
control protocol directly instead of treating authentication status or a cached
|
|
model list as a live catalog. It intentionally retains only non-secret
|
|
ModelInfo metadata.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
|
|
DEFAULT_CLAUDE_BIN = "/opt/data/tools/bin/claude"
|
|
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
MAX_MODELS = 256
|
|
EFFORT_LEVELS = frozenset({"low", "medium", "high", "xhigh", "max"})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ClaudeModel:
|
|
"""Safe metadata for one model advertised by the native Claude CLI."""
|
|
|
|
value: str
|
|
resolved_model: str | None
|
|
display_name: str
|
|
description: str
|
|
supports_effort: bool
|
|
supported_effort_levels: tuple[str, ...]
|
|
supports_adaptive_thinking: bool
|
|
supports_fast_mode: bool
|
|
supports_auto_mode: bool
|
|
disabled: bool
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
"""Serialize only ModelInfo metadata, preserving Claude's alias value."""
|
|
return {
|
|
"value": self.value,
|
|
"resolvedModel": self.resolved_model,
|
|
"displayName": self.display_name,
|
|
"description": self.description,
|
|
"supportsEffort": self.supports_effort,
|
|
"supportedEffortLevels": list(self.supported_effort_levels),
|
|
"supportsAdaptiveThinking": self.supports_adaptive_thinking,
|
|
"supportsFastMode": self.supports_fast_mode,
|
|
"supportsAutoMode": self.supports_auto_mode,
|
|
"disabled": self.disabled,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ClaudeModelDiscovery:
|
|
"""Result of one bounded, subscription-authenticated catalog probe."""
|
|
|
|
models: tuple[ClaudeModel, ...]
|
|
live: bool
|
|
connected: bool
|
|
state: str
|
|
error_code: str | None = None
|
|
|
|
|
|
def _native_environment(base: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
"""Keep mounted OAuth configuration while preventing API-key billing lanes."""
|
|
environment = dict(os.environ if base is None else base)
|
|
environment.pop("ANTHROPIC_API_KEY", None)
|
|
environment.pop("CLAUDE_API_KEY", None)
|
|
return environment
|
|
|
|
|
|
def _command(claude_bin: str) -> list[str]:
|
|
"""Build a no-inference CLI invocation with all tools and MCP disabled."""
|
|
return [
|
|
claude_bin,
|
|
"--input-format",
|
|
"stream-json",
|
|
"--output-format",
|
|
"stream-json",
|
|
"--verbose",
|
|
"--print",
|
|
"--no-session-persistence",
|
|
"--safe-mode",
|
|
"--tools",
|
|
"",
|
|
"--strict-mcp-config",
|
|
"--mcp-config",
|
|
'{"mcpServers":{}}',
|
|
]
|
|
|
|
|
|
def _initialize_request() -> str:
|
|
"""Build the Claude Agent SDK initialize request for ModelInfo discovery."""
|
|
return json.dumps(
|
|
{
|
|
"type": "control_request",
|
|
"request_id": f"hermes-model-catalog-{uuid.uuid4()}",
|
|
"request": {"subtype": "initialize", "hooks": {}},
|
|
},
|
|
separators=(",", ":"),
|
|
)
|
|
|
|
|
|
def _stop_process(process: subprocess.Popen[str]) -> None:
|
|
"""End a timed-out CLI process and its process group without leaking output."""
|
|
if process.poll() is not None:
|
|
return
|
|
try:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
except (OSError, ProcessLookupError):
|
|
process.terminate()
|
|
try:
|
|
process.communicate(timeout=2)
|
|
return
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except (OSError, ProcessLookupError):
|
|
process.kill()
|
|
try:
|
|
process.communicate(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
|
|
|
|
def _run_initialize(
|
|
command: Sequence[str], environment: Mapping[str, str], timeout_seconds: float
|
|
) -> tuple[str | None, str | None]:
|
|
"""Run initialize once, returning stream output or a non-sensitive error code."""
|
|
try:
|
|
process = subprocess.Popen(
|
|
list(command),
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
env=dict(environment),
|
|
start_new_session=True,
|
|
)
|
|
except OSError:
|
|
return None, "spawn-failed"
|
|
try:
|
|
request = _initialize_request() + "\n"
|
|
stdout, _ = process.communicate(request, timeout=timeout_seconds)
|
|
except subprocess.TimeoutExpired:
|
|
_stop_process(process)
|
|
return None, "timeout"
|
|
except OSError:
|
|
_stop_process(process)
|
|
return None, "transport-error"
|
|
if process.returncode != 0:
|
|
return None, "cli-error"
|
|
return stdout, None
|
|
|
|
|
|
def _text(value: Any, *, maximum: int = 1024) -> str:
|
|
"""Return bounded string metadata and reject non-string protocol values."""
|
|
return value[:maximum] if isinstance(value, str) else ""
|
|
|
|
|
|
def _model_info(value: Any) -> ClaudeModel | None:
|
|
"""Validate one ModelInfo record without inventing aliases or identifiers."""
|
|
if not isinstance(value, dict):
|
|
return None
|
|
alias = _text(value.get("value"))
|
|
if not alias:
|
|
return None
|
|
resolved_model = _text(value.get("resolvedModel")) or None
|
|
efforts = value.get("supportedEffortLevels")
|
|
supported_effort_levels = (
|
|
tuple(
|
|
level
|
|
for level in efforts
|
|
if isinstance(level, str) and level in EFFORT_LEVELS
|
|
)
|
|
if isinstance(efforts, list)
|
|
else ()
|
|
)
|
|
return ClaudeModel(
|
|
value=alias,
|
|
resolved_model=resolved_model,
|
|
display_name=_text(value.get("displayName")),
|
|
description=_text(value.get("description")),
|
|
supports_effort=value.get("supportsEffort") is True,
|
|
supported_effort_levels=supported_effort_levels,
|
|
supports_adaptive_thinking=value.get("supportsAdaptiveThinking") is True,
|
|
supports_fast_mode=value.get("supportsFastMode") is True,
|
|
supports_auto_mode=value.get("supportsAutoMode") is True,
|
|
disabled=value.get("disabled") is True,
|
|
)
|
|
|
|
|
|
def _parse_initialize_output(stdout: str) -> ClaudeModelDiscovery:
|
|
"""Extract all ModelInfo pages present in initialize control responses.
|
|
|
|
Claude Code 2.1.226 returns its complete ``models`` array in one response.
|
|
The stream parser still gathers every valid initialize response so a future
|
|
paged/multi-frame implementation is not silently truncated. The protocol
|
|
currently specifies no continuation request, so this function never
|
|
fabricates one.
|
|
"""
|
|
models: list[ClaudeModel] = []
|
|
seen: set[tuple[str, str | None]] = set()
|
|
received_models = False
|
|
first_party = False
|
|
for line in stdout.splitlines():
|
|
try:
|
|
event = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if not isinstance(event, dict) or event.get("type") != "control_response":
|
|
continue
|
|
response = event.get("response")
|
|
if not isinstance(response, dict) or response.get("subtype") != "success":
|
|
continue
|
|
payload = response.get("response")
|
|
if not isinstance(payload, dict) or not isinstance(payload.get("models"), list):
|
|
continue
|
|
received_models = True
|
|
account = payload.get("account")
|
|
if isinstance(account, dict) and account.get("apiProvider") == "firstParty":
|
|
first_party = True
|
|
for raw_model in payload["models"]:
|
|
model = _model_info(raw_model)
|
|
if model is None or len(models) >= MAX_MODELS:
|
|
continue
|
|
key = (model.value, model.resolved_model)
|
|
if key not in seen:
|
|
seen.add(key)
|
|
models.append(model)
|
|
if not received_models:
|
|
return ClaudeModelDiscovery(
|
|
(), False, False, "protocol-error", "protocol-error"
|
|
)
|
|
if not first_party:
|
|
return ClaudeModelDiscovery(
|
|
tuple(models), False, False, "not-subscription", "not-first-party"
|
|
)
|
|
if not models:
|
|
return ClaudeModelDiscovery(
|
|
(), True, True, "connected-subscription", "empty-model-catalog"
|
|
)
|
|
return ClaudeModelDiscovery(tuple(models), True, True, "connected-subscription")
|
|
|
|
|
|
def discover_claude_subscription_models(
|
|
*,
|
|
claude_bin: str | None = None,
|
|
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
environment: Mapping[str, str] | None = None,
|
|
) -> ClaudeModelDiscovery:
|
|
"""Return current subscription models using only Claude's initialize protocol.
|
|
|
|
A successful native authentication-status command or a locally cached model
|
|
list is deliberately insufficient: ``live`` is true only after a valid,
|
|
first-party initialize response supplies the account-visible ``models``.
|
|
"""
|
|
binary = (
|
|
claude_bin
|
|
or os.environ.get("HERMES_CLAUDE_NATIVE_BIN")
|
|
or DEFAULT_CLAUDE_BIN
|
|
)
|
|
if timeout_seconds <= 0:
|
|
return ClaudeModelDiscovery(
|
|
(), False, False, "invalid-request", "invalid-timeout"
|
|
)
|
|
stdout, error_code = _run_initialize(
|
|
_command(binary), _native_environment(environment), timeout_seconds
|
|
)
|
|
if error_code:
|
|
state = "unavailable" if error_code == "spawn-failed" else "degraded"
|
|
return ClaudeModelDiscovery((), False, False, state, error_code)
|
|
return _parse_initialize_output(stdout or "")
|