diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1020afc..c3a825a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,8 +270,44 @@ jobs: FAKE_BIN="$(mktemp -d)" cat > "$FAKE_BIN/codex" <<'SH' #!/usr/bin/env bash - cat >/dev/null + set -euo pipefail + REQUESTS="" + for _ in 1 2 3 4; do + IFS= read -r REQUEST + REQUESTS="${REQUESTS}${REQUESTS:+$'\n'}${REQUEST}" + done + printf '%s\n' "$REQUESTS" | jq --exit-status --slurp ' + map(.method) as $methods | + ($methods | index("initialize")) as $initialize | + ($methods | index("initialized")) as $initialized | + ($methods | index("account/read")) as $account | + ($methods | index("account/rateLimits/read")) as $limits | + $initialize != null and + $initialized == ($initialize + 1) and + $account > $initialized and + $limits > $initialized + ' >/dev/null + + if [ -n "${CODEX_ATTEMPT_FILE:-}" ]; then + ATTEMPT=0 + if [ -f "$CODEX_ATTEMPT_FILE" ]; then + ATTEMPT="$(cat "$CODEX_ATTEMPT_FILE")" + fi + ATTEMPT="$((ATTEMPT + 1))" + printf '%s\n' "$ATTEMPT" > "$CODEX_ATTEMPT_FILE" + + if [ "$ATTEMPT" -eq 1 ] || [ "${CODEX_ALWAYS_FAIL:-0}" = "1" ]; then + jq --compact-output 'select(.id == 1)' \ + "$GITHUB_WORKSPACE/tests/fixtures/codex-app-server.jsonl" + printf '%s\n' '{"id":2,"error":{"code":-32603,"message":"failed to fetch codex rate limits: transient transport error"}}' + sleep 0.1 + exit 0 + fi + fi + + [ -z "${CODEX_DELAY_SECONDS:-}" ] || sleep "$CODEX_DELAY_SECONDS" cat "$GITHUB_WORKSPACE/tests/fixtures/${CODEX_FIXTURE:-codex-app-server.jsonl}" + sleep 0.1 SH chmod +x "$FAKE_BIN/codex" OUT="$(PATH="$FAKE_BIN:$PATH" providers/get-provider-usage "codex" "")" @@ -295,6 +331,34 @@ jobs: .[0].credits.remaining == "1" ' >/dev/null + RETRY_DIR="$(mktemp -d)" + RETRY_OUT="$(CODEX_ATTEMPT_FILE="$RETRY_DIR/attempt" \ + XDG_CACHE_HOME="$RETRY_DIR/cache" \ + PATH="$FAKE_BIN:$PATH" providers/get-provider-usage "codex" "")" + echo "$RETRY_OUT" | jq --exit-status ' + length == 1 and .[0].provider == "codex" and + .[0].usage.primary.usedPercent == 25 + ' >/dev/null + [ "$(cat "$RETRY_DIR/attempt")" = "2" ] + + CACHED_OUT="$(CODEX_ATTEMPT_FILE="$RETRY_DIR/cache-attempt" \ + CODEX_ALWAYS_FAIL=1 XDG_CACHE_HOME="$RETRY_DIR/cache" \ + PATH="$FAKE_BIN:$PATH" providers/get-provider-usage "codex" "")" + echo "$CACHED_OUT" | jq --exit-status ' + length == 1 and .[0].provider == "codex" and + .[0].source == "codex-app-server-cache" and + .[0].usage.primary.usedPercent == 25 + ' >/dev/null + [ "$(cat "$RETRY_DIR/cache-attempt")" = "2" ] + + DELAYED_OUT="$(CODEX_DELAY_SECONDS=5 XDG_CACHE_HOME="$RETRY_DIR/delayed-cache" \ + PATH="$FAKE_BIN:$PATH" providers/get-provider-usage "codex" "")" + echo "$DELAYED_OUT" | jq --exit-status ' + length == 1 and .[0].provider == "codex" and + .[0].source == "codex-app-server" and + .[0].usage.primary.usedPercent == 25 + ' >/dev/null + - name: Integration test — 9Router cached tokens run: | HOME_DIR="$(mktemp -d)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 1adb98a..92e7eda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Codex reliability + +- The Codex adapter now completes the documented app-server initialization handshake, waits for responses instead of closing stdin after four seconds, and retries transient rate-limit transport failures once. A recent successful snapshot remains available for 15 minutes when both live attempts fail, preventing short ChatGPT usage-endpoint outages from incorrectly switching the widget to `ERR` or “Setup required.” + ## 1.8.1 - 2026-07-24 ### Consistency and i18n hardening diff --git a/docs/architecture.md b/docs/architecture.md index 2b462dd..bdd34da 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,7 +64,7 @@ Errors return: ## Codex protocol -`get-codex-usage` starts `codex app-server`, sends `initialize`, `account/read`, and `account/rateLimits/read`, then maps the official response to the common schema. The bridge uses a bounded process lifetime and never reads browser state. +`get-codex-usage` starts `codex app-server`, completes the `initialize` / `initialized` handshake, sends `account/read` and `account/rateLimits/read`, then maps the official response to the common schema. The bridge reads until both account and rate-limit responses arrive instead of closing stdin after a fixed delay. It retries an authenticated account's transient rate-limit failure once, then may reuse a successful snapshot for up to 15 minutes while preserving its original `updatedAt` timestamp. Authentication failures never fall back to cached data. The bridge uses a bounded process lifetime and never reads browser state. Window labels are derived from `windowDurationMins`, not from whether app-server placed a limit in `primary` or `secondary`. This matters for current weekly-only responses, where `primary.windowDurationMins` is `10080` and `secondary` is null. OpenAI's current pricing page still documents a shared five-hour window with additional weekly limits, so a weekly-only app-server payload is handled as a temporary or account-specific response shape rather than interpreted as a confirmed policy removal. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4261e13..332a1b5 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -18,7 +18,7 @@ codex login ./providers/get-codex-usage | jq . ``` -The adapter requires a Codex CLI version with `app-server` and `account/rateLimits/read`. Update Codex if the helper reports that rate limits are unavailable. +The adapter requires a Codex CLI version with `app-server` and `account/rateLimits/read`. It retries a transient rate-limit transport failure once and may reuse a successful snapshot for up to 15 minutes; the original `updatedAt` timestamp is preserved so the card can become visibly stale. Authentication failures never use this cache. Persistent app-server failures surface the underlying JSON-RPC message when one is available. The app-server may temporarily return only a `10080`-minute weekly window in `rateLimits.primary` with `secondary: null`. The plugin labels that window **Weekly** from its duration. OpenAI's current pricing documentation still describes a shared five-hour window plus possible weekly limits, so a missing five-hour row should be treated as a server/account response change or incident, not automatically as a formally announced quota-policy change. diff --git a/providers/get-codex-usage b/providers/get-codex-usage index 1703a26..fdc397a 100755 --- a/providers/get-codex-usage +++ b/providers/get-codex-usage @@ -15,32 +15,111 @@ if ! command -v codex >/dev/null 2>&1; then fi tmp_out="$(mktemp)" -trap 'rm -f "$tmp_out"' EXIT +tmp_error="$(mktemp)" +cache_dir="${XDG_CACHE_HOME:-${HOME}/.cache}/AiOverviewControl" +cache_file="${cache_dir}/codex-usage.json" +cache_ttl=900 +trap 'rm -f "$tmp_out" "$tmp_error"' EXIT SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PLUGIN_VERSION="$(jq -r '.version // "unknown"' "$SCRIPT_DIR/../plugin.json" 2>/dev/null || printf 'unknown')" -{ +account=null +limits=null +limit_error=null + +run_app_server() { + local line id + local account_seen=0 + local limits_seen=0 + local deadline=$((SECONDS + 8)) + + : >"$tmp_out" + : >"$tmp_error" + + # `exec` keeps the coproc from forking an extra subshell, so + # $CODEX_APP_SERVER_PID is the app-server itself and the kill below can + # actually reach it. + coproc CODEX_APP_SERVER { exec codex app-server 2>"$tmp_error"; } + local server_pid="$CODEX_APP_SERVER_PID" + local read_fd="${CODEX_APP_SERVER[0]}" + local write_fd="${CODEX_APP_SERVER[1]}" + printf '%s\n' \ "{\"method\":\"initialize\",\"id\":0,\"params\":{\"clientInfo\":{\"name\":\"ai_overview_control\",\"title\":\"AiOverviewControl\",\"version\":\"${PLUGIN_VERSION}\"}}}" \ + '{"method":"initialized","params":{}}' \ '{"method":"account/read","id":1,"params":{"refreshToken":false}}' \ - '{"method":"account/rateLimits/read","id":2,"params":{}}' - sleep 4 -} | timeout --kill-after=2s 8s codex app-server >"$tmp_out" 2>/dev/null || true + '{"method":"account/rateLimits/read","id":2,"params":{}}' >&"$write_fd" + + while (( SECONDS < deadline )); do + if ! IFS= read -r -t "$((deadline - SECONDS))" -u "$read_fd" line; then + break + fi + + printf '%s\n' "$line" >>"$tmp_out" + id="$(printf '%s' "$line" | jq -r '.id // empty' 2>/dev/null || true)" + [ "$id" != "1" ] || account_seen=1 + [ "$id" != "2" ] || limits_seen=1 + if [ "$account_seen" -eq 1 ] && [ "$limits_seen" -eq 1 ]; then + break + fi + done + + exec {write_fd}>&- + if kill -0 "$server_pid" 2>/dev/null; then + kill "$server_pid" 2>/dev/null || true + # An app-server that ignores SIGTERM used to be reaped by the previous + # `timeout --kill-after=2s`; without this fallback it outlives the helper + # and one process leaks per poll. + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "$server_pid" 2>/dev/null || break + sleep 0.1 + done + kill -9 "$server_pid" 2>/dev/null || true + fi + wait "$server_pid" 2>/dev/null || true + exec {read_fd}<&- +} + +for attempt in 1 2; do + run_app_server -account="$(jq -cs '[.[] | select(.id == 1)][0].result.account // null' "$tmp_out" 2>/dev/null || printf 'null')" -limits="$(jq -cs '[.[] | select(.id == 2)][0].result // null' "$tmp_out" 2>/dev/null || printf 'null')" + account="$(jq -cs '[.[] | select(.id == 1)][0].result.account // null' "$tmp_out" 2>/dev/null || printf 'null')" + limits="$(jq -cs '[.[] | select(.id == 2)][0].result // null' "$tmp_out" 2>/dev/null || printf 'null')" + limit_error="$(jq -cs '[.[] | select(.id == 2)][0].error // null' "$tmp_out" 2>/dev/null || printf 'null')" + + if [ "$limits" != "null" ] && printf '%s' "$limits" | jq -e '.rateLimits' >/dev/null 2>&1; then + break + fi + + if [ "$attempt" -eq 2 ] || [ "$account" = "null" ]; then + break + fi + + sleep 1 +done if [ "$limits" = "null" ] || ! printf '%s' "$limits" | jq -e '.rateLimits' >/dev/null 2>&1; then if [ "$account" = "null" ]; then fail_json 2 "Codex CLI is not authenticated. Run: codex login" else - fail_json 3 "Codex app-server did not return account rate limits. Update Codex CLI and try again." + if [ -f "$cache_file" ] && jq -e '.cached_at and (.data.usage | type == "object")' "$cache_file" >/dev/null 2>&1; then + cached_at="$(jq -r '.cached_at' "$cache_file")" + cache_age=$(( $(date +%s) - cached_at )) + if [ "$cache_age" -ge 0 ] && [ "$cache_age" -le "$cache_ttl" ]; then + jq -c '.data | .source = "codex-app-server-cache"' "$cache_file" + exit 0 + fi + fi + + error_message="$(printf '%s' "$limit_error" | jq -r '.message // empty' 2>/dev/null || true)" + [ -n "$error_message" ] || error_message="Codex app-server did not return account rate limits." + fail_json 3 "$error_message" fi exit 0 fi -jq -cn --argjson account "$account" --argjson response "$limits" ' +result="$(jq -cn --argjson account "$account" --argjson response "$limits" ' def iso($seconds): if $seconds == null then null else ($seconds | todateiso8601) end; def counted($value; $unit): @@ -94,4 +173,10 @@ jq -cn --argjson account "$account" --argjson response "$limits" ' ) } } -' +')" + +if mkdir -p "$cache_dir" 2>/dev/null; then + jq -cn --argjson data "$result" '{cached_at:(now | floor),data:$data}' >"$cache_file" 2>/dev/null || true +fi + +printf '%s\n' "$result"