-
Notifications
You must be signed in to change notification settings - Fork 0
EN Architecture
copilot-relay is just another relay for Claude Code to use a GitHub Copilot
subscription. It exposes Claude-compatible endpoints on localhost and translates
those requests into GitHub Copilot upstream calls.
This page is the map: what the pieces are, how a request moves through them, and where the boundaries sit. For the precise mechanics behind each boundary — module names, invariants, and the reasoning that pins them — see Internals. For day-to-day operation see Configuration and Logs and troubleshooting.
Claude Code thinks it is talking to the Anthropic Messages API. It is talking to a local Hono server that speaks the same protocol and answers using a GitHub Copilot subscription.
Claude Code
|
| Anthropic-style HTTP requests
v
src/server.ts
|
| Hono routes
v
src/routes/claude.ts
|
| Claude payload + tool name mapping
v
src/claude/*
|
| Copilot chat/responses payload
v
src/copilot/*
|
| GitHub Copilot authenticated requests
v
GitHub Copilot API
Everything above src/copilot/* speaks Claude. Everything below it speaks
Copilot. The translation layer in between is the whole product.
Only Claude Code-facing endpoints are public:
POST /v1/messagesPOST /v1/messages/count_tokensGET /v1/modelsGET /healthzGET|HEAD /api/hello
The proxy calls Copilot /chat/completions and /responses internally, but it
does not expose public OpenAI-compatible routes. Unknown routes return 500 and
log method, path, selected headers, and the request payload, so a future
compatibility gap arrives with the evidence needed to close it.
/api/hello is a static reachability probe Claude Code sends on startup and
around real traffic. It is answered by src/server.ts directly and never
contacts Copilot.
/healthz returns {ok: true, version}. It is process-local — it never contacts
Copilot either.
Both answer 200 from a relay whose Copilot token expired an hour ago. A 200
means the relay is listening, not that it can serve a request. Only
POST /v1/messages exercises token refresh and a real Copilot call, which is why
copilot-relay status --deep exists and why the cheap checks are not enough on
their own.
The version in /healthz is the build of the process answering — the running
daemon, not whichever CLI asked. It is the only surface that reports this, and it
is what lets copilot-relay status tell you an upgrade is installed but not yet
restarted.
Routing is deliberately simple and entirely config-driven:
| Requested model | Upstream model |
|---|---|
contains opus
|
opusModel |
| anything else | gptModel |
Shipped defaults:
gptModel: gpt-5.6-sol
opusModel: claude-opus-5src/lib/models.ts owns this mapping and also validates the allowed
thinkEffort values: none, low, medium, high, xhigh, max.
Which upstream API a model uses is a separate question from which model runs.
gpt-5.6-sol and the rest of the gpt-5.5/gpt-5.6 family go through Copilot
/responses; Opus currently uses /chat/completions. Claude Code never sees the
difference — both paths return Claude Messages-style responses. The consequences
of that split are in Internals.
| Module | Responsibility |
|---|---|
src/server.ts |
Creates the Hono server, attaches request logging, registers Claude routes, exposes health/root endpoints. |
src/routes/claude.ts |
Owns the local Claude API surface: parses requests, logs model routing, calls the translator, handles streaming and non-streaming responses, implements count_tokens. |
src/claude/types.ts |
Only the subset of Claude Messages API types the proxy needs. Intentionally not a full Claude SDK. |
src/claude/translate.ts |
Non-streaming translation both ways, including tool calls and thinking/text blocks. |
src/claude/stream.ts |
Converts streaming Copilot chunks into Claude SSE events. Stateful, because Claude requires explicit block start/delta/stop. |
src/claude/web-search-stream.ts |
Lets a turn that advertises WebSearch still stream. |
src/claude/tool-names.ts |
Normalizes Claude tool names into Copilot-compatible names and maps them back. |
src/copilot/client.ts |
Low-level Copilot HTTP client: required headers, bearer tokens, timing logs, transient 5xx retries. |
src/copilot/chat.ts |
Internal chat abstraction used by routes and startup preflight. Applies model routing and think effort. |
src/copilot/responses.ts |
Translates between the Copilot Responses API and chat-completion-like results. |
src/lib/app-config.ts |
Loads and writes ~/.copilot-relay/config.yaml. Hot-reloads while running. |
src/lib/models.ts |
Config-driven model routing and thinkEffort validation. |
src/lib/auth.ts |
GitHub device login, token storage, Copilot bearer refresh before expiry. |
src/lib/preflight.ts |
Runs at startup before binding: verifies configured models exist and the configured effort is usable. |
start command
|
| read ~/.copilot-relay/config.yaml
| create config from config.default.yaml if missing
v
apply runtime config
|
| load/sync github_token
| load/refresh copilot_token.json
v
preflight upstream models and think effort
|
| optionally write Claude Code settings
v
start HTTP server
|
| watch config.yaml for hot reload
v
serve Claude Code requests
Preflight runs before the socket binds. A relay that cannot reach its configured models fails to start rather than accepting traffic it cannot serve.
~/.copilot-relay/
config.yaml
github_token
copilot_token.json
copilot-relay.pid
logs/
copilot-relay.2026-07-31.log <- active, local date
copilot-relay.2026-07-30.log
copilot-relay.2026-07-29.log
github_token is the long-lived login/refresh source. copilot_token.json
caches the short-lived Copilot bearer token with its refresh metadata.
copilot-relay.pid holds {host, pid, port, startedAt, version}, written by the
daemon at startup — so version is the build actually serving. It is the second
of the two daemon-version sources: /healthz is preferred because a live process
cannot report a stale answer, and the pid file covers the window where the daemon
is up but not yet healthy. Both absent means a daemon older than v0.3.1, reported
as unknown rather than silently filled in with the CLI's own version (#43).
The project follows a configuration-first rule: if behavior is likely to vary per
user, it goes in config.yaml rather than being hardcoded.
host: 127.0.0.1
port: 4142
copilotBaseUrl: https://api.githubcopilot.com
claudeSetup: true
logLevel: info
logRetentionDays: 3
thinkEffort: max
upstreamTimeoutSeconds: 180
webSearchBackend:
gptModel: gpt-5.6-sol
opusModel: claude-opus-5host, port, and claudeSetup are read once at startup. The other eight
hot-reload, applying to work that starts after the change. Empty
webSearchBackend uses gptModel. upstreamTimeoutSeconds caps the total
upstream wait budget for a single Claude request.
readAppConfig() writes the resolved config back to disk, so an existing install
has every key materialized and a ?? defaultConfig.x fallback is never consulted
again. A shipped default therefore reaches fresh installs only. That is
deliberate: copilot-relay does not rewrite a value your config already holds, so
a deliberate pin survives every upgrade.
Per-key meaning, validation rules, and the hot-reload/restart split live in Configuration.
Logs go to both the console and
~/.copilot-relay/logs/copilot-relay.<local-date>.log. The active file is
resolved per write, so it rotates at local midnight without a timer, and
retention deletes files older than logRetentionDays local calendar days.
Each entry is one physical line with bounded payload rendering. Both properties are load-bearing rather than cosmetic; the reasoning is in Internals, and the operational recipes are in Logs and troubleshooting.
At debug, every model request logs client, requested model, upstream model,
requested think effort, requested thinking budget, and effective think effort.
Also at debug, Claude and upstream request diagnostics are logged.
The central logger passes every emitted value through scrubSensitiveUrls
before either sink, so a secret-bearing upstream URL tail is redacted at every
level, debug included. That is the only sanitization applied — it rewrites
URLs and nothing else.
Debug diagnostics still carry prompt text, tool definitions and arguments, request bodies, and headers, none of which a URL scrubber touches. Do not share debug logs unreviewed.
Unit tests cover pure routing behavior, config validation, and protocol translation edge cases that should not require a mocked upstream.
Integration tests run the Hono app against a local mocked Copilot upstream. CI must never call real GitHub Copilot services.
Commands, the CI matrix, and the release gate are in Development.