refactor: organize pilot packages

Group review, feedback, evaluation, observability, and entrypoint code into packages. Keep thin top-level compatibility shims for existing scripts and imports, and mirror the structure in the tests.
This commit is contained in:
Claude
2026-09-01 00:59:51 +00:00
parent 3a110ab52c
commit 7a510a926d
66 changed files with 8174 additions and 8039 deletions
+1
View File
@@ -0,0 +1 @@
"""Cost modeling and Langfuse telemetry."""
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env python3
"""pragent pilot — per-review cost model.
Answers "what would this cost on a paid API?" for the pilot's agent loop. The
pilot currently runs on `glm-5.2:cloud` through the on-network headroom proxy at
no per-token charge, so every review's measured usage is *free but real*: it
tells us exactly what the same work would bill on Claude or GPT.
The model is deliberately explicit rather than a single fudge factor, because
the dominant cost in an agent loop is not the diff — it is **resending the
conversation on every step**. A 12-step review re-reads its own prefix 12 times.
Prompt caching is what makes that affordable, and whether caching is on changes
the answer by ~3x, so it's a parameter, not an assumption.
Token accounting per review:
step 1 input = prefix + brief
step k input = prefix + brief + (tool results accumulated through k-1)
total input = sum over steps
cached = the prefix + brief part of steps 2..n (stable, byte-identical)
uncached = step 1 in full + the growing tool-result tail
`prefix` = system + tool schemas + agent definition + the skills this tier loads.
Those sizes are MEASURED from the files in this repo (see `measure_factory`),
not guessed. Diff size, file reads, and step count are per-tier assumptions from
the `attention-tiering` skill's budgets — override them on the CLI to fit your
own repos.
Prices are per million tokens, from the providers' published pricing pages
(fetched 2026-08-18 — re-check before quoting):
https://platform.claude.com/docs/en/about-claude/pricing
https://developers.openai.com/api/docs/pricing
Usage:
python3 pilot/cost_model.py # all tiers, all models
python3 pilot/cost_model.py --prs-per-month 350
python3 pilot/cost_model.py --mix 5,35,55,5 # trivial,lite,full,oversized %
python3 pilot/cost_model.py --no-cache # what caching is worth
"""
from __future__ import annotations
import argparse
import os
from dataclasses import dataclass, field
CHARS_PER_TOKEN = 4 # English prose/code rule of thumb; ±15% is normal
# ---------------------------------------------------------------------------
# Prices — USD per million tokens
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Price:
"""Per-MTok prices. `cache_write` and `cache_read` are absolute rates, not
multipliers, so providers with different cache economics stay comparable.
`provider` is the opencode provider name (`headroom`, `vllm-qwen38`, ...). It
doubles as the dispatch key for `.pr-review.json:model` overrides — when
a per-repo override is set, `_resolve_display_model` returns
`f"{provider}/{key}"` so the opencode subprocess routes correctly.
Default `headroom` preserved for the existing roster."""
name: str
input: float
output: float
cache_write: float
cache_read: float
provider: str = "headroom"
@property
def batch_input(self) -> float:
return self.input / 2
@property
def batch_output(self) -> float:
return self.output / 2
# Anthropic: cache write = 1.25x input (5-minute TTL), cache read = 0.1x input.
# OpenAI: cached input is a published rate (0.1x input); there is no separate
# cache-write charge — writes are billed as ordinary input.
PRICES: dict[str, Price] = {
"claude-opus-5": Price("Claude Opus 5", 5.00, 25.00, 6.25, 0.50),
"claude-sonnet-5": Price("Claude Sonnet 5", 2.00, 10.00, 2.50, 0.20),
"claude-haiku-4-5": Price("Claude Haiku 4.5", 1.00, 5.00, 1.25, 0.10),
"gpt-5.6-sol": Price("GPT-5.6 Sol", 5.00, 30.00, 5.00, 0.50),
"gpt-5.6-terra": Price("GPT-5.6 Terra", 2.00, 12.00, 2.00, 0.20),
"gpt-5.6-luna": Price("GPT-5.6 Luna", 0.20, 1.20, 0.20, 0.02),
# OpenAI — cached_input 0.1x, no separate cache_write
"gpt-5": Price("GPT-5", 1.25, 10.00, 1.25, 0.125),
"gpt-5-mini": Price("GPT-5 mini", 0.25, 2.00, 0.25, 0.025),
# Google Gemini — cache_write = input
"gemini-2.5-pro": Price("Gemini 2.5 Pro", 1.875, 12.50, 1.875, 0.1875),
"gemini-2.5-flash": Price("Gemini 2.5 Flash", 0.30, 2.50, 0.30, 0.03),
# xAI Grok — cache_write = input
"grok-4.5": Price("Grok 4.5", 2.00, 6.00, 2.00, 0.30),
"grok-4.3": Price("Grok 4.3", 1.25, 2.50, 1.25, 0.20),
# Self-hosted — AI workstation RTX 3090, vLLM + DFlash2 spec-decode, no
# per-token charge. provider="vllm-qwen38" so the opencode subprocess
# routes via the matching provider block in opencode.json
# (baseURL=http://192.168.1.79:18020/v1). Equivalent-cost column reads $0
# — the cost-comparison signal is that the same work would bill $X on a
# paid model.
"qwen3.8-27b": Price("Qwen3.8-27B (vLLM, MTP, 150k ctx)", 0.0, 0.0, 0.0, 0.0, provider="vllm-qwen38"),
}
# ---------------------------------------------------------------------------
# Factory footprint — measured from this repo
# ---------------------------------------------------------------------------
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Skills the primary always loads, and the conditional ones per tier. Mirrors
# the load table in .opencode/agents/pragent.md.
ALWAYS_SKILLS = ("review-methodology", "findings-schema", "attention-tiering")
TIER_SKILLS: dict[str, tuple[str, ...]] = {
"trivial": (),
"lite": ("comment-craft",),
"full": ("linter-playbook", "security-lens", "comment-craft"),
"oversized": ("linter-playbook", "security-lens", "comment-craft", "malicious-change"),
}
# opencode's own system prompt + the JSON tool schemas it sends (read, grep,
# glob, bash, webfetch, skill, task, …). Not in this repo, so this is the one
# component that is an estimate rather than a measurement.
HARNESS_TOKENS = 3500
def _tok(path: str) -> int:
try:
with open(path, "rb") as f:
return len(f.read()) // CHARS_PER_TOKEN
except OSError:
return 0
def measure_factory(root: str = _ROOT) -> dict[str, int]:
"""Token size of each prompt component, measured from the files on disk."""
out = {"agent": _tok(os.path.join(root, ".opencode", "agents", "pragent.md"))}
skills_dir = os.path.join(root, ".opencode", "skills")
if os.path.isdir(skills_dir):
for name in sorted(os.listdir(skills_dir)):
p = os.path.join(skills_dir, name, "SKILL.md")
if os.path.isfile(p):
out[f"skill:{name}"] = _tok(p)
for lens in ("security", "tests", "perf"):
out[f"subagent:{lens}"] = _tok(os.path.join(root, ".opencode", "agents", f"{lens}.md"))
return out
def prefix_tokens(tier: str, factory: dict[str, int]) -> int:
"""Stable per-step prefix: harness + agent definition + loaded skills."""
total = HARNESS_TOKENS + factory.get("agent", 0)
for s in ALWAYS_SKILLS + TIER_SKILLS.get(tier, ()):
total += factory.get(f"skill:{s}", 0)
return total
# ---------------------------------------------------------------------------
# Per-tier workload assumptions
# ---------------------------------------------------------------------------
@dataclass
class Tier:
"""One tier's workload. Defaults follow the `attention-tiering` budgets."""
name: str
diff_tokens: int # the diff as it lands in the brief
steps: int # model turns in the agent loop
file_reads: int # files read from the checkout
tokens_per_read: int # avg tokens returned per read/grep/linter result
output_tokens: int # assistant output across all steps (incl. reasoning)
subagents: int = 0 # lens subagents spawned
brief_fixed: int = 600 # brief template + PR meta + prior reviews
share: float = 0.0 # fraction of PRs at this tier (for the monthly mix)
_factory: dict = field(default_factory=dict, repr=False)
DEFAULT_TIERS = [
# diff_tok steps reads tok/read output subs share
Tier("trivial", 400, 2, 0, 0, 600, 0, share=0.05),
Tier("lite", 1500, 6, 4, 2000, 2500, 0, share=0.35),
Tier("full", 6000, 24, 20, 3300, 12000, 0, share=0.55),
Tier("oversized", 25000, 35, 30, 3500, 20000, 2, share=0.05),
]
# ---------------------------------------------------------------------------
# Observed runs — the calibration anchor
# ---------------------------------------------------------------------------
# Real usage reported by opencode's step_finish events. Keep this list
# events. Keep this list append-only: it is the only thing separating this model
# from a guess, and the first entry corrected the tier assumptions by ~15x.
OBSERVED_RUNS: list[dict] = [
{
"label": "internal/hardening-PR (16 files, 1020 insertions / 91 deletions)",
"date": "2026-08-18",
"tier": "full",
"diff_tokens": 17_600, # 16 files, 1020 insertions / 91 deletions
"steps": 28,
"duration_s": 348.3,
"input": 2_071_025,
"output": 17_303,
"cache_read": 0,
"cache_write": 0,
"subagents": 0,
},
{
"label": "internal/hardening-PR (same PR, two commits later)",
"date": "2026-08-18",
"tier": "full",
"diff_tokens": 21_000, # same PR, two commits later
"steps": 31,
"duration_s": 189.8,
"input": 2_213_077,
"output": 9_058,
"cache_read": 0,
"cache_write": 0,
"subagents": 0,
},
# A third run of the same PR (sha 2613b3e, 31 steps' worth of work in 330s)
# ended without a parseable findings block and so reported no usage at all —
# the reason `salvage_summary` now keeps the usage section on that path.
]
def observed_usage(run: dict) -> Usage:
return Usage(
uncached_input=run["input"] - run.get("cache_read", 0),
cached_input=run.get("cache_read", 0),
cache_writes=run.get("cache_write", 0),
output=run["output"],
)
@dataclass
class Usage:
uncached_input: int = 0
cached_input: int = 0
cache_writes: int = 0
output: int = 0
@property
def total_input(self) -> int:
return self.uncached_input + self.cached_input
def tier_usage(tier: Tier, factory: dict[str, int], caching: bool = True) -> Usage:
"""Token usage for one review at this tier.
The agent loop resends the whole conversation each step. The prefix + brief
are byte-identical across steps, so with caching they are written once and
read back on every later step; the tool-result tail grows and is charged as
ordinary input. Without caching every step pays full input price for
everything it has accumulated — which is the quadratic term that makes an
uncached agent loop expensive.
"""
prefix = prefix_tokens(tier.name, factory)
stable = prefix + tier.brief_fixed + tier.diff_tokens
# Tool results arrive one per step, after the first.
result_steps = max(0, min(tier.file_reads, tier.steps - 1))
per_result = tier.tokens_per_read
u = Usage(output=tier.output_tokens)
if caching:
u.cache_writes = stable
u.cached_input = stable * max(0, tier.steps - 1)
u.uncached_input = 0
else:
u.uncached_input = stable * tier.steps
# The growing tail of tool results: a result produced at step i is resent on
# every step after it, so it is counted (steps - i) times.
tail = 0
for i in range(1, result_steps + 1):
tail += per_result * (tier.steps - i)
u.uncached_input += tail
# Each lens subagent is its own loop: its own prefix, the diff, a few reads.
for _ in range(tier.subagents):
sub_prefix = HARNESS_TOKENS + factory.get("subagent:security", 600)
sub_stable = sub_prefix + tier.diff_tokens
sub_steps = 6
if caching:
u.cache_writes += sub_stable
u.cached_input += sub_stable * (sub_steps - 1)
else:
u.uncached_input += sub_stable * sub_steps
for i in range(1, 4):
u.uncached_input += per_result * (sub_steps - i)
u.output += 1500
return u
def cost(u: Usage, price: Price, batch: bool = False) -> float:
"""USD for one review's usage at these prices."""
inp = price.batch_input if batch else price.input
out = price.batch_output if batch else price.output
cw = price.cache_write / 2 if batch else price.cache_write
cr = price.cache_read / 2 if batch else price.cache_read
return (
u.uncached_input * inp
+ u.cached_input * cr
+ u.cache_writes * cw
+ u.output * out
) / 1_000_000
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def blended_cost(tiers: list[Tier], factory: dict, price: Price, caching: bool) -> float:
"""Weighted cost of one average PR across the tier mix."""
total_share = sum(t.share for t in tiers) or 1.0
return sum(
cost(tier_usage(t, factory, caching), price) * (t.share / total_share)
for t in tiers
)
def report(tiers: list[Tier], prs_per_month: int, caching: bool, models: list[str]) -> str:
factory = measure_factory()
lines: list[str] = []
lines.append(f"Factory footprint (measured, {CHARS_PER_TOKEN} chars/token):")
for k, v in sorted(factory.items()):
lines.append(f" {k:<34} {v:>6,} tok")
lines.append(f" {'harness (opencode + tool schemas, est.)':<34} {HARNESS_TOKENS:>6,} tok")
lines.append("")
lines.append(f"Per-review tokens (prompt caching: {'on' if caching else 'OFF'})")
lines.append(f" {'tier':<11} {'prefix':>8} {'uncached':>10} {'cached':>10} {'cwrite':>8} {'output':>8}")
for t in tiers:
u = tier_usage(t, factory, caching)
lines.append(
f" {t.name:<11} {prefix_tokens(t.name, factory):>8,} {u.uncached_input:>10,} "
f"{u.cached_input:>10,} {u.cache_writes:>8,} {u.output:>8,}"
)
lines.append("")
lines.append("Cost per review (USD)")
header = f" {'model':<18}" + "".join(f"{t.name:>12}" for t in tiers) + f"{'blended':>12}"
lines.append(header)
for key in models:
p = PRICES[key]
row = f" {p.name:<18}"
for t in tiers:
row += f"{cost(tier_usage(t, factory, caching), p):>12.4f}"
row += f"{blended_cost(tiers, factory, p, caching):>12.4f}"
lines.append(row)
lines.append("")
mix = ", ".join(f"{t.name} {t.share:.0%}" for t in tiers)
lines.append(f"Monthly at {prs_per_month} PRs/month (mix: {mix})")
lines.append(f" {'model':<18} {'per PR':>10} {'per month':>12} {'batch -50%':>12}")
for key in models:
p = PRICES[key]
per_pr = blended_cost(tiers, factory, p, caching)
lines.append(
f" {p.name:<18} {per_pr:>10.4f} {per_pr * prs_per_month:>12.2f}"
f" {per_pr * prs_per_month / 2:>12.2f}"
)
lines.append("")
lines.append("Batch column applies the 50% async discount; it is shown for scale only —")
lines.append("PR review is latency-sensitive and a stateful agent loop is not batchable.")
lines.append("")
lines.append(observed_report(models))
return "\n".join(lines)
def observed_report(models: list[str]) -> str:
"""Price the runs actually measured through the opencode usage telemetry."""
if not OBSERVED_RUNS:
return "No observed runs recorded yet."
lines = ["Observed runs (measured via opencode step_finish events)"]
for run in OBSERVED_RUNS:
u = observed_usage(run)
lines.append(
f" {run['label']} — tier {run['tier']}, {run['steps']} steps, "
f"{run['duration_s']:.0f}s, {run['input']:,} in / {run['output']:,} out, "
f"cache {run['cache_read']:,} read / {run['cache_write']:,} write"
)
row = " "
for key in models:
p = PRICES[key]
row += f" {p.name}: ${cost(u, p):.2f} "
lines.append(row)
lines.append("")
lines.append(" NOTE: the pilot's headroom/glm-5.2 path reports zero cache read and zero")
lines.append(" cache write, i.e. prompt caching is NOT in play today. On a provider where")
lines.append(" it is, the stable prefix (agent + skills + brief + diff, resent every step)")
lines.append(" drops to 0.1x — worth roughly a third of the bill on a run like the one")
lines.append(" above. Budget with caching OFF until the measured cache columns are nonzero.")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="pragent per-review cost model")
ap.add_argument("--prs-per-month", type=int, default=350)
ap.add_argument("--mix", default="", help="trivial,lite,full,oversized as percentages")
ap.add_argument("--no-cache", action="store_true", help="model without prompt caching")
ap.add_argument("--models", default=",".join(PRICES))
args = ap.parse_args(argv)
tiers = DEFAULT_TIERS
if args.mix:
shares = [float(x) for x in args.mix.split(",")]
if len(shares) != len(tiers):
ap.error(f"--mix needs {len(tiers)} comma-separated values")
for t, s in zip(tiers, shares):
t.share = s / 100.0
models = [m.strip() for m in args.models.split(",") if m.strip()]
unknown = [m for m in models if m not in PRICES]
if unknown:
ap.error(f"unknown model(s): {', '.join(unknown)}")
print(report(tiers, args.prs_per_month, not args.no_cache, models))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+468
View File
@@ -0,0 +1,468 @@
#!/usr/bin/env python3
"""pragent pilot — Langfuse trace emission.
Ships one trace per PR review to a self-hosted Langfuse (v3) so the reviewer's
token spend, latency and per-model behaviour are queryable outside the review
body. The review body already renders a usage table; that table is per-PR and
disappears into Gitea. This is the same numbers, aggregated.
Why hand-rolled instead of the `langfuse` SDK: the pilot image is stdlib-only
(see pilot/Dockerfile — no requirements.txt anywhere in the repo), and the
ingestion API is a single authenticated POST of a JSON batch. Pulling an SDK
plus its otel dependency tree into a fail-open telemetry side-path is a bad
trade.
Provider split
--------------
`environment` on every trace is either `ollama` or `claude`, derived from the
resolved display model (`resolve_environment`). That is what keeps the two
spend stories separate in Langfuse: every view, filter and cost breakdown
takes an environment selector, so "what did the local/self-hosted path cost"
and "what did the Claude path cost" are two views of one project rather than
two projects with two key pairs to rotate. Tags carry the finer split
(`provider:headroom`, `model:...`, `engine:opencode`).
Cost
----
The pilot's own path bills $0 (headroom proxy, no per-token charge), so the
`cost` reported to Langfuse is the *equivalent* cost from `cost_model` — what
the same tokens would bill on the comparison model. That is the number worth
trending; a chart of $0.00 is not.
A model is "free" when `cost_model.PRICES` has no entry for it (MiniMax-M2.7,
glm-5.2:cloud) or when its entry is all zeros (the self-hosted vLLM qwen). In
both cases the reported cost is priced against the comparison target instead —
same precedence the review body uses: `.pr-review.json:cost_target` >
`PRAGENT_PRICE_TARGET` > `claude-sonnet-5`. A paid model is priced as itself.
Because a hypothetical and a real charge must never be read as the same
number, every trace is tagged `cost:actual` or `cost:equivalent:<target>`, and
the generation's metadata carries `cost_basis`.
Fail-open: every entry point swallows its own exceptions. Telemetry must never
cost a review.
Env:
LANGFUSE_HOST e.g. http://langfuse-web.langfuse.svc.cluster.local:3000
LANGFUSE_PUBLIC_KEY pk-lf-...
LANGFUSE_SECRET_KEY sk-lf-...
LANGFUSE_TIMEOUT seconds, default 5
LANGFUSE_DEBUG 1 to log ingestion failures to stderr
Disabled (silently) when host or either key is unset.
"""
from __future__ import annotations
import base64
import json
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
from datetime import datetime, timezone
INGESTION_PATH = "/api/public/ingestion"
# Model-key prefixes that mean "this review ran against Anthropic-shaped
# billing". Everything else (glm, MiniMax, qwen, local vLLM) is the ollama /
# self-hosted side of the split.
_CLAUDE_PREFIXES = ("claude-", "anthropic/")
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def _enabled() -> tuple[str, str, str] | None:
host = (os.environ.get("LANGFUSE_HOST") or "").strip().rstrip("/")
pk = (os.environ.get("LANGFUSE_PUBLIC_KEY") or "").strip()
sk = (os.environ.get("LANGFUSE_SECRET_KEY") or "").strip()
if not host or not pk or not sk:
return None
return host, pk, sk
def _debug(msg: str) -> None:
if os.environ.get("LANGFUSE_DEBUG"):
print(f"pragent/langfuse: {msg}", file=sys.stderr, flush=True)
def strip_provider(model: str) -> str:
"""`headroom/claude-sonnet-5` -> `claude-sonnet-5`. Bare names pass through."""
return model.split("/", 1)[1] if "/" in model else model
def provider_of(model: str) -> str:
"""The opencode provider block a display model routes through."""
return model.split("/", 1)[0] if "/" in model else "headroom"
def resolve_environment(model: str) -> str:
"""Which spend story this review belongs to: `claude` or `ollama`.
Keyed off the bare model name, not the provider, because both paths route
through the same `headroom` proxy — `headroom/claude-sonnet-5` is Claude
spend, `headroom/glm-5.2:cloud` is not.
"""
bare = strip_provider(model).lower()
return "claude" if bare.startswith(_CLAUDE_PREFIXES) else "ollama"
def _usage_details(usage: dict) -> dict:
"""opencode's usage dict -> Langfuse `usageDetails`.
Langfuse sums every key except the ones it knows are derived, so `input`
here is the *uncached* portion: reporting both `input` (which opencode
reports as the full input, cache included) and `cache_read_input_tokens`
would double-count.
"""
inp = int(usage.get("input") or 0)
cache_read = int(usage.get("cache_read") or 0)
cache_write = int(usage.get("cache_write") or 0)
details = {
"input": max(0, inp - cache_read),
"output": int(usage.get("output") or 0),
}
if cache_read:
details["cache_read_input_tokens"] = cache_read
if cache_write:
details["cache_write_input_tokens"] = cache_write
reasoning = int(usage.get("reasoning") or 0)
if reasoning:
details["reasoning"] = reasoning
return details
DEFAULT_PRICE_TARGET = "claude-sonnet-5"
def resolve_price_target(price_target: str | None = None) -> str:
"""The model to price free/unknown runs against.
Mirrors `ai_review._resolve_price_target`: an explicit target (which the
caller reads from `.pr-review.json:cost_target`) wins, then
`PRAGENT_PRICE_TARGET`, then Sonnet.
"""
if price_target and price_target.strip():
return price_target.strip()
env = os.environ.get("PRAGENT_PRICE_TARGET", "").strip()
return env or DEFAULT_PRICE_TARGET
def _is_free(price) -> bool:
"""A price entry that charges nothing — self-hosted or proxied at no cost."""
return price.input == 0 and price.output == 0
def _cost_details(usage: dict, model: str, price_target: str | None = None) -> tuple[dict, str]:
"""USD for this usage plus the basis it was computed on.
Returns `({"total": …}, basis)` where basis is `actual` for a model that
genuinely bills, or `equivalent:<target>` for one that does not. `({}, "")`
when nothing can be priced at all — better no number than a wrong one.
Local import + broad except: `cost_model` is only present on the opencode
path, and an unknown model key must not break telemetry.
"""
try:
from cost_model import PRICES, Usage, cost
bare = strip_provider(model)
price = PRICES.get(bare)
basis = "actual"
if price is None or _is_free(price):
# MiniMax / glm / self-hosted qwen: $0 through the proxy, so the
# useful number is what these tokens would have billed elsewhere.
target = resolve_price_target(price_target)
price = PRICES.get(target)
if price is None:
_debug(f"comparison target {target!r} not in PRICES")
return {}, ""
basis = f"equivalent:{target}"
u = Usage(
uncached_input=max(0, int(usage.get("input") or 0) - int(usage.get("cache_read") or 0)),
cached_input=int(usage.get("cache_read") or 0),
cache_writes=int(usage.get("cache_write") or 0),
output=int(usage.get("output") or 0),
)
return {"total": round(cost(u, price), 6)}, basis
except Exception as e: # pragma: no cover - defensive
_debug(f"cost lookup failed for {model!r}: {e}")
return {}, ""
def _severity_counts(findings: list[dict] | None) -> dict:
counts: dict[str, int] = {}
for f in findings or []:
sev = str(f.get("severity") or "unknown").lower()
counts[sev] = counts.get(sev, 0) + 1
return counts
def build_batch(
*,
repo: str,
index: str,
sha: str,
title: str,
model: str,
usage: dict | None,
findings: list[dict] | None = None,
summary: str = "",
engine: str = "opencode",
tier: str = "",
lenses: list[str] | None = None,
trace_id: str | None = None,
release: str = "",
price_target: str | None = None,
dropped_count: float | None = None,
) -> list[dict]:
"""The ingestion batch for one review: a trace, a generation, and scores.
Split out from `emit_review_trace` so the shape is testable without a
Langfuse to POST to.
`dropped_count` is how many findings the parser rejected for an unusable
`path`/`line`, measured where the model output was parsed. Passing it turns
on the `dropped_findings` score; leaving it `None` omits that score rather
than reporting a zero the caller never measured.
"""
usage = usage or {}
tid = trace_id or str(uuid.uuid4())
ts = _now_iso()
env = resolve_environment(model)
duration = float(usage.get("duration_s") or 0.0)
started = datetime.fromtimestamp(
time.time() - duration, tz=timezone.utc
).isoformat().replace("+00:00", "Z")
tags = [
f"provider:{provider_of(model)}",
f"model:{strip_provider(model)}",
f"engine:{engine}",
f"repo:{repo}",
]
if tier:
tags.append(f"tier:{tier}")
for lens in lenses or []:
tags.append(f"lens:{lens}")
costs, cost_basis = _cost_details(usage, model, price_target) if usage else ({}, "")
if cost_basis:
# Filterable in Langfuse, so an equivalent-cost chart can never be
# mistaken for money actually spent.
tags.append(f"cost:{cost_basis}")
metadata = {
"repo": repo,
"pr": index,
"sha": sha,
"engine": engine,
"steps": usage.get("steps"),
"duration_s": duration or None,
"findings": len(findings or []),
"severities": _severity_counts(findings),
"provider_cost_usd": usage.get("cost"),
"cost_basis": cost_basis or None,
}
if lenses:
metadata["lenses"] = lenses
if tier:
metadata["tier"] = tier
metadata = {k: v for k, v in metadata.items() if v not in (None, {}, [])}
trace_body = {
"id": tid,
"name": "pr-review",
"timestamp": ts,
"environment": env,
"sessionId": f"{repo}#{index}",
"input": _review_input(repo, index, sha, title),
"output": _review_output(summary, findings),
"metadata": metadata,
"tags": tags,
}
if release:
trace_body["release"] = release
events = [
{
"id": str(uuid.uuid4()),
"type": "trace-create",
"timestamp": ts,
"body": trace_body,
}
]
if usage:
gen_body = {
"id": str(uuid.uuid4()),
"traceId": tid,
"type": "GENERATION",
"name": f"{engine}-review",
"environment": env,
"startTime": started,
"endTime": ts,
"model": strip_provider(model),
"usageDetails": _usage_details(usage),
"metadata": metadata,
"level": "DEFAULT",
# Repeated from the trace on purpose: an evaluator's variable
# mapping reads the *observation's* input/output, so a generation
# left blank cannot be judged at all.
"input": _review_input(repo, index, sha, title),
"output": _review_output(summary, findings),
}
if costs:
gen_body["costDetails"] = costs
events.append(
{
"id": str(uuid.uuid4()),
"type": "generation-create",
"timestamp": ts,
"body": gen_body,
}
)
events.extend(
_score_events(
trace_id=tid,
findings=findings,
environment=env,
cost_usd=costs.get("total"),
dropped_count=dropped_count,
timestamp=ts,
cost_basis=cost_basis,
)
)
return events
MAX_JUDGED_FINDINGS = 25
_FIELD_CAP = 600
def _review_input(repo: str, index, sha: str, title: str) -> dict:
return {"repo": repo, "pr": index, "sha": sha, "title": title}
def _review_output(summary: str, findings) -> dict:
"""What the reviewer actually said, in a shape an evaluator can read.
The findings themselves are included, not just their count. A judge given
only `{"summary": ..., "findings": 3}` can say nothing about whether those
three findings are specific, actionable, or consistent with the summary —
which is the whole question worth asking of a reviewer that has no ground
truth to check against.
Capped rather than complete: this rides in every ingestion batch, and a
review with 80 findings would push the payload past what is reasonable to
store per trace. `finding_count` stays exact so nothing reading the count
is misled by the cap.
"""
items = list(findings or [])
return {
"summary": summary[:2000],
"finding_count": len(items),
"findings_truncated": len(items) > MAX_JUDGED_FINDINGS,
"findings": [
{
"path": f.get("path"),
"line": f.get("line"),
"severity": f.get("severity"),
"problem": str(f.get("problem") or "")[:_FIELD_CAP],
"fix": str(f.get("fix") or "")[:_FIELD_CAP],
}
for f in items[:MAX_JUDGED_FINDINGS]
],
}
def _score_events(*, cost_basis: str, **kwargs) -> list[dict]:
"""Deterministic scores for this review, or [] if the scorer is missing.
Local import + blanket except for the same reason the rest of this module
swallows: `eval_scores` is optional, and a scoring bug must not cost the
trace it was supposed to annotate.
"""
try:
import eval_scores
# The cost score is only meaningful next to its basis — a $/finding
# figure computed from an equivalent price is not money that was spent.
comment = f"cost basis: {cost_basis}" if cost_basis else ""
return eval_scores.build_scores(comment=comment, **kwargs)
except Exception as e: # pragma: no cover - defensive
_debug(f"scoring failed: {e}")
return []
def _post(host: str, pk: str, sk: str, batch: list[dict], timeout: float) -> int:
payload = json.dumps({"batch": batch}).encode("utf-8")
auth = base64.b64encode(f"{pk}:{sk}".encode("utf-8")).decode("ascii")
req = urllib.request.Request(
host + INGESTION_PATH,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Basic {auth}",
"User-Agent": "pragent-pilot/1.0",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
_warn_on_rejected_events(resp.read())
return resp.status
def _warn_on_rejected_events(raw: bytes) -> None:
"""Surface per-event rejections hiding inside a 207.
The ingestion endpoint answers 207 Multi-Status when *some* events failed,
so a caller that only checks the status code reads a batch where every
single event was rejected as a success. That failure mode is invisible
exactly when it matters — the traces simply never appear.
"""
try:
body = json.loads(raw or b"{}")
errors = body.get("errors") or []
if errors:
first = errors[0]
_debug(
f"{len(errors)} event(s) rejected by ingestion; "
f"first: status={first.get('status')} {first.get('error')}"
)
except Exception: # pragma: no cover - never let logging break emission
pass
def emit_review_trace(**kwargs) -> bool:
"""Ship one review's trace. Returns True if Langfuse accepted it.
No-op (False) when Langfuse is unconfigured. Never raises — a telemetry
outage must not turn into a failed review.
"""
conf = _enabled()
if conf is None:
return False
host, pk, sk = conf
try:
timeout = float(os.environ.get("LANGFUSE_TIMEOUT", "5"))
except ValueError:
timeout = 5.0
try:
batch = build_batch(**kwargs)
status = _post(host, pk, sk, batch, timeout)
if status not in (200, 201, 207):
_debug(f"ingestion returned HTTP {status}")
return False
return True
except urllib.error.HTTPError as e:
_debug(f"ingestion HTTP {e.code}: {e.read()[:300]!r}")
except Exception as e:
_debug(f"ingestion failed: {e}")
return False