Skip to content

Copilot agent gateway (Claude Code + Codex)

Back Claude Code with the models served by a GitHub Copilot subscription (Claude when entitled; otherwise a role-aware OpenAI fallback), via a local reverse-engineered proxy — the maintained fork caozhiyuan/copilot-api (npm @jeffreycao/copilot-api). The original ericc-ch/copilot-api is officially unmaintained (issue #233 points at the fork) but still works via COPILOT_API_PKG=copilot-api@0.7.0. Both packages share the same token file (~/.local/share/copilot-api/github_token), so switching needs no re-auth.

  • Shell helpers: ~/.config/shell/43_copilot_proxy.sh (copilot-proxy, claude-copilot, codex-copilot, copilot-run, copilot-here, copilot-model)
  • Runner: @jeffreycao/copilot-api (pinned), installed once into ~/.local/share/copilot-api/pkg and executed directly from there. Deliberately not bunx at launch: bunx re-resolves the package on every start, and bun stalls forever resolving through a socks ALL_PROXY — see Gotchas. A warm start does zero network before it binds the port.
  • Not installed by ansible — installed on first copilot-proxy start, so it stays off the provisioning path. copilot-proxy update --check is read-only; only an explicit exact copilot-proxy update VERSION changes the pin.

This violates GitHub Copilot's Terms of Service

Using a Copilot subscription to power a non-GitHub agent is not permitted, and copilot-api is reverse-engineered/unofficial. It can trigger GitHub's abuse detection and lead to temporary suspension of Copilot access. Claude Code is token-hungry (frequent background calls, large context). The fork has no native rate limiter, so the local shim conservatively queues/adapts concurrent requests; COPILOT_PROXY_QUIET=1 additionally reduces background chatter. Neither is an upstream-guaranteed safe limit. Use at your own risk; prefer a personal account over a corporate seat.

Quick start

copilot-proxy auth      # one-time: GitHub device login (stores a ghu_ token)
copilot-proxy start
copilot-model --auto    # best live main + role profile, independent of previous state

claude-copilot-once     # single-session CLI settings; auto-starts the proxy
claude-copilot-once --fast  # live-catalog fast sibling, session only
codex-copilot-once      # single-session provider; auto-picks OpenAI first
# The shorter claude-copilot / codex-copilot names are identical aliases.
claude-copilot-once --specstory-mode exit  # export history after the agent exits
codex-copilot-once --specstory-mode exit

# One-shot planning presets. Codex still needs `/plan` after the TUI opens;
# Codex 0.151.0 exposes no startup collaboration-mode flag.
codex-copilot -c 'plan_mode_reasoning_effort="ultra"' -c 'service_tier="fast"'
claude-copilot-once --fast --permission-mode plan --settings '{"ultracode":true}'

copilot-here on         # OR: pin THIS project — plain `claude` uses the proxy
copilot-here off        # unpin — back to the real Anthropic backend

Do not add --effort to the Claude command: a launch-effort pin prevents the session-only ultracode switch from taking effect. The Codex overrides are invocation-only; after launch, enter /plan to activate Plan mode.

How it works

Claude Code ──Anthropic /v1/messages──▶ throttle/metrics shim (:4142) ─▶ copilot-api (:4141)
                                          │ Claude: native Messages path
                                          │ GPT: Anthropic → Responses translation
                                          ▼
                                   api.githubcopilot.com  (your Copilot sub)

Codex ──OpenAI /v1/responses──────────▶ the same :4142 managed gateway
        (provider/model are one-invocation `-c` / `-m` overrides)
  • Claude Code speaks only the Anthropic Messages API (/v1/messages).
  • The fork uses Copilot's native Anthropic path for Claude ids and translates Claude Code requests to the Responses API for GPT ids. Since 2.3.4, the forwards Claude Code's output_config.effort to reasoning.effort, preserves reasoning state, and handles the GPT-5.6 request shape.
  • The original (copilot-api@0.7.0) always translates through /chat/completions — thinking blocks are dropped and WebSearch doesn't work.
  • Claude Code is pointed at the proxy via ANTHROPIC_BASE_URL — injected either in one merged CLI --settings object (both Claude launchers), or via the gitignored ./.claude/settings.local.json (copilot-here on). See "Settings-layer design".

Settings-layer design (which file gets the proxy config, and why)

Claude Code merges settings low → high: ~/.claude/settings.json (user) → ./.claude/settings.json (project, committed) → ./.claude/settings.local.json (local, gitignored) → CLI flags. The docs (and third-party writeups) claim shell env vars beat every settings-file env block, but empirically (verified 2026-07) current Claude Code lets the settings.local.json env block beat inherited shell env — see Gotchas.

Two of those layers are already owned by other tooling and must stay clean:

Layer Owner Why proxy config must NOT go here
~/.claude/settings.json chezmoi (dot_claude/modify_settings.json.tmpl) always-on for every project; fights the chezmoi merge. Its top-level model must not hold a proxy-only GPT/Gemini id.
./.claude/settings.json claude-plans-here (plansDirectory) committed to git — proxy config would leak to the team

The explicit launchers and persistent pin have separate lifetimes:

Enable Mechanism Scope Disable
claude-copilot-once / claude-copilot merged CLI --settings one session exit the session
codex-copilot-once / codex-copilot CLI -c / -m one session exit the session
copilot-run <cmd...> process environment one process exit the process
copilot-here on local Claude settings project, persistent copilot-here off

Claude 2.1.281 was tested against a localhost mock: CLI settings override a local pin and preserve unspecified user/project keys. Repeated --settings flags use only the last one, so the launcher reads the effective JSON or file, merges it with the proxy defaults, and supplies one settings argument. Explicit caller values win. A settings-level model or permission mode is recognized before choosing the context budget or adding default bypass flags. No project pin is created, refreshed, or removed by a single-session launcher.

Ordinary new claude / codex / specstory run sessions keep their previous routing unless you explicitly enabled a persistent pin. Resuming a Copilot thread is different: Codex saves the thread's provider, and the user overlay registers copilot_api so those threads can be reopened.

One upstream UI action crosses those ownership boundaries: pressing Enter in Claude Code's /model picker means "set as default" and writes the selected custom id to ~/.claude/settings.json. That write survives both a per-process launcher and copilot-here off. The launchers therefore guard only the top-level model key: a proxy-only value is removed (or the previous native value restored) on exit, while hooks/plugins/permissions and every other live setting are preserved. copilot-here off also cleans a stale proxy-only user model even when no local pin file exists.

Shell helpers

copilot-proxy [start|stop|status|stats|events|limiter|logs|quota|bench|update|...]

Manages the background proxy on $COPILOT_PROXY_PORT (default 4141).

With lazyclash v0.1.7+, startup and restart check the selected endpoint for service use before installing a package, rotating logs or touching a running backend. Temporary shell/foreground SSH endpoints are refused, including copied URLs. Use a stable local proxy or explicit stable endpoint. Resolver ambiguity, authentication and unavailable-session errors stop startup; only a genuinely unconfigured proxy allows auto to go direct. Stable overrides replace all six proxy variables and remove inherited SSH lifetime markers from the backend. Older lazyclash versions retain the compatibility resolver until explicitly upgraded. The integration probes --consumer support rather than trusting a version string. For a fixed COPILOT_HTTP_PROXY=http://127.0.0.1:7897, the lifetime check uses empty target settings while still reading the independent SSH session registry. Thus unrelated target/profile schema errors cannot block that fixed endpoint. auto continues to use configured target discovery and requires valid lazyclash settings. Lazyclash is optional; without the supported interface, the compatibility resolver remains available, but inherited temporary-proxy metadata requires a compatible CLI.

Env var Default Meaning
COPILOT_PROXY_PORT 4141 port the proxy listens on
COPILOT_HTTP_PROXY auto How Node reaches GitHub /models at startup: auto uses a detected proxy, or direct only when none is configured; always requires a proxy; never clears inherited proxy variables for direct access; or an explicit http://127.0.0.1:PORT. Node ignores the macOS System Proxy — TUN/Mixin used to hide this by capturing all TCP.
COPILOT_API_PKG unset Highest-priority temporary package override. Otherwise the persisted exact selection is used, then the built-in @jeffreycao/copilot-api@2.8.2 pin. While set, update/rollback refuse to change persisted state.
COPILOT_ASTRA_COMPACT_RATIO 0.70 New Astra sessions/pins compact against this fraction of the live prompt ceiling; numeric 0 < ratio <= 1. Explicit client compact settings take precedence.
COPILOT_PROXY_RATE 15 --rate-limit seconds — original package only (the fork has no rate limiter)
COPILOT_SHIM_MIN 4 adaptive concurrency floor and startup limit
COPILOT_SHIM_MAX 8 adaptive ceiling; set equal to MIN for a fixed cap
COPILOT_PROXY_QUIET 0 1 = inject extra quota-saving Claude Code env (see below); off by default because it slightly degrades the UX
COPILOT_INSTALL_NOPROXY 0 1 = install the package with the proxy env stripped, skipping the 45s stall on a host where bun cannot resolve through the proxy

Set these in ~/.shellrc.adhoc (or the per-shell secrets files). start refuses to run until copilot-proxy auth has stored a token, and waits up to ~20s for the proxy to answer before returning. start detects the package flavor from COPILOT_API_PKG: only the exact original copilot-api gets --rate-limit/--wait (the fork's start doesn't have those flags).

The first start installs the pinned package into ~/.local/share/copilot-api/pkg (stamped with the spec) and every later start just execs the resulting binary — no registry round-trip, no per-launch resolve. The install tries your ambient env first (needed where the registry is only reachable through the proxy) and retries with the proxy stripped if that stalls; either attempt is killed on a timeout, so a stalled install can never keep bun's global cache lock and wedge the next one. If start times out it now also kills the server it spawned, instead of leaving an orphan behind for every retry.

copilot-proxy quota [--json] (also whoami) is the real live plan/quota check. On the fork it reads /usage, the same payload used by /usage-viewer; it requires the proxy to be running. Use it instead of opening the plaintext token.

Traffic statistics and safe benchmarks

The shim is enabled by default for managed Claude/Codex launchers. If it is enabled but cannot start, launchers fail closed instead of silently bypassing measurement; copilot-proxy shim off is the explicit direct-to-:4141 break-glass mode.

Before upstream model bytes are exposed, the shim permits at most two backend dispatches: one initial request and one replay of the same body/model. A confirmed body-read 408 user_request_timeout and eligible completed HTTP failures may use that replay; permission/auth/policy rejections do not. A local timeout or broken connection leaves upstream work unknown and is never automatically replayed. Cancellation while queued or in backoff removes the request. After dispatch, canceling the client keeps the permit while the backend response drains; it cannot admit replacement work until that execution settles. Codex launchers set request/stream retries to 0/0 with the shim, and 3/1 in explicit direct mode. Caller -c overrides remain later in argv on both the direct and SpecStory paths. Every successful streamed response must have the SSE media type, including responses inside the grace window. For slow streams the shim sends SSE comments while queueing/retrying; if that pre-header pipeline then yields a non-2xx or a non-SSE HTTP 200, it emits the native Anthropic Messages or OpenAI Responses terminal error shape instead of splicing JSON after comment frames. Responses classify 402 as quota, 429 as rate limit, other post-commit 4xx as invalid prompt, and 5xx/transport failures as server error so Codex does not retry nonretryable failures. A delayed bare 401 must use invalid_prompt as a stopgap because Codex has no nonretryable Responses authentication code; only a pre-commit HTTP 401 preserves authentication semantics. Once model bytes have started, a later body error/stall terminates the stream and is not retried. An upstream 422 cyber_policy is a provider content-policy decision: the shim does not retry, rewrite, or attempt to bypass it.

Admission control is adaptive rather than a fixed semaphore. It starts at COPILOT_SHIM_MIN=4 and may grow one slot at a time to COPILOT_SHIM_MAX=8 only after sustained successful traffic while requests are actually queued. An upstream 403/429 immediately returns it to the floor and holds a five-minute cooldown; Retry-After is respected for up to five minutes. Set MIN=MAX for a fixed cap. Live changes do not interrupt active streams and do not require a restart:

copilot-proxy limiter status
copilot-proxy limiter set --min 4 --max 8 --limit 6
copilot-proxy limiter reset     # startup env values; all live changes are ephemeral

The write endpoint is loopback-only and requires the manager's dedicated admin header. To persist a range, export COPILOT_SHIM_MIN/MAX in ~/.shellrc.adhoc and restart the shim later.

copilot-proxy stats week --model gpt-6-sol
copilot-proxy events day --limit 20 --json
copilot-proxy quota --json
copilot-proxy bench --model gpt-6-sol --runs 3 --max-output 256

stats defaults to normal interactive traffic; choose --scope benchmark or --scope all explicitly. It reports request/error/retry counts, separates client cancellations from upstream failures, and shows p50/p90/max queue time, upstream-header time, first-byte time, stream/end-to-end duration, tokens, AIU and output tokens/sec. Output throughput is only calculated for a completed stream that has a matching token event; otherwise it is null.

Timing rows are stored without prompts or response bodies in $XDG_STATE_HOME/copilot-proxy/metrics.sqlite (WAL, 90-day retention). The fork separately owns $XDG_DATA_HOME/copilot-api/copilot-api.sqlite; its token_usage_events table is what /usage-viewer and /token-usage* read. There is no quota-history table: /usage is live GitHub data. The CLI joins the two databases by x-trace-id, aggregating multiple token events for the same request/model first. stats and events work offline while both processes are down.

bench sends real streaming Responses requests and consumes real quota. Safety limits are enforced: 1–10 runs, 32–2048 maximum output tokens and concurrency 1–4. Benchmark rows are tagged separately and do not pollute normal-use stats.

Package supply and explicit updates

Warm starts execute the installed binary and do not contact npm, so registry availability only affects first install, reinstall or update. Normal npm unpublishing is unlikely for an established package, but maintainer, policy and Copilot-protocol risks remain; the package is still unofficial and has a small maintainer surface.

copilot-proxy update --check queries canonical npm (configured registry only as a warned fallback) and never installs. update VERSION requires exact semver, verifies the downloaded SHA-512 archive, installs that same local archive, checks the installed version and runs a help smoke test before touching the live prefix. Trusted first installs use the same artifact path; stale manager locks cannot vouch for different runtime bytes. The built-in target is @jeffreycao/copilot-api@2.8.2, SRI sha512-YRVOVD98Tbfru8osmbPnl25HKFNi2B0BEH/KC41BoW+zyXK3wu973dL5Cl8vtAhtry53EwEG8CnW8yz6DVY6DA==. An existing selection remains authoritative until copilot-proxy update 2.8.2.

Update retains a private pkg.rollback.* generation containing the previous package/selection, backend config, consistent backend/metrics SQLite images, deployed wrapper/shim and selected launch settings. The selected {spec, integrity, registry, selected_at} remains in $XDG_STATE_HOME/copilot-proxy/package.json; its sibling rollback identifies the recoverable generation. An already running Unix service is restarted, with automatic package/config recovery if the candidate fails startup or selection.

copilot-proxy rollback works offline. It restores the previous package, selection and original responsesTransport/upstreamTransport fields, while preserving unrelated current config, current credentials and post-upgrade usage. The rejected package and post-upgrade config remain available for diagnosis; database snapshots are recovery evidence and are not automatically restored. The command reports the retained deployment snapshot: restore its matching wrapper/shim and launch settings separately, then reload the shell. A snapshot captures files present at update time, so retain old deployment files before deploying a changed wrapper when you need an earlier shim generation.

For isolated Ubuntu dogfood, scripts/copilot_proxy/dogfood.py prepare accepts --root, absolute --node/--bun, --shim, --archive-2-3-4 and --archive-2-5-2; run uses that root and the runtime paths. The dedicated trial uses only Ubuntu's local credential copy, separate config/SQLite/metrics and recorded PIDs. The default --expected-host David-Ubuntu guard runs before creating a trial directory or copying credentials; listener PIDs must match the trial child before inference. It permits 12 logical requests / 24 physical dispatches total, with --proxy-env only when explicitly needed by that host. Baseline and candidate use the same shim/client policy. Switching immutable trial slots restores the whole matching bundle without touching the default installation or Mac session.

If the initial client execution fails, verify-candidate can use only the unspent portion of that same budget, once; allocations use exclusive creation and are not reset by rerunning. The default client sandbox is workspace-write. An explicit --sandbox-mode danger-full-access matches the normal launcher posture on hosts where bubblewrap cannot initialize; it changes only the test process and provides no OS filesystem sandbox. The trial directory then remains a workflow convention, not a security boundary. Results from differing client policies are functional validation, not a controlled performance comparison. Trial directories contain private credential/config copies and must not be shared or committed wholesale. See the implementation and measured Ubuntu results.

The 2026-10 Mac follow-up compared repaired 2.5.2 with verified 2.8.2: Messages, tool continuation, Responses streaming, both WebSearch modes and Sol Fast passed; native Claude was unavailable in that account. The Mac was then explicitly upgraded to 2.8.2 with a retained 2.5.2 rollback generation; production JSON/streaming WebSearch and Responses Fast passed after cutover. That report also records 2.8.2 startup config migrations. Dogfood cleanup handles Darwin's intermittent process-group probe EPERM only after independently checking group membership; permission errors alone never prove that a process has stopped.

Why the gateway still exists with 2.8.2

The historical "shim" is now a gateway with admission, compatibility and observation responsibilities, about 1,940 lines. It has accumulated overlapping work, but removing the whole layer currently changes working behavior:

Responsibility Native 2.8.2 coverage Decision
Decode Codex zstd Implemented on Responses routes Upstream owns decompression; the gateway still decodes when it must inspect/rewrite a body
Repair blank tool descriptions Repairs empty namespace descriptions only Keep the general MCP/function repair until upstream handles missing/blank descriptions too
Codex Fast tier → fast sibling Copilot path deletes service_tier without changing the model Keep the live-catalog mapping
Concurrent request admission and uncertain-work tracking Quota headers are logged; no equivalent configurable admission gate Keep bounded admission, draining and quarantine policy
SSE heartbeat while waiting for HTTP upstream headers HTTP Responses flow awaits upstream headers before returning SSE Keep the early heartbeat; this host retains HTTP transport
Request timing and usage Native first-token/duration/outcome metrics and event plugins exist Prefer native timing as the future source; avoid maintaining two equivalent telemetry systems
Semantic failure and retry evidence Native request-end outcome uses HTTP status/body completion Keep terminal-event classification and physical-attempt evidence until equivalent upstream coverage exists

This was checked against the installed package with bun tests/fixtures/copilot-native-2-8-2-audit.mjs. It runs the actual Hono handler against a loopback fake with synthetic credentials, independent config and SQLite, and zero external inference. It confirms native zstd/namespace handling, unchanged blank MCP descriptions, stripped Fast tier, and an HTTP-200 response.failed stream reported as completed by the native request-end event. A deliberately delayed fake also confirms that HTTP response headers are awaited; its timing is not a production latency measurement.

The next refactor should separate admission, Responses compatibility and telemetry, then remove each workaround when a pinned upstream version passes its parity case. Do not add another protocol repair to the same file without that ownership/removal check. Native plugins currently expose asynchronous observation events, not blocking request filters, so moving the whole gateway into a plugin is not a supported replacement. No gateway behavior was removed during this upgrade.

Upstream references: Responses handler, Copilot transport, request outcomes, plugin API.

copilot-proxy doctor [--live] (alias: test)

Diagnoses the whole path and exits non-zero on any failure. Read-only by default; --live adds one real POST /v1/messages (max_tokens: 1, a non-[1m] chat model) that costs one quota unit but is the only check that exercises streaming. status makes no inference request: it reports shim admission and the latest observed authentication result, or unverified when there is no recent evidence. The token file proves only that a credential is stored. doctor --live classifies the bounded error response: shim quarantine calls for a controlled restart, an expired IDE token calls for a restart and retry, and rejected stored credentials call for copilot-proxy auth followed by a restart. It never prints the token.

Sections, in order: prerequisites (bun/curl/jq) → package → token file → proxy and throttle-shim liveness → stale installer → models → WebSearch → upstream reachability → local proxy/VPN → Codex Apps → live inference probe.

The package check reports whether the pinned spec is installed in the prefix and where its binary is. "Not installed" is a note, not a failure — the next start installs it once.

The stale-installer check is a safety net for the trap described in Gotchas: any live bun add … copilot-api at rest means an install has stalled (bun hangs resolving through a socks proxy) and is holding bun's global cache lock. The installer now bounds and kills its own attempts, so this should stay empty; if it ever fires, doctor prints the clear-and-restart one-liner.

The models section is the one that earns the command. It compares what the proxy serves against what GitHub serves right now, which is the only way to separate the two indistinguishable causes of a 400 model_not_supported:

Proxy has claude? Upstream has claude? Verdict Fix
no yes stale cache copilot-proxy restart
no no org policy disables Anthropic a restart will not help

It validates the main model and the Fable/Opus/Sonnet/Haiku aliases against the served list, including [1m] aliases. A stale background-role id can make workflows fail with 400 even while ordinary chat succeeds. No Claude entitlement is a warning rather than a failure when the computed OpenAI profile is usable.

The WebSearch check reads messageApiWebSearchModel from ${COPILOT_API_HOME:-$HOME/.local/share/copilot-api}/config.json. The fork uses this independent model for pure Claude Code WebSearch requests, regardless of the main model or role aliases selected with copilot-model. A configured Copilot model absent from the served catalog, or explicitly lacking Responses support, is a failure. Empty search-model settings and disabled Responses search are shown as disabled; missing/invalid config, provider aliases, unavailable catalogs and missing endpoint metadata are shown as unverified. A catalog pass does not prove that a real search succeeds. The check never writes config or sends inference, including under --live (which still adds only the existing ordinary chat probe).

WebSearch returns model_not_available_for_integrator

On older fork installations, messageApiWebSearchModel can still be gpt-5-mini after that model disappears from the Copilot catalog. Search then returns HTTP 400 mentioning integrator vscode-chat even though the header is being sent correctly. Run copilot-proxy doctor, privately back up the backend config, and set only messageApiWebSearchModel to a served Responses model (currently gpt-6-luna on the tested account). Wait for active/queued work to finish, run copilot-proxy restart, then retry WebSearch. copilot-model alone does not change this backend setting.

The former 2.5.2 pin already supported this repair. Upstream 2.6.7 changed the default, and 2.7.36 added startup migration of exact gpt-5-mini values in alphaSearchModel, messageApiWebSearchModel and claudeAutoModel; custom/provider IDs and empty strings are preserved. Details and measured JSON/streaming reproduction: pitfall.

Upstream reachability

Upstream reachability probes both api.enterprise.githubcopilot.com and api.githubcopilot.com, directly and through the macOS system proxy when one is set, so a Clash/mihomo rule that black-holes one host shows up immediately. An unauthenticated 400/401 counts as reached — only a connect/read failure is a fault. doctor never prints your token: it passes credentials to curl via -K - (stdin), never argv, since argv is readable through ps.

With --live, the Codex Apps section also probes https://chatgpt.com/backend-api/wham/apps directly and through the detected HTTP proxy. This GET consumes no model inference quota; it separates a codex_apps startup interruption from the localhost Copilot route. Any real HTTP status (including an auth rejection) proves network reachability, while timeouts and TLS certificate failures remain distinct. codex_apps is a remote ChatGPT MCP, not an Apple-Silicon-only Codex Desktop bridge.

claude-copilot-once / claude-copilot — one session

Both names auto-start the proxy and inject the same role profile and live compact ceiling using CLI settings. They need jq and Python 3.11+ (or uv). They override a project pin for this session without changing its file or asking to refresh it. Review a persistent pin explicitly with copilot-here on.

--fast resolves the selected model's live fast sibling; absence produces a warning and keeps the standard model. Put wrapper flags before agent arguments. --fast with a -- boundary is rejected because appending a model there would turn it into prompt data. The user-level /model cleanup guard remains active.

Recording: --specstory-mode run|exit|off

This option is shared by both Claude and both Codex launcher names:

Mode Behavior
run (default; --specstory) live recording through specstory run; raw fallback if SpecStory is absent
exit native agent terminal; specstory sync claude or codex after it exits
off (--no-specstory) bare provider command, no new recording request

exit prevents this launcher from continuously rewriting history while the agent commits. It exports all saved sessions for the same project/provider, not a guessed latest session. Other separately launched recorders can still write history. Within these launchers, sync waits for the last active session of that provider and project; project sync operations are serialized.

Normal exit, nonzero exit and Ctrl+C all wait for the agent before syncing. Repeated Ctrl+C does not cancel the sync, which has a 30-second limit. A failed or interrupted export remains pending under $XDG_STATE_HOME/copilot-proxy/ (default ~/.local/state/copilot-proxy/) and is retried before the next launch in that project when no same-provider launcher is active. An off launch can therefore finish an older pending export. Agent exit status is preserved; sync errors are reported separately. SIGKILL of the supervisor or power loss cannot run cleanup immediately; native history not flushed to disk cannot be recovered. Background/detached, worktree-creating and non-persistent session flags are rejected in exit mode. SpecStory must be installed for that mode.

run and exit preserve [providers].claude_cmd / codex_cmd from project then user SpecStory TOML. Settings, quoting, empty arguments and prompt boundaries survive the handoff. Explicit caller permission choices override the configured command, then the wrapper supplies bypass defaults only for missing axes. off does not inherit the configured command. Existing SpecStory output, redaction and cloud preferences govern sync. Neither recording mode commits files.

copilot-run <cmd...> — generic env injector

Auto-starts the proxy and runs any command with the proxy env. Useful for other Anthropic-compatible tools or a custom specstory invocation:

The injected block comes from _copilot_env_json_for_model --live — the same single source of truth copilot-here on writes and _copilot_here_drift compares against, so the two can no longer disagree about which keys exist. --live is the only difference: it resolves ANTHROPIC_BASE_URL with _copilot_client_base (what this process should talk to now) instead of the pinned base a settings file records.

copilot-run specstory run claude    # environment injection; local pins can override it
copilot-run claude --resume         # raw claude, no specstory

codex-copilot [--no-specstory] [codex args...] — one-off Codex session

codex-copilot and codex-copilot-once are identical, zero-persistence launchers. They start the gateway/shim when necessary and pass a custom copilot_api Responses provider to Codex as CLI -c overrides. They never edit ~/.codex/config.toml or .codex/config.toml, so plain codex remains on its normal provider.

On Codex 0.162.1, these command-line configuration overrides require an embedded app server. Startup reports: Running without the shared background server: command-line configuration overrides (-c, --enable, --disable, or --search) requires embedded mode. The launcher's /daemon menu therefore reports that it is not connected to the shared background server. The shared Command Center (codex agents / /agents) and the current session's /subagents picker are different surfaces; enabling multi-agent does not make the launcher join the shared daemon. This was verified with a no-prompt startup check.

For GUI or plain-CLI resumes, chezmoi seeds a missing [model_providers.copilot_api] table in the user config. A saved thread retains the provider ID, but not the launcher's temporary provider definition. The fallback points to http://localhost:4142 and uses the local gateway's literal dummy bearer, so a GUI need not inherit GITHUB_COPILOT_API_KEY. It does not select the provider globally. Existing provider definitions survive apply; set the saved base_url yourself when using a custom port. See codex-thread-resume-provider-not-found.

Codex uses the shim on localhost:4142 by default. Besides throttling and measurement, that boundary normalizes blank descriptions in Codex mcp_list_tools Responses items. GitHub Copilot rejects those with Invalid 'input[0].tools[0].description': empty string, while MCP servers and the native Codex path may omit them. The shim fills only those tool definition fields and leaves prompts, schemas, and tool names unchanged. Codex currently zstd-compresses these requests; the shim decodes only the Responses body it must repair, forwards ordinary JSON, and removes the stale content-encoding header. Explicit copilot-proxy shim off bypasses this compatibility repair as well as metrics, so it is only a break-glass diagnostic mode.

This is a separate picker from Claude Code's copilot-model --auto: that path remains Claude-first, while only this Codex launcher is OpenAI-first.

  • If the caller supplies -m / --model, that value wins. Otherwise the live raw gateway catalog uses the same tier-aware policy as copilot-model, but in Codex's vendor order: OpenAI/Codex first, then Claude, grok, Gemini, and any remaining chat model. Embedding, disabled, picker-hidden, and -fast main candidates are excluded.
  • The launcher builds a provider-derived model_catalog_json from the installed codex debug models --bundled descriptors and the same live Copilot snapshot. For exact matching model IDs, only context_window and max_context_window are updated to the live context value; reasoning, tools, prompts and other metadata stay bundled. Setting only -c model_context_window=... is insufficient: Codex clamps it to the descriptor's max_context_window.
  • The catalog lives under $XDG_CACHE_HOME/copilot-proxy/codex-models/ (default ~/.cache/copilot-proxy/codex-models/). Its cache key includes the Codex version, cache schema and a hash of the canonical live model-to-context map; validated JSON is published atomically. Prompt/output limits do not key this cache: the launcher recomputes the compact override on each launch. The provider-global ~/.codex/models_cache.json is left alone.
  • A caller-supplied -c model_catalog_json=... bypasses managed catalog generation; the caller owns that catalog's descriptors and limits. Otherwise, selecting gpt-6-sol or gpt-6-luna without an exact bundled descriptor stops launch with an upgrade hint. Use Codex 0.156.1 or newer; the helper does not copy another model's reasoning/tool metadata. See the context-clamping investigation.
  • Claude/Gemini fallback uses the gateway's Responses Lite translation. Basic tools, compaction and multi-agent orchestration remain available, but Responses tool_search is not supported on that path. Auto selection therefore keeps native Responses OpenAI models ahead of Anthropic.
  • The launcher enables gateway-backed remote compaction and excludes the mcp__codex_apps__sites namespace that depends on unavailable tool_search. Explicit later -c arguments can still override either per invocation.
  • When SpecStory is installed, the launcher defaults to specstory run codex. It preserves the effective codex_cmd (project config > user config > bare codex) before appending provider/model/user arguments. The raw --no-specstory path independently asserts the same --ask-for-approval never --sandbox danger-full-access posture unless the caller supplied an approval/sandbox policy of their own.

Codex 0.156.1 ignores project-local model_provider / model_providers. Consequently copilot-here codex on reports this limitation without writing a config; use codex-copilot-once. Project .codex/config.toml remains available for legitimate shared customization. Parser-only debug models validation missed this restriction; the actual startup warning and a version-sensitive retest procedure are recorded in the project-provider pitfall.

Experimental direct provider (not used by the helper)

This skips localhost and is useful only when the account's GitHub credential can authenticate directly to the matching Copilot endpoint:

[model_providers.copilot-enterprise]
name = "GitHub Copilot Enterprise"
base_url = "https://api.enterprise.githubcopilot.com"
wire_api = "responses"
http_headers = { "Copilot-Integration-Id" = "vscode-chat", "Editor-Version" = "vscode/1.99.0", "Editor-Plugin-Version" = "copilot-chat/0.0.1", "User-Agent" = "GithubCopilot/1.0" }

[model_providers.copilot-enterprise.auth]
command = "gh"
args = ["auth", "token", "--hostname", "github.com"]
timeout_ms = 5000
refresh_interval_ms = 300000

This is an example, not managed config. On the tested EMU/Enterprise account, the raw gh auth token produced 421 Misdirected Request at the enterprise API and could not perform the Copilot token exchange (403). The token saved by copilot-proxy auth, followed by the normal short-lived Copilot token exchange, did work. Endpoint routing also comes from that exchange; hard-coding personal versus enterprise hosts is not portable. For those reasons the supported helper uses the already authenticated localhost gateway.

copilot-here [claude] [on [MODEL] [--yes]|off|status]

An explicit persistent Claude project pin. Plain claude and specstory run claude use it until it is turned off.

  • on creates a fresh pin, does nothing when unchanged, or shows the changes and asks before replacing an existing pin (default: keep). --yes accepts an update in scripts; non-interactive updates otherwise return nonzero unchanged. Authentication values are redacted in the preview.
  • A tracked/staged target is refused even with --yes. An existing unignored file also needs confirmation because the whole file becomes locally ignored. Git's resolved info/exclude path supports worktrees and nested projects.
  • New pins save their previous owned fields privately. off restores those fields only if they still match the last write, preserving unrelated and subsequently edited values. Older pins without a journal retain the legacy remove-owned-fields behavior. The native user-model cleanup still runs.
  • status shows pin drift and the plain-Claude launch audit. Pin updates keep plansDirectory and other settings intact. The proxy remains running.

copilot-model [<id>|-l|-L|-c|--auto|--why|--json]

Switches the pinned Copilot model. Requires jq. Write target — never the committed .claude/settings.json:

  • copilot-here is ON in the current project → edits ./.claude/settings.local.json.
  • otherwise → writes the global state file ~/.local/state/copilot-proxy/model, which claude-copilot, copilot-run and the next copilot-here on pick up. ($COPILOT_CLAUDE_MODEL overrides the state file; final fallback is gpt-5.6-sol[1m].)

Behavior:

  • Fuzzy id: copilot-model opus-4-8 resolves to claude-opus-4-8; dotted input is normalized (opus-4.8 works too).
  • [1m] is a Claude Code-only context hint. The helper now derives it from live /v1/models max_context_window_tokens metadata for every provider; manual explicit suffixes remain usable while offline.
  • Auto-compact uses the separate live max_prompt_tokens ceiling through CLAUDE_CODE_AUTO_COMPACT_WINDOW. Claude Code then applies its own default threshold (roughly 95%); no fixed percentage override is injected. When the explicit prompt limit is absent, the helper derives it from context minus maximum output. copilot-model -c shows the resulting value.
  • Validated against the live proxy /v1/models (a static discovery list remains available for manual picking while offline); typos and ambiguous prefixes are rejected. --auto never writes from that offline list.
  • --auto / -a requires a live catalog. It rejects policy-disabled, picker-hidden, embedding-only, and -fast entries, then follows the vendor order Claude > OpenAI > grok > Gemini. Inside a vendor it reads Copilot's model_picker_category (powerful > versatile > lightweight) and compares generations only inside the winning tier. A curated per-vendor allowlist wins on known ids and same-generation siblings; an unknown newer flagship can win without waiting for a dotfiles update. Missing category metadata (an older proxy) degrades to the historical allowlist rather than guessing. Automatic candidates depend only on that live catalog, never on the previous model, project pin, or whether the state file exists. Billing plan labels (restricted_to) and price metadata are diagnostic only, not evidence of the current account's entitlement. Main selection, derived roles, --why, the details auto marker, and implicit codex-copilot use the same eligible set.
  • OpenAI generation and capability tier are independent. Among selectable known OpenAI models, main preference is gpt-6-astra > gpt-6-sol > gpt-5.6-sol; gpt-6-luna stays in the lightweight/Haiku tier and cannot displace a flagship. GitHub announced GPT-6 Sol and Luna on 2026-09-22. Terra remains gpt-5.6-terra; lower-role Luna prefers gpt-6-luna, then gpt-5.6-luna, from the selectable live entries. The backward-compatible offline default remains gpt-5.6-sol[1m]; it is not an entitlement guarantee. copilot-model -L and --json show live plan/policy metadata, but billing.restricted_to does not prove the active billing target's access. A later entitlement rejection still requires choosing another served model manually. Context and compact values below are a 2026-09-23 live gateway snapshot, not hard-coded promises for every account.
  • No argument → fzf picker. -c prints the main model, source layer, and complete role profile. -l is the pipeable bare-id list; -L / --details shows the live tier, price category, context/output limits, reasoning range, fast sibling, eligible plan, and picker state. * marks the current model and -> the authoritative automatic pick. Rows are grouped by tier/generation for comparison; their display order does not replace vendor/allowlist policy.
  • --why performs a dry-run explanation without writing; --auto --why emits the explanation and then writes. --json returns the raw live /v1/models payload. That HTTP catalog is distinct from the shim's /_shim/fast-routing endpoint and from Codex's generated on-disk provider-derived catalog under ~/.cache/copilot-proxy/codex-models/.
  • A local pin writes the complete role set. For the current OpenAI profile: Main/Fable/Opus = Astra (or the selected main), Sonnet = Terra, Haiku/background = GPT-6 Luna (then GPT-5.6 Luna). Missing tiers fall back to the selected main, never an unserved hard-coded id. Grok has no static version allowlist; the newest served model in its highest reported tier wins.
  • The global state file stays a backward-compatible one-line main id; wrappers derive the live role profile when they inject env. Changes take effect on the next claude launch and do not require restarting the proxy.

If an older auto-selection saved gpt-5-mini despite available flagships, deploy and reload the corrected shell fragment, then run copilot-model --auto again. There is no need to delete the state file or project settings: the old selection is no longer used to restrict new candidates. --why previews the same decision without writing; actual entitlement rejections remain the gateway's responsibility.

Recommended sequence:

copilot-proxy start
copilot-model --why        # explain the automatic pick; write nothing
copilot-model -L            # inspect live tier/price/context/plan metadata
copilot-model --auto        # save the main model / refresh a live project pin
copilot-model -c            # inspect main + Fable/Opus/Sonnet/Haiku
# Explicit alternatives: copilot-model gpt-6-sol; codex-copilot -m gpt-6-luna
copilot-here on             # sticky project, or use claude-copilot-once

claude-copilot-once injects the same full profile through CLI settings and preserves the previous local state. An existing copilot-here pin still outranks shell env; refresh it with copilot-model --auto or copilot-here on.

The role variables are read at Claude Code startup, so the change only takes effect on the next claude launch (env is read at startup).

Injected env (what both layers set)

Example: explicitly selected gpt-6-sol, with selectable Terra/Luna lower roles.

{
  "env": {
    "ANTHROPIC_BASE_URL": "http://localhost:4141",
    "ANTHROPIC_AUTH_TOKEN": "dummy",
    "ANTHROPIC_MODEL": "gpt-6-sol[1m]",
    "ANTHROPIC_DEFAULT_FABLE_MODEL": "gpt-6-sol[1m]",
    "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-6-sol[1m]",
    "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.6-terra[1m]",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-6-luna[1m]",
    "ANTHROPIC_SMALL_FAST_MODEL": "gpt-6-luna[1m]",
    "CLAUDE_CODE_AUTO_COMPACT_WINDOW": "872000",
    "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"
  }
}

ANTHROPIC_AUTH_TOKEN is ignored by the proxy but must be set.

The 2026-09-23 live gateway snapshot separates provider limits from client compaction:

Model Provider context Prompt ceiling Maximum output Launcher compact budget
gpt-6-astra 1,050,000 1,050,000 128,000 735,000 (70%)
gpt-6-sol 1,000,000 872,000 128,000 872,000
gpt-6-luna 1,000,000 872,000 128,000 872,000

Explicit live max_prompt_tokens is authoritative, even when it does not equal context minus maximum output. Subtract maximum output only when the prompt field is absent. [1m] remains a Claude Code display/context hint; raw API IDs have no suffix. Sol/Luna do not inherit Astra's 70% experiment.

Astra and its -fast sibling use floor(live prompt ceiling × COPILOT_ASTRA_COMPACT_RATIO), default 0.70: 1,050,000 becomes 735,000. The ratio must satisfy 0 < ratio <= 1; 1 restores the full prompt budget. Claude's 100,000-token minimum remains enforced; Codex requires a positive budget. This reduces large compact uploads but is not a proven fix for remote body-read 408s. Valid explicit CLAUDE_CODE_AUTO_COMPACT_WINDOW and -c model_auto_compact_token_limit=... retain precedence; Codex still enforces its own compact cap.

With the matching provider-derived descriptors, Codex 0.156.1 uses 95% of the context as usable input and caps auto-compact at 90% of the full context:

Model Codex usable context Effective Codex auto-compact trigger
gpt-6-astra 997,500 735,000
gpt-6-sol / gpt-6-luna 950,000 872,000

The trigger is min(launcher compact budget, 90% × context), not 90% of the already-reduced usable context. These values follow Codex's model calculations. Claude separately applies its roughly-95% threshold to the injected budget (about 698,250 for Astra and 828,400 for Sol/Luna). Refresh managed pins and relaunch the wrapper after changing models; in-session switching does not recompute launch-time overrides. Existing safe pins and active session history are not rewritten. These settings do not change VS Code's context picker.

Missing live metadata retains the existing client fallback with a warning. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 cuts background chatter (helps with rate limits). Do not paste this into the committed .claude/settings.json — use copilot-here on instead.

With COPILOT_PROXY_QUIET=1 (opt-in, default off), both layers additionally inject the fork-README quota savers:

Extra env Effect UX cost
CLAUDE_CODE_ATTRIBUTION_HEADER=0 no billing/version info in system prompts → avoids prompt-cache invalidation none noticeable
CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false no prompt-suggestion calls no suggestions
CLAUDE_CODE_ENABLE_AWAY_SUMMARY=0 no away-summary calls no away summaries
DISABLE_NON_ESSENTIAL_MODEL_CALLS=1 fewer background model calls fewer niceties (haiku flavor text etc.)

copilot-here off removes these keys regardless of the current COPILOT_PROXY_QUIET value.

Claude Code feature compatibility through the proxy

The important split is local orchestration vs Anthropic cloud services:

Feature Through Copilot + GPT Notes
CLI, tools, hooks, skills, memory, plugins, MCP, checkpoints, sandboxing Yes These are local Claude Code features; model behavior can still differ because GPT receives translated Claude prompts/tool schemas.
Subagents and dynamic workflows Yes Do not set CLAUDE_CODE_SUBAGENT_MODEL globally here, so workflow scripts/frontmatter retain normal routing. Workflow docs
ultracode Yes on 2.3.4 Ultracode is xhigh effort plus dynamic workflows, not a separate model. The upgraded fork forwards the requested effort to GPT-5.6.
Thinking/reasoning Translated GPT uses Responses reasoning rather than Anthropic-native thinking semantics. Persisted reasoning support is proxy-dependent.
Fast inference Yes when catalogued Codex /fast is translated by the shim to Copilot's separate -fast sibling; Claude Code uses claude-copilot --fast. No eligible sibling means a warned standard fallback.
Web search, auto mode, MCP tool search Provider-dependent The base-URL gateway and Copilot endpoint decide availability; non-first-party tool search may require an extra bridge/plugin.
Ultrareview, Remote Control, Chrome, cloud Code Review, routines, web/mobile/Slack sessions No These require Claude.ai authentication/cloud services; a local API gateway cannot supply the subscription identity. ultrareview is unrelated to ultracode.

See Claude Code's feature availability, model configuration, gateway protocol, and Ultrareview references.

Gotchas (these cost real debugging time)

start used to hang at "Resolving dependencies" behind a socks proxy

Fixed (the runner no longer resolves at launch), but worth understanding because the failure mode is so misleading — and because it will bite any other bunx-based tool on a proxied host.

start used to run bunx <pkg> start, and bunx re-resolves the package on every launch. bun hangs indefinitely resolving through a socks ALL_PROXY — while curl through that same proxy reaches the registry in under half a second. So every obvious check passes and nothing points at the installer. All you get is:

copilot-proxy: did not come up in time — check 'copilot-proxy logs'.
$ copilot-proxy logs
nohup: ignoring input
Resolving dependencies

Two things turned one stall into a permanent wedge: the stalled bun add kept bun's global install-cache lock, so the next start hung on the lock too; and start's timeout returned without killing what it spawned, so each retry left another orphan (five, in the wild) and none ever bound the port.

The runner now installs the pinned package once into ~/.local/share/copilot-api/pkg and execs that binary, so a warm start does no network at all. The install itself is bounded and killed on timeout, falls back to a proxy-stripped retry, and finally uses npm's CA stack if Bun reports UNKNOWN_CERTIFICATE_VERIFICATION_ERROR. TLS verification stays enabled; this works around the observed Bun-vs-Node CA-store difference rather than disabling certificate checks. Note the registry here is npmmirror (a domestic mirror, set in ~/.bunfig.toml for GFW speed) — routing it through the proxy buys nothing and is exactly what breaks bun.

Full post-mortem, with the verbatim symptoms to grep: pitfalls/copilot-proxy-start-hangs-at-resolving-dependencies.md.

The model list is fetched ONCE at startup — geo + flaky fetches poison the session

copilot-api fetches /models from GitHub when the process starts, caches the result for its whole lifetime, and never re-fetches. Two common ways that one request returns a Claude-less catalog:

  1. Egress geo-filter (GFW hosts, post-TUN) — GitHub serves Claude ids when the request egresses overseas (e.g. Singapore VLESS), and omits them on direct/CN egress. Node does not honor the macOS System Proxy, so with only System Proxy / no TUN, copilot-api and OpenCode fetch the direct catalog (0 Claude) while curl through Clash still sees Claude. Fix: copilot-proxy start with COPILOT_HTTP_PROXY=auto (default) attaches --proxy-env + HTTPS_PROXY when proxy-status finds a local proxy. OpenCode needs the same env in its launch environment.
  2. Flaky node — a degraded Clash node returns a truncated list; restart after the node is healthy.

The tell is in the startup banner:

ℹ Models refresh: 17 new     ← often the direct/CN catalog (no claude ids)
ℹ Models refresh: 25 new     ← healthy via overseas proxy (includes claude-*)

copilot-proxy doctor A/Bs upstream direct vs upstream via proxy vs the served cache so it no longer mis-labels geo-filter as "org entitlement". Full write-up: pitfalls/copilot-api-caches-degraded-model-list-at-startup.md.

settings.local.json env beats shell env

The generic copilot-run only sets inherited environment variables, which a Claude local settings file can override. Both Claude launchers use CLI --settings instead (verified with Claude 2.1.281), so they can override a local pin for one session. Update the persistent pin separately with copilot-here on.

OpenAI models go silent while they think — the shim keeps the socket warm

copilot-api does not open the SSE stream early: measured against :4141 with gpt-5.6-sol, the response headers arrive at 8.11s and the first body chunk at 8.12s, so a reasoning model's whole think time is spent inside one fetch() with zero bytes on the wire. Real Anthropic streams cover that with periodic ping events; this gateway emits none. Add the shim's concurrency queue on top (adaptive range 4..8 — requests beyond the current live limit wait) and any idle timer in the path is free to reap a perfectly healthy connection. The agent then hangs with no error, and the only way out is Esc + continue.

So for client-requested streams the shim commits the text/event-stream response itself after COPILOT_SHIM_PING_AFTER_MS of silence and emits SSE comment frames (: copilot-shim keepalive, discarded by every spec-compliant parser, so invisible to both the Anthropic and OpenAI SDKs) every COPILOT_SHIM_PING_MS until real bytes arrive — covering queue time, think time, and mid-stream reasoning gaps alike. The backend owns its normal 300-second header/inactivity deadlines; the shim has a 330-second finite fallback. A shim timeout or broken backend connection retains an unknown permit and reports recovery required. It never replays an execution that might still run. Health reports active/draining/unknown, actual artifact versions and deadline ordering; all-unknown admission fails promptly. Outstanding leases persist in metrics.sqlite.admission.json, so restarting only the shim cannot erase them. After tracked work settles, controlled copilot-proxy restart confirms both processes have exited before clearing that marker. If a PID file was lost, it also inventories matching processes rather than treating the missing file as proof of shutdown. start, status, and doctor report a quarantined marker instead of calling a listening but unusable shim healthy. Do not delete the marker manually. A Codex "high demand" message following a local 503 is generic; the shim's admission is quarantined response identifies this failure. When only part of the capacity is unknown, health may still report admission_available=true: status/doctor call this degraded, not blocked. A controlled restart recovers the retained capacity. Doctor uses the same service proxy resolver as start/restart, so invalid lazyclash settings are reported as a failure rather than "none detected". A 408 user_request_timeout means the upstream timed out reading the request body; restarting clears local retained state but does not establish that the upstream upload problem is fixed.

Env Default Meaning
COPILOT_SHIM_MIN 4 adaptive concurrency floor and startup limit
COPILOT_SHIM_MAX 8 adaptive ceiling; set equal to MIN for a fixed cap
COPILOT_SHIM_PING_MS 15000 keepalive interval; 0 disables
COPILOT_SHIM_PING_AFTER_MS 10000 silence tolerated before the SSE response is committed early
COPILOT_SHIM_STALL_MS 330000 positive finite outer header/inactivity fallback; must exceed the backend deadlines
COPILOT_SHIM_RETRIES 1 at most one replay, two total dispatches per ingress
COPILOT_SHIM_HOST 127.0.0.1 listener address; the default is loopback only

Fast non-2xx responses keep their real status and headers, so a 400 model_not_supported or 401 IDE token expired still reports itself properly. A fast HTTP 2xx for stream:true is accepted only when its media type is SSE; the shim can still reject a protocol mismatch before committing headers. Once the SSE response is committed, a late non-2xx can only be delivered as a protocol-native terminal event, so don't shrink COPILOT_SHIM_PING_AFTER_MS to zero.

For /responses, only response.completed counts as successful SSE completion; response.failed, response.incomplete and missing terminal events are separate failures, even with HTTP 200. Non-streaming JSON is classified by protocol result. Metrics record compact classification/source, request byte counts, attempts, timeout owner and drain outcome without storing bodies or credentials. The shim never fabricates a completion. If the fork log shows a matching WebSocket error, set useResponsesApiWebSocket to false in its data-dir config.json and restart; the fork does not automatically fall back from a failed WebSocket to HTTP. See pitfalls/copilot-responses-stream-closed-before-completed.md for the 0ms-stream fingerprint and the verified A/B procedure.

0 tok in FleetView is not evidence of this bug — that counter is only filled in when an agent finishes. Check agent-<id>.jsonl for growing assistant entries first. Full diagnosis: pitfalls/copilot-proxy-openai-model-silent-stall.md.

Fast mode is a catalog model route, not a forwarded Copilot tier

OpenAI's Responses API represents Fast Mode with service_tier="fast" (and historically priority). The pinned Copilot fork removes that field, while GitHub Copilot advertises fast inference as a separate model id such as an eligible <standard>-fast sibling. The front shim now bridges those two models of the world: every five minutes it derives standard-to-fast pairs from the live /v1/models catalog, rewrites Codex /fast requests to the sibling, then removes the unsupported tier before the fork sees it. See OpenAI's Fast Mode guide.

Claude Code's native /fast remains unavailable on this custom Anthropic gateway, so use claude-copilot --fast (or pass it through claude-copilot-once). The wrapper selects the same live-catalog sibling with a session-only --model override. If discovery fails, the cached last-good map is kept; if there is no eligible sibling at all, both paths use the standard model and emit a deduplicated warning. copilot-proxy status and doctor show the routing state. Explicit copilot-proxy shim off also disables this translation. No automatic probe sends paid inference merely to test Fast availability.

An old shim build wedges the port instead of being replaced

_copilot_shim_alive probes /_shim/health rather than merely connecting, so an arbitrary process — or an older build of the shim — cannot pass as a healthy metrics shim. An older build proxies that path upstream, so :4141 answers 404 and the probe correctly says "dead" while the OS still says "occupied". Startup used to read "dead" as "port free" and spawn a process that died instantly with EADDRINUSE, leaving every managed launcher failing closed against a shim that was in fact running.

_copilot_shim_start now reads the port directly (lsof -tiTCP -sTCP:LISTEN): a listener whose command line matches copilot-throttle-shim.js is ours and gets reclaimed; anything else is named (PID + command) and the start refuses, because killing an unrelated process on a well-known port is not its call. Use COPILOT_SHIM_PORT to move out of the way instead. The fingerprint to look for is a flood of GET /_shim/health 404 in the proxy's log (pitfall).

The fork has no rate limiter

The fork's start dropped --rate-limit/--wait. Its README's mitigation is reducing Claude Code's chatter instead — that's exactly what COPILOT_PROXY_QUIET=1 injects (off by default here; we prioritize UX over Copilot quota). The front shim now supplies concurrency admission and transient retry, but it is not a time-based request-rate limiter. If that exact behavior is required, fall back to the original: COPILOT_API_PKG=copilot-api@0.7.0.

Context management is translated, not Anthropic-native

Older fork releases could forward Claude Code's context_management field to an endpoint that rejected it with 400 (caozhiyuan#305). The The GPT-5.6 path since 2.3.4 suppresses that incompatible field and relies on Responses reasoning/context handling instead. This avoids the 400, but Anthropic's exact context-editing and prompt-cache semantics are not reproduced; long-session behavior remains a compatibility surface to watch.

Do not use Claude Code's /model picker

There are two separate traps:

  1. Pressing Enter means "set as default", not "this session only" (s). Claude Code writes the custom proxy id to the top-level model in ~/.claude/settings.json. Without the launcher guard, later specstory run claude sessions keep showing GPT/Terra/Luna even after copilot-here off and with no ANTHROPIC_* env.
  2. Picking a built-in Anthropic entry can send an official dated id (e.g. claude-opus-4-8-YYYYMMDD), which the Copilot backend rejects:
API Error: 400 {"error":{"message":"The requested model is not supported.",
"code":"model_not_supported", ...}}

Pin the proxy model with copilot-model instead — undated hyphenated ids (claude-opus-4-8) work; only the picker's dated ids fail. Diagnose the current launch without opening Claude Code via copilot-here status.

Dotted ids cause the "[Opus 4] retired" warning and a >100% context HUD

Historic gotcha, fixed by the hyphenated defaults. With a dotted id (claude-opus-4.8, the only shape the original proxy accepted), Claude Code fails to match its built-in model table, so it:

  • displays [Opus 4] and warns "Claude Opus 4 was retired" (falls back to the nearest known, retired name), and
  • assumes a 200k context window, while Copilot actually serves opus-5 / opus-4-8 / sonnet-5 with 1M (max_context_window_tokens: 1000000 in /v1/models) — so HUD/statusline context can read >100% and compaction triggers on the wrong budget.

The helpers use hyphenated ids and derive [1m] from each live model's context metadata. That applies to GPT ids too (gpt-6-sol[1m], for example). They separately derive the auto-compact capacity from max_prompt_tokens; [1m] alone is not an exact prompt-limit declaration. Claude Code strips the suffix before sending; a raw API client must use the plain id.

The token gotcha: gho_ vs ghu_

There are two different GitHub tokens, and they are not interchangeable:

Source Prefix copilot_internal/v2/token exchange
OpenCode's stored auth gho_ fails (404)
copilot-proxy auth (device login) ghu_ works

Available models and role discovery

Treat GET /v1/models as live truth: GitHub can change the catalog by account, organization policy, rollout and egress. Current Claude Code recognizes Fable, Opus, Sonnet and Haiku roles; copilot-model --auto maps only ids actually in that catalog. Use copilot-model -l for raw served ids and copilot-model -c for the effective role mapping. Do not copy a dated Anthropic id or manually guess which models have 1M context.

Useful commands

claude-copilot                       # one-off proxy session (specstory-wrapped)
claude-copilot-once                  # single-session merged CLI settings
copilot-here status                  # is this project pinned to the proxy?
copilot-model -c                     # current model + which layer it came from
copilot-proxy status                 # up? which Claude models?
copilot-proxy limiter status         # live adaptive limit / active / queued
copilot-proxy limiter set --limit 6  # temporary, no restart or stream abort
copilot-proxy whoami                 # validate token → account / plan / quota
copilot-proxy logs 60                # tail the proxy log
copilot-proxy logs -f 60             # follow it across restart/log rotation
copilot-proxy logs shim -f            # follow adaptive limiter + keepalive events
# usage dashboard (bundled locally by the fork):
#   http://localhost:4141/usage-viewer?endpoint=http://localhost:4141/usage

See also