eval-harness 0.2.18
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.
- package/README.md +1046 -0
- package/README.zh-CN.md +597 -0
- package/bin/eval-harness.js +65 -0
- package/config/eval.example.json +70 -0
- package/docs/agent-runner-compatibility.md +47 -0
- package/docs/runners.md +82 -0
- package/package.json +43 -0
- package/prompts/judge.md +27 -0
- package/pyproject.toml +48 -0
- package/schemas/batch-comparison.schema.json +64 -0
- package/schemas/batch-summary.schema.json +85 -0
- package/schemas/evaluation.schema.json +81 -0
- package/schemas/experiment.schema.json +63 -0
- package/schemas/history-index.schema.json +37 -0
- package/schemas/history-record.schema.json +56 -0
- package/schemas/model-report.schema.json +38 -0
- package/schemas/next-action.schema.json +62 -0
- package/schemas/skill-improvement-input.schema.json +65 -0
- package/src/eval/__init__.py +3 -0
- package/src/eval/__main__.py +5 -0
- package/src/eval/cli.py +11150 -0
- package/src/eval/runners/__init__.py +0 -0
- package/src/eval/runners/base.py +197 -0
- package/src/eval/runners/claude.py +516 -0
- package/src/eval/runners/codex.py +412 -0
- package/src/eval/runners/mimocode.py +676 -0
- package/src/eval/runners/opencode.py +554 -0
- package/src/eval/schema.py +151 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .base import DEFAULT_AGENT_PROMPT, _command_get_named_arg, _command_set_named_arg, normalize_agent_value
|
|
10
|
+
from .opencode import OpenCodeRunner
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClaudeCodeRunner(OpenCodeRunner):
|
|
14
|
+
"""Claude Code CLI agent runner adapter."""
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def name(self) -> str:
|
|
18
|
+
return "claude"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def binary(self) -> str:
|
|
22
|
+
return "claude"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def config_key(self) -> str:
|
|
26
|
+
return "claude"
|
|
27
|
+
|
|
28
|
+
# -- command building
|
|
29
|
+
|
|
30
|
+
def default_command(self, config: dict[str, Any]) -> list[str]:
|
|
31
|
+
executable = self.executable(config)
|
|
32
|
+
return [
|
|
33
|
+
executable,
|
|
34
|
+
"-p",
|
|
35
|
+
"--output-format",
|
|
36
|
+
"stream-json",
|
|
37
|
+
"--verbose",
|
|
38
|
+
"--dangerously-skip-permissions",
|
|
39
|
+
DEFAULT_AGENT_PROMPT,
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def executable(self, config: dict[str, Any]) -> str:
|
|
43
|
+
for value in (
|
|
44
|
+
(config.get("agent") or {}).get("executable") if isinstance(config.get("agent"), dict) else None,
|
|
45
|
+
(config.get("claude") or {}).get("executable") if isinstance(config.get("claude"), dict) else None,
|
|
46
|
+
(config.get("execution") or {}).get("claude_executable") if isinstance(config.get("execution"), dict) else None,
|
|
47
|
+
((config.get("execution") or {}).get("claude") or {}).get("executable")
|
|
48
|
+
if isinstance(config.get("execution"), dict) and isinstance((config.get("execution") or {}).get("claude"), dict)
|
|
49
|
+
else None,
|
|
50
|
+
):
|
|
51
|
+
text = str(value or "").strip()
|
|
52
|
+
if text:
|
|
53
|
+
return text
|
|
54
|
+
return "claude"
|
|
55
|
+
|
|
56
|
+
def format_flags(self) -> list[str]:
|
|
57
|
+
return ["--output-format", "stream-json"]
|
|
58
|
+
|
|
59
|
+
def command_with_model(self, command: list[str], model: str) -> list[str]:
|
|
60
|
+
model = _claude_model_name(model)
|
|
61
|
+
if _command_get_named_arg(command, "--model"):
|
|
62
|
+
return _command_set_named_arg(command, "--model", model)
|
|
63
|
+
if _command_get_named_arg(command, "-m"):
|
|
64
|
+
return _command_set_named_arg(command, "-m", model)
|
|
65
|
+
if command and _is_claude_executable(str(command[0])):
|
|
66
|
+
previous = str(command[-2]) if len(command) >= 2 else ""
|
|
67
|
+
last = str(command[-1])
|
|
68
|
+
if last and not last.startswith("-") and previous not in _CLAUDE_VALUE_FLAGS:
|
|
69
|
+
return [*command[:-1], "--model", model, command[-1]]
|
|
70
|
+
return [*command, "--model", model]
|
|
71
|
+
|
|
72
|
+
def command_with_variant(self, command: list[str], variant: str) -> list[str]:
|
|
73
|
+
return command
|
|
74
|
+
|
|
75
|
+
def variant_cli_flag(self) -> str:
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
def variant_from_command(self, command: list[str]) -> str:
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
def default_worker_tools(self, config: dict[str, Any] | None = None) -> list[str]:
|
|
82
|
+
return [self.executable(config or {})]
|
|
83
|
+
|
|
84
|
+
# -- log directory
|
|
85
|
+
|
|
86
|
+
def log_dir_name(self) -> str:
|
|
87
|
+
return "claude"
|
|
88
|
+
|
|
89
|
+
# -- asset installation
|
|
90
|
+
|
|
91
|
+
def default_asset_installs(self) -> list[dict[str, Any]]:
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
"source": "work/work/skills/*",
|
|
95
|
+
"target": ".claude/skills",
|
|
96
|
+
"kind": "skills",
|
|
97
|
+
"entries": "directories",
|
|
98
|
+
"required": False,
|
|
99
|
+
"clean": True,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"source": "work/work/skills/versions.json",
|
|
103
|
+
"target": ".claude/skills",
|
|
104
|
+
"kind": "skill-manifest",
|
|
105
|
+
"entries": "files",
|
|
106
|
+
"required": False,
|
|
107
|
+
"clean": False,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"source": "work/skills/*",
|
|
111
|
+
"target": ".claude/skills",
|
|
112
|
+
"kind": "skills-flat-fallback",
|
|
113
|
+
"entries": "directories",
|
|
114
|
+
"required": False,
|
|
115
|
+
"clean": False,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"source": "work/skills/versions.json",
|
|
119
|
+
"target": ".claude/skills",
|
|
120
|
+
"kind": "skill-manifest-flat-fallback",
|
|
121
|
+
"entries": "files",
|
|
122
|
+
"required": False,
|
|
123
|
+
"clean": False,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"source": "work/work/skills/*.md",
|
|
127
|
+
"target": ".claude/agents",
|
|
128
|
+
"kind": "agents",
|
|
129
|
+
"required": False,
|
|
130
|
+
"clean": True,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"source": "work/skills/*.md",
|
|
134
|
+
"target": ".claude/agents",
|
|
135
|
+
"kind": "agents-flat-fallback",
|
|
136
|
+
"required": False,
|
|
137
|
+
"clean": False,
|
|
138
|
+
},
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
# -- runtime profile seeding
|
|
142
|
+
|
|
143
|
+
def runtime_profile_candidates(
|
|
144
|
+
self, base_env: dict[str, str], runtime: dict[str, str]
|
|
145
|
+
) -> list[tuple[Path | None, Path, str]]:
|
|
146
|
+
from ..cli import existing_path
|
|
147
|
+
|
|
148
|
+
home = Path(runtime["home_dir"])
|
|
149
|
+
appdata = Path(runtime.get("appdata_dir", home / "AppData" / "Roaming"))
|
|
150
|
+
localappdata = Path(runtime.get("localappdata_dir", home / "AppData" / "Local"))
|
|
151
|
+
config_dir = Path(runtime["config_dir"])
|
|
152
|
+
data_dir = Path(runtime["data_dir"])
|
|
153
|
+
|
|
154
|
+
return [
|
|
155
|
+
(existing_path(base_env.get("USERPROFILE"), ".claude"), home / ".claude", "USERPROFILE/.claude"),
|
|
156
|
+
(existing_path(base_env.get("HOME"), ".claude"), home / ".claude", "HOME/.claude"),
|
|
157
|
+
(existing_path(base_env.get("USERPROFILE"), ".config", "claude"), config_dir / "claude", "USERPROFILE/.config/claude"),
|
|
158
|
+
(existing_path(base_env.get("USERPROFILE"), ".local", "share", "claude"), data_dir / "claude", "USERPROFILE/.local/share/claude"),
|
|
159
|
+
(existing_path(base_env.get("USERPROFILE"), ".cache", "claude"), Path(runtime["cache_dir"]) / "claude", "USERPROFILE/.cache/claude"),
|
|
160
|
+
(existing_path(base_env.get("APPDATA"), "Claude"), appdata / "Claude", "APPDATA/Claude"),
|
|
161
|
+
(existing_path(base_env.get("APPDATA"), "claude"), appdata / "claude", "APPDATA/claude"),
|
|
162
|
+
(existing_path(base_env.get("LOCALAPPDATA"), "Claude"), localappdata / "Claude", "LOCALAPPDATA/Claude"),
|
|
163
|
+
(existing_path(base_env.get("LOCALAPPDATA"), "claude"), localappdata / "claude", "LOCALAPPDATA/claude"),
|
|
164
|
+
(existing_path(base_env.get("XDG_CONFIG_HOME"), "claude"), config_dir / "claude", "XDG_CONFIG_HOME/claude"),
|
|
165
|
+
(existing_path(base_env.get("XDG_DATA_HOME"), "claude"), data_dir / "claude", "XDG_DATA_HOME/claude"),
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
# -- provider config
|
|
169
|
+
|
|
170
|
+
def write_agent_config(self, manifest: dict[str, Any]) -> str | None:
|
|
171
|
+
runtime = manifest.get("runtime") or {}
|
|
172
|
+
home = runtime.get("home_dir")
|
|
173
|
+
config_dir = Path(home) / ".claude" if home else None
|
|
174
|
+
if config_dir:
|
|
175
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
settings_path = config_dir / "settings.json" if config_dir else None
|
|
177
|
+
|
|
178
|
+
from ..cli import agent_inspector_settings, execution_agent_provider
|
|
179
|
+
|
|
180
|
+
execution = manifest.setdefault("execution", {})
|
|
181
|
+
if not isinstance(execution, dict):
|
|
182
|
+
return str(config_dir) if config_dir else None
|
|
183
|
+
|
|
184
|
+
provider = execution_agent_provider(execution, self)
|
|
185
|
+
inspector = agent_inspector_settings(execution)
|
|
186
|
+
base_url = ""
|
|
187
|
+
api_key = ""
|
|
188
|
+
if isinstance(provider, dict):
|
|
189
|
+
base_url = str(provider.get("baseURL") or provider.get("base_url") or "").strip()
|
|
190
|
+
api_key = str(provider.get("apiKey") or provider.get("api_key") or "").strip()
|
|
191
|
+
if not base_url and inspector.get("enabled"):
|
|
192
|
+
base_url = str(inspector.get("proxy_url") or "").strip()
|
|
193
|
+
if base_url:
|
|
194
|
+
env = execution.setdefault("env", {})
|
|
195
|
+
if not isinstance(env, dict):
|
|
196
|
+
raise RuntimeError("execution.env must be a JSON object")
|
|
197
|
+
env.setdefault("ANTHROPIC_BASE_URL", base_url.rstrip("/"))
|
|
198
|
+
env.setdefault("ANTHROPIC_API_KEY", api_key or "agent-inspector")
|
|
199
|
+
env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1")
|
|
200
|
+
env.setdefault("DISABLE_TELEMETRY", "1")
|
|
201
|
+
active_model = _claude_active_model(execution)
|
|
202
|
+
if active_model:
|
|
203
|
+
env.setdefault("ANTHROPIC_MODEL", active_model)
|
|
204
|
+
env.setdefault("CLAUDE_CODE_SUBAGENT_MODEL", active_model)
|
|
205
|
+
for key in _CLAUDE_ALIAS_MODEL_ENV_KEYS:
|
|
206
|
+
env.setdefault(key, active_model)
|
|
207
|
+
if settings_path:
|
|
208
|
+
_write_claude_settings_model_pin(settings_path, active_model, env)
|
|
209
|
+
return str(config_dir) if config_dir else None
|
|
210
|
+
|
|
211
|
+
# -- log parsing: sessions
|
|
212
|
+
|
|
213
|
+
def extract_sessions_from_text(
|
|
214
|
+
self, text: str, *, inspector_base_url: str = "http://127.0.0.1:25947"
|
|
215
|
+
) -> dict[str, Any]:
|
|
216
|
+
from ..cli import OPENCODE_SESSION_SCHEMA, plain_log_text, utc_now_seconds_text
|
|
217
|
+
|
|
218
|
+
records: dict[str, dict[str, Any]] = {}
|
|
219
|
+
order: list[str] = []
|
|
220
|
+
primary_session_id: str | None = None
|
|
221
|
+
|
|
222
|
+
def remember(session_id: str) -> dict[str, Any]:
|
|
223
|
+
if session_id not in records:
|
|
224
|
+
order.append(session_id)
|
|
225
|
+
records[session_id] = {
|
|
226
|
+
"session_id": session_id,
|
|
227
|
+
"parent_id": None,
|
|
228
|
+
"agent": None,
|
|
229
|
+
"title": None,
|
|
230
|
+
"provider": "anthropic",
|
|
231
|
+
"model": None,
|
|
232
|
+
"mode": None,
|
|
233
|
+
"first_seen": None,
|
|
234
|
+
"last_seen": None,
|
|
235
|
+
"step_count": 0,
|
|
236
|
+
"tool_call_count": 0,
|
|
237
|
+
"text_part_count": 0,
|
|
238
|
+
"error_count": 0,
|
|
239
|
+
"tokens": {"input": 0, "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0},
|
|
240
|
+
}
|
|
241
|
+
return records[session_id]
|
|
242
|
+
|
|
243
|
+
for raw_line in text.splitlines():
|
|
244
|
+
line = plain_log_text(raw_line).strip()
|
|
245
|
+
if not line.startswith("{"):
|
|
246
|
+
continue
|
|
247
|
+
try:
|
|
248
|
+
payload = json.loads(line)
|
|
249
|
+
except json.JSONDecodeError:
|
|
250
|
+
continue
|
|
251
|
+
if not isinstance(payload, dict):
|
|
252
|
+
continue
|
|
253
|
+
session_id = normalize_agent_value(
|
|
254
|
+
str(payload.get("session_id") or payload.get("sessionID") or "")
|
|
255
|
+
)
|
|
256
|
+
if not session_id:
|
|
257
|
+
continue
|
|
258
|
+
record = remember(session_id)
|
|
259
|
+
timestamp = normalize_agent_value(str(payload.get("timestamp") or ""))
|
|
260
|
+
_update_seen(record, timestamp)
|
|
261
|
+
event_type = str(payload.get("type") or "")
|
|
262
|
+
if event_type == "result":
|
|
263
|
+
record["step_count"] = int(record.get("step_count", 0)) + 1
|
|
264
|
+
elif event_type == "error":
|
|
265
|
+
record["error_count"] = int(record.get("error_count", 0)) + 1
|
|
266
|
+
message = payload.get("message")
|
|
267
|
+
if isinstance(message, dict):
|
|
268
|
+
model = normalize_agent_value(str(message.get("model") or ""))
|
|
269
|
+
if model:
|
|
270
|
+
record["model"] = model
|
|
271
|
+
_apply_claude_usage(record, message.get("usage"))
|
|
272
|
+
content = message.get("content")
|
|
273
|
+
if isinstance(content, list):
|
|
274
|
+
for block in content:
|
|
275
|
+
if not isinstance(block, dict):
|
|
276
|
+
continue
|
|
277
|
+
block_type = str(block.get("type") or "")
|
|
278
|
+
if block_type == "tool_use":
|
|
279
|
+
record["tool_call_count"] = int(record.get("tool_call_count", 0)) + 1
|
|
280
|
+
elif block_type == "text":
|
|
281
|
+
record["text_part_count"] = int(record.get("text_part_count", 0)) + 1
|
|
282
|
+
model = normalize_agent_value(str(payload.get("model") or ""))
|
|
283
|
+
if model:
|
|
284
|
+
record["model"] = model
|
|
285
|
+
if primary_session_id is None:
|
|
286
|
+
primary_session_id = session_id
|
|
287
|
+
|
|
288
|
+
sessions = [records[session_id] for session_id in order]
|
|
289
|
+
base = inspector_base_url.rstrip("/") or "http://127.0.0.1:25947"
|
|
290
|
+
for record in sessions:
|
|
291
|
+
record["inspector_url"] = f"{base}/?sessionId={urllib.parse.quote(str(record['session_id']))}"
|
|
292
|
+
if not primary_session_id and sessions:
|
|
293
|
+
primary_session_id = str(sessions[0]["session_id"])
|
|
294
|
+
return {
|
|
295
|
+
"schema": OPENCODE_SESSION_SCHEMA,
|
|
296
|
+
"generated_at": utc_now_seconds_text(),
|
|
297
|
+
"inspector_base_url": base,
|
|
298
|
+
"inspector_sessions_api": f"{base}/api/sessions",
|
|
299
|
+
"primary_session_id": primary_session_id,
|
|
300
|
+
"session_count": len(sessions),
|
|
301
|
+
"subagent_session_count": sum(1 for item in sessions if item.get("parent_id")),
|
|
302
|
+
"sessions": sessions,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
def render_sessions_md(self, summary: dict[str, Any]) -> str:
|
|
306
|
+
text = super().render_sessions_md(summary)
|
|
307
|
+
return text.replace("# Claude Sessions", "# Claude Code Sessions", 1)
|
|
308
|
+
|
|
309
|
+
# -- log parsing: model
|
|
310
|
+
|
|
311
|
+
def model_from_log(self, run_dir: Path) -> str:
|
|
312
|
+
log_path = run_dir / self.log_dir_name() / "opencode.log"
|
|
313
|
+
if not log_path.exists():
|
|
314
|
+
return ""
|
|
315
|
+
from ..cli import read_trace_text
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
text = read_trace_text(log_path)
|
|
319
|
+
except OSError:
|
|
320
|
+
return ""
|
|
321
|
+
command_match = re.search(r"^\$\s+.+?\s--model\s+(\S+)", text, re.MULTILINE)
|
|
322
|
+
if command_match:
|
|
323
|
+
return command_match.group(1)
|
|
324
|
+
for line in text.splitlines():
|
|
325
|
+
line = line.strip()
|
|
326
|
+
if not line.startswith("{"):
|
|
327
|
+
continue
|
|
328
|
+
try:
|
|
329
|
+
payload = json.loads(line)
|
|
330
|
+
except json.JSONDecodeError:
|
|
331
|
+
continue
|
|
332
|
+
if not isinstance(payload, dict):
|
|
333
|
+
continue
|
|
334
|
+
model = normalize_agent_value(str(payload.get("model") or ""))
|
|
335
|
+
message = payload.get("message")
|
|
336
|
+
if not model and isinstance(message, dict):
|
|
337
|
+
model = normalize_agent_value(str(message.get("model") or ""))
|
|
338
|
+
if model:
|
|
339
|
+
return model
|
|
340
|
+
return ""
|
|
341
|
+
|
|
342
|
+
# -- log parsing: activity
|
|
343
|
+
|
|
344
|
+
def extract_activity_from_text(self, text: str, *, tail_lines: int = 500) -> dict[str, Any]:
|
|
345
|
+
from ..cli import plain_log_text
|
|
346
|
+
|
|
347
|
+
lines = text.splitlines()[-tail_lines:]
|
|
348
|
+
sessions: set[str] = set()
|
|
349
|
+
latest: dict[str, Any] = {}
|
|
350
|
+
event_count = 0
|
|
351
|
+
touched_files: list[str] = []
|
|
352
|
+
|
|
353
|
+
for raw_line in lines:
|
|
354
|
+
line = plain_log_text(raw_line).strip()
|
|
355
|
+
if not line.startswith("{"):
|
|
356
|
+
continue
|
|
357
|
+
try:
|
|
358
|
+
payload = json.loads(line)
|
|
359
|
+
except json.JSONDecodeError:
|
|
360
|
+
continue
|
|
361
|
+
if not isinstance(payload, dict):
|
|
362
|
+
continue
|
|
363
|
+
session_id = normalize_agent_value(str(payload.get("session_id") or payload.get("sessionID") or ""))
|
|
364
|
+
if not session_id:
|
|
365
|
+
continue
|
|
366
|
+
event_count += 1
|
|
367
|
+
sessions.add(session_id)
|
|
368
|
+
message = payload.get("message")
|
|
369
|
+
model = normalize_agent_value(str(payload.get("model") or ""))
|
|
370
|
+
if not model and isinstance(message, dict):
|
|
371
|
+
model = normalize_agent_value(str(message.get("model") or ""))
|
|
372
|
+
latest = {
|
|
373
|
+
"latest_session_id": session_id,
|
|
374
|
+
"latest_mode": None,
|
|
375
|
+
"latest_agent": None,
|
|
376
|
+
"latest_model": model,
|
|
377
|
+
"latest_message": normalize_agent_value(str(payload.get("type") or "")),
|
|
378
|
+
"latest_timestamp": normalize_agent_value(str(payload.get("timestamp") or "")),
|
|
379
|
+
}
|
|
380
|
+
if isinstance(message, dict) and isinstance(message.get("content"), list):
|
|
381
|
+
for block in message["content"]:
|
|
382
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
383
|
+
name = str(block.get("name") or "")
|
|
384
|
+
if name in {"Edit", "Write", "MultiEdit"}:
|
|
385
|
+
tool_input = block.get("input")
|
|
386
|
+
if isinstance(tool_input, dict) and tool_input.get("file_path"):
|
|
387
|
+
touched_files.append(str(tool_input["file_path"]))
|
|
388
|
+
|
|
389
|
+
latest.update(
|
|
390
|
+
{
|
|
391
|
+
"event_count": event_count,
|
|
392
|
+
"session_count": len(sessions),
|
|
393
|
+
"subagent_session_count": 0,
|
|
394
|
+
"has_subagent_activity": False,
|
|
395
|
+
"touched_file_count": len(touched_files),
|
|
396
|
+
"latest_touched_file": touched_files[-1] if touched_files else "",
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
return latest
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
_CLAUDE_VALUE_FLAGS = {
|
|
403
|
+
"--add-dir",
|
|
404
|
+
"--agent",
|
|
405
|
+
"--agents",
|
|
406
|
+
"--allowedTools",
|
|
407
|
+
"--allowed-tools",
|
|
408
|
+
"--append-system-prompt",
|
|
409
|
+
"--debug-file",
|
|
410
|
+
"--disallowedTools",
|
|
411
|
+
"--disallowed-tools",
|
|
412
|
+
"--effort",
|
|
413
|
+
"--fallback-model",
|
|
414
|
+
"--file",
|
|
415
|
+
"--input-format",
|
|
416
|
+
"--json-schema",
|
|
417
|
+
"--max-budget-usd",
|
|
418
|
+
"--mcp-config",
|
|
419
|
+
"--model",
|
|
420
|
+
"-m",
|
|
421
|
+
"--name",
|
|
422
|
+
"-n",
|
|
423
|
+
"--output-format",
|
|
424
|
+
"--permission-mode",
|
|
425
|
+
"--plugin-dir",
|
|
426
|
+
"--plugin-url",
|
|
427
|
+
"--resume",
|
|
428
|
+
"-r",
|
|
429
|
+
"--session-id",
|
|
430
|
+
"--setting-sources",
|
|
431
|
+
"--settings",
|
|
432
|
+
"--system-prompt",
|
|
433
|
+
"--tools",
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _is_claude_executable(value: str) -> bool:
|
|
438
|
+
name = Path(value).name.lower()
|
|
439
|
+
return name in {"claude", "claude.exe", "claude.cmd", "claude.ps1"}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _claude_model_name(model: str) -> str:
|
|
443
|
+
text = str(model or "").strip()
|
|
444
|
+
prefix = "agent-inspector/"
|
|
445
|
+
if text.startswith(prefix):
|
|
446
|
+
return text[len(prefix) :]
|
|
447
|
+
return text
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
_CLAUDE_ALIAS_MODEL_ENV_KEYS = (
|
|
451
|
+
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
452
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
453
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
454
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _claude_active_model(execution: dict[str, Any]) -> str:
|
|
459
|
+
for key in ("actual_model", "model"):
|
|
460
|
+
value = _claude_model_name(str(execution.get(key) or "")).strip()
|
|
461
|
+
if value:
|
|
462
|
+
return value
|
|
463
|
+
env = execution.get("env")
|
|
464
|
+
if isinstance(env, dict):
|
|
465
|
+
for key in ("ANTHROPIC_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"):
|
|
466
|
+
value = _claude_model_name(str(env.get(key) or "")).strip()
|
|
467
|
+
if value and value != "inherit":
|
|
468
|
+
return value
|
|
469
|
+
return ""
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _write_claude_settings_model_pin(settings_path: Path, active_model: str, env: dict[str, Any]) -> None:
|
|
473
|
+
settings: dict[str, Any] = {}
|
|
474
|
+
if settings_path.exists():
|
|
475
|
+
try:
|
|
476
|
+
loaded = json.loads(settings_path.read_text(encoding="utf-8-sig"))
|
|
477
|
+
if isinstance(loaded, dict):
|
|
478
|
+
settings = loaded
|
|
479
|
+
except json.JSONDecodeError:
|
|
480
|
+
settings = {}
|
|
481
|
+
settings["model"] = str(env.get("ANTHROPIC_MODEL") or active_model)
|
|
482
|
+
settings_env = settings.setdefault("env", {})
|
|
483
|
+
if not isinstance(settings_env, dict):
|
|
484
|
+
settings_env = {}
|
|
485
|
+
settings["env"] = settings_env
|
|
486
|
+
for key in ("ANTHROPIC_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL", *_CLAUDE_ALIAS_MODEL_ENV_KEYS):
|
|
487
|
+
value = env.get(key)
|
|
488
|
+
if value:
|
|
489
|
+
settings_env[key] = str(value)
|
|
490
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
491
|
+
settings_path.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _update_seen(record: dict[str, Any], timestamp: str | None) -> None:
|
|
495
|
+
if not timestamp:
|
|
496
|
+
return
|
|
497
|
+
if record.get("first_seen") is None or str(timestamp) < str(record.get("first_seen")):
|
|
498
|
+
record["first_seen"] = timestamp
|
|
499
|
+
if record.get("last_seen") is None or str(timestamp) > str(record.get("last_seen")):
|
|
500
|
+
record["last_seen"] = timestamp
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _apply_claude_usage(record: dict[str, Any], usage: Any) -> None:
|
|
504
|
+
if not isinstance(usage, dict):
|
|
505
|
+
return
|
|
506
|
+
tokens = record.setdefault("tokens", {})
|
|
507
|
+
mapping = {
|
|
508
|
+
"input_tokens": "input",
|
|
509
|
+
"output_tokens": "output",
|
|
510
|
+
"cache_read_input_tokens": "cache_read",
|
|
511
|
+
"cache_creation_input_tokens": "cache_write",
|
|
512
|
+
}
|
|
513
|
+
for source, target in mapping.items():
|
|
514
|
+
value = usage.get(source)
|
|
515
|
+
if isinstance(value, int):
|
|
516
|
+
tokens[target] = int(tokens.get(target, 0)) + value
|