From 19639c7d40bcb125f62d3f98b0796c0dff2aa9fc Mon Sep 17 00:00:00 2001 From: munir-weave Date: Fri, 28 Aug 2026 12:30:17 -0700 Subject: [PATCH 1/5] feat(codex): source session savings from the router The Codex status hook could show which model served a turn but not what it saved. Porting the Claude Code statusline's per-turn arithmetic is not an option here: Codex persists its own requested model on every turn and never the one that served, so pricing both sides of the comparison locally always yields zero. The router already computes the real number. Add GET /v1/sessions/:session_id/cost, authed by the rk_ key the Codex install already holds and scoped to the caller's installation, returning the session's committed actual/requested cost and their difference. It sums served turns plus billed auxiliary inference, so the total is what the session actually cost. The hook renders that value, fetching it in a detached subshell whose result the next turn reads, so no turn blocks on the network and every failure path degrades to model-only. A session where the router spent more shows no clause rather than a negative saving. Signed-off-by: munir-weave (cherry picked from commit 2b53c64bc4d6bee104769aeb5101a1adb99fee45) --- README.md | 1 + db/queries/model_router_request_telemetry.sql | 23 +++ install/README.md | 2 + install/codex-status.sh | 154 +++++++++++++++++- install/tests/codex-status_test.sh | 107 ++++++++++++ internal/api/admin/session_cost.go | 78 +++++++++ internal/api/admin/session_cost_test.go | 144 ++++++++++++++++ internal/api/admin/telemetry_stub_test.go | 58 +++++++ internal/postgres/telemetry.go | 41 +++++ .../auxiliary_inference_internal_test.go | 4 + .../fire_telemetry_panic_internal_test.go | 4 + internal/proxy/service.go | 19 +++ internal/proxy/service_observation_test.go | 4 + internal/proxy/telemetry.go | 21 +++ internal/proxy/turnloop_test.go | 4 + internal/server/server.go | 5 + internal/server/server_test.go | 3 + .../model_router_request_telemetry.sql.go | 80 +++++++++ 18 files changed, 748 insertions(+), 4 deletions(-) create mode 100644 internal/api/admin/session_cost.go create mode 100644 internal/api/admin/session_cost_test.go create mode 100644 internal/api/admin/telemetry_stub_test.go diff --git a/README.md b/README.md index 12502fc9d..4b9aa7830 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ dashboard, where selection is an organization-wide setting. See | `POST /v1/route` | Returns the decision, no upstream call | | `GET /v1/models`  ·  `POST /v1/messages/count_tokens` | Anthropic passthrough | | `GET /health`  ·  `GET /readyz`  ·  `GET /validate` | liveness + dependency readiness + key check | +| `GET /v1/sessions/:session_id/cost` | One session's committed cost + savings, scoped to your key | | `GET /v1/analytics/routing-decisions` | Raw routing decisions as cursor-paginated NDJSON ([docs](docs/ANALYTICS_EXPORT.md)) | | `GET /v1/analytics/schema`  ·  `GET /v1/analytics/models` | Export field dictionary + price book | diff --git a/db/queries/model_router_request_telemetry.sql b/db/queries/model_router_request_telemetry.sql index 826403506..64a4176a6 100644 --- a/db/queries/model_router_request_telemetry.sql +++ b/db/queries/model_router_request_telemetry.sql @@ -626,3 +626,26 @@ WHERE t.installation_id = @installation_id::uuid ) ORDER BY t.created_at ASC, t.id ASC LIMIT @row_limit::int; + +-- Committed cost of one client session for this installation. Includes +-- served turns and billed auxiliary inference so the total matches the +-- Weave public session-cost contract. No-rows when the session has no +-- committed telemetry yet. +-- name: GetSessionCost :one +SELECT + t.session_id, + COUNT(*)::bigint AS request_count, + COALESCE(SUM(t.actual_input_cost_usd), 0)::bigint AS actual_input_cost_usd, + COALESCE(SUM(t.actual_output_cost_usd), 0)::bigint AS actual_output_cost_usd, + COALESCE(SUM(t.requested_input_cost_usd), 0)::bigint AS requested_input_cost_usd, + COALESCE(SUM(t.requested_output_cost_usd), 0)::bigint AS requested_output_cost_usd, + COALESCE(SUM(t.input_tokens), 0)::bigint AS input_tokens, + COALESCE(SUM(t.output_tokens), 0)::bigint AS output_tokens, + COALESCE(SUM(t.cache_creation_tokens), 0)::bigint AS cache_creation_tokens, + COALESCE(SUM(t.cache_read_tokens), 0)::bigint AS cache_read_tokens, + MAX(t.created_at)::timestamptz AS last_recorded_at +FROM router.model_router_request_telemetry t +WHERE t.installation_id = @installation_id::uuid + AND t.session_id = @session_id::varchar + AND t.span_type IN ('router.upstream', 'router.auxiliary_inference') +GROUP BY t.session_id; diff --git a/install/README.md b/install/README.md index a6088a8cb..e51c90182 100644 --- a/install/README.md +++ b/install/README.md @@ -281,6 +281,8 @@ nothing lands in a repo working tree. **Codex status integration.** Codex 0.150+ supports lifecycle hooks. The installer enables hooks and adds managed `SessionStart` and `Stop` handlers. They maintain a small local state file and set the terminal title to `Weave Router · ` when the router provides a routed-model marker. On ordinary turns where the model is unchanged, the title remains the last known routed model; before the first routed response it shows `Weave Router · active`. The hook also emits a compact status message after a completed turn. It is not a replacement for Codex's requested-model line: that line continues to show the model selected in Codex configuration, while the Weave status identifies the model that actually served. Existing user and project hooks remain outside the managed block and are preserved on reinstall/uninstall. +**Session savings.** The title also carries `· saved $X.XX` when the router has beaten the model Codex asked for. The number comes from the router — the hook reads `GET /v1/sessions//cost` with the router key already in `config.toml` — and is never computed locally: Codex records only its *requested* model on every turn, never the one that served, so client-side pricing would compare a model against itself and always report zero. The fetch is detached and its result is cached for the following turn, so no turn ever blocks on the network; a slow, unreachable, or older router simply leaves the title model-only. A session where the router spent more than the requested model would have shows no clause at all rather than a negative number, and a total under a cent reads `saved <$0.01`. Set `WEAVE_CODEX_STATUS_SAVINGS=0` to turn the lookup off entirely. + The helper requires `jq` for per-turn updates. If `jq` is unavailable, the install still succeeds and the initial active terminal title remains available; no model metadata is updated by the hook. Disable or remove the integration with the normal Codex off/uninstall commands. ## Adding or changing a directive diff --git a/install/codex-status.sh b/install/codex-status.sh index 516eaeeb1..8646582c6 100755 --- a/install/codex-status.sh +++ b/install/codex-status.sh @@ -7,6 +7,15 @@ # keeps the last known routed model per session and reflects it in the terminal # title, so the active router remains visible between turns without injecting # another message into the conversation. +# +# Savings come from the router, not from local arithmetic. Codex records its +# own requested model on every turn and never the served one, so the per-turn +# pricing the Claude Code statusline does cannot be reproduced here — it would +# price both sides of the comparison at the same model and report zero. The +# router already sums the real thing per session, so the hook fetches +# GET /v1/sessions//cost and renders what it returns. The fetch runs +# in a detached subshell writing a cache the NEXT turn reads, so no turn ever +# blocks on the network, and every failure path leaves the title model-only. set -euo pipefail @@ -14,6 +23,11 @@ state_root="${XDG_CACHE_HOME:-$HOME/.cache}/weave-router/codex" helper_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)" disabled_marker="$helper_dir/.weave-router-disabled" router_badge_sentinel=$'⁣⁠⁣⁠' +# Must stay verbatim in sync with install.sh / uninstall.sh: the endpoint read +# below is scoped to this block so a key-shaped string elsewhere in the user's +# config.toml is never adopted. +codex_begin_marker="# >>> weave-router managed (do not edit between markers) >>>" +codex_end_marker="# <<< weave-router managed <<<" emit_title() { local title="$1" @@ -63,6 +77,127 @@ write_state() { mv "$tmp" "$file" } +cost_file_for() { + local id + id="$(safe_session_id "$1")" || return 1 + printf '%s/%s.cost' "$state_root" "$id" +} + +# Reads the router base URL and key out of the Codex config this install owns. +# Resolved from the helper's own location first so a project-scope install never +# reads (or leaks) the user-scope key: the project helper lives in the same +# .codex directory as its config, while the user-scope helper sits in ~/.weave +# and reads ~/.codex. Values are scoped to the managed block so a key-shaped +# string the user wrote elsewhere in the file is never adopted. awk, not a TOML +# parser, because the Codex target deliberately does not require jq for config +# reads. +read_codex_endpoint() { + local config="" candidate + for candidate in "$helper_dir/config.toml" "$HOME/.codex/config.toml"; do + if [ -f "$candidate" ]; then + config="$candidate" + break + fi + done + [ -n "$config" ] || return 0 + awk -v begin="$codex_begin_marker" -v end="$codex_end_marker" ' + $0 == begin { inblk = 1; next } + $0 == end { inblk = 0; next } + !inblk { next } + match($0, /base_url[[:space:]]*=[[:space:]]*"[^"]*"/) { + v = substr($0, RSTART, RLENGTH) + sub(/^.*=[[:space:]]*"/, "", v); sub(/"$/, "", v) + url = v + } + match($0, /"X-Weave-Router-Key"[[:space:]]*=[[:space:]]*"[^"]*"/) { + v = substr($0, RSTART, RLENGTH) + sub(/^.*=[[:space:]]*"/, "", v); sub(/"$/, "", v) + key = v + } + END { if (url != "" && key != "") printf "%s\n%s\n", url, key } + ' "$config" 2>/dev/null || true +} + +# Kicks off a detached fetch of this session's committed cost. The result lands +# in a cache the next turn reads; this turn renders whatever is already there. +# Fire-and-forget on purpose — a slow or unreachable router must never stall a +# Codex turn, and every failure simply leaves the previous cache in place. +refresh_session_cost() { + local id="$1" file="$2" + [ "${WEAVE_CODEX_STATUS_SAVINGS:-1}" = "0" ] && return 0 + command -v curl >/dev/null 2>&1 || return 0 + + local endpoint base_url key + endpoint="$(read_codex_endpoint)" || return 0 + base_url="$(printf '%s' "$endpoint" | sed -n 1p)" + key="$(printf '%s' "$endpoint" | sed -n 2p)" + [ -n "$base_url" ] && [ -n "$key" ] || return 0 + + mkdir -p "$state_root" 2>/dev/null || return 0 + chmod 700 "$state_root" 2>/dev/null || true + + ( + exec /dev/null; then + lock_mtime="$(stat -c %Y "$lock" 2>/dev/null || stat -f %m "$lock" 2>/dev/null)" || lock_mtime=0 + lock_now="$(date +%s 2>/dev/null)" || lock_now=0 + if [ "${lock_mtime:-0}" -le 0 ] || [ $(( lock_now - lock_mtime )) -le 30 ]; then + exit 0 + fi + rm -rf "$lock" 2>/dev/null + mkdir "$lock" 2>/dev/null || exit 0 + fi + trap 'rmdir "$lock" 2>/dev/null' EXIT + + # A file:// base is the offline/test seam: curl reads it as the response + # body directly, so the endpoint path is meaningless for it. + url="${base_url%/}" + case "$url" in + file://*) ;; + *) url="${url%/v1}/v1/sessions/$id/cost" ;; + esac + body="$(curl -fsS --max-time 5 -H "X-Weave-Router-Key: $key" "$url" 2>/dev/null)" || exit 0 + # savings_usd is the router's own (requested - actual). A body without it + # (404, error envelope, older router) writes nothing and leaves the cache. + savings="$(printf '%s' "$body" | jq -r '.savings_usd // empty' 2>/dev/null)" || exit 0 + case "$savings" in + ''|*[!0-9.eE+-]*) exit 0 ;; + esac + tmp="$file.tmp.$$" + mkdir -p "$(dirname "$file")" 2>/dev/null + if printf '%s' "$savings" >"$tmp" 2>/dev/null; then + chmod 600 "$tmp" 2>/dev/null + mv "$tmp" "$file" 2>/dev/null + fi + rm -f "$tmp" 2>/dev/null + ) >/dev/null 2>&1 & + disown 2>/dev/null || true +} + +# Renders the cached savings as a display clause, or nothing. Values below a +# cent read as "<$0.01" rather than "$0.00", which would be indistinguishable +# from "the router ran and did not beat your selection". Negative totals are +# omitted entirely: the router picked a pricier model for quality on this +# session and "saved -$0.02" is a worse answer than staying quiet. +savings_clause() { + local file="$1" raw + [ "${WEAVE_CODEX_STATUS_SAVINGS:-1}" = "0" ] && return 0 + [ -f "$file" ] || return 0 + raw="$(cat "$file" 2>/dev/null)" || return 0 + case "$raw" in + ''|*[!0-9.eE+-]*) return 0 ;; + esac + awk -v v="$raw" 'BEGIN{ + v = v + 0 + if (v < 0.005) { exit } + if (v < 0.01) { printf " · saved <$0.01"; exit } + printf " · saved $%.2f", v + }' 2>/dev/null || true +} + set -e case "${1:-hook}" in @@ -148,14 +283,25 @@ if [ -n "$file" ]; then write_state "$file" fi +# The cache holds the previous turn's fetch; the refresh below serves the next +# one. Router telemetry is written asynchronously anyway, so a just-finished +# turn would not be included even in a blocking read — reading first and +# refreshing after costs a turn of freshness and buys never blocking Codex. +savings="" +cost_file="" +if cost_file="$(cost_file_for "$session_id" 2>/dev/null)"; then + savings="$(savings_clause "$cost_file")" + refresh_session_cost "$(safe_session_id "$session_id")" "$cost_file" +fi + if [ -n "$routed_model" ] && [ -n "$requested_model" ] && [ "$routed_model" != "$requested_model" ]; then - title="Weave Router · $routed_model ← $requested_model" + title="Weave Router · $routed_model ← $requested_model$savings" elif [ -n "$routed_model" ]; then - title="Weave Router · $routed_model" + title="Weave Router · $routed_model$savings" elif [ -n "$requested_model" ]; then - title="Weave Router · active ← $requested_model" + title="Weave Router · active ← $requested_model$savings" else - title="Weave Router · active" + title="Weave Router · active$savings" fi emit_title "$title" if [ -n "$marker_model" ] || [ -n "$force_model" ]; then diff --git a/install/tests/codex-status_test.sh b/install/tests/codex-status_test.sh index 6a117cf7a..71afc05c0 100755 --- a/install/tests/codex-status_test.sh +++ b/install/tests/codex-status_test.sh @@ -73,4 +73,111 @@ XDG_CACHE_HOME="$cache" WEAVE_CODEX_STATUS_TITLE_FILE="$title_file" "$helper" -- exit 1 } +# ---------- server-sourced savings ---------- +# +# Savings come from the router's own (requested - actual), never from local +# pricing: Codex records only its requested model, so client-side arithmetic +# would price both sides identically and report zero. + +savings_home="$work/home" +mkdir -p "$savings_home/.codex" +cost_body="$work/cost.json" +printf '%s\n' '{"session_id":"session-2","savings_usd":0.32}' >"$cost_body" +cat >"$savings_home/.codex/config.toml" <>> weave-router managed (do not edit between markers) >>> +model_provider = "weave" + +[model_providers.weave] +base_url = "file://$cost_body" +http_headers = { "X-Weave-Router-Key" = "rk_test", "X-App" = "codex" } +# <<< weave-router managed <<< +TOML + +savings_cache="$work/cache-savings" +run_savings_turn() { + printf '%s\n' '{"session_id":"session-2","model":"gpt-5.6-terra","last_assistant_message":"✦ **Weave Router** → claude-sonnet-5 · best pick for this turn"}' \ + | HOME="$savings_home" XDG_CACHE_HOME="$savings_cache" \ + WEAVE_CODEX_STATUS_TITLE_FILE="$title_file" "$helper" >/dev/null +} + +# The first turn has no cache yet, so it renders model-only and kicks off the +# fetch that serves the next turn — the hook must never block on the network. +run_savings_turn +[ "$(cat "$title_file")" = "Weave Router · claude-sonnet-5 ← gpt-5.6-terra" ] || { + echo "first turn rendered savings before any fetch had completed" >&2 + exit 1 +} + +cost_cache="$savings_cache/weave-router/codex/session-2.cost" +for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -f "$cost_cache" ] && break + sleep 0.2 +done +[ -f "$cost_cache" ] || { + echo "background fetch never wrote the session cost cache" >&2 + exit 1 +} + +run_savings_turn +[ "$(cat "$title_file")" = "Weave Router · claude-sonnet-5 ← gpt-5.6-terra · saved \$0.32" ] || { + echo "server-sourced savings did not reach the title: $(cat "$title_file")" >&2 + exit 1 +} + +# The remaining rendering cases run with no reachable config ($HOME has no +# config.toml), so the fetch is a no-op and the seeded cache is what the turn +# renders. That also proves an unreachable router leaves the last good value in +# place rather than wiping it. +render_cached_savings() { + printf '%s' "$1" >"$cost_cache" + printf '%s\n' '{"session_id":"session-2","model":"gpt-5.6-terra","last_assistant_message":"✦ **Weave Router** → claude-sonnet-5 · best pick"}' \ + | HOME="$work/empty-home" XDG_CACHE_HOME="$savings_cache" \ + WEAVE_CODEX_STATUS_TITLE_FILE="$title_file" "$helper" >/dev/null +} + +render_cached_savings '0.32' +[ "$(cat "$title_file")" = "Weave Router · claude-sonnet-5 ← gpt-5.6-terra · saved \$0.32" ] || { + echo "an unreachable router discarded the cached savings: $(cat "$title_file")" >&2 + exit 1 +} + +# A router that spent more than the requested model would have is reported by +# staying silent, never as a negative saving. +render_cached_savings '-0.5' +[ "$(cat "$title_file")" = "Weave Router · claude-sonnet-5 ← gpt-5.6-terra" ] || { + echo "negative savings leaked into the title: $(cat "$title_file")" >&2 + exit 1 +} + +# Sub-cent totals must not read as "$0.00", which is indistinguishable from +# "the router ran and did not beat your selection". +render_cached_savings '0.004' +[ "$(cat "$title_file")" = "Weave Router · claude-sonnet-5 ← gpt-5.6-terra" ] || { + echo "a total below half a cent should render no savings clause" >&2 + exit 1 +} +render_cached_savings '0.006' +[ "$(cat "$title_file")" = "Weave Router · claude-sonnet-5 ← gpt-5.6-terra · saved <\$0.01" ] || { + echo "sub-cent savings did not render as <\$0.01: $(cat "$title_file")" >&2 + exit 1 +} + +# A garbage cache must degrade to model-only rather than rendering junk. +render_cached_savings 'not-a-number' +[ "$(cat "$title_file")" = "Weave Router · claude-sonnet-5 ← gpt-5.6-terra" ] || { + echo "a malformed cost cache leaked into the title: $(cat "$title_file")" >&2 + exit 1 +} + +# Opting out must suppress the fetch entirely, not just the rendering. +optout_cache="$work/cache-optout" +printf '%s\n' '{"session_id":"session-3","model":"gpt-5.6-terra","last_assistant_message":"✦ **Weave Router** → claude-sonnet-5 · best pick"}' \ + | HOME="$savings_home" XDG_CACHE_HOME="$optout_cache" WEAVE_CODEX_STATUS_SAVINGS=0 \ + WEAVE_CODEX_STATUS_TITLE_FILE="$title_file" "$helper" >/dev/null +sleep 0.5 +[ ! -f "$optout_cache/weave-router/codex/session-3.cost" ] || { + echo "WEAVE_CODEX_STATUS_SAVINGS=0 still fetched the session cost" >&2 + exit 1 +} + echo "Codex status helper regression tests passed" diff --git a/internal/api/admin/session_cost.go b/internal/api/admin/session_cost.go new file mode 100644 index 000000000..ab897a7f6 --- /dev/null +++ b/internal/api/admin/session_cost.go @@ -0,0 +1,78 @@ +package admin + +import ( + "errors" + "net/http" + + "workweave/router/internal/proxy" + "workweave/router/internal/server/middleware" + + "github.com/gin-gonic/gin" +) + +// usdMicrosPerUSD converts the authoritative integer micros into the decimal +// display fields. Applied ONLY at response encoding, so no float rounding +// accumulates across a session's rows. +const usdMicrosPerUSD = 1_000_000.0 + +// sessionCostResponse is the committed router cost of one client session. +// The *_usd_micros integers are authoritative; the decimal fields are derived +// for display. savings = requested - actual. +type sessionCostResponse struct { + SessionID string `json:"session_id"` + RequestCount int64 `json:"request_count"` + ActualCostUSDMicros int64 `json:"actual_cost_usd_micros"` + ActualCostUSD float64 `json:"actual_cost_usd"` + RequestedCostUSDMicros int64 `json:"requested_cost_usd_micros"` + RequestedCostUSD float64 `json:"requested_cost_usd"` + SavingsUSDMicros int64 `json:"savings_usd_micros"` + SavingsUSD float64 `json:"savings_usd"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheCreationTokens int64 `json:"cache_creation_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + LastRecordedAt string `json:"last_recorded_at"` +} + +// SessionCostHandler returns the committed router cost of one client session, +// scoped to the installation behind the rk_ key. It lets a client that already +// holds a router key (the Codex status hook) render real savings instead of +// recomputing them from a local price table it cannot keep in sync. +func SessionCostHandler(proxySvc *proxy.Service) gin.HandlerFunc { + return func(c *gin.Context) { + installation := middleware.InstallationFrom(c) + if installation == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_key"}) + return + } + + cost, err := proxySvc.SessionCost(c.Request.Context(), installation.ID, c.Param("session_id")) + // One response for unknown, foreign, and not-yet-committed sessions: + // distinguishing them would confirm a foreign session's existence. + if errors.Is(err, proxy.ErrSessionCostNotFound) { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "session_cost_not_found"}) + return + } + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch session cost."}) + return + } + + savings := cost.RequestedCostUSDMicros - cost.ActualCostUSDMicros + c.JSON(http.StatusOK, sessionCostResponse{ + SessionID: cost.SessionID, + RequestCount: cost.RequestCount, + ActualCostUSDMicros: cost.ActualCostUSDMicros, + ActualCostUSD: float64(cost.ActualCostUSDMicros) / usdMicrosPerUSD, + RequestedCostUSDMicros: cost.RequestedCostUSDMicros, + RequestedCostUSD: float64(cost.RequestedCostUSDMicros) / usdMicrosPerUSD, + SavingsUSDMicros: savings, + SavingsUSD: float64(savings) / usdMicrosPerUSD, + InputTokens: cost.InputTokens, + OutputTokens: cost.OutputTokens, + CacheCreationTokens: cost.CacheCreationTokens, + CacheReadTokens: cost.CacheReadTokens, + LastRecordedAt: cost.LastRecordedAt.UTC().Format("2006-01-02T15:04:05Z07:00"), + }) + } +} diff --git a/internal/api/admin/session_cost_test.go b/internal/api/admin/session_cost_test.go new file mode 100644 index 000000000..e026c7de8 --- /dev/null +++ b/internal/api/admin/session_cost_test.go @@ -0,0 +1,144 @@ +package admin_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "workweave/router/internal/api/admin" + "workweave/router/internal/auth" + "workweave/router/internal/proxy" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// sessionCostRepo serves one session's cost and records the scope it was asked +// for, so the handler's installation scoping is observable. +type sessionCostRepo struct { + stubTelemetryRepo + cost proxy.SessionCost + err error + seenInstallationID string + seenSessionID string +} + +func (r *sessionCostRepo) GetSessionCost(_ context.Context, installationID, sessionID string) (proxy.SessionCost, error) { + r.seenInstallationID = installationID + r.seenSessionID = sessionID + return r.cost, r.err +} + +func sessionCostEngine(t *testing.T, repo proxy.TelemetryRepository, installation *auth.Installation) *gin.Engine { + t.Helper() + svc := proxy.NewService(nil, nil, nil, false, nil, nil, false, "", "", repo) + engine := gin.New() + engine.GET("/v1/sessions/:session_id/cost", func(c *gin.Context) { + if installation != nil { + c.Set("router_installation", installation) + } + }, admin.SessionCostHandler(svc)) + return engine +} + +type sessionCostBody struct { + SessionID string `json:"session_id"` + RequestCount int64 `json:"request_count"` + ActualCostUSDMicros int64 `json:"actual_cost_usd_micros"` + RequestedCostUSDMicros int64 `json:"requested_cost_usd_micros"` + SavingsUSDMicros int64 `json:"savings_usd_micros"` + SavingsUSD float64 `json:"savings_usd"` + InputTokens int64 `json:"input_tokens"` +} + +func TestSessionCostHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("reports savings as requested minus actual", func(t *testing.T) { + repo := &sessionCostRepo{cost: proxy.SessionCost{ + SessionID: "session-1", + RequestCount: 3, + ActualCostUSDMicros: 250_000, + RequestedCostUSDMicros: 570_000, + InputTokens: 1200, + LastRecordedAt: time.Unix(1700000000, 0).UTC(), + }} + engine := sessionCostEngine(t, repo, &auth.Installation{ID: uuid.NewString()}) + + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/sessions/session-1/cost", nil)) + + require.Equal(t, http.StatusOK, rec.Code) + var body sessionCostBody + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, "session-1", body.SessionID) + require.Equal(t, int64(3), body.RequestCount) + require.Equal(t, int64(320_000), body.SavingsUSDMicros) + require.InDelta(t, 0.32, body.SavingsUSD, 1e-9) + require.Equal(t, int64(1200), body.InputTokens) + require.Equal(t, "session-1", repo.seenSessionID) + }) + + t.Run("scopes the lookup to the calling installation", func(t *testing.T) { + installationID := uuid.NewString() + repo := &sessionCostRepo{cost: proxy.SessionCost{SessionID: "session-1"}} + engine := sessionCostEngine(t, repo, &auth.Installation{ID: installationID}) + + engine.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/v1/sessions/session-1/cost", nil)) + + require.Equal(t, installationID, repo.seenInstallationID) + }) + + t.Run("reports a negative total when the router spent more", func(t *testing.T) { + repo := &sessionCostRepo{cost: proxy.SessionCost{ + SessionID: "session-1", + ActualCostUSDMicros: 900_000, + RequestedCostUSDMicros: 400_000, + }} + engine := sessionCostEngine(t, repo, &auth.Installation{ID: uuid.NewString()}) + + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/sessions/session-1/cost", nil)) + + require.Equal(t, http.StatusOK, rec.Code) + var body sessionCostBody + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Equal(t, int64(-500_000), body.SavingsUSDMicros) + }) + + t.Run("404s an unknown or foreign session", func(t *testing.T) { + repo := &sessionCostRepo{err: proxy.ErrSessionCostNotFound} + engine := sessionCostEngine(t, repo, &auth.Installation{ID: uuid.NewString()}) + + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/sessions/session-1/cost", nil)) + + require.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("500s a repository failure", func(t *testing.T) { + repo := &sessionCostRepo{err: errors.New("postgres is down")} + engine := sessionCostEngine(t, repo, &auth.Installation{ID: uuid.NewString()}) + + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/sessions/session-1/cost", nil)) + + require.Equal(t, http.StatusInternalServerError, rec.Code) + }) + + t.Run("401s without an authenticated installation", func(t *testing.T) { + repo := &sessionCostRepo{cost: proxy.SessionCost{SessionID: "session-1"}} + engine := sessionCostEngine(t, repo, nil) + + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/sessions/session-1/cost", nil)) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + require.Empty(t, repo.seenSessionID, "an unauthenticated caller must not reach the repository") + }) +} diff --git a/internal/api/admin/telemetry_stub_test.go b/internal/api/admin/telemetry_stub_test.go new file mode 100644 index 000000000..e9ee54c3b --- /dev/null +++ b/internal/api/admin/telemetry_stub_test.go @@ -0,0 +1,58 @@ +package admin_test + +import ( + "context" + "time" + + "workweave/router/internal/proxy" + + "github.com/google/uuid" +) + +// stubTelemetryRepo satisfies proxy.TelemetryRepository with no-ops so a test +// can embed it and override only the method under test. +type stubTelemetryRepo struct{} + +func (stubTelemetryRepo) InsertRequestTelemetry(context.Context, proxy.InsertTelemetryParams) error { + return nil +} + +func (stubTelemetryRepo) GetTelemetrySummary(context.Context, string, time.Time, time.Time) (proxy.TelemetrySummary, error) { + return proxy.TelemetrySummary{}, nil +} + +func (stubTelemetryRepo) GetTelemetryTimeseries(context.Context, string, time.Time, time.Time, string) ([]proxy.TelemetryBucket, error) { + return nil, nil +} + +func (stubTelemetryRepo) GetTelemetrySummaryAll(context.Context, time.Time, time.Time) (proxy.TelemetrySummary, error) { + return proxy.TelemetrySummary{}, nil +} + +func (stubTelemetryRepo) GetTelemetryTimeseriesAll(context.Context, time.Time, time.Time, string) ([]proxy.TelemetryBucket, error) { + return nil, nil +} + +func (stubTelemetryRepo) GetTelemetryRows(context.Context, string, time.Time, time.Time, int32) ([]proxy.TelemetryRow, error) { + return nil, nil +} + +func (stubTelemetryRepo) GetTelemetryRowsAll(context.Context, time.Time, time.Time, int32) ([]proxy.TelemetryRow, error) { + return nil, nil +} + +func (stubTelemetryRepo) GetTelemetryModelBreakdown(context.Context, string, time.Time, time.Time, string) ([]proxy.TelemetryModelBucket, error) { + return nil, nil +} + +func (stubTelemetryRepo) GetTelemetryModelBreakdownAll(context.Context, time.Time, time.Time, string) ([]proxy.TelemetryModelBucket, error) { + return nil, nil +} + +func (stubTelemetryRepo) GetTelemetryBySessionSequence(context.Context, uuid.UUID, []byte, string, int) (proxy.TelemetryTurnResult, error) { + return proxy.TelemetryTurnResult{}, nil +} + +func (stubTelemetryRepo) GetSessionCost(context.Context, string, string) (proxy.SessionCost, error) { + return proxy.SessionCost{}, proxy.ErrSessionCostNotFound +} diff --git a/internal/postgres/telemetry.go b/internal/postgres/telemetry.go index 497a8cadc..434681350 100644 --- a/internal/postgres/telemetry.go +++ b/internal/postgres/telemetry.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "errors" "time" "workweave/router/internal/proxy" @@ -9,6 +10,7 @@ import ( "workweave/router/internal/sqlc" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -677,6 +679,45 @@ func (r *TelemetryRepo) GetTelemetryRowsAll(ctx context.Context, from, to time.T return out, nil } +// GetSessionCost aggregates one session's committed cost rows. The +// installation_id predicate is the authorization boundary: a session id from +// another installation matches nothing and comes back as not-found rather than +// as another tenant's cost. +func (r *TelemetryRepo) GetSessionCost(ctx context.Context, installationID, sessionID string) (proxy.SessionCost, error) { + id, err := uuid.Parse(installationID) + if err != nil { + return proxy.SessionCost{}, err + } + q := sqlc.New(r.tx) + row, err := q.GetSessionCost(ctx, sqlc.GetSessionCostParams{ + InstallationID: id, + SessionID: sessionID, + }) + if errors.Is(err, pgx.ErrNoRows) { + return proxy.SessionCost{}, proxy.ErrSessionCostNotFound + } + if err != nil { + return proxy.SessionCost{}, err + } + // GROUP BY t.session_id with a non-null session_id predicate means the + // column is never NULL here; SQLC types it as a pointer because the + // underlying column is nullable. + if row.SessionID == nil { + return proxy.SessionCost{}, proxy.ErrSessionCostNotFound + } + return proxy.SessionCost{ + SessionID: *row.SessionID, + RequestCount: row.RequestCount, + ActualCostUSDMicros: row.ActualInputCostUsd + row.ActualOutputCostUsd, + RequestedCostUSDMicros: row.RequestedInputCostUsd + row.RequestedOutputCostUsd, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + CacheCreationTokens: row.CacheCreationTokens, + CacheReadTokens: row.CacheReadTokens, + LastRecordedAt: row.LastRecordedAt.Time, + }, nil +} + // telemetryRowFromRow centralizes SQLC -> domain conversion. The {all, per-installation} // queries emit isomorphic but distinctly named row types, so we accept individual fields. func telemetryRowFromRow( diff --git a/internal/proxy/auxiliary_inference_internal_test.go b/internal/proxy/auxiliary_inference_internal_test.go index 462229c8f..3f95a6bfa 100644 --- a/internal/proxy/auxiliary_inference_internal_test.go +++ b/internal/proxy/auxiliary_inference_internal_test.go @@ -84,6 +84,10 @@ func (r *auxTelemetryRepo) GetTelemetryModelBreakdownAll(context.Context, time.T return nil, nil } +func (r *auxTelemetryRepo) GetSessionCost(context.Context, string, string) (SessionCost, error) { + return SessionCost{}, ErrSessionCostNotFound +} + func (r *auxTelemetryRepo) GetTelemetryBySessionSequence(context.Context, uuid.UUID, []byte, string, int) (TelemetryTurnResult, error) { return TelemetryTurnResult{}, nil } diff --git a/internal/proxy/fire_telemetry_panic_internal_test.go b/internal/proxy/fire_telemetry_panic_internal_test.go index 07112fa46..07c4f0fc5 100644 --- a/internal/proxy/fire_telemetry_panic_internal_test.go +++ b/internal/proxy/fire_telemetry_panic_internal_test.go @@ -54,6 +54,10 @@ func (panicTelemetryRepo) GetTelemetryModelBreakdownAll(ctx context.Context, fro return nil, nil } +func (panicTelemetryRepo) GetSessionCost(ctx context.Context, installationID, sessionID string) (SessionCost, error) { + return SessionCost{}, ErrSessionCostNotFound +} + func (panicTelemetryRepo) GetTelemetryBySessionSequence(ctx context.Context, installationID uuid.UUID, sessionKey []byte, role string, seq int) (TelemetryTurnResult, error) { return TelemetryTurnResult{}, nil } diff --git a/internal/proxy/service.go b/internal/proxy/service.go index 7b95a5d4b..ee7c3622e 100644 --- a/internal/proxy/service.go +++ b/internal/proxy/service.go @@ -1973,6 +1973,25 @@ func (s *Service) MetricsSummary(ctx context.Context, installationID string, fro return s.telemetry.GetTelemetrySummary(ctx, installationID, from, to) } +// ErrSessionCostNotFound means this installation has no committed cost-bearing +// telemetry for the session id — an unknown id, another installation's session, +// or a session whose asynchronous telemetry has not landed yet. The three are +// deliberately indistinguishable so a caller cannot probe for the existence of +// a session it does not own. +var ErrSessionCostNotFound = errors.New("no committed router telemetry for session") + +// SessionCost returns the committed router cost of one client session, scoped +// to the calling installation. +func (s *Service) SessionCost(ctx context.Context, installationID, sessionID string) (SessionCost, error) { + if s.telemetry == nil { + return SessionCost{}, ErrSessionCostNotFound + } + if sessionID == "" || len(sessionID) > MaxClientIdentifierLen { + return SessionCost{}, ErrSessionCostNotFound + } + return s.telemetry.GetSessionCost(ctx, installationID, sessionID) +} + // MetricsTimeseries returns per-bucket cost rows for the cost savings chart. func (s *Service) MetricsTimeseries(ctx context.Context, installationID string, from, to time.Time, granularity string) ([]TelemetryBucket, error) { if s.telemetry == nil { diff --git a/internal/proxy/service_observation_test.go b/internal/proxy/service_observation_test.go index 6be93d356..e5ce19493 100644 --- a/internal/proxy/service_observation_test.go +++ b/internal/proxy/service_observation_test.go @@ -97,6 +97,10 @@ func (c *captureTelemetry) GetTelemetryModelBreakdownAll(context.Context, time.T return nil, nil } +func (c *captureTelemetry) GetSessionCost(context.Context, string, string) (proxy.SessionCost, error) { + return proxy.SessionCost{}, proxy.ErrSessionCostNotFound +} + func (c *captureTelemetry) GetTelemetryBySessionSequence(_ context.Context, _ uuid.UUID, _ []byte, _ string, seq int) (proxy.TelemetryTurnResult, error) { c.mu.Lock() c.seqCalls = append(c.seqCalls, seq) diff --git a/internal/proxy/telemetry.go b/internal/proxy/telemetry.go index dee1b1ffd..ec0ae7652 100644 --- a/internal/proxy/telemetry.go +++ b/internal/proxy/telemetry.go @@ -22,6 +22,27 @@ type TelemetryRepository interface { GetTelemetryModelBreakdown(ctx context.Context, installationID string, from, to time.Time, granularity string) ([]TelemetryModelBucket, error) GetTelemetryModelBreakdownAll(ctx context.Context, from, to time.Time, granularity string) ([]TelemetryModelBucket, error) GetTelemetryBySessionSequence(ctx context.Context, installationID uuid.UUID, sessionKey []byte, role string, seq int) (TelemetryTurnResult, error) + GetSessionCost(ctx context.Context, installationID, sessionID string) (SessionCost, error) +} + +// SessionCost is the committed router cost of one client session, aggregated +// across every cost-bearing telemetry row that carries the session id. +// +// Money is USD micros ($1.00 = 1,000,000) end to end: costs are persisted in +// micros and summed in micros, so no float rounding accumulates across a +// session. Actual is what the session really cost on the binding the router +// chose; Requested is what the client's originally-requested model would have +// cost, so (Requested - Actual) is the router's savings. +type SessionCost struct { + SessionID string + RequestCount int64 + ActualCostUSDMicros int64 + RequestedCostUSDMicros int64 + InputTokens int64 + OutputTokens int64 + CacheCreationTokens int64 + CacheReadTokens int64 + LastRecordedAt time.Time } // InsertTelemetryParams mirrors one router.upstream span row. diff --git a/internal/proxy/turnloop_test.go b/internal/proxy/turnloop_test.go index 0bc49be24..37befdff9 100644 --- a/internal/proxy/turnloop_test.go +++ b/internal/proxy/turnloop_test.go @@ -1078,6 +1078,10 @@ func (recordingTelemetry) GetTelemetryModelBreakdownAll(ctx context.Context, fro return nil, nil } +func (recordingTelemetry) GetSessionCost(ctx context.Context, installationID, sessionID string) (proxy.SessionCost, error) { + return proxy.SessionCost{}, proxy.ErrSessionCostNotFound +} + func (recordingTelemetry) GetTelemetryBySessionSequence(ctx context.Context, installationID uuid.UUID, sessionKey []byte, role string, seq int) (proxy.TelemetryTurnResult, error) { return proxy.TelemetryTurnResult{}, nil } diff --git a/internal/server/server.go b/internal/server/server.go index feb62f4a4..c6ceb5600 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -243,6 +243,11 @@ func Register(engine *gin.Engine, authSvc *auth.Service, proxySvc *proxy.Service passthroughGroup.GET("/v1/models/:model", anthropicapi.PassthroughHandler(proxySvc)) // Rides the passthrough group (cheap, no billing middleware) — read-only, no routing side-effects. passthroughGroup.GET("/v1/display-settings", admin.DisplaySettingsHandler) + // Same rationale: one indexed aggregate, scoped to the caller's + // installation. Product surface, not admin — the Codex status hook holds an + // rk_ key and needs the router's own savings number rather than a client + // price table it cannot keep in sync. + passthroughGroup.GET("/v1/sessions/:session_id/cost", admin.SessionCostHandler(proxySvc)) routeMiddleware := []gin.HandlerFunc{ middleware.WithTimeout(routeTimeout), diff --git a/internal/server/server_test.go b/internal/server/server_test.go index a299271c9..214514b08 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -53,6 +53,9 @@ func TestRegister_DeploymentMode(t *testing.T) { "POST /v1/messages/count_tokens", "GET /v1/models", "GET /v1/models/:model", + // The Codex status hook reads this on hosted (managed) installs, so + // mounting it inside the selfhosted block would strand every customer. + "GET /v1/sessions/:session_id/cost", } // Self-hoster dashboard surface — gated by DeploymentModeSelfHosted. diff --git a/internal/sqlc/model_router_request_telemetry.sql.go b/internal/sqlc/model_router_request_telemetry.sql.go index 27f836b1b..0c74e2c38 100644 --- a/internal/sqlc/model_router_request_telemetry.sql.go +++ b/internal/sqlc/model_router_request_telemetry.sql.go @@ -316,6 +316,86 @@ func (q *Queries) GetRoutingDecisionsForExport(ctx context.Context, arg GetRouti return items, nil } +const getSessionCost = `-- name: GetSessionCost :one +SELECT + t.session_id, + COUNT(*)::bigint AS request_count, + COALESCE(SUM(t.actual_input_cost_usd), 0)::bigint AS actual_input_cost_usd, + COALESCE(SUM(t.actual_output_cost_usd), 0)::bigint AS actual_output_cost_usd, + COALESCE(SUM(t.requested_input_cost_usd), 0)::bigint AS requested_input_cost_usd, + COALESCE(SUM(t.requested_output_cost_usd), 0)::bigint AS requested_output_cost_usd, + COALESCE(SUM(t.input_tokens), 0)::bigint AS input_tokens, + COALESCE(SUM(t.output_tokens), 0)::bigint AS output_tokens, + COALESCE(SUM(t.cache_creation_tokens), 0)::bigint AS cache_creation_tokens, + COALESCE(SUM(t.cache_read_tokens), 0)::bigint AS cache_read_tokens, + MAX(t.created_at)::timestamptz AS last_recorded_at +FROM router.model_router_request_telemetry t +WHERE t.installation_id = $1::uuid + AND t.session_id = $2::varchar + AND t.span_type IN ('router.upstream', 'router.auxiliary_inference') +GROUP BY t.session_id +` + +type GetSessionCostParams struct { + InstallationID uuid.UUID + SessionID string +} + +type GetSessionCostRow struct { + SessionID *string + RequestCount int64 + ActualInputCostUsd int64 + ActualOutputCostUsd int64 + RequestedInputCostUsd int64 + RequestedOutputCostUsd int64 + InputTokens int64 + OutputTokens int64 + CacheCreationTokens int64 + CacheReadTokens int64 + LastRecordedAt pgtype.Timestamptz +} + +// Committed cost of one client session for this installation. Includes +// served turns and billed auxiliary inference so the total matches the +// Weave public session-cost contract. No-rows when the session has no +// committed telemetry yet. +// +// SELECT +// t.session_id, +// COUNT(*)::bigint AS request_count, +// COALESCE(SUM(t.actual_input_cost_usd), 0)::bigint AS actual_input_cost_usd, +// COALESCE(SUM(t.actual_output_cost_usd), 0)::bigint AS actual_output_cost_usd, +// COALESCE(SUM(t.requested_input_cost_usd), 0)::bigint AS requested_input_cost_usd, +// COALESCE(SUM(t.requested_output_cost_usd), 0)::bigint AS requested_output_cost_usd, +// COALESCE(SUM(t.input_tokens), 0)::bigint AS input_tokens, +// COALESCE(SUM(t.output_tokens), 0)::bigint AS output_tokens, +// COALESCE(SUM(t.cache_creation_tokens), 0)::bigint AS cache_creation_tokens, +// COALESCE(SUM(t.cache_read_tokens), 0)::bigint AS cache_read_tokens, +// MAX(t.created_at)::timestamptz AS last_recorded_at +// FROM router.model_router_request_telemetry t +// WHERE t.installation_id = $1::uuid +// AND t.session_id = $2::varchar +// AND t.span_type IN ('router.upstream', 'router.auxiliary_inference') +// GROUP BY t.session_id +func (q *Queries) GetSessionCost(ctx context.Context, arg GetSessionCostParams) (GetSessionCostRow, error) { + row := q.db.QueryRow(ctx, getSessionCost, arg.InstallationID, arg.SessionID) + var i GetSessionCostRow + err := row.Scan( + &i.SessionID, + &i.RequestCount, + &i.ActualInputCostUsd, + &i.ActualOutputCostUsd, + &i.RequestedInputCostUsd, + &i.RequestedOutputCostUsd, + &i.InputTokens, + &i.OutputTokens, + &i.CacheCreationTokens, + &i.CacheReadTokens, + &i.LastRecordedAt, + ) + return i, err +} + const getTelemetryBySessionAsc = `-- name: GetTelemetryBySessionAsc :one SELECT request_id, From 835a1923250f94290b754e7823ff6b42cc4d4711 Mon Sep 17 00:00:00 2001 From: munir-weave Date: Fri, 28 Aug 2026 12:36:09 -0700 Subject: [PATCH 2/5] fix(codex): sync the install.sh status embed and guard it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh embeds the Codex status helper as a heredoc for the standalone curl | sh path, which has no sibling asset to copy. The savings lookup landed only in install/codex-status.sh, so every curl install kept shipping the old helper and could never render saved $X.XX. Regenerate the embed from the canonical helper and add the byte-identical check that install/tests/cc-statusline_test.sh already has for its own heredoc — nothing was keeping these two copies in sync, which is how they diverged in the first place. Signed-off-by: munir-weave (cherry picked from commit 43348b1319deb41abb9f0350b06a010934a4fd64) --- install/install.sh | 154 ++++++++++++++++++++++++++++- install/tests/codex-status_test.sh | 21 ++++ 2 files changed, 171 insertions(+), 4 deletions(-) diff --git a/install/install.sh b/install/install.sh index 28b7eb5a7..72761ab2a 100755 --- a/install/install.sh +++ b/install/install.sh @@ -3155,6 +3155,15 @@ install_codex_status_script() { # keeps the last known routed model per session and reflects it in the terminal # title, so the active router remains visible between turns without injecting # another message into the conversation. +# +# Savings come from the router, not from local arithmetic. Codex records its +# own requested model on every turn and never the served one, so the per-turn +# pricing the Claude Code statusline does cannot be reproduced here — it would +# price both sides of the comparison at the same model and report zero. The +# router already sums the real thing per session, so the hook fetches +# GET /v1/sessions//cost and renders what it returns. The fetch runs +# in a detached subshell writing a cache the NEXT turn reads, so no turn ever +# blocks on the network, and every failure path leaves the title model-only. set -euo pipefail @@ -3162,6 +3171,11 @@ state_root="${XDG_CACHE_HOME:-$HOME/.cache}/weave-router/codex" helper_dir="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)" disabled_marker="$helper_dir/.weave-router-disabled" router_badge_sentinel=$'⁣⁠⁣⁠' +# Must stay verbatim in sync with install.sh / uninstall.sh: the endpoint read +# below is scoped to this block so a key-shaped string elsewhere in the user's +# config.toml is never adopted. +codex_begin_marker="# >>> weave-router managed (do not edit between markers) >>>" +codex_end_marker="# <<< weave-router managed <<<" emit_title() { local title="$1" @@ -3211,6 +3225,127 @@ write_state() { mv "$tmp" "$file" } +cost_file_for() { + local id + id="$(safe_session_id "$1")" || return 1 + printf '%s/%s.cost' "$state_root" "$id" +} + +# Reads the router base URL and key out of the Codex config this install owns. +# Resolved from the helper's own location first so a project-scope install never +# reads (or leaks) the user-scope key: the project helper lives in the same +# .codex directory as its config, while the user-scope helper sits in ~/.weave +# and reads ~/.codex. Values are scoped to the managed block so a key-shaped +# string the user wrote elsewhere in the file is never adopted. awk, not a TOML +# parser, because the Codex target deliberately does not require jq for config +# reads. +read_codex_endpoint() { + local config="" candidate + for candidate in "$helper_dir/config.toml" "$HOME/.codex/config.toml"; do + if [ -f "$candidate" ]; then + config="$candidate" + break + fi + done + [ -n "$config" ] || return 0 + awk -v begin="$codex_begin_marker" -v end="$codex_end_marker" ' + $0 == begin { inblk = 1; next } + $0 == end { inblk = 0; next } + !inblk { next } + match($0, /base_url[[:space:]]*=[[:space:]]*"[^"]*"/) { + v = substr($0, RSTART, RLENGTH) + sub(/^.*=[[:space:]]*"/, "", v); sub(/"$/, "", v) + url = v + } + match($0, /"X-Weave-Router-Key"[[:space:]]*=[[:space:]]*"[^"]*"/) { + v = substr($0, RSTART, RLENGTH) + sub(/^.*=[[:space:]]*"/, "", v); sub(/"$/, "", v) + key = v + } + END { if (url != "" && key != "") printf "%s\n%s\n", url, key } + ' "$config" 2>/dev/null || true +} + +# Kicks off a detached fetch of this session's committed cost. The result lands +# in a cache the next turn reads; this turn renders whatever is already there. +# Fire-and-forget on purpose — a slow or unreachable router must never stall a +# Codex turn, and every failure simply leaves the previous cache in place. +refresh_session_cost() { + local id="$1" file="$2" + [ "${WEAVE_CODEX_STATUS_SAVINGS:-1}" = "0" ] && return 0 + command -v curl >/dev/null 2>&1 || return 0 + + local endpoint base_url key + endpoint="$(read_codex_endpoint)" || return 0 + base_url="$(printf '%s' "$endpoint" | sed -n 1p)" + key="$(printf '%s' "$endpoint" | sed -n 2p)" + [ -n "$base_url" ] && [ -n "$key" ] || return 0 + + mkdir -p "$state_root" 2>/dev/null || return 0 + chmod 700 "$state_root" 2>/dev/null || true + + ( + exec /dev/null; then + lock_mtime="$(stat -c %Y "$lock" 2>/dev/null || stat -f %m "$lock" 2>/dev/null)" || lock_mtime=0 + lock_now="$(date +%s 2>/dev/null)" || lock_now=0 + if [ "${lock_mtime:-0}" -le 0 ] || [ $(( lock_now - lock_mtime )) -le 30 ]; then + exit 0 + fi + rm -rf "$lock" 2>/dev/null + mkdir "$lock" 2>/dev/null || exit 0 + fi + trap 'rmdir "$lock" 2>/dev/null' EXIT + + # A file:// base is the offline/test seam: curl reads it as the response + # body directly, so the endpoint path is meaningless for it. + url="${base_url%/}" + case "$url" in + file://*) ;; + *) url="${url%/v1}/v1/sessions/$id/cost" ;; + esac + body="$(curl -fsS --max-time 5 -H "X-Weave-Router-Key: $key" "$url" 2>/dev/null)" || exit 0 + # savings_usd is the router's own (requested - actual). A body without it + # (404, error envelope, older router) writes nothing and leaves the cache. + savings="$(printf '%s' "$body" | jq -r '.savings_usd // empty' 2>/dev/null)" || exit 0 + case "$savings" in + ''|*[!0-9.eE+-]*) exit 0 ;; + esac + tmp="$file.tmp.$$" + mkdir -p "$(dirname "$file")" 2>/dev/null + if printf '%s' "$savings" >"$tmp" 2>/dev/null; then + chmod 600 "$tmp" 2>/dev/null + mv "$tmp" "$file" 2>/dev/null + fi + rm -f "$tmp" 2>/dev/null + ) >/dev/null 2>&1 & + disown 2>/dev/null || true +} + +# Renders the cached savings as a display clause, or nothing. Values below a +# cent read as "<$0.01" rather than "$0.00", which would be indistinguishable +# from "the router ran and did not beat your selection". Negative totals are +# omitted entirely: the router picked a pricier model for quality on this +# session and "saved -$0.02" is a worse answer than staying quiet. +savings_clause() { + local file="$1" raw + [ "${WEAVE_CODEX_STATUS_SAVINGS:-1}" = "0" ] && return 0 + [ -f "$file" ] || return 0 + raw="$(cat "$file" 2>/dev/null)" || return 0 + case "$raw" in + ''|*[!0-9.eE+-]*) return 0 ;; + esac + awk -v v="$raw" 'BEGIN{ + v = v + 0 + if (v < 0.005) { exit } + if (v < 0.01) { printf " · saved <$0.01"; exit } + printf " · saved $%.2f", v + }' 2>/dev/null || true +} + set -e case "${1:-hook}" in @@ -3296,14 +3431,25 @@ if [ -n "$file" ]; then write_state "$file" fi +# The cache holds the previous turn's fetch; the refresh below serves the next +# one. Router telemetry is written asynchronously anyway, so a just-finished +# turn would not be included even in a blocking read — reading first and +# refreshing after costs a turn of freshness and buys never blocking Codex. +savings="" +cost_file="" +if cost_file="$(cost_file_for "$session_id" 2>/dev/null)"; then + savings="$(savings_clause "$cost_file")" + refresh_session_cost "$(safe_session_id "$session_id")" "$cost_file" +fi + if [ -n "$routed_model" ] && [ -n "$requested_model" ] && [ "$routed_model" != "$requested_model" ]; then - title="Weave Router · $routed_model ← $requested_model" + title="Weave Router · $routed_model ← $requested_model$savings" elif [ -n "$routed_model" ]; then - title="Weave Router · $routed_model" + title="Weave Router · $routed_model$savings" elif [ -n "$requested_model" ]; then - title="Weave Router · active ← $requested_model" + title="Weave Router · active ← $requested_model$savings" else - title="Weave Router · active" + title="Weave Router · active$savings" fi emit_title "$title" if [ -n "$marker_model" ] || [ -n "$force_model" ]; then diff --git a/install/tests/codex-status_test.sh b/install/tests/codex-status_test.sh index 71afc05c0..7ea20eed4 100755 --- a/install/tests/codex-status_test.sh +++ b/install/tests/codex-status_test.sh @@ -180,4 +180,25 @@ sleep 0.5 exit 1 } +# install.sh embeds this helper as a heredoc so the standalone `curl | sh` +# install has no sibling asset to copy. Nothing keeps the two copies in sync, +# so an edit to one silently ships the other stale — which is exactly how the +# savings lookup missed every curl installer once already. +installer="$script_dir/../install.sh" +if [ -f "$installer" ]; then + start="$(grep -n 'CODEX_STATUS_EOF' "$installer" | head -1 | cut -d: -f1)" + end="$(grep -n 'CODEX_STATUS_EOF' "$installer" | tail -1 | cut -d: -f1)" + if [ -n "$start" ] && [ -n "$end" ] && [ "$end" -gt "$start" ]; then + awk -v s="$start" -v e="$end" 'NR>s && NR"$work/codex-heredoc.sh" + diff -q "$work/codex-heredoc.sh" "$helper" >/dev/null 2>&1 || { + echo "install.sh heredoc has drifted from codex-status.sh:" >&2 + diff "$work/codex-heredoc.sh" "$helper" | head -10 >&2 + exit 1 + } + else + echo "could not locate the CODEX_STATUS_EOF markers in install.sh" >&2 + exit 1 + fi +fi + echo "Codex status helper regression tests passed" From 636cc3879349c2300b508984bba9276f26ead09a Mon Sep 17 00:00:00 2001 From: munir-weave Date: Fri, 28 Aug 2026 12:42:35 -0700 Subject: [PATCH 3/5] docs: tighten session-cost comments per review Apply workweave-bot's comment-length suggestions verbatim across the session-cost surface. Each keeps the non-obvious WHY (deliberate not-found indistinguishability, micros-at-encoding rounding, the installation_id authz boundary, SQLC's nullable-column pointer) while dropping the restated mechanics. Signed-off-by: munir-weave (cherry picked from commit d40e3d2db9b1ff668faeb0d4e3b5208f15ee55c2) --- internal/api/admin/session_cost.go | 10 +++------- internal/postgres/telemetry.go | 10 +++------- internal/proxy/service.go | 7 ++----- internal/proxy/telemetry.go | 12 ++++-------- internal/server/server.go | 5 +---- 5 files changed, 13 insertions(+), 31 deletions(-) diff --git a/internal/api/admin/session_cost.go b/internal/api/admin/session_cost.go index ab897a7f6..2dbadcd14 100644 --- a/internal/api/admin/session_cost.go +++ b/internal/api/admin/session_cost.go @@ -10,9 +10,7 @@ import ( "github.com/gin-gonic/gin" ) -// usdMicrosPerUSD converts the authoritative integer micros into the decimal -// display fields. Applied ONLY at response encoding, so no float rounding -// accumulates across a session's rows. +// usdMicrosPerUSD converts micros to USD for display only; applied at encoding so float rounding never accumulates. const usdMicrosPerUSD = 1_000_000.0 // sessionCostResponse is the committed router cost of one client session. @@ -34,10 +32,8 @@ type sessionCostResponse struct { LastRecordedAt string `json:"last_recorded_at"` } -// SessionCostHandler returns the committed router cost of one client session, -// scoped to the installation behind the rk_ key. It lets a client that already -// holds a router key (the Codex status hook) render real savings instead of -// recomputing them from a local price table it cannot keep in sync. +// SessionCostHandler returns committed router cost for a session, scoped to the rk_ key's installation. +// Exists so the Codex status hook gets real savings without a local price table it cannot keep in sync. func SessionCostHandler(proxySvc *proxy.Service) gin.HandlerFunc { return func(c *gin.Context) { installation := middleware.InstallationFrom(c) diff --git a/internal/postgres/telemetry.go b/internal/postgres/telemetry.go index 434681350..8089fa777 100644 --- a/internal/postgres/telemetry.go +++ b/internal/postgres/telemetry.go @@ -679,10 +679,8 @@ func (r *TelemetryRepo) GetTelemetryRowsAll(ctx context.Context, from, to time.T return out, nil } -// GetSessionCost aggregates one session's committed cost rows. The -// installation_id predicate is the authorization boundary: a session id from -// another installation matches nothing and comes back as not-found rather than -// as another tenant's cost. +// GetSessionCost aggregates committed cost for one session. installation_id is the authorization +// boundary — a foreign session id matches nothing and returns not-found, not another tenant's cost. func (r *TelemetryRepo) GetSessionCost(ctx context.Context, installationID, sessionID string) (proxy.SessionCost, error) { id, err := uuid.Parse(installationID) if err != nil { @@ -699,9 +697,7 @@ func (r *TelemetryRepo) GetSessionCost(ctx context.Context, installationID, sess if err != nil { return proxy.SessionCost{}, err } - // GROUP BY t.session_id with a non-null session_id predicate means the - // column is never NULL here; SQLC types it as a pointer because the - // underlying column is nullable. + // Non-null WHERE predicate means session_id is never NULL here; SQLC types it as *string because the column is nullable. if row.SessionID == nil { return proxy.SessionCost{}, proxy.ErrSessionCostNotFound } diff --git a/internal/proxy/service.go b/internal/proxy/service.go index ee7c3622e..3e45421b2 100644 --- a/internal/proxy/service.go +++ b/internal/proxy/service.go @@ -1973,11 +1973,8 @@ func (s *Service) MetricsSummary(ctx context.Context, installationID string, fro return s.telemetry.GetTelemetrySummary(ctx, installationID, from, to) } -// ErrSessionCostNotFound means this installation has no committed cost-bearing -// telemetry for the session id — an unknown id, another installation's session, -// or a session whose asynchronous telemetry has not landed yet. The three are -// deliberately indistinguishable so a caller cannot probe for the existence of -// a session it does not own. +// ErrSessionCostNotFound is returned for unknown, foreign, or not-yet-committed +// sessions — deliberately indistinguishable so callers cannot probe foreign sessions. var ErrSessionCostNotFound = errors.New("no committed router telemetry for session") // SessionCost returns the committed router cost of one client session, scoped diff --git a/internal/proxy/telemetry.go b/internal/proxy/telemetry.go index ec0ae7652..c635727b9 100644 --- a/internal/proxy/telemetry.go +++ b/internal/proxy/telemetry.go @@ -25,14 +25,10 @@ type TelemetryRepository interface { GetSessionCost(ctx context.Context, installationID, sessionID string) (SessionCost, error) } -// SessionCost is the committed router cost of one client session, aggregated -// across every cost-bearing telemetry row that carries the session id. -// -// Money is USD micros ($1.00 = 1,000,000) end to end: costs are persisted in -// micros and summed in micros, so no float rounding accumulates across a -// session. Actual is what the session really cost on the binding the router -// chose; Requested is what the client's originally-requested model would have -// cost, so (Requested - Actual) is the router's savings. +// SessionCost is the committed router cost of one client session. +// Costs are USD micros ($1.00 = 1,000,000): persisted and summed as integers +// so no float rounding accumulates. Actual = router's chosen binding; +// Requested = client's originally-requested model; savings = Requested - Actual. type SessionCost struct { SessionID string RequestCount int64 diff --git a/internal/server/server.go b/internal/server/server.go index c6ceb5600..027ec6ebd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -243,10 +243,7 @@ func Register(engine *gin.Engine, authSvc *auth.Service, proxySvc *proxy.Service passthroughGroup.GET("/v1/models/:model", anthropicapi.PassthroughHandler(proxySvc)) // Rides the passthrough group (cheap, no billing middleware) — read-only, no routing side-effects. passthroughGroup.GET("/v1/display-settings", admin.DisplaySettingsHandler) - // Same rationale: one indexed aggregate, scoped to the caller's - // installation. Product surface, not admin — the Codex status hook holds an - // rk_ key and needs the router's own savings number rather than a client - // price table it cannot keep in sync. + // Product surface (not admin): the Codex status hook's rk_ key needs the router's savings number. passthroughGroup.GET("/v1/sessions/:session_id/cost", admin.SessionCostHandler(proxySvc)) routeMiddleware := []gin.HandlerFunc{ From 181a1c251e155fb4e70d40d8a970e055396d48f4 Mon Sep 17 00:00:00 2001 From: munir-weave Date: Fri, 28 Aug 2026 12:49:17 -0700 Subject: [PATCH 4/5] docs: tighten two session-cost comments per review Apply workweave-bot's remaining comment-length suggestions verbatim. Signed-off-by: munir-weave (cherry picked from commit 93349140322b8b3a5d744a12ccf005c5f806d7d2) --- internal/api/admin/session_cost.go | 5 ++--- internal/proxy/telemetry.go | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/api/admin/session_cost.go b/internal/api/admin/session_cost.go index 2dbadcd14..4fc7274e2 100644 --- a/internal/api/admin/session_cost.go +++ b/internal/api/admin/session_cost.go @@ -13,9 +13,8 @@ import ( // usdMicrosPerUSD converts micros to USD for display only; applied at encoding so float rounding never accumulates. const usdMicrosPerUSD = 1_000_000.0 -// sessionCostResponse is the committed router cost of one client session. -// The *_usd_micros integers are authoritative; the decimal fields are derived -// for display. savings = requested - actual. +// sessionCostResponse is the JSON envelope for GET /v1/sessions/:id/cost. +// The *_usd_micros integers are authoritative; decimal fields are derived for display only. type sessionCostResponse struct { SessionID string `json:"session_id"` RequestCount int64 `json:"request_count"` diff --git a/internal/proxy/telemetry.go b/internal/proxy/telemetry.go index c635727b9..421682391 100644 --- a/internal/proxy/telemetry.go +++ b/internal/proxy/telemetry.go @@ -26,9 +26,8 @@ type TelemetryRepository interface { } // SessionCost is the committed router cost of one client session. -// Costs are USD micros ($1.00 = 1,000,000): persisted and summed as integers -// so no float rounding accumulates. Actual = router's chosen binding; -// Requested = client's originally-requested model; savings = Requested - Actual. +// Costs are USD micros ($1.00 = 1,000,000) summed as integers so no float rounding accumulates. +// Actual = router's chosen binding; Requested = client's originally-requested model. type SessionCost struct { SessionID string RequestCount int64 From f2e4059b8208f6c996b3a6127c028978ad7172b8 Mon Sep 17 00:00:00 2001 From: munir-weave Date: Fri, 28 Aug 2026 16:53:24 -0700 Subject: [PATCH 5/5] chore(install): bump router npm package to 0.2.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.2.11 predates the Codex status integration, so the published tarball ships no codex-status.sh and an install.sh that writes no Stop hook. Every Codex user is therefore still on the pre-hooks installer with no way to pick the feature up — the helper has no self-refresh, so a publish is the only delivery path. Signed-off-by: munir-weave --- install/npm/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/npm/package.json b/install/npm/package.json index b323a9f5e..8592c06e9 100644 --- a/install/npm/package.json +++ b/install/npm/package.json @@ -1,6 +1,6 @@ { "name": "@workweave/router", - "version": "0.2.11", + "version": "0.2.12", "description": "One-command installer that points Claude Code, Codex, opencode, or pi at the Weave Router. For pi it also ships the routing extension, loaded via pi.extensions.", "bin": { "weave-router": "bin.js",