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/pkgand executed directly from there. Deliberately notbunxat launch: bunx re-resolves the package on every start, and bun stalls forever resolving through a socksALL_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 --checkis read-only; only an explicit exactcopilot-proxy update VERSIONchanges 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'soutput_config.efforttoreasoning.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--settingsobject (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 ascopilot-model, but in Codex's vendor order: OpenAI/Codex first, then Claude, grok, Gemini, and any remaining chat model. Embedding, disabled, picker-hidden, and-fastmain candidates are excluded. - The launcher builds a provider-derived
model_catalog_jsonfrom the installedcodex debug models --bundleddescriptors and the same live Copilot snapshot. For exact matching model IDs, onlycontext_windowandmax_context_windoware 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'smax_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.jsonis left alone. - A caller-supplied
-c model_catalog_json=...bypasses managed catalog generation; the caller owns that catalog's descriptors and limits. Otherwise, selectinggpt-6-solorgpt-6-lunawithout 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_searchis 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__sitesnamespace that depends on unavailabletool_search. Explicit later-carguments can still override either per invocation. - When SpecStory is installed, the launcher defaults to
specstory run codex. It preserves the effectivecodex_cmd(project config > user config > barecodex) before appending provider/model/user arguments. The raw--no-specstorypath independently asserts the same--ask-for-approval never --sandbox danger-full-accessposture 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.
oncreates a fresh pin, does nothing when unchanged, or shows the changes and asks before replacing an existing pin (default: keep).--yesaccepts 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 resolvedinfo/excludepath supports worktrees and nested projects. - New pins save their previous owned fields privately.
offrestores 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. statusshows pin drift and the plain-Claude launch audit. Pin updates keepplansDirectoryand 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-hereis ON in the current project → edits./.claude/settings.local.json.- otherwise → writes the global state file
~/.local/state/copilot-proxy/model, whichclaude-copilot,copilot-runand the nextcopilot-here onpick up. ($COPILOT_CLAUDE_MODELoverrides the state file; final fallback isgpt-5.6-sol[1m].)
Behavior:
- Fuzzy id:
copilot-model opus-4-8resolves toclaude-opus-4-8; dotted input is normalized (opus-4.8works too). [1m]is a Claude Code-only context hint. The helper now derives it from live/v1/modelsmax_context_window_tokensmetadata for every provider; manual explicit suffixes remain usable while offline.- Auto-compact uses the separate live
max_prompt_tokensceiling throughCLAUDE_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 -cshows 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.--autonever writes from that offline list. --auto/-arequires a live catalog. It rejects policy-disabled, picker-hidden, embedding-only, and-fastentries, then follows the vendor order Claude > OpenAI > grok > Gemini. Inside a vendor it reads Copilot'smodel_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 implicitcodex-copilotuse 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-lunastays in the lightweight/Haiku tier and cannot displace a flagship. GitHub announced GPT-6 Sol and Luna on 2026-09-22. Terra remainsgpt-5.6-terra; lower-role Luna prefersgpt-6-luna, thengpt-5.6-luna, from the selectable live entries. The backward-compatible offline default remainsgpt-5.6-sol[1m]; it is not an entitlement guarantee.copilot-model -Land--jsonshow live plan/policy metadata, butbilling.restricted_todoes 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 →
fzfpicker.-cprints the main model, source layer, and complete role profile.-lis the pipeable bare-id list;-L/--detailsshows 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. --whyperforms a dry-run explanation without writing;--auto --whyemits the explanation and then writes.--jsonreturns the raw live/v1/modelspayload. That HTTP catalog is distinct from the shim's/_shim/fast-routingendpoint 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
claudelaunch 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:
- 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-apiand OpenCode fetch the direct catalog (0 Claude) whilecurlthrough Clash still sees Claude. Fix:copilot-proxy startwithCOPILOT_HTTP_PROXY=auto(default) attaches--proxy-env+HTTPS_PROXYwhenproxy-statusfinds a local proxy. OpenCode needs the same env in its launch environment. - 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:
- Pressing Enter means "set as default", not "this session only" (
s). Claude Code writes the custom proxy id to the top-levelmodelin~/.claude/settings.json. Without the launcher guard, laterspecstory run claudesessions keep showing GPT/Terra/Luna even aftercopilot-here offand with noANTHROPIC_*env. - 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: 1000000in/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¶
- Copilot embeddings → semantic search — the same proxy's
/v1/embeddingsendpoint wired intocopilot-embed+semsearch(local semantic search) - Raycast AI BYOK → the local Copilot proxy — the same proxy
behind Raycast's Quick AI / AI Chat / AI Commands (
copilot-raycast), plus the zero-quota probe that tells usable model ids from ones/v1/modelsonly claims - caozhiyuan/copilot-api — the
maintained fork this setup runs (npm
@jeffreycao/copilot-api) - ericc-ch/copilot-api — the
unmaintained original (#233);
still usable via
COPILOT_API_PKG=copilot-api@0.7.0 - Copilot agent gateway commands
- Claude Code settings precedence — why
settings.local.json/ env vars are the right injection layers - Agent overlays — the chezmoi-managed
~/.claude/settings.jsonthis design deliberately stays out of - OpenCode's native GitHub Copilot provider (no proxy needed for OpenCode itself)