--- name: test-codex-locally description: Run the Weave router locally in docker compose and drive it with `codex exec` to reproduce and verify routing/translation/marker behavior for Codex's Responses API path. Use when verifying a router fix end-to-end for Codex, reproducing a prod Codex routing bug, confirming routing-marker / force-model / subscription-passthrough behavior, or testing a `/force-model` route — without touching the user's global Codex config. --- # Testing the router locally with Codex > For an **automated** pre-merge regression net (fixture-driven, asserts caching/streaming/decision-headers against real Anthropic), run `make smoke` — see [docs/SMOKE.md](../../../docs/SMOKE.md). This skill is the interactive counterpart for the **Codex CLI** (`codex exec`): stand the stack up by hand and drive it via a throwaway `CODEX_HOME` so `~/.codex/config.toml` is never edited. Stand up the router in docker compose, point a one-off `codex exec` at it via `CODEX_HOME`, and read the local server logs to confirm behavior. Two upstream modes: the **real** provider API (needs a working key + credits, and for native GPT models a ChatGPT OAuth login) or a **mock** upstream that emits an exact SSE shape (deterministic, no credits). The sibling skill [test-claude-locally](../test-claude-locally/SKILL.md) is the Claude Code (`claude -p`) counterpart. Stack bring-up and seeding are identical; only the client driver differs. ## Critical gotchas (read first) - **Never edit `~/.codex/config.toml` or `~/.codex/auth.json`.** Those are the user's live Codex install (prod Weave Router + ChatGPT OAuth). Redirect with a throwaway `CODEX_HOME` directory that contains its own `config.toml` (and a *copy* of `auth.json` when ChatGPT OAuth is required). `CODEX_HOME` is how Codex finds config; there is no `--settings` flag equivalent to Claude Code. - **`requires_openai_auth = true` is required.** Codex 0.149 hangs or never issues a `/v1/responses` request if the custom provider uses `requires_openai_auth = false` or `env_key`. Copy `~/.codex/auth.json` into the throwaway `CODEX_HOME` so Codex can attach the ChatGPT JWT + `ChatGPT-Account-ID`. The mock-router experiment that dropped OAuth never even hit the mock. - **Codex always sends a ChatGPT JWT + `ChatGPT-Account-ID`.** The local router will classify that as a Codex subscription (`codexResponsesRequest` → native Responses passthrough for OpenAI decisions). That is the path this skill is usually exercising (routing markers on verbatim GPT frames). To force the prepaid/BYOK translation path instead, flip `subscription_routing_disabled=true` on the seeded installation (see step 3). - **`codex exec` is one-shot.** A standalone `/force-model` call does not persist to the next `codex exec` (new session id every time, unless you `codex exec resume`). Put ` /force-model ` as the first line of the SAME prompt. The leading space is load-bearing — Codex consumes unknown slash tokens as local commands; a leading space makes it a normal user message the router can parse. - **Pin via `config.toml`, not `codex -c`.** On Codex 0.149.1, `-c 'model_providers.weave-local.http_headers."x-weave-force-model"="…"'` did **not** merge into the outgoing request (no `x-weave-force-model applied` in server logs; the scorer served something else). Put `"x-weave-force-model" = ""` on the same `http_headers` table in the throwaway `config.toml` (step 4). Confirm with `docker compose logs server | grep 'x-weave-force-model applied'` before drawing conclusions. - **The router ignores the request's `model` field** for routing (cluster scorer / pins win). Codex's `-m gpt-5.5` only changes what Codex *requests*; `/force-model` or `x-weave-force-model` is the only way to pin a specific routed model. Native GPT family (`gpt-5.6-sol` / `terra` / `luna`, plus whatever `-m` Codex defaulted to) still goes through the scorer unless forced. - **No GNU `timeout` on macOS.** Drive `codex exec` directly. If a run hangs (`Reading additional input from stdin...` with no banner), Codex is waiting on a TTY/stdin — do not pipe into it, and make sure `-s read-only` (or `--dangerously-bypass-approvals-and-sandbox`) is set. - **Docker may not be running.** `docker compose` fails with a `unix://…/.docker/run/docker.sock` connect error if Desktop is stopped. `open -a Docker` and wait for `docker info` before step 1. - **Other agents in the same workspace can `docker compose down` and delete `/tmp` scratch.** If health suddenly 404s or `CODEX_HOME` vanishes mid-run, re-up the stack and reseed — do not reuse a key you can no longer `/validate`. - **Session key is `apiKeyID` + first user message.** Reusing the same curl/prompt body on the same key reuses the pin slot. `routingMarkerFor` then returns empty when `PriorServedModel == ServedIdentity()` (sticky same-model). For first-turn marker tests, seed a **new** key or change the first user text. Isolated `/validate` first: `curl -sS -o /dev/null -w '%{http_code}\n' -H "X-Weave-Router-Key: $KEY" http://localhost:8080/validate` must be `200`. - **Native GPT passthrough + tool-only emits a synthetic badge item.** `SetPassthroughBadge` rewrites existing `response.output_text.*` events and, when a ChatGPT-subscription turn has no assistant text, inserts a leading assistant message item before native tool/reasoning output. Verify the badge in the Responses SSE/JSON; the Codex TUI can bury the first-turn line under tool chatter. - **`GET /v1/models` 501 from a mock is fine.** Codex probes `/models` first, logs an HTML 501, then continues to `POST /v1/responses`. The real local router implements `GET /v1/models` as Anthropic passthrough, so this only shows up against a Python mock. - **Port 8085 conflict.** The monorepo's pubsub emulator may already own host port 8085. Drop the router's host binding with a `docker-compose.override.yml` (see workflow). The server still reaches the emulator over the compose network. - **No credits / no key = no reproduction.** If the real upstream returns an error (e.g. OpenRouter "Insufficient credits"), use the mock-upstream path instead. ## Workflow ``` - [ ] 1. Bring up the stack (handle port 8085) - [ ] 2. Seed an API key - [ ] 3. Choose upstream + subscription vs prepaid path - [ ] 4. Write a throwaway CODEX_HOME (copy auth.json) - [ ] 5. Drive with `CODEX_HOME=... codex exec`, forcing the target model - [ ] 6. Read local logs to verify behavior - [ ] 7. Clean up ``` ### 1. Bring up the stack ```bash cd # Drop the pubsub host-port binding to avoid an 8085 conflict: cat > docker-compose.override.yml <<'EOF' services: pubsub-emulator: ports: !reset [] EOF docker compose up -d --build server # --build picks up code changes until curl -sf http://localhost:8080/health >/dev/null; do sleep 2; done ``` The override file is gitignored-by-intent scaffolding — delete it in cleanup. ### 2. Seed an API key ```bash docker compose run --rm seed ``` Copy the `rk_...` key it prints — **only the line under** `Weave Router key (shown once — store it now):`. Seed stdout repeats the same token in curl examples and installer snippets; `grep -oE 'rk_[A-Za-z0-9]+'` can grab a stale key from an earlier seed in the same log. Always `/validate` before driving Codex: ```bash curl -sS -o /dev/null -w '%{http_code}\n' \ -H "X-Weave-Router-Key: rk_REPLACE_ME" http://localhost:8080/validate # expect 200 ``` A 401 mid-session usually means the stack was torn down and reseeded (new installation, old token dead). ### 3. Choose the upstream **Real provider** — set the provider key in `.env.local` (e.g. `OPENAI_API_KEY=...`, `FIREWORKS_API_KEY=...`) and restart `docker compose up -d server`. Confirm the boot log shows ` provider enabled` with the real base_url. Use this to confirm a model genuinely produces the behavior. For an actual Codex subscription response, do not set `ROUTER_CODEX_BASE_URL` and do not run either mock. Remove that environment entry from `docker-compose.override.yml`, then rebuild the server: ```bash unset ROUTER_CODEX_BASE_URL # also remove it from docker-compose.override.yml docker compose up -d --build server ``` Use a throwaway `CODEX_HOME` containing a copy of the real `~/.codex/auth.json`, with `requires_openai_auth = true` and `X-App = "codex"`. The router detects the OAuth bearer plus `ChatGPT-Account-ID` and calls the real `https://chatgpt.com/backend-api/codex/responses` endpoint. This consumes the user's ChatGPT quota and requires a valid login; the mock is only for repeatable tests when that endpoint is unavailable or too expensive. **Mock upstream** — for a deterministic, credit-free repro of a precise SSE shape. Point the provider's base URL at a local mock and restart. Codex talks to the *router* (`POST /v1/responses`); the mock sits behind the router as the upstream the router dispatches to: ```bash python3 .claude/skills/test-claude-locally/scripts/mock_openai_upstream.py >/tmp/mock.log 2>&1 & # serves :8099 # In docker-compose.override.yml under `server:`, add: # environment: # FIREWORKS_BASE_URL: http://host.docker.internal:8099/v1 # FIREWORKS_API_KEY: sk-mock # extra_hosts: ["host.docker.internal:host-gateway"] docker compose up -d server ``` To exercise the production-equivalent native Codex subscription path, use a native Responses mock instead of the Chat Completions mock: ```bash python3 .claude/skills/test-codex-locally/scripts/mock_codex_upstream.py >/tmp/mock-codex.log 2>&1 & ``` Add this to the `server` service in `docker-compose.override.yml`: ```yaml environment: ROUTER_CODEX_BASE_URL: http://host.docker.internal:8099/v1 extra_hosts: ["host.docker.internal:host-gateway"] ``` This preserves real Codex subscription detection (OAuth bearer plus `ChatGPT-Account-ID`) and native Responses passthrough, while replacing only the outbound ChatGPT backend with a deterministic tool-only stream. The mock emits a function call, so the expected result is a synthetic marker message at `output_index: 0`, followed by the native tool at `output_index: 1`. Edit the mock's emitted chunks to match the upstream shape you're reproducing. Provider→env-var names live in `internal/providers/provider.go`; base-URL overrides are read in `cmd/router/main.go` (`_BASE_URL`). For local testing, `ROUTER_CODEX_BASE_URL` overrides only the subscription backend; the default remains `https://chatgpt.com/backend-api/codex`. **Subscription vs prepaid (important for marker / passthrough tests):** Codex will attach a ChatGPT JWT. The local router then treats the turn as a Codex subscription: - OpenAI-family decision → verbatim Responses passthrough (`SetPassthrough` / `SetPassthroughBadge`) to the Codex backend (or `ROUTER_CODEX_BASE_URL` in the native mock setup). This is the path the routing-marker-on-Codex work exercises. - Non-OpenAI decision → Chat Completions translation + `ResponsesWriter.SetBadgeText`. To force the prepaid/BYOK translation path (no ChatGPT backend, uses `OPENAI_API_KEY` / other provider keys): ```bash docker compose exec postgres psql -U router -d router -c \ "SET search_path TO router; UPDATE model_router_installations SET subscription_routing_disabled=true WHERE external_id='__router_admin__';" ``` Revert with `subscription_routing_disabled=false` when done. ### 4. Throwaway CODEX_HOME Do **not** point this at `~/.codex`. A unique temp dir keeps the user's prod Weave + ChatGPT login intact. ```bash export CODEX_HOME=/tmp/weave-codex-local rm -rf "$CODEX_HOME" mkdir -p "$CODEX_HOME" cp ~/.codex/auth.json "$CODEX_HOME/auth.json" # required; see gotchas cat > "$CODEX_HOME/config.toml" < CODEX_HOME=/tmp/weave-codex-local \ codex exec --skip-git-repo-check -s read-only -C "$(pwd)" \ ' First send exactly: /force-model z-ai/glm-5.1 Then , then stop.' ``` Notes: - `--skip-git-repo-check` lets you run against `/tmp` or any non-git scratch dir. - `-s read-only` (or `--dangerously-bypass-approvals-and-sandbox` in a throwaway dir) avoids the interactive approval TUI, which `codex exec` otherwise cannot answer. - Leading space before `/force-model` is required (Codex otherwise treats it as an unknown local slash command and never sends it to the router). - `codex exec` prints `provider: weave-local` in its session banner when the throwaway config took; if it prints `provider: weave` it is still reading `~/.codex` — `CODEX_HOME` was not exported. - Confirm Codex actually reached the local router: `docker compose logs server --since=1m` should show a `POST /v1/responses` (and `ProxyOpenAIChatCompletion start`). Headless pin, no slash command — bake the header into the throwaway `config.toml` (step 4), then: ```bash CODEX_HOME=/tmp/weave-codex-local \ codex exec --skip-git-repo-check -s read-only -C "$(pwd)" \ "say hi and stop" ``` Do **not** use `codex exec -c 'model_providers.weave-local.http_headers."x-weave-force-model"=…'` as the pin. On 0.149.1 that override is silently dropped. After the run, `grep 'x-weave-force-model applied'` in server logs must fire; if it doesn't, Codex never sent the header. ### 6. Verify via logs Codex ingress is `ProxyOpenAIResponses` → `ProxyOpenAIChatCompletion`. The decision + completion log per action is `ProxyOpenAIChatCompletion complete`. Strip ANSI first: ```bash docker compose logs server --since=3m 2>&1 | sed -E 's/\x1b\[[0-9;]*m//g' \ | grep 'ProxyOpenAIChatCompletion complete' | grep 'decision_model=z-ai/glm-5.1' ``` Useful fields: `decision_model`, `decision_provider`, `decision_reason`, `routing_marker`, `hard_pinned`, `cross_format`, `turn_type`, `proxy_err`, `upstream_status`. Also grep for `recovery nudge`, `tool-call loop`, `no-progress`. `SetBadgeText` is not logged — look at the Codex transcript / `codex` output for the `✦ **Weave Router** → ` (or `**Weave Router** — ` legacy) line. **Confirm the model was actually served** before drawing conclusions: ```bash docker compose logs server --since=3m 2>&1 | sed -E 's/\x1b\[[0-9;]*m//g' \ | grep 'ProxyOpenAIChatCompletion complete' | grep -oE 'decision_model=[^ ]+' | sort | uniq -c ``` If you only see other models, the pin didn't take. Recheck: (1) `x-weave-force-model` is in `CODEX_HOME/config.toml` (not only a `-c` flag), (2) server log has `x-weave-force-model applied`, (3) leading space on in-prompt `/force-model` if you used that path. Codex's session banner `model: gpt-5.6-sol` is what *Codex requested*, not what the router served. **Routing-marker specific checks (the usual reason to use this skill):** - Cross-format (non-OpenAI decision, or prepaid OpenAI): first assistant text is `✦ **Weave Router** → · `. Log field `routing_marker` is non-empty. - Verbatim GPT passthrough (Codex subscription + OpenAI decision): same marker is injected into the first native assistant text, or as a synthetic assistant message item before tool/reasoning-only output (`SetPassthroughBadge`). Log still has `routing_marker`. - `X-Weave-Routing-Marker: off` (and not subscription-only warning): no badge. Subscription-only depleted-credits warning still wins over the opt-out. - Same model, second action (`prior_served_model` equals `decision_model`): `routing_marker=""`. Expected. Seed a new key (or change the first user text) to re-see a first-turn badge. - Tool-call-only on the **translated** path: marker is a leading `output_item` (`output_index` 0) ahead of the function call — including `stream:false` JSON. Confirm in the SSE / JSON, not only in `codex exec` stdout (the TUI can bury the first-turn line under tool chatter). - Tool-call-only on **verbatim GPT passthrough**: a leading synthetic message item carries the badge, followed by the native tool call. Sequence/output indices are shifted to remain valid. - Non-Codex client (`X-App` unset) on a translated decision still gets the `✦ **Weave Router** → …` text badge today; native OpenAI passthrough without `X-App: codex` stays byte-identical (`SetPassthrough` with no badge rewrite). - For curl-level checks (no Codex CLI), `POST /v1/responses` with `X-Weave-Router-Key` + `X-App: codex` is enough. Isolate first-turn cases with a freshly seeded key. ### 7. Clean up ```bash pkill -f mock_openai_upstream.py 2>/dev/null pkill -f mock_codex_upstream.py 2>/dev/null rm -rf /tmp/weave-codex-local rm -f docker-compose.override.yml # `docker compose down` if you want to stop the stack # do NOT touch ~/.codex ``` ## Notes - Local cluster version comes from `ROUTER_CLUSTER_VERSION` in `.env.local`; it may differ from prod, which is why `/force-model` (not the scorer) is the reliable way to hit one model. - Native Codex models `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna` use the caller's ChatGPT OAuth plan when subscription routing is on. Other OpenAI / Anthropic / Gemini / OpenAI-compatible models use WorkWeave deployment or BYOK credentials, same as Claude Code. - To confirm a deploy contains a given router commit: the prod Cloud Run revision name maps to a monorepo commit; `git ls-tree router-internal/router` shows the pinned router submodule SHA. - `codex exec --ignore-user-config` is **not** a substitute for `CODEX_HOME`: it skips `$CODEX_HOME/config.toml` entirely (so you'd have no weave-local provider) while auth still uses `CODEX_HOME`. Always write a full throwaway config. - Codex rollout transcripts live under `$CODEX_HOME/sessions///
/rollout-*.jsonl`. Grep those for `Weave Router` when `codex exec` stdout is noisy. - `docker-compose.override.yml` is gitignored. If you add a mock `*_BASE_URL` there, remember to rewrite the file back to the pubsub-only `ports: !reset []` (or delete it) before a real-provider Codex run — a leftover mock URL will serve `sk-mock` against production-looking model names.