Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
23 changes: 23 additions & 0 deletions db/queries/model_router_request_telemetry.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 2 additions & 0 deletions install/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 · <routed-model> ← <requested-model>` 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 <base-url>/v1/sessions/<session-id>/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
Expand Down
154 changes: 150 additions & 4 deletions install/codex-status.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,27 @@
# 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 <base>/v1/sessions/<id>/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

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"
Expand Down Expand Up @@ -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
Comment on lines +96 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Project helper uses user configuration

When a project-scoped helper remains after its adjacent config.toml has been removed, this loop selects ~/.codex/config.toml. The Stop hook then fetches the project session's cost using the user installation's router URL and key, so the terminal can show savings from the wrong installation. Only permit the home configuration for the known user-scoped helper; a project helper with no local managed configuration should skip the refresh.

Artifacts

Isolated Codex status scope reproduction script

  • The authored executable harness creates missing and unreadable project-config cases and records the curl arguments, demonstrating which configuration scope the hook uses.

Codex status hook execution showing user credentials used when project config is missing

  • A successful isolated hook execution records curl receiving the user-scoped URL and USER_SCOPE_SECRET for the missing project-config case, while the unreadable case makes no request; the fallback is real only for absence.

Repeated Codex status hook execution confirming scope behavior

  • A second successful execution produces the same missing-config credential use and unreadable-config no-request result, confirming the observed shell behavior.

View artifacts

T-Rex Ran code and verified through T-Rex

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
# mkdir is the portable atomic test-and-set. A crashed holder would block
# refreshes forever, so a lock older than the fetch timeout is reclaimed.
lock="$file.lock"
if ! mkdir "$lock" 2>/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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading