doesitfire 0.1.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.
- doesitfire/__init__.py +3 -0
- doesitfire/agents.py +310 -0
- doesitfire/cli.py +263 -0
- doesitfire/config.py +251 -0
- doesitfire/data/demo-results.json +1393 -0
- doesitfire/docker/Dockerfile +24 -0
- doesitfire/docker/egress-proxy.mjs +113 -0
- doesitfire/docker/entrypoint.sh +14 -0
- doesitfire/errors.py +17 -0
- doesitfire/html.py +26 -0
- doesitfire/metrics.py +117 -0
- doesitfire/providers.py +121 -0
- doesitfire/report.py +175 -0
- doesitfire/runner.py +283 -0
- doesitfire/sandbox.py +276 -0
- doesitfire/templates/report.html +341 -0
- doesitfire-0.1.2.dist-info/METADATA +306 -0
- doesitfire-0.1.2.dist-info/RECORD +21 -0
- doesitfire-0.1.2.dist-info/WHEEL +4 -0
- doesitfire-0.1.2.dist-info/entry_points.txt +2 -0
- doesitfire-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Agent sandbox image for doesitfire. CLI versions are pinned on purpose:
|
|
2
|
+
# their JSON event formats change between releases.
|
|
3
|
+
FROM node:26.13.0-bookworm-slim
|
|
4
|
+
|
|
5
|
+
ARG CLAUDE_CODE_VERSION=2.1.317
|
|
6
|
+
ARG CODEX_VERSION=0.166.0
|
|
7
|
+
ARG OPENCODE_VERSION=1.19.5
|
|
8
|
+
|
|
9
|
+
RUN npm install -g --no-audit --no-fund \
|
|
10
|
+
@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
|
|
11
|
+
@openai/codex@${CODEX_VERSION} \
|
|
12
|
+
opencode-ai@${OPENCODE_VERSION} \
|
|
13
|
+
&& npm cache clean --force \
|
|
14
|
+
&& apt-get update && apt-get install -y --no-install-recommends ca-certificates git ripgrep \
|
|
15
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
16
|
+
|
|
17
|
+
COPY egress-proxy.mjs /opt/doesitfire/egress-proxy.mjs
|
|
18
|
+
COPY entrypoint.sh /opt/doesitfire/entrypoint.sh
|
|
19
|
+
RUN chmod 0755 /opt/doesitfire/entrypoint.sh
|
|
20
|
+
|
|
21
|
+
RUN mkdir -p /home/node/work && chown -R node:node /home/node
|
|
22
|
+
USER node
|
|
23
|
+
WORKDIR /home/node
|
|
24
|
+
ENTRYPOINT ["/opt/doesitfire/entrypoint.sh"]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Minimal egress proxy: the only way out of the internal Docker network.
|
|
2
|
+
// Allows HTTP CONNECT (HTTPS) and plain HTTP forwarding to an allowlist of
|
|
3
|
+
// host:port pairs given in DOESITFIRE_ALLOW (comma separated). Everything
|
|
4
|
+
// else gets 403. No logging of request bodies.
|
|
5
|
+
import http from "node:http";
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
|
|
8
|
+
const allow = new Set(
|
|
9
|
+
(process.env.DOESITFIRE_ALLOW || "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean),
|
|
10
|
+
);
|
|
11
|
+
const port = Number(process.env.DOESITFIRE_PROXY_PORT || 8888);
|
|
12
|
+
// Ollama's OpenAI Responses endpoint drops input messages with role "developer", which is
|
|
13
|
+
// where Codex puts its skill list. For local Ollama only (plain HTTP), rewrite them to
|
|
14
|
+
// "system", which Ollama keeps. HTTPS traffic to paid APIs is tunnelled and never touched.
|
|
15
|
+
const developerAsSystem = process.env.DOESITFIRE_DEVELOPER_AS_SYSTEM === "1";
|
|
16
|
+
|
|
17
|
+
function rewriteDeveloper(buf) {
|
|
18
|
+
try {
|
|
19
|
+
const body = JSON.parse(buf.toString("utf8"));
|
|
20
|
+
if (!Array.isArray(body.input)) return buf;
|
|
21
|
+
let changed = false;
|
|
22
|
+
for (const item of body.input) {
|
|
23
|
+
if (item && item.role === "developer") {
|
|
24
|
+
item.role = "system";
|
|
25
|
+
changed = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return changed ? Buffer.from(JSON.stringify(body), "utf8") : buf;
|
|
29
|
+
} catch {
|
|
30
|
+
return buf;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function allowed(host, p) {
|
|
35
|
+
return allow.has(`${String(host).toLowerCase()}:${p}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const server = http.createServer((req, res) => {
|
|
39
|
+
req.on("error", () => {});
|
|
40
|
+
res.on("error", () => {});
|
|
41
|
+
let url;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(req.url);
|
|
44
|
+
} catch {
|
|
45
|
+
res.writeHead(400).end("absolute URL required\n");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const p = url.port || (url.protocol === "https:" ? 443 : 80);
|
|
49
|
+
if (url.protocol !== "http:" || !allowed(url.hostname, p)) {
|
|
50
|
+
console.error(`deny ${req.method} ${url.hostname}:${p}`);
|
|
51
|
+
res.writeHead(403).end("blocked by doesitfire egress proxy\n");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
console.error(`allow ${req.method} ${url.hostname}:${p}${url.pathname}`);
|
|
55
|
+
const headers = { ...req.headers };
|
|
56
|
+
delete headers["proxy-connection"];
|
|
57
|
+
delete headers["proxy-authorization"];
|
|
58
|
+
const forward = (body) => {
|
|
59
|
+
if (body) {
|
|
60
|
+
headers["content-length"] = String(body.length);
|
|
61
|
+
delete headers["transfer-encoding"];
|
|
62
|
+
}
|
|
63
|
+
const upstream = http.request(
|
|
64
|
+
{ host: url.hostname, port: p, method: req.method, path: url.pathname + url.search, headers },
|
|
65
|
+
(up) => {
|
|
66
|
+
res.writeHead(up.statusCode || 502, up.headers);
|
|
67
|
+
up.pipe(res);
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
upstream.on("error", () => {
|
|
71
|
+
if (!res.headersSent) res.writeHead(502);
|
|
72
|
+
res.end();
|
|
73
|
+
});
|
|
74
|
+
res.on("close", () => upstream.destroy());
|
|
75
|
+
if (body) upstream.end(body);
|
|
76
|
+
else req.pipe(upstream);
|
|
77
|
+
};
|
|
78
|
+
if (developerAsSystem && req.method === "POST" && url.pathname.endsWith("/responses")) {
|
|
79
|
+
const chunks = [];
|
|
80
|
+
req.on("data", (c) => chunks.push(c));
|
|
81
|
+
req.on("end", () => forward(rewriteDeveloper(Buffer.concat(chunks))));
|
|
82
|
+
} else {
|
|
83
|
+
forward(null);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
server.on("connect", (req, socket, head) => {
|
|
88
|
+
socket.on("error", () => {});
|
|
89
|
+
const [host, p] = String(req.url).split(":");
|
|
90
|
+
if (!allowed(host, p || 443)) {
|
|
91
|
+
console.error(`deny CONNECT ${req.url}`);
|
|
92
|
+
socket.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
console.error(`allow CONNECT ${req.url}`);
|
|
96
|
+
const upstream = net.connect(Number(p || 443), host, () => {
|
|
97
|
+
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
98
|
+
upstream.write(head);
|
|
99
|
+
upstream.pipe(socket);
|
|
100
|
+
socket.pipe(upstream);
|
|
101
|
+
});
|
|
102
|
+
upstream.on("error", () => socket.destroy());
|
|
103
|
+
socket.on("error", () => upstream.destroy());
|
|
104
|
+
socket.on("close", () => upstream.destroy());
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
server.on("clientError", (err, socket) => socket.destroy());
|
|
108
|
+
// A proxy must never die because one client hung up mid-stream.
|
|
109
|
+
process.on("uncaughtException", (err) => console.error(`proxy error: ${err.code || err.message}`));
|
|
110
|
+
|
|
111
|
+
server.listen(port, "0.0.0.0", () => {
|
|
112
|
+
console.error(`egress proxy on :${port}, allow=${[...allow].join(",") || "(nothing)"}`);
|
|
113
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Copies the skills mounted read-only at /skills into the discovery directory
|
|
3
|
+
# of the agent under test (DOESITFIRE_SKILLS_DIR, relative to a fresh
|
|
4
|
+
# workspace), writes optional agent config, then runs the agent command.
|
|
5
|
+
set -eu
|
|
6
|
+
WORK=/home/node/work
|
|
7
|
+
mkdir -p "$WORK"
|
|
8
|
+
cd "$WORK"
|
|
9
|
+
git init -q . >/dev/null 2>&1 || true
|
|
10
|
+
if [ -d /skills ] && [ -n "${DOESITFIRE_SKILLS_DIR:-}" ]; then
|
|
11
|
+
mkdir -p "$WORK/$DOESITFIRE_SKILLS_DIR"
|
|
12
|
+
cp -R /skills/. "$WORK/$DOESITFIRE_SKILLS_DIR/"
|
|
13
|
+
fi
|
|
14
|
+
exec "$@"
|
doesitfire/errors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""User-facing errors. The CLI prints these without a traceback."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DoesItFireError(Exception):
|
|
5
|
+
"""An error with a message that tells the user what to do next."""
|
|
6
|
+
|
|
7
|
+
exit_code = 2
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BudgetExceeded(DoesItFireError):
|
|
11
|
+
"""Raised when --max-cost would be exceeded; carries the partial results."""
|
|
12
|
+
|
|
13
|
+
exit_code = 3
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str, results: dict | None = None):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.results = results
|
doesitfire/html.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Static, self-contained HTML report (one file, no external JS)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .metrics import overall, summarize
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_html(results: dict) -> str:
|
|
13
|
+
summary = summarize(results)
|
|
14
|
+
payload = {
|
|
15
|
+
"meta": {k: v for k, v in results.items() if k != "trials"},
|
|
16
|
+
"summary": summary,
|
|
17
|
+
"overall": overall(summary),
|
|
18
|
+
"runs": len(results.get("trials", [])),
|
|
19
|
+
}
|
|
20
|
+
data = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
|
|
21
|
+
template = (resources.files("doesitfire") / "templates" / "report.html").read_text(encoding="utf-8")
|
|
22
|
+
return template.replace("__DOESITFIRE_DATA__", data)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_html(results: dict, path: Path) -> None:
|
|
26
|
+
path.write_text(render_html(results), encoding="utf-8")
|
doesitfire/metrics.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Turn raw trials into the activation matrix.
|
|
2
|
+
|
|
3
|
+
Per agent x skill:
|
|
4
|
+
- recall = fired runs / valid runs, over should-fire prompts
|
|
5
|
+
- false-fire rate = fired runs / valid runs, over should-not-fire prompts
|
|
6
|
+
- precision = true fires / (true fires + false fires)
|
|
7
|
+
Runs that errored (timeout, API error) are excluded from every denominator and counted apart.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Cell:
|
|
17
|
+
agent: str
|
|
18
|
+
skill: str
|
|
19
|
+
fire_runs: int = 0 # valid runs on should-fire prompts
|
|
20
|
+
true_fires: int = 0
|
|
21
|
+
nofire_runs: int = 0 # valid runs on should-not-fire prompts
|
|
22
|
+
false_fires: int = 0
|
|
23
|
+
errors: int = 0
|
|
24
|
+
prompts: list[dict] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def recall(self) -> float | None:
|
|
28
|
+
return self.true_fires / self.fire_runs if self.fire_runs else None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def false_fire_rate(self) -> float | None:
|
|
32
|
+
return self.false_fires / self.nofire_runs if self.nofire_runs else None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def precision(self) -> float | None:
|
|
36
|
+
fired = self.true_fires + self.false_fires
|
|
37
|
+
return self.true_fires / fired if fired else None
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict:
|
|
40
|
+
d = asdict(self)
|
|
41
|
+
d.update(recall=self.recall, false_fire_rate=self.false_fire_rate, precision=self.precision)
|
|
42
|
+
return d
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _add(cell: Cell, t: dict) -> None:
|
|
46
|
+
if t.get("error"):
|
|
47
|
+
cell.errors += 1
|
|
48
|
+
return
|
|
49
|
+
if t["expect"] == "fire":
|
|
50
|
+
cell.fire_runs += 1
|
|
51
|
+
cell.true_fires += bool(t["fired"])
|
|
52
|
+
else:
|
|
53
|
+
cell.nofire_runs += 1
|
|
54
|
+
cell.false_fires += bool(t["fired"])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def summarize(results: dict) -> dict:
|
|
58
|
+
agents = [a["name"] for a in results["agents"]]
|
|
59
|
+
skills = [s["name"] for s in results["skills"]]
|
|
60
|
+
cells = {(a, s): Cell(a, s) for a in agents for s in skills}
|
|
61
|
+
totals = {a: Cell(a, "*") for a in agents}
|
|
62
|
+
per_prompt: dict[tuple, dict] = {}
|
|
63
|
+
for t in results["trials"]:
|
|
64
|
+
key = (t["agent"], t["skill"])
|
|
65
|
+
if key not in cells:
|
|
66
|
+
continue
|
|
67
|
+
_add(cells[key], t)
|
|
68
|
+
_add(totals[t["agent"]], t)
|
|
69
|
+
pk = (t["agent"], t["skill"], t["expect"], t["prompt"])
|
|
70
|
+
row = per_prompt.setdefault(
|
|
71
|
+
pk, {"prompt": t["prompt"], "expect": t["expect"], "fired": 0, "runs": 0, "errors": 0, "other": []}
|
|
72
|
+
)
|
|
73
|
+
if t.get("error"):
|
|
74
|
+
row["errors"] += 1
|
|
75
|
+
else:
|
|
76
|
+
row["runs"] += 1
|
|
77
|
+
row["fired"] += bool(t["fired"])
|
|
78
|
+
for other in t.get("activated") or []:
|
|
79
|
+
if other != t["skill"] and other not in row["other"]:
|
|
80
|
+
row["other"].append(other)
|
|
81
|
+
for (a, s, _e, _p), row in per_prompt.items():
|
|
82
|
+
cells[(a, s)].prompts.append(row)
|
|
83
|
+
return {
|
|
84
|
+
"agents": agents,
|
|
85
|
+
"skills": skills,
|
|
86
|
+
"cells": {f"{a}|{s}": c.to_dict() for (a, s), c in cells.items()},
|
|
87
|
+
"totals": {a: c.to_dict() for a, c in totals.items()},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def overall(summary: dict) -> dict:
|
|
92
|
+
"""Pooled figures across agents and skills (used by the badge)."""
|
|
93
|
+
tf = sum(c["true_fires"] for c in summary["totals"].values())
|
|
94
|
+
fr = sum(c["fire_runs"] for c in summary["totals"].values())
|
|
95
|
+
ff = sum(c["false_fires"] for c in summary["totals"].values())
|
|
96
|
+
nr = sum(c["nofire_runs"] for c in summary["totals"].values())
|
|
97
|
+
return {
|
|
98
|
+
"recall": tf / fr if fr else None,
|
|
99
|
+
"false_fire_rate": ff / nr if nr else None,
|
|
100
|
+
"precision": tf / (tf + ff) if (tf + ff) else None,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def pct(value: float | None) -> str:
|
|
105
|
+
return "n/a" if value is None else f"{round(value * 100):d}%"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_thresholds(summary: dict, min_recall: float | None, max_false_fire: float | None) -> list[str]:
|
|
109
|
+
"""Return human-readable failures for cells below the thresholds."""
|
|
110
|
+
failures = []
|
|
111
|
+
for key, c in summary["cells"].items():
|
|
112
|
+
agent, skill = key.split("|", 1)
|
|
113
|
+
if min_recall is not None and c["recall"] is not None and c["recall"] < min_recall:
|
|
114
|
+
failures.append(f"{skill} on {agent}: recall {pct(c['recall'])} < {pct(min_recall)}")
|
|
115
|
+
if max_false_fire is not None and c["false_fire_rate"] is not None and c["false_fire_rate"] > max_false_fire:
|
|
116
|
+
failures.append(f"{skill} on {agent}: false-fire rate {pct(c['false_fire_rate'])} > {pct(max_false_fire)}")
|
|
117
|
+
return failures
|
doesitfire/providers.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Model provider presets and which agent can talk to which provider.
|
|
2
|
+
|
|
3
|
+
Keys are read from environment variables only and are never written to disk,
|
|
4
|
+
printed, or passed on a command line: Docker receives the variable *name* and
|
|
5
|
+
copies the value from this process's environment.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
from .errors import DoesItFireError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Provider:
|
|
19
|
+
name: str
|
|
20
|
+
openai_base: str # OpenAI-compatible base URL (".../v1")
|
|
21
|
+
key_env: str | None # env var holding the API key; None for local models
|
|
22
|
+
anthropic_base: str | None = None # Anthropic Messages-compatible base, if any
|
|
23
|
+
responses_api: bool = False # serves the OpenAI Responses API (needed by Codex)
|
|
24
|
+
local: bool = False
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def paid(self) -> bool:
|
|
28
|
+
return not self.local
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ollama_root() -> str:
|
|
32
|
+
host = os.environ.get("OLLAMA_HOST", "http://localhost:11434").strip().rstrip("/")
|
|
33
|
+
if "://" not in host:
|
|
34
|
+
host = "http://" + host
|
|
35
|
+
parsed = urlparse(host)
|
|
36
|
+
if parsed.hostname in ("0.0.0.0", "", None):
|
|
37
|
+
host = f"{parsed.scheme}://localhost:{parsed.port or 11434}"
|
|
38
|
+
return host
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_provider(name: str) -> Provider:
|
|
42
|
+
if name == "ollama":
|
|
43
|
+
root = _ollama_root()
|
|
44
|
+
return Provider("ollama", root + "/v1", None, anthropic_base=root, responses_api=True, local=True)
|
|
45
|
+
if name == "openrouter":
|
|
46
|
+
return Provider(
|
|
47
|
+
"openrouter",
|
|
48
|
+
"https://openrouter.ai/api/v1",
|
|
49
|
+
"OPENROUTER_API_KEY",
|
|
50
|
+
anthropic_base="https://openrouter.ai/api",
|
|
51
|
+
responses_api=True,
|
|
52
|
+
)
|
|
53
|
+
if name == "openai":
|
|
54
|
+
return Provider("openai", "https://api.openai.com/v1", "OPENAI_API_KEY", responses_api=True)
|
|
55
|
+
if name == "mistral":
|
|
56
|
+
return Provider("mistral", "https://api.mistral.ai/v1", "MISTRAL_API_KEY")
|
|
57
|
+
if name == "anthropic":
|
|
58
|
+
return Provider(
|
|
59
|
+
"anthropic",
|
|
60
|
+
"https://api.anthropic.com/v1",
|
|
61
|
+
"ANTHROPIC_API_KEY",
|
|
62
|
+
anthropic_base="https://api.anthropic.com",
|
|
63
|
+
)
|
|
64
|
+
raise DoesItFireError(f"Unknown provider '{name}'. Use one of: {', '.join(PROVIDER_NAMES)}.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
PROVIDER_NAMES = ("ollama", "openrouter", "openai", "mistral", "anthropic")
|
|
68
|
+
|
|
69
|
+
# Which providers each agent CLI can use, and through which API.
|
|
70
|
+
SUPPORTED = {
|
|
71
|
+
"claude-code": ("anthropic", "ollama", "openrouter"), # Anthropic Messages API
|
|
72
|
+
"codex": ("ollama", "openai", "openrouter"), # OpenAI Responses API
|
|
73
|
+
"opencode": ("ollama", "openrouter", "openai", "mistral", "anthropic"),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_supported(agent: str, provider: str) -> None:
|
|
78
|
+
if provider not in SUPPORTED[agent]:
|
|
79
|
+
why = {
|
|
80
|
+
"claude-code": "Claude Code speaks the Anthropic Messages API",
|
|
81
|
+
"codex": "Codex needs the OpenAI Responses API",
|
|
82
|
+
}.get(agent, "")
|
|
83
|
+
raise DoesItFireError(
|
|
84
|
+
f"Agent '{agent}' cannot use provider '{provider}'"
|
|
85
|
+
+ (f" ({why})" if why else "")
|
|
86
|
+
+ f". Supported: {', '.join(SUPPORTED[agent])}."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_key(agent: str, provider: Provider) -> tuple[str, str] | None:
|
|
91
|
+
"""Return (env var name, value) for the key this agent needs, or None for local models."""
|
|
92
|
+
if provider.key_env is None:
|
|
93
|
+
return None
|
|
94
|
+
candidates = [provider.key_env]
|
|
95
|
+
if agent == "claude-code" and provider.name == "anthropic":
|
|
96
|
+
# A Claude subscription token from `claude setup-token` works too.
|
|
97
|
+
candidates.append("CLAUDE_CODE_OAUTH_TOKEN")
|
|
98
|
+
for var in candidates:
|
|
99
|
+
value = os.environ.get(var, "").strip()
|
|
100
|
+
if value:
|
|
101
|
+
return var, value
|
|
102
|
+
raise DoesItFireError(
|
|
103
|
+
f"Missing API key for provider '{provider.name}' (agent '{agent}'). "
|
|
104
|
+
f"Set the {' or '.join(candidates)} environment variable, or switch the agent to "
|
|
105
|
+
"provider 'ollama' in doesitfire.yaml."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def container_url(url: str) -> str:
|
|
110
|
+
"""Rewrite localhost URLs so they work from inside a container."""
|
|
111
|
+
parsed = urlparse(url)
|
|
112
|
+
if parsed.hostname in ("localhost", "127.0.0.1", "::1"):
|
|
113
|
+
netloc = "host.docker.internal" + (f":{parsed.port}" if parsed.port else "")
|
|
114
|
+
return parsed._replace(netloc=netloc).geturl()
|
|
115
|
+
return url
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def host_port(url: str) -> str:
|
|
119
|
+
parsed = urlparse(url)
|
|
120
|
+
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
121
|
+
return f"{parsed.hostname}:{port}"
|
doesitfire/report.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Terminal matrix, JUnit XML, badge SVG and shields.io endpoint JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from xml.sax.saxutils import escape, quoteattr
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from .metrics import overall, pct, summarize
|
|
14
|
+
|
|
15
|
+
OK, WARN, ERR = "#11813c", "#a45e00", "#c22826"
|
|
16
|
+
ACCENT = "#a1319b"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def recall_color(v: float | None) -> str:
|
|
20
|
+
if v is None:
|
|
21
|
+
return "dim"
|
|
22
|
+
return "green" if v >= 0.8 else "yellow" if v >= 0.5 else "red"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def false_color(v: float | None) -> str:
|
|
26
|
+
if v is None:
|
|
27
|
+
return "dim"
|
|
28
|
+
return "green" if v <= 0.1 else "yellow" if v <= 0.3 else "red"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def recorded_banner(results: dict) -> str:
|
|
32
|
+
models = ", ".join(sorted({f"{a['provider']}/{a['model']}" for a in results["agents"]}))
|
|
33
|
+
host = results.get("host") or {}
|
|
34
|
+
when = (results.get("started_at") or "")[:10]
|
|
35
|
+
parts = [f"{when}", f"doesitfire {results.get('version')}", models]
|
|
36
|
+
if host.get("hardware"):
|
|
37
|
+
parts.append(host["hardware"])
|
|
38
|
+
return " | ".join(p for p in parts if p)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def print_matrix(results: dict, console: Console | None = None) -> dict:
|
|
42
|
+
console = console or Console()
|
|
43
|
+
summary = summarize(results)
|
|
44
|
+
if results.get("recorded"):
|
|
45
|
+
console.print(Text(f"RECORDED RESULT, not a live run: {recorded_banner(results)}", style="bold magenta"))
|
|
46
|
+
table = Table(title="Skill activation (fired runs / valid runs)", title_justify="left", expand=False)
|
|
47
|
+
table.add_column("skill", style="bold")
|
|
48
|
+
for agent in results["agents"]:
|
|
49
|
+
label = agent["name"] + (" (experimental)" if agent.get("experimental") else "")
|
|
50
|
+
table.add_column(f"{label}\n[dim]{agent['model']}[/dim]", justify="left")
|
|
51
|
+
for skill in summary["skills"]:
|
|
52
|
+
row: list[Text | str] = [skill]
|
|
53
|
+
for agent in summary["agents"]:
|
|
54
|
+
c = summary["cells"][f"{agent}|{skill}"]
|
|
55
|
+
t = Text()
|
|
56
|
+
t.append(f"fires {c['true_fires']}/{c['fire_runs']}", style=recall_color(c["recall"]))
|
|
57
|
+
t.append("\n")
|
|
58
|
+
t.append(f"false {c['false_fires']}/{c['nofire_runs']}", style=false_color(c["false_fire_rate"]))
|
|
59
|
+
if c["errors"]:
|
|
60
|
+
t.append(f"\nerrors {c['errors']}", style="red")
|
|
61
|
+
row.append(t)
|
|
62
|
+
table.add_row(*row)
|
|
63
|
+
table.add_section()
|
|
64
|
+
for label, key, colorer in (
|
|
65
|
+
("recall", "recall", recall_color),
|
|
66
|
+
("false-fire rate", "false_fire_rate", false_color),
|
|
67
|
+
("precision", "precision", recall_color),
|
|
68
|
+
):
|
|
69
|
+
row = [Text(label, style="dim")]
|
|
70
|
+
for agent in summary["agents"]:
|
|
71
|
+
v = summary["totals"][agent][key]
|
|
72
|
+
row.append(Text(pct(v), style=colorer(v)))
|
|
73
|
+
table.add_row(*row)
|
|
74
|
+
console.print(table)
|
|
75
|
+
cost = results.get("cost_usd") or 0.0
|
|
76
|
+
reps = {a.get("repeats", results.get("repeats")) for a in results["agents"]}
|
|
77
|
+
rep_text = (
|
|
78
|
+
f"{reps.pop()} repeat(s) per prompt"
|
|
79
|
+
if len(reps) == 1
|
|
80
|
+
else "repeats " + ", ".join(f"{a['name']} {a.get('repeats')}" for a in results["agents"])
|
|
81
|
+
)
|
|
82
|
+
console.print(
|
|
83
|
+
f"[dim]{len(results['trials'])} runs, {rep_text}, "
|
|
84
|
+
f"{results.get('duration_s') or 0:.0f}s, cost {cost:.4f} USD[/dim]"
|
|
85
|
+
)
|
|
86
|
+
if any(a.get("experimental") for a in results["agents"]):
|
|
87
|
+
console.print(
|
|
88
|
+
"[dim]Codex is experimental: it reports no activation event, so a run counts as fired "
|
|
89
|
+
"when Codex reads the skill's SKILL.md.[/dim]"
|
|
90
|
+
)
|
|
91
|
+
return summary
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_junit(results: dict, path: Path) -> None:
|
|
95
|
+
summary = summarize(results)
|
|
96
|
+
suites, total = [], {"tests": 0, "failures": 0, "errors": 0}
|
|
97
|
+
for agent in summary["agents"]:
|
|
98
|
+
cases, counts = [], {"tests": 0, "failures": 0, "errors": 0}
|
|
99
|
+
for skill in summary["skills"]:
|
|
100
|
+
for row in summary["cells"][f"{agent}|{skill}"]["prompts"]:
|
|
101
|
+
counts["tests"] += 1
|
|
102
|
+
label = "should fire" if row["expect"] == "fire" else "should not fire"
|
|
103
|
+
name = f"[{label}] {row['prompt']}"
|
|
104
|
+
body = ""
|
|
105
|
+
result = f"fired {row['fired']}/{row['runs']} valid runs"
|
|
106
|
+
if row["runs"] == 0:
|
|
107
|
+
counts["errors"] += 1
|
|
108
|
+
errored = f"all {row['errors']} runs errored"
|
|
109
|
+
body = f"<error message={quoteattr(errored)}/>"
|
|
110
|
+
else:
|
|
111
|
+
majority = row["fired"] * 2 > row["runs"]
|
|
112
|
+
ok = majority if row["expect"] == "fire" else not majority
|
|
113
|
+
if not ok:
|
|
114
|
+
counts["failures"] += 1
|
|
115
|
+
body = f"<failure message={quoteattr(result)}/>"
|
|
116
|
+
cases.append(
|
|
117
|
+
f" <testcase classname={quoteattr(f'{agent}.{skill}')} name={quoteattr(name)}>"
|
|
118
|
+
f"{body}<system-out>{escape(result)}</system-out></testcase>"
|
|
119
|
+
)
|
|
120
|
+
for k in total:
|
|
121
|
+
total[k] += counts[k]
|
|
122
|
+
suites.append(
|
|
123
|
+
f' <testsuite name={quoteattr(agent)} tests="{counts["tests"]}" failures="{counts["failures"]}" '
|
|
124
|
+
f'errors="{counts["errors"]}">\n' + "\n".join(cases) + "\n </testsuite>"
|
|
125
|
+
)
|
|
126
|
+
xml = (
|
|
127
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
128
|
+
f'<testsuites name="doesitfire" tests="{total["tests"]}" failures="{total["failures"]}" '
|
|
129
|
+
f'errors="{total["errors"]}">\n' + "\n".join(suites) + "\n</testsuites>\n"
|
|
130
|
+
)
|
|
131
|
+
path.write_text(xml, encoding="utf-8")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _badge_parts(results: dict) -> tuple[str, str]:
|
|
135
|
+
o = overall(summarize(results))
|
|
136
|
+
message = f"fires {pct(o['recall'])} · false {pct(o['false_fire_rate'])}"
|
|
137
|
+
r = o["recall"]
|
|
138
|
+
color = OK if r is not None and r >= 0.8 else WARN if r is not None and r >= 0.5 else ERR
|
|
139
|
+
return message, color
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _text_width(s: str) -> int:
|
|
143
|
+
# Approximate Verdana 11px advance widths, enough for a badge.
|
|
144
|
+
narrow, wide = set("fijlrt ·:|!.,'"), set("mwMW%")
|
|
145
|
+
return int(sum(4 if c in narrow else 10 if c in wide else 7 for c in s)) + 10
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def badge_svg(results: dict, label: str = "doesitfire") -> str:
|
|
149
|
+
message, color = _badge_parts(results)
|
|
150
|
+
lw, mw = _text_width(label), _text_width(message)
|
|
151
|
+
w = lw + mw
|
|
152
|
+
aria = f"{escape(label)}: {escape(message)}"
|
|
153
|
+
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img" aria-label="{aria}">
|
|
154
|
+
<title>{escape(label)}: {escape(message)}</title>
|
|
155
|
+
<clipPath id="r"><rect width="{w}" height="20" rx="3" fill="#fff"/></clipPath>
|
|
156
|
+
<g clip-path="url(#r)">
|
|
157
|
+
<rect width="{lw}" height="20" fill="{ACCENT}"/>
|
|
158
|
+
<rect x="{lw}" width="{mw}" height="20" fill="{color}"/>
|
|
159
|
+
</g>
|
|
160
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
|
161
|
+
<text x="{lw / 2:.1f}" y="14">{escape(label)}</text>
|
|
162
|
+
<text x="{lw + mw / 2:.1f}" y="14">{escape(message)}</text>
|
|
163
|
+
</g>
|
|
164
|
+
</svg>
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def badge_endpoint(results: dict, label: str = "doesitfire") -> dict:
|
|
169
|
+
message, color = _badge_parts(results)
|
|
170
|
+
return {"schemaVersion": 1, "label": label, "message": message, "color": color.lstrip("#")}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def write_badges(results: dict, out: Path) -> None:
|
|
174
|
+
(out / "badge.svg").write_text(badge_svg(results), encoding="utf-8")
|
|
175
|
+
(out / "badge.json").write_text(json.dumps(badge_endpoint(results), indent=2) + "\n", encoding="utf-8")
|