claude-auth-manager 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_auth_manager/__init__.py +3 -0
- claude_auth_manager/__main__.py +3 -0
- claude_auth_manager/agents.py +180 -0
- claude_auth_manager/anthropic.py +30 -0
- claude_auth_manager/catalogs.py +259 -0
- claude_auth_manager/check.py +216 -0
- claude_auth_manager/cli.py +1058 -0
- claude_auth_manager/fallback.py +176 -0
- claude_auth_manager/fallback_transport.py +394 -0
- claude_auth_manager/google.py +606 -0
- claude_auth_manager/launcher.py +58 -0
- claude_auth_manager/login.py +426 -0
- claude_auth_manager/models.py +414 -0
- claude_auth_manager/openrouter.py +139 -0
- claude_auth_manager/paths.py +130 -0
- claude_auth_manager/picker.py +693 -0
- claude_auth_manager/proxy.py +787 -0
- claude_auth_manager/registry.py +595 -0
- claude_auth_manager/service.py +325 -0
- claude_auth_manager/settings.py +670 -0
- claude_auth_manager/storage.py +57 -0
- claude_auth_manager/uninstall.py +91 -0
- claude_auth_manager/update.py +244 -0
- claude_auth_manager-0.0.2.dist-info/METADATA +607 -0
- claude_auth_manager-0.0.2.dist-info/RECORD +28 -0
- claude_auth_manager-0.0.2.dist-info/WHEEL +4 -0
- claude_auth_manager-0.0.2.dist-info/entry_points.txt +3 -0
- claude_auth_manager-0.0.2.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Managed Claude Code subagents for exact provider/account favorites."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .models import claude_model, compact_model_name, managed_model
|
|
14
|
+
from .paths import agent_manifest_path, claude_agents_dir
|
|
15
|
+
from .storage import atomic_write_json, atomic_write_text, read_json_object
|
|
16
|
+
|
|
17
|
+
MANIFEST_VERSION = 1
|
|
18
|
+
MANAGED_MARKER = "<!-- Managed by claude-auth-manager; regenerated by setup/select. -->"
|
|
19
|
+
HOOK_COMMAND_SUFFIX = "-m claude_auth_manager _agent-hook"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def agent_name(model_id: str) -> str:
|
|
23
|
+
"""Return a readable, collision-resistant Claude subagent name."""
|
|
24
|
+
slug = re.sub(r"[^a-z0-9]+", "-", model_id.casefold()).strip("-")
|
|
25
|
+
slug = slug[:48].rstrip("-") or "model"
|
|
26
|
+
digest = hashlib.sha256(model_id.encode()).hexdigest()[:8]
|
|
27
|
+
return f"cam-{slug}-{digest}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _agent_document(model: dict[str, Any], name: str) -> str:
|
|
31
|
+
model_id = str(model["id"])
|
|
32
|
+
label = compact_model_name(model)
|
|
33
|
+
description = (
|
|
34
|
+
f"Run a delegated task with the exact {model.get('provider')} favorite "
|
|
35
|
+
f"{label} ({model_id}) using {model.get('credential')}. "
|
|
36
|
+
"Choose this subagent when that model is requested. Do not pass the Agent model "
|
|
37
|
+
"parameter; this definition owns the exact model route."
|
|
38
|
+
)
|
|
39
|
+
return f"""---
|
|
40
|
+
name: {json.dumps(name)}
|
|
41
|
+
description: {json.dumps(description)}
|
|
42
|
+
model: {json.dumps(claude_model(model))}
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
{MANAGED_MARKER}
|
|
46
|
+
|
|
47
|
+
Complete the delegated task using this configured provider route. Be explicit about
|
|
48
|
+
limitations and identify the provider accurately when asked.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def hook_command() -> str:
|
|
53
|
+
executable = shlex.quote(str(Path(sys.executable).resolve()))
|
|
54
|
+
return f"{executable} {HOOK_COMMAND_SUFFIX}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def managed_hook_group() -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"matcher": "Agent",
|
|
60
|
+
"hooks": [{"type": "command", "command": hook_command()}],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_managed_hook_group(value: Any) -> bool:
|
|
65
|
+
if not isinstance(value, dict) or value.get("matcher") != "Agent":
|
|
66
|
+
return False
|
|
67
|
+
hooks = value.get("hooks")
|
|
68
|
+
return isinstance(hooks, list) and any(
|
|
69
|
+
isinstance(hook, dict)
|
|
70
|
+
and isinstance(hook.get("command"), str)
|
|
71
|
+
and hook["command"].endswith(HOOK_COMMAND_SUFFIX)
|
|
72
|
+
for hook in hooks
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _safe_managed_path(filename: str) -> Path | None:
|
|
77
|
+
if Path(filename).name != filename:
|
|
78
|
+
return None
|
|
79
|
+
path = claude_agents_dir() / filename
|
|
80
|
+
if path.resolve().parent != claude_agents_dir().resolve():
|
|
81
|
+
return None
|
|
82
|
+
return path
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _remove_manifest_files(manifest: dict[str, Any]) -> None:
|
|
86
|
+
agents = manifest.get("agents")
|
|
87
|
+
if not isinstance(agents, dict):
|
|
88
|
+
return
|
|
89
|
+
for entry in agents.values():
|
|
90
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("file"), str):
|
|
91
|
+
continue
|
|
92
|
+
path = _safe_managed_path(entry["file"])
|
|
93
|
+
if path is None or not path.exists():
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
managed = MANAGED_MARKER in path.read_text(encoding="utf-8")
|
|
97
|
+
except OSError:
|
|
98
|
+
managed = False
|
|
99
|
+
if managed:
|
|
100
|
+
path.unlink()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def sync_managed_agents(models: list[dict[str, Any]]) -> dict[str, str]:
|
|
104
|
+
"""Replace cam-owned subagent definitions and return name-to-model routes."""
|
|
105
|
+
manifest_path = agent_manifest_path()
|
|
106
|
+
old_manifest = read_json_object(manifest_path, missing_ok=True)
|
|
107
|
+
_remove_manifest_files(old_manifest)
|
|
108
|
+
|
|
109
|
+
entries: dict[str, dict[str, str]] = {}
|
|
110
|
+
routes: dict[str, str] = {}
|
|
111
|
+
for model in models:
|
|
112
|
+
route = managed_model(model)
|
|
113
|
+
# The credential is part of the agent identity. Two OpenRouter or
|
|
114
|
+
# Google keys can expose the same upstream model and must still produce
|
|
115
|
+
# two independently selectable subagents.
|
|
116
|
+
name = agent_name(route)
|
|
117
|
+
filename = f"{name}.md"
|
|
118
|
+
path = _safe_managed_path(filename)
|
|
119
|
+
assert path is not None
|
|
120
|
+
if path.exists() and MANAGED_MARKER not in path.read_text(encoding="utf-8"):
|
|
121
|
+
raise RuntimeError(f"refusing to replace non-cam Claude subagent: {path}")
|
|
122
|
+
atomic_write_text(path, _agent_document(model, name), 0o600)
|
|
123
|
+
entries[name] = {"file": filename, "model": claude_model(model)}
|
|
124
|
+
routes[name] = claude_model(model)
|
|
125
|
+
|
|
126
|
+
atomic_write_json(
|
|
127
|
+
manifest_path,
|
|
128
|
+
{"version": MANIFEST_VERSION, "agents": entries},
|
|
129
|
+
)
|
|
130
|
+
return routes
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def remove_managed_agents() -> None:
|
|
134
|
+
manifest_path = agent_manifest_path()
|
|
135
|
+
manifest = read_json_object(manifest_path, missing_ok=True)
|
|
136
|
+
_remove_manifest_files(manifest)
|
|
137
|
+
manifest_path.unlink(missing_ok=True)
|
|
138
|
+
directory = claude_agents_dir()
|
|
139
|
+
if directory.exists() and not any(directory.iterdir()):
|
|
140
|
+
directory.rmdir()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def rewrite_agent_input(event: Any) -> dict[str, Any] | None:
|
|
144
|
+
"""Pin a cam custom subagent to its definition instead of a native alias."""
|
|
145
|
+
if not isinstance(event, dict) or event.get("tool_name") != "Agent":
|
|
146
|
+
return None
|
|
147
|
+
tool_input = event.get("tool_input")
|
|
148
|
+
if not isinstance(tool_input, dict):
|
|
149
|
+
return None
|
|
150
|
+
subagent_type = tool_input.get("subagent_type")
|
|
151
|
+
if not isinstance(subagent_type, str):
|
|
152
|
+
return None
|
|
153
|
+
manifest = read_json_object(agent_manifest_path(), missing_ok=True)
|
|
154
|
+
agents = manifest.get("agents")
|
|
155
|
+
if not isinstance(agents, dict) or subagent_type not in agents:
|
|
156
|
+
return None
|
|
157
|
+
updated = dict(tool_input)
|
|
158
|
+
updated.pop("model", None)
|
|
159
|
+
return {
|
|
160
|
+
"hookSpecificOutput": {
|
|
161
|
+
"hookEventName": "PreToolUse",
|
|
162
|
+
"permissionDecision": "allow",
|
|
163
|
+
"permissionDecisionReason": (
|
|
164
|
+
"Using the exact provider/account model selected by this cam subagent."
|
|
165
|
+
),
|
|
166
|
+
"updatedInput": updated,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def run_agent_hook() -> int:
|
|
172
|
+
"""Best-effort Claude Code hook entry point; never disrupt unrelated tools."""
|
|
173
|
+
try:
|
|
174
|
+
event = json.load(sys.stdin)
|
|
175
|
+
result = rewrite_agent_input(event)
|
|
176
|
+
if result is not None:
|
|
177
|
+
print(json.dumps(result, separators=(",", ":")))
|
|
178
|
+
except (OSError, RuntimeError, ValueError, json.JSONDecodeError):
|
|
179
|
+
return 0
|
|
180
|
+
return 0
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Private Anthropic API credential storage for optional API-billed routing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .paths import anthropic_credential_path
|
|
8
|
+
from .storage import atomic_write_text
|
|
9
|
+
|
|
10
|
+
KEY_PATTERN = re.compile(r"^sk-ant-[^\s]{10,}$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_anthropic_key_shape(key: str) -> None:
|
|
14
|
+
if not KEY_PATTERN.fullmatch(key):
|
|
15
|
+
raise ValueError("the Anthropic key has an unexpected format (expected sk-ant-...)")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_anthropic_credential() -> str:
|
|
19
|
+
path = anthropic_credential_path()
|
|
20
|
+
try:
|
|
21
|
+
key = path.read_text(encoding="utf-8").strip()
|
|
22
|
+
except FileNotFoundError as exc:
|
|
23
|
+
raise RuntimeError(f"Anthropic API credential not found at {path}") from exc
|
|
24
|
+
validate_anthropic_key_shape(key)
|
|
25
|
+
return key
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_anthropic_credential(key: str) -> None:
|
|
29
|
+
validate_anthropic_key_shape(key)
|
|
30
|
+
atomic_write_text(anthropic_credential_path(), f"{key}\n", 0o600)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Unified model catalog across subscriptions and API-key providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from . import google, openrouter
|
|
8
|
+
from .models import claude_model, compact_model_name, managed_model, search_models
|
|
9
|
+
from .registry import key_entry, list_accounts, list_keys, read_key
|
|
10
|
+
|
|
11
|
+
ANTHROPIC_MODELS: tuple[dict[str, Any], ...] = (
|
|
12
|
+
{
|
|
13
|
+
"id": "claude-fable-5-1",
|
|
14
|
+
"name": "Claude Fable 5.1",
|
|
15
|
+
"description": "Most capable Claude model for long-running agents",
|
|
16
|
+
"context_length": 1_000_000,
|
|
17
|
+
"supported_parameters": ["tools", "tool_choice", "images"],
|
|
18
|
+
"architecture": {"input_modalities": ["text", "image"]},
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "claude-opus-5",
|
|
22
|
+
"name": "Claude Opus 5",
|
|
23
|
+
"description": "Latest flagship Claude model",
|
|
24
|
+
"context_length": 1_000_000,
|
|
25
|
+
"supported_parameters": ["tools", "tool_choice", "images"],
|
|
26
|
+
"architecture": {"input_modalities": ["text", "image"]},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "claude-sonnet-5",
|
|
30
|
+
"name": "Claude Sonnet 5",
|
|
31
|
+
"description": "Latest balanced Claude model",
|
|
32
|
+
"context_length": 1_000_000,
|
|
33
|
+
"supported_parameters": ["tools", "tool_choice", "images"],
|
|
34
|
+
"architecture": {"input_modalities": ["text", "image"]},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "claude-fable-5",
|
|
38
|
+
"name": "Claude Fable 5",
|
|
39
|
+
"description": "Fast agentic Claude 5 model",
|
|
40
|
+
"context_length": 1_000_000,
|
|
41
|
+
"supported_parameters": ["tools", "tool_choice", "images"],
|
|
42
|
+
"architecture": {"input_modalities": ["text", "image"]},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "claude-opus-4-8",
|
|
46
|
+
"name": "Claude Opus 4.8",
|
|
47
|
+
"description": "High-capability Claude model",
|
|
48
|
+
"context_length": 1_000_000,
|
|
49
|
+
"supported_parameters": ["tools", "tool_choice", "images"],
|
|
50
|
+
"architecture": {"input_modalities": ["text", "image"]},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"id": "claude-opus-4-7",
|
|
54
|
+
"name": "Claude Opus 4.7",
|
|
55
|
+
"description": "High-capability Claude model",
|
|
56
|
+
"context_length": 1_000_000,
|
|
57
|
+
"supported_parameters": ["tools", "tool_choice", "images"],
|
|
58
|
+
"architecture": {"input_modalities": ["text", "image"]},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "claude-opus-4-6",
|
|
62
|
+
"name": "Claude Opus 4.6",
|
|
63
|
+
"description": "Most capable Claude model",
|
|
64
|
+
"context_length": 1_000_000,
|
|
65
|
+
"supported_parameters": ["tools", "tool_choice", "images"],
|
|
66
|
+
"architecture": {"input_modalities": ["text", "image"]},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"id": "claude-sonnet-4-6",
|
|
70
|
+
"name": "Claude Sonnet 4.6",
|
|
71
|
+
"description": "Balanced Claude coding model",
|
|
72
|
+
"context_length": 1_000_000,
|
|
73
|
+
"supported_parameters": ["tools", "tool_choice", "images"],
|
|
74
|
+
"architecture": {"input_modalities": ["text", "image"]},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"id": "claude-haiku-4-5-20251001",
|
|
78
|
+
"name": "Claude Haiku 4.5",
|
|
79
|
+
"description": "Fast Claude model",
|
|
80
|
+
"context_length": 200_000,
|
|
81
|
+
"supported_parameters": ["tools", "tool_choice", "images"],
|
|
82
|
+
"architecture": {"input_modalities": ["text", "image"]},
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _decorate(
|
|
88
|
+
models: list[dict[str, Any]] | tuple[dict[str, Any], ...],
|
|
89
|
+
provider: str,
|
|
90
|
+
credential: str,
|
|
91
|
+
credential_label: str | None = None,
|
|
92
|
+
) -> list[dict[str, Any]]:
|
|
93
|
+
result: list[dict[str, Any]] = []
|
|
94
|
+
for source in models:
|
|
95
|
+
model = dict(source)
|
|
96
|
+
model["provider"] = provider
|
|
97
|
+
model["credential"] = credential
|
|
98
|
+
if credential_label:
|
|
99
|
+
model["credential_label"] = credential_label
|
|
100
|
+
model["name"] = compact_model_name(model)
|
|
101
|
+
model["route"] = managed_model(model)
|
|
102
|
+
result.append(model)
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def account_models() -> list[dict[str, Any]]:
|
|
107
|
+
models: list[dict[str, Any]] = []
|
|
108
|
+
for account in list_accounts():
|
|
109
|
+
label = str(account.get("label") or account["id"])
|
|
110
|
+
account_catalog = _decorate(
|
|
111
|
+
ANTHROPIC_MODELS,
|
|
112
|
+
"anthropic",
|
|
113
|
+
str(account["id"]),
|
|
114
|
+
label,
|
|
115
|
+
)
|
|
116
|
+
for field in ("subscription", "rate_limit_tier"):
|
|
117
|
+
value = account.get(field)
|
|
118
|
+
if isinstance(value, str) and value:
|
|
119
|
+
for model in account_catalog:
|
|
120
|
+
model[field] = value
|
|
121
|
+
models.extend(account_catalog)
|
|
122
|
+
return models
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def anthropic_api_models() -> list[dict[str, Any]]:
|
|
126
|
+
result: list[dict[str, Any]] = []
|
|
127
|
+
for entry in list_keys("anthropic-api"):
|
|
128
|
+
result.extend(
|
|
129
|
+
_decorate(
|
|
130
|
+
ANTHROPIC_MODELS,
|
|
131
|
+
"anthropic-api",
|
|
132
|
+
str(entry["id"]),
|
|
133
|
+
str(entry.get("label") or entry["id"]),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def refresh_key_catalog(key_id: str) -> list[dict[str, Any]]:
|
|
140
|
+
entry = key_entry(key_id)
|
|
141
|
+
provider = str(entry["provider"])
|
|
142
|
+
key = read_key(key_id, provider=provider)
|
|
143
|
+
if provider == "openrouter":
|
|
144
|
+
models = openrouter.refresh_catalog(key, key_id)
|
|
145
|
+
elif provider == "google":
|
|
146
|
+
models = google.refresh_catalog(key_id, key)
|
|
147
|
+
elif provider == "anthropic-api":
|
|
148
|
+
models = list(ANTHROPIC_MODELS)
|
|
149
|
+
else: # registry validation should make this unreachable
|
|
150
|
+
raise RuntimeError(f"unsupported provider: {provider}")
|
|
151
|
+
return _decorate(models, provider, key_id, str(entry.get("label") or key_id))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_key_catalog(key_id: str) -> list[dict[str, Any]]:
|
|
155
|
+
entry = key_entry(key_id)
|
|
156
|
+
provider = str(entry["provider"])
|
|
157
|
+
if provider == "openrouter":
|
|
158
|
+
models = openrouter.load_catalog(key_id)
|
|
159
|
+
elif provider == "google":
|
|
160
|
+
models = google.load_catalog(key_id)
|
|
161
|
+
elif provider == "anthropic-api":
|
|
162
|
+
models = list(ANTHROPIC_MODELS)
|
|
163
|
+
else:
|
|
164
|
+
raise RuntimeError(f"unsupported provider: {provider}")
|
|
165
|
+
return _decorate(models, provider, key_id, str(entry.get("label") or key_id))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def refresh_all_catalogs() -> list[dict[str, Any]]:
|
|
169
|
+
models = account_models() + anthropic_api_models()
|
|
170
|
+
for entry in list_keys():
|
|
171
|
+
if entry.get("provider") in {"openrouter", "google"}:
|
|
172
|
+
models.extend(refresh_key_catalog(str(entry["id"])))
|
|
173
|
+
return models
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def refresh_select_catalogs() -> list[dict[str, Any]]:
|
|
177
|
+
"""Refresh key-filtered OpenRouter catalogs and load every other route.
|
|
178
|
+
|
|
179
|
+
Selection must not offer stale OpenRouter routes because the effective
|
|
180
|
+
model allowlist can change when a key's guardrails change. Other provider
|
|
181
|
+
catalogs retain their existing explicit refresh behavior.
|
|
182
|
+
"""
|
|
183
|
+
models = account_models() + anthropic_api_models()
|
|
184
|
+
for entry in list_keys():
|
|
185
|
+
key_id = str(entry["id"])
|
|
186
|
+
if entry.get("provider") == "openrouter":
|
|
187
|
+
models.extend(refresh_key_catalog(key_id))
|
|
188
|
+
elif entry.get("provider") == "google":
|
|
189
|
+
models.extend(load_key_catalog(key_id))
|
|
190
|
+
return models
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load_all_catalogs(*, tolerate_missing: bool = False) -> list[dict[str, Any]]:
|
|
194
|
+
models = account_models() + anthropic_api_models()
|
|
195
|
+
for entry in list_keys():
|
|
196
|
+
if entry.get("provider") not in {"openrouter", "google"}:
|
|
197
|
+
continue
|
|
198
|
+
try:
|
|
199
|
+
models.extend(load_key_catalog(str(entry["id"])))
|
|
200
|
+
except RuntimeError:
|
|
201
|
+
if not tolerate_missing:
|
|
202
|
+
raise
|
|
203
|
+
return models
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def exact_routes(models: list[dict[str, Any]], requested: list[str]) -> list[dict[str, Any]]:
|
|
207
|
+
"""Resolve exact managed ids or convenient provider/credential/model specs."""
|
|
208
|
+
by_route = {managed_model(model): model for model in models}
|
|
209
|
+
by_route.update({claude_model(model): model for model in models})
|
|
210
|
+
by_spec = {
|
|
211
|
+
f"{model['provider']}/{model['credential']}/{model['id']}": model for model in models
|
|
212
|
+
}
|
|
213
|
+
by_plain: dict[str, list[dict[str, Any]]] = {}
|
|
214
|
+
for model in models:
|
|
215
|
+
by_plain.setdefault(str(model["id"]), []).append(model)
|
|
216
|
+
selected: list[dict[str, Any]] = []
|
|
217
|
+
missing: list[str] = []
|
|
218
|
+
for request in requested:
|
|
219
|
+
model = by_route.get(request) or by_spec.get(request)
|
|
220
|
+
if model is None:
|
|
221
|
+
candidates = by_plain.get(request, [])
|
|
222
|
+
if len(candidates) == 1:
|
|
223
|
+
model = candidates[0]
|
|
224
|
+
elif len(candidates) > 1:
|
|
225
|
+
choices = ", ".join(
|
|
226
|
+
f"{item['provider']}/{item['credential']}/{item['id']}" for item in candidates
|
|
227
|
+
)
|
|
228
|
+
raise ValueError(f"model {request} is ambiguous; choose one of: {choices}")
|
|
229
|
+
if model is None:
|
|
230
|
+
missing.append(request)
|
|
231
|
+
elif managed_model(model) not in {managed_model(item) for item in selected}:
|
|
232
|
+
selected.append(model)
|
|
233
|
+
if missing:
|
|
234
|
+
raise ValueError(f"model route not found in the current indexes: {', '.join(missing)}")
|
|
235
|
+
if not selected:
|
|
236
|
+
raise ValueError("select at least one model")
|
|
237
|
+
return selected
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def search_all(models: list[dict[str, Any]], queries: list[str]) -> list[dict[str, Any]]:
|
|
241
|
+
"""Search regular fields plus provider and credential labels."""
|
|
242
|
+
augmented: list[dict[str, Any]] = []
|
|
243
|
+
for model in models:
|
|
244
|
+
copy = dict(model)
|
|
245
|
+
extra = " ".join(
|
|
246
|
+
str(value)
|
|
247
|
+
for value in (
|
|
248
|
+
model.get("description"),
|
|
249
|
+
model.get("provider"),
|
|
250
|
+
model.get("credential"),
|
|
251
|
+
model.get("credential_label"),
|
|
252
|
+
)
|
|
253
|
+
if value
|
|
254
|
+
)
|
|
255
|
+
copy["description"] = extra
|
|
256
|
+
augmented.append(copy)
|
|
257
|
+
found = search_models(augmented, queries)
|
|
258
|
+
routes = {managed_model(model) for model in found}
|
|
259
|
+
return [model for model in models if managed_model(model) in routes]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Live Claude Code tool-compatibility probe for an OpenRouter model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from decimal import Decimal, InvalidOperation
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .launcher import find_claude
|
|
18
|
+
from .models import managed_model
|
|
19
|
+
from .proxy import LOCAL_TOKEN_HEADER, HybridRouterServer, router_base_url
|
|
20
|
+
|
|
21
|
+
PROBE_MARKER = "CAM_TOOL_CHECK_OK"
|
|
22
|
+
PROBE_FILENAME = "cam-tool-probe.txt"
|
|
23
|
+
PROBE_INPUT_TOKEN_ESTIMATE = 15_000
|
|
24
|
+
PROBE_OUTPUT_TOKEN_ESTIMATE = 500
|
|
25
|
+
PROBE_INPUT_TOKEN_PLANNING_MAX = 500_000
|
|
26
|
+
PROBE_OUTPUT_TOKEN_PLANNING_MAX = 2_000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ToolProbeResult:
|
|
31
|
+
tool_called: bool
|
|
32
|
+
tool_completed: bool
|
|
33
|
+
acknowledged_result: bool
|
|
34
|
+
returncode: int
|
|
35
|
+
total_cost_usd: float | None
|
|
36
|
+
final_text: str
|
|
37
|
+
diagnostic: str
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def passed(self) -> bool:
|
|
41
|
+
return (
|
|
42
|
+
self.returncode == 0
|
|
43
|
+
and self.tool_called
|
|
44
|
+
and self.tool_completed
|
|
45
|
+
and self.acknowledged_result
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def estimate_probe_cost(
|
|
50
|
+
model: dict[str, Any],
|
|
51
|
+
*,
|
|
52
|
+
input_tokens: int = PROBE_INPUT_TOKEN_ESTIMATE,
|
|
53
|
+
output_tokens: int = PROBE_OUTPUT_TOKEN_ESTIMATE,
|
|
54
|
+
) -> Decimal | None:
|
|
55
|
+
"""Estimate a probe from catalog rates and the expected Claude Code context."""
|
|
56
|
+
pricing = model.get("pricing")
|
|
57
|
+
if not isinstance(pricing, dict):
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
prompt = Decimal(str(pricing["prompt"]))
|
|
61
|
+
completion = Decimal(str(pricing["completion"]))
|
|
62
|
+
except (InvalidOperation, KeyError, TypeError, ValueError):
|
|
63
|
+
return None
|
|
64
|
+
if not prompt.is_finite() or not completion.is_finite() or prompt < 0 or completion < 0:
|
|
65
|
+
return None
|
|
66
|
+
return prompt * input_tokens + completion * output_tokens
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _stream_events(output: str) -> list[dict[str, Any]]:
|
|
70
|
+
events: list[dict[str, Any]] = []
|
|
71
|
+
for line in output.splitlines():
|
|
72
|
+
try:
|
|
73
|
+
event = json.loads(line)
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
continue
|
|
76
|
+
if isinstance(event, dict):
|
|
77
|
+
events.append(event)
|
|
78
|
+
return events
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_probe_result(
|
|
82
|
+
output: str, debug_log: str, *, returncode: int, stderr: str = ""
|
|
83
|
+
) -> ToolProbeResult:
|
|
84
|
+
events = _stream_events(output)
|
|
85
|
+
tool_ids: set[str] = set()
|
|
86
|
+
completed_ids: set[str] = set()
|
|
87
|
+
final_text = ""
|
|
88
|
+
total_cost: float | None = None
|
|
89
|
+
for event in events:
|
|
90
|
+
message = event.get("message")
|
|
91
|
+
if isinstance(message, dict):
|
|
92
|
+
content = message.get("content")
|
|
93
|
+
if isinstance(content, list):
|
|
94
|
+
for block in content:
|
|
95
|
+
if not isinstance(block, dict):
|
|
96
|
+
continue
|
|
97
|
+
if block.get("type") == "tool_use" and block.get("name") == "Glob":
|
|
98
|
+
tool_id = block.get("id")
|
|
99
|
+
if isinstance(tool_id, str):
|
|
100
|
+
tool_ids.add(tool_id)
|
|
101
|
+
if block.get("type") == "tool_result":
|
|
102
|
+
tool_id = block.get("tool_use_id")
|
|
103
|
+
if isinstance(tool_id, str) and not block.get("is_error", False):
|
|
104
|
+
completed_ids.add(tool_id)
|
|
105
|
+
if event.get("type") == "result":
|
|
106
|
+
result = event.get("result")
|
|
107
|
+
if isinstance(result, str):
|
|
108
|
+
final_text = result
|
|
109
|
+
cost = event.get("total_cost_usd")
|
|
110
|
+
if isinstance(cost, int | float):
|
|
111
|
+
total_cost = float(cost)
|
|
112
|
+
|
|
113
|
+
debug_called = re.search(r"tool_dispatch_start\s+tool=Glob\b", debug_log) is not None
|
|
114
|
+
debug_completed = (
|
|
115
|
+
re.search(r"tool_dispatch_end\s+tool=Glob\b[^\n]*\boutcome=ok\b", debug_log) is not None
|
|
116
|
+
)
|
|
117
|
+
tool_called = bool(tool_ids) or debug_called
|
|
118
|
+
tool_completed = bool(tool_ids & completed_ids) or debug_completed
|
|
119
|
+
diagnostics = [stderr.strip(), final_text.strip()]
|
|
120
|
+
diagnostic = "\n".join(part for part in diagnostics if part)[-2000:]
|
|
121
|
+
return ToolProbeResult(
|
|
122
|
+
tool_called=tool_called,
|
|
123
|
+
tool_completed=tool_completed,
|
|
124
|
+
acknowledged_result=PROBE_MARKER in final_text,
|
|
125
|
+
returncode=returncode,
|
|
126
|
+
total_cost_usd=total_cost,
|
|
127
|
+
final_text=final_text,
|
|
128
|
+
diagnostic=diagnostic,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def probe_model(model: dict[str, Any], *, timeout: int = 180) -> ToolProbeResult:
|
|
133
|
+
"""Run an isolated, billable Glob round-trip through real Claude Code."""
|
|
134
|
+
route = managed_model(model)
|
|
135
|
+
token = secrets.token_urlsafe(32)
|
|
136
|
+
server = HybridRouterServer(
|
|
137
|
+
("127.0.0.1", 0),
|
|
138
|
+
local_token=token,
|
|
139
|
+
routes={route: model},
|
|
140
|
+
record_status=False,
|
|
141
|
+
)
|
|
142
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
143
|
+
thread.start()
|
|
144
|
+
try:
|
|
145
|
+
with tempfile.TemporaryDirectory(prefix="cam-tool-check-") as directory:
|
|
146
|
+
workdir = Path(directory)
|
|
147
|
+
(workdir / PROBE_FILENAME).write_text("tool round-trip probe\n", encoding="utf-8")
|
|
148
|
+
debug_path = workdir / "claude-debug.log"
|
|
149
|
+
environment = dict(os.environ)
|
|
150
|
+
for name in (
|
|
151
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
152
|
+
"CLAUDE_CODE_USE_FOUNDRY",
|
|
153
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
154
|
+
):
|
|
155
|
+
environment.pop(name, None)
|
|
156
|
+
environment.update(
|
|
157
|
+
{
|
|
158
|
+
"ANTHROPIC_BASE_URL": router_base_url(server.server_port),
|
|
159
|
+
"ANTHROPIC_API_KEY": "",
|
|
160
|
+
"ANTHROPIC_AUTH_TOKEN": token,
|
|
161
|
+
"ANTHROPIC_CUSTOM_HEADERS": f"{LOCAL_TOKEN_HEADER}: {token}",
|
|
162
|
+
"ENABLE_TOOL_SEARCH": "false",
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
command = [
|
|
166
|
+
find_claude(),
|
|
167
|
+
"-p",
|
|
168
|
+
"--model",
|
|
169
|
+
route,
|
|
170
|
+
"--name",
|
|
171
|
+
"cam-tool-check",
|
|
172
|
+
"--setting-sources",
|
|
173
|
+
"project,local",
|
|
174
|
+
"--tools",
|
|
175
|
+
"Glob",
|
|
176
|
+
"--permission-mode",
|
|
177
|
+
"dontAsk",
|
|
178
|
+
"--no-session-persistence",
|
|
179
|
+
"--output-format",
|
|
180
|
+
"stream-json",
|
|
181
|
+
"--verbose",
|
|
182
|
+
"--debug-file",
|
|
183
|
+
str(debug_path),
|
|
184
|
+
(
|
|
185
|
+
f"Use the Glob tool exactly once to find {PROBE_FILENAME} in the current "
|
|
186
|
+
f"directory. Only after reading the tool result, reply exactly {PROBE_MARKER}."
|
|
187
|
+
),
|
|
188
|
+
]
|
|
189
|
+
try:
|
|
190
|
+
completed = subprocess.run(
|
|
191
|
+
command,
|
|
192
|
+
cwd=workdir,
|
|
193
|
+
env=environment,
|
|
194
|
+
stdin=subprocess.DEVNULL,
|
|
195
|
+
capture_output=True,
|
|
196
|
+
text=True,
|
|
197
|
+
check=False,
|
|
198
|
+
timeout=timeout,
|
|
199
|
+
)
|
|
200
|
+
except subprocess.TimeoutExpired as exc:
|
|
201
|
+
output = exc.stdout if isinstance(exc.stdout, str) else ""
|
|
202
|
+
stderr = exc.stderr if isinstance(exc.stderr, str) else ""
|
|
203
|
+
debug_log = debug_path.read_text(errors="replace") if debug_path.exists() else ""
|
|
204
|
+
result = parse_probe_result(output, debug_log, returncode=124, stderr=stderr)
|
|
205
|
+
return replace(result, diagnostic="tool check timed out")
|
|
206
|
+
debug_log = debug_path.read_text(errors="replace") if debug_path.exists() else ""
|
|
207
|
+
return parse_probe_result(
|
|
208
|
+
completed.stdout,
|
|
209
|
+
debug_log,
|
|
210
|
+
returncode=completed.returncode,
|
|
211
|
+
stderr=completed.stderr,
|
|
212
|
+
)
|
|
213
|
+
finally:
|
|
214
|
+
server.shutdown()
|
|
215
|
+
server.server_close()
|
|
216
|
+
thread.join(timeout=2)
|