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
74 changes: 57 additions & 17 deletions .github/workflows/pricing-sync.yml
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
name: Pricing Sync

# Refresh the bundled model-price table (cli/core/pricing/models.json) from
# models.dev and open a PR when prices changed. Runs monthly; run it by hand
# models.dev and open a PR when prices changed. Runs weekly; run it by hand
# (workflow_dispatch) after a model launch or a price change.
#
# The commit is `fix(cli)`, which releases a patch — the table is compiled into
# the binaries, so a refresh only reaches users when they update. That release
# is the point of the job.
#
# This mirrors the cloud's own monthly pricing-sync. The two tables are
# deliberately allowed to drift: the cloud is authoritative for cost and
# re-prices historical rows, while this copy exists so `dira status` /
# `dira report` have a number to show offline. `est_cost_usd` on the wire is
# contract-documented as a label, never a billing base, so a stale table here
# never moves an invoice.
# Weekly, not the cloud's own monthly cadence: claude-fable-5-1 launched
# 2026-09-01, and a monthly job would leave it estimated at the sonnet fallback
# ($3/$15) against a real $10/$50 for up to a month. Weekly also caps a
# transient models.dev outage at a week of staleness instead of a month. The
# two tables are still deliberately allowed to drift regardless of cadence: the
# cloud is authoritative for cost and re-prices historical rows, while this
# copy exists so `dira status` / `dira report` have a number to show offline.
# `est_cost_usd` on the wire is contract-documented as a label, never a billing
# base, so a stale table here never moves an invoice.
#
# models.dev being down just fails the run (no PR); retry manually or wait for
# next month — pricing freshness is never critical.
# The fetch step below retries through transient failures and stalls on its
# own; models.dev being down for longer than that retry budget still fails the
# run with no PR, but the next scheduled run is a week away, not a month.

on:
schedule:
- cron: "0 6 1 * *" # monthly, 1st at 06:00 UTC
- cron: "0 6 * * 1" # weekly, Mondays at 06:00 UTC
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -49,20 +53,56 @@ jobs:
- uses: Swatinem/rust-cache@v2
- name: refresh the table from models.dev
shell: bash
# Mirrors `just pricing-sync`. Writes via a temp file so a failed fetch
# or a rejected payload can never truncate the vendored table.
# Mirrors `just pricing-sync`.
#
# Build before anything touches a pipe. This is the actual fix for the
# 2026-09-02 outage (run 33498614567: `curl: (18) Transferred a partial
# file`, then an EOF parse error on the truncated JSON). The old step
# piped curl straight into `cargo run`, which spent ~2 minutes compiling
# before reading a single byte — the pipe buffer filled, curl blocked,
# and models.dev dropped the idle connection. The job's reported 2m13s
# runtime was entirely build time, and the sync binary's own truncation
# guard caught the bad payload, so the failure was silent: no bad table,
# but also no PR and no signal. Building the binary first means nothing
# is ever sitting in a pipe while cargo compiles.
#
# --speed-limit 1024 --speed-time 60 turns a stalled transfer (under
# 1KB/s for 60s) into a retried error instead of a multi-minute hang —
# exactly the failure mode above, now caught even if something else
# downstream of curl gets slow again.
#
# Still writes via a temp file and only `mv`s once `pricing_sync` exits
# 0, so a rejected payload can never truncate the vendored table — that
# guarantee is unchanged, just split across the raw fetch and the
# tool's output instead of living in a single piped command.
#
# The trailing `cli/core/pricing/models.json` argument hands the sync
# binary the current table so a refresh only ever appends: it can no
# longer drop a model id that models.dev has stopped publishing. The
# append-only behavior lives in the binary; this step just wires the
# argument through.
run: |
set -euo pipefail
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
curl -fsSL https://models.dev/api.json \
| cargo run -q -p dira-core --bin pricing_sync > "$tmp"
cargo build -q -p dira-core --bin pricing_sync
raw="$(mktemp)"; tmp="$(mktemp)"
trap 'rm -f "$raw" "$tmp"' EXIT
curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 \
--connect-timeout 20 --max-time 300 --speed-limit 1024 --speed-time 60 \
-o "$raw" https://models.dev/api.json
cargo run -q -p dira-core --bin pricing_sync -- cli/core/pricing/models.json \
< "$raw" > "$tmp"
mv "$tmp" cli/core/pricing/models.json
- name: validate the refreshed table
# The pricing tests assert the table parses, covers every harness
# family, and still splits generations correctly — so a bad refresh
# fails here rather than shipping.
run: cargo test -p dira-core --lib pricing tokens
#
# The `--` is load-bearing: `cargo test` takes a single filter, so the
# bare `… --lib pricing tokens` this used to run died on "unexpected
# argument 'tokens'" before a test could execute. Nothing noticed,
# because the fetch above had never once succeeded far enough to reach
# this step. Past `--`, libtest takes both filters.
run: cargo test -p dira-core --lib -- pricing tokens
- name: open PR
uses: peter-evans/create-pull-request@v8
with:
Expand Down
2 changes: 2 additions & 0 deletions .grok/rules/zavet.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ agent context at session start. Keep it short and non-negotiable.
- DIRASH-0030 — Full-content knowledge sync is opted into by its own prompt, never implied by linking (active)
- DIRASH-0031 — One backoff ladder lives in dira_core; callers own their attempt budget (active)
- DIRASH-0032 — A record's first-sight triple is repaired as a unit, from recorded facts (active)
- DIRASH-0035 — A newer schema is refused loudly, never run against (active)
- DIRASH-0036 — A pricing refresh appends and never drops a vendored id (active)

### Living specs (.zavet/specs/ — keep current while you work)

Expand Down
2 changes: 2 additions & 0 deletions .zavet/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ a handful of documents. The decisions block below is regenerated by
- **DIRASH-0030** — Full-content knowledge sync is opted into by its own prompt, never implied by linking (active)
- **DIRASH-0031** — One backoff ladder lives in dira_core; callers own their attempt budget (active)
- **DIRASH-0032** — A record's first-sight triple is repaired as a unit, from recorded facts (active)
- **DIRASH-0035** — A newer schema is refused loudly, never run against (active)
- **DIRASH-0036** — A pricing refresh appends and never drops a vendored id (active)
<!-- zavet:decisions:end -->

## Specs
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
id: DIRASH-0036
title: A pricing refresh appends and never drops a vendored id
status: active
guards:
- cli/core/src/bin/pricing_sync.rs
- cli/core/pricing/
checks:
- the table still carries one id per harness family :: cargo test -p dira-core --lib -- pricing tokens
origin: session
verified: true
---

## Decision

`pricing_sync` takes the table it is replacing as an optional positional
argument, and with it the refresh is append-only: a key models.dev still
publishes gets the fresh price, a key models.dev has stopped publishing keeps
its last-known price. The only supported way for an id to leave
`cli/core/pricing/models.json` is an explicit `null` in `overrides.json`.
Both the workflow and `just pricing-sync` pass the argument; omitting it is a
clean regenerate, kept for a from-scratch run.

## Why

models.dev prunes ids as vendors retire them, and a regenerate-from-scratch
sync turns that into silent data loss. The cloud's counterpart re-prices
historical `token_usage` rows against its copy of this table; once a key is
gone the resolve cascade has nothing to fall back to — there is no
`gemini-3` family key under `gemini-3-pro-preview` — so those rows become
permanently unpriceable. The refresh of 2026-09 would have dropped four
Gemini ids this way, and it is what broke the cloud's canary test.

Retention also makes the canaries honest. Pinning literal ids in a test is
only reasonable when a re-sync cannot empty them out; before this, a rotting
canary looked like an upstream rename and the tempting fix was to loosen the
assertion, which is how the test stopped guarding the models dira actually
observes.

The cost is that a genuinely wrong price can no longer be corrected by
waiting for upstream to fix it — the entry persists until someone suppresses
it. That is the right trade: `overrides.json` already exists for exactly this,
and a wrong price is a visible, fixable estimate, while a missing one is
unrecoverable history.

## Rejected

- **Let ids drop and re-point the canaries each time** — the simplest diff,
but it accepts the cloud's history loss and guarantees the canaries rot
again on the next vendor rename.
- **Hand-copy dropped ids into `overrides.json`** — uses the documented
escape hatch, but it is manual work on every upstream prune and nothing
detects a prune that nobody noticed.
- **Fall back to a clean regenerate when the path argument is missing or
unreadable** — a typo'd path would drop every retained key with no
warning, which is the exact failure this decision exists to prevent. A bad
path is a hard error.

## Agent directives

- Never remove an entry from `cli/core/pricing/models.json` by hand, and
never "clean up" ids the catalog no longer carries. Suppress with a `null`
in `overrides.json` instead, and say why.
- The sanity gates in `pricing_sync` (`catalog.len() < 10`, missing
providers) are load-bearing under retention: a truncated catalog no longer
shows up as a shrunken table, it shows up as prices that quietly stop
moving. Do not soften them.
- The canary test asserts table **membership**, not `resolve().is_some()`.
The cascade's prefix step will answer for a missing id out of a shorter
sibling and hide the gap.
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ agent context at session start. Keep it short and non-negotiable.
- DIRASH-0030 — Full-content knowledge sync is opted into by its own prompt, never implied by linking (active)
- DIRASH-0031 — One backoff ladder lives in dira_core; callers own their attempt budget (active)
- DIRASH-0032 — A record's first-sight triple is repaired as a unit, from recorded facts (active)
- DIRASH-0035 — A newer schema is refused loudly, never run against (active)
- DIRASH-0036 — A pricing refresh appends and never drops a vendored id (active)

### Living specs (.zavet/specs/ — keep current while you work)

Expand Down
57 changes: 39 additions & 18 deletions cli/core/pricing/models.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$comment": "Generated by `just pricing-sync` from https://models.dev/api.json — do not hand-edit; corrections go in overrides.json. Prices are USD per 1M tokens. Scope: tool-calling, cost-bearing models from the providers behind the supported harnesses, minus dated aliases the resolver already reaches by stripping the pin. The cloud keeps its own copy and is authoritative; this one only labels local views and may drift between monthly refreshes.",
"$comment": "Generated by `just pricing-sync` from https://models.dev/api.json — do not hand-edit; corrections go in overrides.json. Prices are USD per 1M tokens. Scope: tool-calling, cost-bearing models from the providers behind the supported harnesses, minus dated aliases the resolver already reaches by stripping the pin. Append-only: a refresh updates the price of any key upstream still publishes but never drops a key upstream stops publishing, so the cloud's re-pricing cascade always has a key to resolve historical usage against. To actually remove an entry, add an explicit null override in overrides.json — that is the only supported way a key leaves this table. The cloud keeps its own copy and is authoritative; this one only labels local views and may drift between weekly refreshes.",
"models": {
"claude-3-5-haiku": {
"cacheRead": 0.08,
Expand All @@ -13,6 +13,12 @@
"input": 10.0,
"output": 50.0
},
"claude-fable-5-1": {
"cacheRead": 0.25,
"cacheWrite": 12.5,
"input": 10.0,
"output": 50.0
},
"claude-haiku-4-5": {
"cacheRead": 0.1,
"cacheWrite": 1.25,
Expand Down Expand Up @@ -191,19 +197,24 @@
"output": 2.5
},
"gemini-3.6-flash": {
"cacheRead": 0.15,
"input": 1.5,
"output": 7.5
"cacheRead": 0.075,
"input": 0.75,
"output": 3.75
},
"gemini-3.7-flash": {
"cacheRead": 0.075,
"input": 0.75,
"output": 3.75
},
"gemini-flash-latest": {
"cacheRead": 0.15,
"input": 1.5,
"output": 9.0
"cacheRead": 0.075,
"input": 0.75,
"output": 3.75
},
"gemini-flash-lite-latest": {
"cacheRead": 0.025,
"input": 0.25,
"output": 1.5
"cacheRead": 0.03,
"input": 0.3,
"output": 2.5
},
"gemini-robotics-er-1.6-preview": {
"input": 1.0,
Expand Down Expand Up @@ -388,10 +399,10 @@
"output": 180.0
},
"gpt-5.6": {
"cacheRead": 0.5,
"cacheWrite": 6.25,
"input": 5.0,
"output": 30.0
"cacheRead": 0.4,
"cacheWrite": 5.0,
"input": 4.0,
"output": 20.0
},
"gpt-5.6-luna": {
"cacheRead": 0.02,
Expand All @@ -400,10 +411,10 @@
"output": 1.2
},
"gpt-5.6-sol": {
"cacheRead": 0.5,
"cacheWrite": 6.25,
"input": 5.0,
"output": 30.0
"cacheRead": 0.4,
"cacheWrite": 5.0,
"input": 4.0,
"output": 20.0
},
"gpt-5.6-terra": {
"cacheRead": 0.2,
Expand Down Expand Up @@ -436,6 +447,11 @@
"input": 2.0,
"output": 6.0
},
"grok-4.6": {
"cacheRead": 0.5,
"input": 2.0,
"output": 6.0
},
"grok-build-0.1": {
"cacheRead": 0.2,
"input": 1.0,
Expand Down Expand Up @@ -491,6 +507,11 @@
"input": 0.3,
"output": 1.2
},
"muse-spark-1.2": {
"cacheRead": 0.15,
"input": 1.25,
"output": 4.25
},
"o1": {
"cacheRead": 7.5,
"input": 15.0,
Expand Down
Loading