Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: CI

on:
push:
branches: [master]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Test
run: go test ./... -count=1
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,11 @@ go.work.sum

#IDE
.idea

# Augur build + generated artifacts (keep the repo clean of local runs)
/augur
/augur-report.md
/augur-report.json
/trace.jsonl
/report.md
/report.json
90 changes: 87 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,90 @@ augur gate --traffic traffic.yaml --budget budget.yaml

`augur gate` is the one you wire into CI.

### Record once, replay for free

Running the agent against the real provider on every CI push spends real tokens.
Record the responses once, then replay them — `augur run --replay` re-executes
the agent against the recorded responses and regenerates the trace **without
contacting the provider**:

```sh
# Once (locally or nightly), against the real provider:
augur run --scenarios scenarios.yaml --record cassette.jsonl

# In CI, on every push — zero tokens spent:
augur run --scenarios scenarios.yaml --replay cassette.jsonl --trace trace.jsonl
augur gate --traffic traffic.yaml --budget budget.yaml
```

Because replay re-runs the *agent* (not just the trace), a cost regression from
an agent code change still surfaces — exercised against the old responses, for
free. Commit `cassette.jsonl` alongside your scenarios. (A call the agent makes
that wasn't recorded is a replay miss — reported loudly, so divergence from the
recording never passes silently.)

### What-if sensitivity analysis

`project` and `gate` take what-if multipliers that re-cost the *recorded* trace
under hypothetical agentic cost drivers — no agent re-run, no tokens:

```sh
# "What if retries climb 30%, sub-agent fan-out adds 50% more calls, and
# context grows to 2× as conversations lengthen?"
augur project --retry-rate 0.3 --fanout 1.5 --context-growth 2

# Gate against a pessimistic scenario, not just today's happy path:
augur gate --context-growth 1.5 --budget budget.yaml
```

Retries and fan-out scale the whole call (more calls); context growth inflates
only the prompt side, not the completion — so the model reflects *which* driver
moved, not a flat fudge factor.

### Self-hosted models (TCO)

For a model you run yourself there's no per-token API price — you pay for an
instance by the hour. Describe the deployment in `tco.yaml` (instance $/hr,
serving throughput, utilization) and Augur derives the effective $/Mtok:

```sh
augur tco --tco tco.yaml # show the derived effective $/Mtok
augur gate --tco tco.yaml ... # cost the trace against self-hosted pricing
```

`--tco` is accepted by `aggregate`, `project`, and `gate` as an alternative to
`--pricing`. Utilization is the honest part: you pay for the box 24/7 but it's
rarely saturated, and a half-idle instance doubles the effective token price.

### GitHub Action

Augur ships as a composite action that runs the gate on a pull request, posts
the report as a comment, and fails the check if the projection is over budget.
Combined with a committed cassette, the PR check spends no tokens:

```yaml
# .github/workflows/cost-gate.yml
name: Cost gate
on: pull_request
jobs:
augur:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # to post the report comment
steps:
- uses: actions/checkout@v4
- uses: your-org/augur@v1
with:
cassette: cassette.jsonl # replay → zero tokens
traffic: traffic.yaml
budget: budget.yaml
```

See [`action.yml`](action.yml) for all inputs and
[`examples/github-workflow.yml`](examples/github-workflow.yml) for a fuller
example.

---

## Design decisions
Expand Down Expand Up @@ -182,10 +266,10 @@ dependency is `gopkg.in/yaml.v3`):
| Hito 2 | scenario runner + per-scenario aggregation |
| Hito 3 | projection engine with bootstrap confidence intervals |
| Hito 4 | budget gate + Markdown/JSON report + CI exit codes |
| Hito 5 | record/replay cassette (`--record`/`--replay`), what-if knobs (`--retry-rate`/`--fanout`/`--context-growth`), self-hosted TCO mode (`augur tco`, `--tco`), GitHub Action (`action.yml`) |

**Roadmap (stretch, each independently shippable):** GitHub Action wrapper •
what-if multiplier knobs • record-once/replay to cut CI token cost • self-hosted
TCO mode • a Python output-length prediction sidecar.
**Roadmap (stretch):** a Python output-length prediction sidecar (the analytical
piece that would earn a second language).

See [`SPEC.md`](SPEC.md) for the full design.

Expand Down
144 changes: 144 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
name: "Augur cost gate"
description: "Project an AI agent's production cost from a recorded trace and fail the PR if it's over budget."
branding:
icon: "dollar-sign"
color: "purple"

inputs:
trace:
description: "Path to the cost trace (JSONL). Ignored if `cassette` is set."
default: "trace.jsonl"
pricing:
description: "Path to the pricing snapshot."
default: "pricing.yaml"
tco:
description: "Path to a self-hosted TCO config. Overrides `pricing` when set."
default: ""
traffic:
description: "Path to the traffic profile."
default: "traffic.yaml"
budget:
description: "Path to the budget thresholds."
default: "budget.yaml"
scenarios:
description: "Path to scenarios.yaml. Only used when replaying a cassette."
default: "scenarios.yaml"
cassette:
description: "If set, replay this cassette to regenerate the trace (no tokens spent). Requires the agent command to be runnable in CI."
default: ""
extra-args:
description: "Extra flags passed to `augur gate` (e.g. what-if knobs: --context-growth 1.5)."
default: ""
comment:
description: "Post (or update) the report as a PR comment."
default: "true"
working-directory:
description: "Directory the config files live in."
default: "."
go-version:
description: "Go version used to build augur."
default: "1.25"

outputs:
pass:
description: "'true' if within budget, 'false' if over."
value: ${{ steps.gate.outputs.pass }}
report-path:
description: "Absolute path to the generated Markdown report."
value: ${{ steps.gate.outputs.report-path }}

runs:
using: "composite"
steps:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ inputs.go-version }}

- name: Build augur
shell: bash
run: |
cd "${{ github.action_path }}"
go build -o "${RUNNER_TEMP}/augur" .
echo "AUGUR=${RUNNER_TEMP}/augur" >> "$GITHUB_ENV"

- name: Replay cassette
if: ${{ inputs.cassette != '' }}
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
"$AUGUR" run \
--scenarios "${{ inputs.scenarios }}" \
--replay "${{ inputs.cassette }}" \
--trace "${{ inputs.trace }}"

- name: Augur gate
id: gate
shell: bash
working-directory: ${{ inputs.working-directory }}
run: |
pricing_args="--pricing ${{ inputs.pricing }}"
if [ -n "${{ inputs.tco }}" ]; then
pricing_args="--tco ${{ inputs.tco }}"
fi

set +e
"$AUGUR" gate \
--trace "${{ inputs.trace }}" \
$pricing_args \
--traffic "${{ inputs.traffic }}" \
--budget "${{ inputs.budget }}" \
--report-md augur-report.md \
--report-json augur-report.json \
${{ inputs.extra-args }}
code=$?
set -e

if [ "$code" -eq 0 ]; then
echo "pass=true" >> "$GITHUB_OUTPUT"
elif [ -f augur-report.md ]; then
# Report exists => a genuine over-budget verdict (not an internal error).
echo "pass=false" >> "$GITHUB_OUTPUT"
else
echo "::error::augur gate failed before producing a report (exit $code)"
exit "$code"
fi
echo "exit-code=$code" >> "$GITHUB_OUTPUT"
echo "report-path=$(pwd)/augur-report.md" >> "$GITHUB_OUTPUT"

- name: Post report comment
if: ${{ always() && inputs.comment == 'true' && github.event_name == 'pull_request' && steps.gate.outputs.report-path != '' }}
uses: actions/github-script@v7
env:
AUGUR_REPORT_PATH: ${{ steps.gate.outputs.report-path }}
with:
script: |
const fs = require('fs');
const marker = '<!-- augur-cost-report -->';
let report;
try {
report = fs.readFileSync(process.env.AUGUR_REPORT_PATH, 'utf8');
} catch (e) {
report = '_Augur produced no report._';
}
const body = `${marker}\n${report}`;
const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}

- name: Enforce budget
shell: bash
run: |
if [ "${{ steps.gate.outputs.pass }}" != "true" ]; then
echo "::error::Augur: projected cost is over budget — see the report comment."
exit 1
fi
echo "Augur: within budget."
53 changes: 52 additions & 1 deletion aggregate/aggregate.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,63 @@ type Result struct {
Runs []Run `json:"runs"`
}

// Knobs are what-if multipliers applied to every call's cost, for sensitivity
// analysis that reuses an existing trace instead of re-running the agent. The
// zero value is the identity (the observed cost). They model the agentic cost
// drivers:
//
// RetryRate extra fraction of calls retried (0.2 = 20% more calls)
// FanoutFactor multiplier on call count from sub-agent/tool fan-out
// ContextGrowth multiplier on prompt (input + cached) cost from history growth
//
// Retries and fan-out add whole calls, so they scale the entire call cost;
// context growth inflates the prompt only, not the completion. Per call:
//
// cost' = (1 + RetryRate) * FanoutFactor * (ContextGrowth*prompt + output)
type Knobs struct {
RetryRate float64
FanoutFactor float64
ContextGrowth float64
}

// normalized fills in identity defaults: FanoutFactor and ContextGrowth default
// to 1 (no change) when left at zero, RetryRate stays 0.
func (k Knobs) normalized() Knobs {
if k.FanoutFactor == 0 {
k.FanoutFactor = 1
}
if k.ContextGrowth == 0 {
k.ContextGrowth = 1
}
return k
}

// IsIdentity reports whether the knobs leave cost unchanged.
func (k Knobs) IsIdentity() bool {
n := k.normalized()
return n.RetryRate == 0 && n.FanoutFactor == 1 && n.ContextGrowth == 1
}

// apply returns the what-if cost of a single call given its component breakdown.
func (k Knobs) apply(b cost.Breakdown) float64 {
n := k.normalized()
callMult := (1 + n.RetryRate) * n.FanoutFactor
return callMult * (n.ContextGrowth*b.PromptUSD() + b.OutputUSD)
}

// Aggregate prices every record against pricing and builds per-scenario cost
// distributions. An un-priceable model is a hard error (wrapping
// cost.ErrUnknownModel): an un-priced call means an unknowable bill, the exact
// surprise Augur exists to prevent, so we refuse to silently omit it.
//
// An empty trace yields a zero Result and no error.
func Aggregate(records []trace.Record, pricing cost.Pricing) (Result, error) {
return AggregateWithKnobs(records, pricing, Knobs{})
}

// AggregateWithKnobs is Aggregate with what-if multipliers applied to every
// call's cost (see Knobs). With the zero Knobs it is identical to Aggregate.
func AggregateWithKnobs(records []trace.Record, pricing cost.Pricing, knobs Knobs) (Result, error) {
type runKey struct{ scenario, run string }

runs := make(map[runKey]*Run)
Expand All @@ -81,11 +131,12 @@ func Aggregate(records []trace.Record, pricing cost.Pricing) (Result, error) {
OutputTokens: rec.OutputTokens,
CachedTokens: rec.CachedTokens,
}
c, err := pricing.Cost(rec.Model, u)
b, err := pricing.Breakdown(rec.Model, u)
if err != nil {
return Result{}, fmt.Errorf("aggregate: scenario %q run %q seq %d: %w",
rec.ScenarioID, rec.RunID, rec.Seq, err)
}
c := knobs.apply(b)

k := runKey{rec.ScenarioID, rec.RunID}
r, ok := runs[k]
Expand Down
Loading
Loading