Skip to content
cccskills
FreePublished on GitHub

test-codex-locally

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.

The English summary is being prepared. Showing the original description.

See how to install

Files included (2)

  • SKILL.md18.4 KB
  • scripts/mock_codex_upstream.py2.0 KB

SKILL.md (original)

Review the instructions the agent will receive before you install.

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. 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 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 <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" = "<id>" 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 <base_url>/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

cd <router-repo>
# 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

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:

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> 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:

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:

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:

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:

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 (<PROVIDER>_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):

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.

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" <<EOF
model_provider = "weave-local"

[model_providers.weave-local]
name = "Weave Router (local)"
base_url = "http://localhost:8080/v1"
wire_api = "responses"
requires_openai_auth = true
http_headers = { "X-Weave-Router-Key" = "rk_REPLACE_ME", "X-App" = "codex" }
EOF

Optional headers on the same http_headers table:

HeaderPurpose
"X-Weave-User-Email" = "dev@localhost"Attribution in local logs
"x-weave-force-model" = "z-ai/glm-5.1"Preferred pin. Put it here — do not rely on codex -c (see gotchas).
"X-Weave-Routing-Marker" = "off"Suppress the in-band routing marker

Do not set X-Weave-Router-Strategy unless you are deliberately pinning a strategy — omitting it uses the local deployment default (same rationale as install/install.sh --codex --local).

5. Drive it

cd <scratch-dir-with-files-to-act-on>
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 <task that requires tool use>, 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:

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:

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** → <model> (or **Weave Router** — <model> legacy) line.

Confirm the model was actually served before drawing conclusions:

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** → <model> · <reason>. 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

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 <monorepo-commit> 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/<yyyy>/<mm>/<dd>/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.

Reviews

No reviews yet. Tell others what you thought.

Comments

No login needed. Share how it worked for you, ask a question, or note where it did not run.

No comments yet. Be the first to write one.

Comments are published right away. Do not include personal information such as email addresses or phone numbers. Up to 2 URLs.

0 / 1,000 characters

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

More skills from this repository

Investigate a specific Claude Code session by session ID — correlate the local transcript (`~/.claude/projects/...jsonl`) with the router's production logs to understand what the client rendered vs. what the upstream served. Use when given a session ID and asked "why did X render?" for a Claude Code conversation routed through the router.

The English summary is being prepared. Showing the original description.

weave-os/router5.6KUpdated Oct 18, 2026

Investigate a specific Codex CLI session by session ID — correlate the local rollout transcript (`~/.codex/sessions/YYYY/MM/DD/rollout-*-<SESSION_ID>.jsonl`) with the router's production logs to understand what Codex rendered vs. what the upstream served on the /v1/responses path. Use when given a session ID and asked "why did X render?" (missing thinking, missing routing marker, wrong model, tool-call weirdness) for a Codex conversation routed through the router.

The English summary is being prepared. Showing the original description.

weave-os/router5.6KUpdated Oct 18, 2026

Switch Codex back to its normal provider for future sessions.

The English summary is being prepared. Showing the original description.

weave-os/router5.6KUpdated Oct 18, 2026

fm

Free

Alias for force-model — pin this Codex session to a specific model through the Weave Router.

The English summary is being prepared. Showing the original description.

weave-os/router5.6KUpdated Oct 18, 2026

Pin this Codex session to a specific model through the Weave Router.

The English summary is being prepared. Showing the original description.

weave-os/router5.6KUpdated Oct 18, 2026

Install language servers (gopls, typescript-language-server, pyright, rust-analyzer) and their prerequisite toolchains (Go, Node/npm, rustup) so the lsp tool works. Use when the user asks to enable or install LSP / language-server support, when lsp_enable reports a missing toolchain, or when the lsp tool reports no language server is installed.

The English summary is being prepared. Showing the original description.

weave-os/router5.6KUpdated Oct 18, 2026

See all skills by weave-os

Report a problem with this skill