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.
@@ -0,0 +1,3 @@
1
+ """Manage multiple Claude subscriptions and provider keys in Claude Code."""
2
+
3
+ __version__ = "0.0.2"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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)