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
66 changes: 65 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" "")"
Expand All @@ -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)"
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
105 changes: 95 additions & 10 deletions providers/get-codex-usage
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"
Loading