API Reference
Home · Getting Started · Configuration · API Reference · Claude Code & Codex · Headroom Plugin
All endpoints listen on http://<address>:<port> (default
http://127.0.0.1:8314). By default no API key is required by the proxy itself —
authentication to GitHub Copilot is handled internally. You can optionally
require a key on the LLM endpoints; see Authentication below.
Endpoints
| Method & path | Description |
|---|---|
POST /v1/chat/completions |
OpenAI chat completions (also /chat/completions) |
POST /v1/responses |
OpenAI Responses API for Codex (also /responses) |
POST /v1/messages |
Anthropic Messages API |
POST /v1/messages/count_tokens |
Anthropic token counting (real BPE, local estimate fallback) |
POST /v1beta/models/{model}:generateContent |
Gemini generate content |
POST /v1beta/models/{model}:streamGenerateContent |
Gemini streaming (SSE) |
POST /v1beta/models/{model}:countTokens |
Gemini token counting |
POST /v1/embeddings |
Embeddings (also /embeddings) |
GET /v1/models |
List available models (also /models, /api/models) |
GET /v1/models/{model} |
Retrieve a single model (also /models/{model}) |
GET /v1/models/full/ |
Raw upstream model catalog with capabilities |
GET /usage |
Copilot plan and quota usage |
GET /health |
Liveness/readiness probe |
GET / |
Web analytics dashboard — overview |
GET /metrics/dashboard |
Metrics dashboard UI |
GET /metrics |
OpenMetrics exposition endpoint |
GET /requests |
Request browser |
GET /app.css |
Stylesheet shared by the three dashboard pages |
GET /api/stats |
Dashboard statistics (JSON) |
GET /api/cache |
Prompt-cache statistics, overall and per model |
GET /api/requests |
Recent requests (JSON) |
GET /api/audit |
Filtered audit records |
GET /api/audit/summary |
Aggregated audit summary |
POST /api/config/reload |
Reload config.yaml without restart |
POST /api/config/debug |
Turn request/response body capture on or off |
GET /openapi.json |
OpenAPI v3 specification of the LLM endpoints |
GET /v1/responses (Upgrade) |
Responses API over WebSocket |
Responses over WebSocket
Several models advertise ws:/responses in supported_endpoints alongside
/responses. GET /v1/responses (or /responses) with a WebSocket upgrade
exposes that transport.
The protocol is the streaming Responses API with a different carrier: the same
response.* event vocabulary, one event per text frame, so a client already
written against the SSE stream needs no new parsing. Send one frame to start a
turn:
{"type": "response.create", "model": "gpt-5.5", "input": "...", "stream": true}
The frame is flat — model sits at the top level beside the request fields, not
nested under a response object. Omitting type or nesting the body is
rejected.
A model that does not advertise the transport is refused with an error frame naming the alternative rather than left waiting on the socket:
{"type": "error", "error": {"code": "unsupported_api_for_model",
"message": "Model 'claude-opus-4.6' does not support ws:/responses. Use POST /v1/responses instead."}}
WebSocket turns are recorded like any other request, under the endpoint
ws:/responses.
Read-only dashboard endpoints are reachable without an API key so local
monitoring keeps working. The /api/config/ routes are not: they mutate the
running process, and one of them turns on body capture, which writes whatever
the client sent — credentials included — into the request log.
Statistics and failed attempts
GET /api/stats counts requests that produced an answer. Attempts that did not
— a non-2xx status, or a failure_kind on an otherwise successful one — are
reported separately as failed_requests and excluded from the rest, so a burst
of rejected calls cannot dilute a rate computed over requests that consumed
nothing. Token and billing totals count either way: a stream cut off partway
consumed what it consumed.
Prompt cache statistics
GET /api/cache reports where input tokens came from. The hit rate is the early
warning for a broken prompt prefix: on an agent workload it should sit high and
stable, and a sudden drop means the prompt stopped matching and every turn is
paying full input price again.
{
"totals": {
"input_tokens": 9397,
"cache_read_tokens": 4682,
"cache_creation_tokens": 4682,
"fresh_tokens": 33,
"hit_rate": 0.498,
"request_count": 4
},
"dispositions": { "served_from_cache": 1, "wrote_to_cache": 1, "no_cache": 2 },
"sampled_requests": 4,
"by_model": [
{
"model": "gemini-3.5-flash",
"requests": 4,
"input_tokens": 9397,
"cache_read_tokens": 4682,
"cache_creation_tokens": 0,
"fresh_tokens": 33,
"hit_rate": 0.498,
"saved_nano_aiu": 547830000
}
]
}
totals are the all-time running counters. by_model is derived from the
retained ring buffer, so it describes the most recent sampled_requests calls
rather than every one ever served.
What a zero means
Copilot states its own per-token rates on each response, in
copilot_usage.token_details. saved_nano_aiu is computed from those rates
rather than from a price list, so a model Copilot includes at no charge
contributes nothing instead of an imagined discount. Positive means the cache
paid for itself; negative is the normal shape of a turn that populated a cache
it has not read back yet. null means no response for this model reported the
rates to compute one from — distinct from 0, which is a real figure: nothing
was cached, so nothing was saved.
cache_creation_tokens is only ever non-zero on the Anthropic surface with an
explicit cache_control marker. Every other surface caches implicitly: the
first call reads nothing, the next reads the whole prefix back, and no write is
billed. Measured across twelve calls with a 27k-token prefix, exactly one
reported a write, on /v1/messages. The dashboard hides the column outright
when nothing wrote.
Cache lifetime
A cache_control breakpoint with no ttl gets the five-minute tier. That is
short enough to be self-defeating on long turns: the entry is written during
prefill, so a turn that itself runs longer than five minutes has already
outlived its own cache by the time it finishes, and the next turn pays a full
cold prefill of the whole conversation.
Setting extend_cache_ttl: true promotes breakpoints that carry no explicit
ttl to the one-hour tier. An explicit ttl is always left as the client sent
it. Copilot honours both tiers and accounts for them separately, in
cache_creation.ephemeral_5m_input_tokens and ephemeral_1h_input_tokens.
It is off by default because it is not free. Extended writes bill at a higher rate than five-minute ones while reads cost the same, so the premium is charged on every write and the saving only lands on an expiry that would otherwise have happened. On a conversation that does many small incremental writes between rare expiries it costs more than it saves; it pays off when turns routinely run past five minutes.
A model with no cache activity at all is usually not a fault either: Copilot
needs a minimum cacheable prefix before any of the prompt is eligible.
claude-haiku-4.5 was observed caching a 6902-token prefix but not a
4082-token one.
Body capture
POST /api/config/debug with {"debug": true} or {"debug": false} turns
request/response body capture on or off for the running process. The flag is
read live on every request, so it applies from the next call — no restart, and
no need to have predicted in advance that you would want the bodies.
It is deliberately not written back to config.yaml. Capture puts prompts, tool
output and any credentials they carry into memory and the log, so it lapses on
restart rather than staying on because someone forgot. GET /health reports the
current value as debug.
Streaming (SSE) is supported on the chat, responses, and messages endpoints by
setting "stream": true in the request body. The Gemini surface streams via the
dedicated :streamGenerateContent action.
Health check
GET /health answers without contacting the upstream, so it is cheap enough for
a service supervisor or container probe to poll frequently. It is never guarded
by the optional API key.
curl http://127.0.0.1:8314/health
{
"status": "ok",
"ready": true,
"version": "1.4.5",
"uptime_seconds": 128,
"copilot_token": { "present": true, "expires_in_seconds": 1487 },
"models_loaded": 77,
"requests_served": 42,
"auth_required": false
}
ready is true once a Copilot token has been obtained and the model catalog
has loaded. A degraded proxy still answers 200 with ready: false so probes
can distinguish “process alive” from “able to serve traffic”. Add ?strict=true
to get 503 Service Unavailable instead when the proxy is not ready.
The quota object holds the most recent per-SKU allowance reported by the
upstream. Copilot attaches it to every response, so it is current without any
extra API call, but the health payload stays empty until the first request has
been proxied. The overview dashboard also loads /usage, so its quota panel
works immediately on a new or idle process. The same figures are exported on
/metrics as ghc_proxy_quota_* gauges labelled by sku.
Usage and quota
GET /usage contacts Copilot’s account endpoint and returns the plan, account,
reset date, and one entry per quota SKU. It does not depend on requests recorded
by this proxy.
{
"plan": "enterprise",
"login": "example",
"token_based_billing": true,
"quota_reset_date": "2026-09-01T00:00:00Z",
"quotas": {
"premium_interactions": {
"unlimited": false,
"entitlement": 10000000,
"remaining": 9229138,
"percent_remaining": 92.2,
"credits_used": 770861
}
}
}
For a token-billed premium_interactions SKU, entitlement, remaining, and
credits_used are measured in AI units. An AI unit is an account billing
unit, not a token, request, premium interaction, or currency amount; the model,
token mix, cache use, and billing rates determine how many units a call costs.
percent_remaining is a percentage rather than an AI-unit count.
Enterprise token-billed seats expose counterintuitive upstream names:
credits_used is the available balance, while remaining and
percent_remaining describe the consumed side. The example therefore means
770,861 of 10,000,000 AI units remain (about 7.7%) and 9,229,138 have been
consumed. The dashboard corrects this direction only for
enterprise + token_based_billing; ordinary plans retain the upstream field
direction. raw preserves the complete upstream payload for callers that need
fields omitted from the compact quotas object.
Retrieve a model
GET /v1/models/{model} returns a single catalog entry in the OpenAI shape,
including the raw capabilities and supported_endpoints reported upstream.
Model aliases from model_mappings are resolved, so /v1/models/opus returns
the mapped Copilot model. Unknown ids return 404 with an OpenAI-style error
body.
curl http://127.0.0.1:8314/v1/models/claude-opus-5
Token counting
POST /v1/messages/count_tokens forwards to the upstream Anthropic
count_tokens endpoint for models that expose the native /v1/messages
surface, returning exact counts. For every other model (and whenever the
upstream call fails) the proxy falls back to a local tiktoken estimate using the
tokenizer advertised in the model catalog. Estimated responses are marked:
{ "input_tokens": 812, "estimated": true }
OpenAI SDK
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8314/v1", api_key="not-needed")
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello!"}],
)
print(resp.choices[0].message.content)
Anthropic SDK
import anthropic
client = anthropic.Anthropic(base_url="http://127.0.0.1:8314", api_key="not-needed")
msg = client.messages.create(
model="claude-sonnet-4",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}],
)
print(msg.content)
The proxy serves Anthropic requests directly from Copilot’s native
/v1/messages endpoint when the model supports it, and otherwise translates
them through chat completions transparently.
Gemini
curl "http://127.0.0.1:8314/v1beta/models/gemini-2.5-pro:generateContent" \
-H "Content-Type: application/json" \
-d '{"contents": [{"role": "user", "parts": [{"text": "Hello!"}]}]}'
The model is taken from the URL path and translated per your
mappings. Gemini requests are translated
through chat completions, so any Copilot model works. Streaming uses the
:streamGenerateContent action and emits data: SSE lines.
Authentication
By default the proxy accepts all local requests. Set api_key in config.yaml
(or GHC_PROXY_API_KEY) to require a key on the LLM endpoints. The key is
accepted from any of the standard provider headers and compared in constant time:
curl http://127.0.0.1:8314/v1/messages -H "x-api-key: KEY" ...
curl http://127.0.0.1:8314/v1/chat/completions -H "Authorization: Bearer KEY" ...
curl "http://127.0.0.1:8314/v1beta/models/gemini-2.5-pro:generateContent" -H "x-goog-api-key: KEY" ...
The dashboard, metrics, and static pages stay open so local monitoring works
without a key. Unauthenticated requests to protected endpoints return 401.
cURL
# Chat completions
curl http://127.0.0.1:8314/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello!"}]}'
# List models
curl http://127.0.0.1:8314/v1/models
# Usage / quota
curl http://127.0.0.1:8314/usage
Model discovery
GET /v1/models returns the OpenAI-style list. Codex appends a
client_version query parameter; when present, the same endpoint returns its
native { "models": [...] } catalog with active and maximum context windows
derived from Copilot. This lets Codex size and compact each selected model
without a static client-side override.
For full upstream capability data — context-window limits, supported endpoints, vision, tokenizer — use:
curl http://127.0.0.1:8314/v1/models/full/
This is the authoritative source for which models support a 1M-token context
window (those advertising max_context_window_tokens greater than 200,000).
Audit API
GET /api/audit returns recent request records with their extracted audit
fields. All filters are optional and combine with AND:
| Parameter | Effect |
|---|---|
endpoint |
Substring match on the endpoint path |
status |
Exact HTTP status code |
tool_name |
Keeps records whose request offered a matching tool |
agent |
true/false — agent- vs user-initiated requests |
model |
Substring match on the requested or translated model |
page, per_page |
Pagination (per_page is clamped to 500) |
GET /api/audit/summary aggregates the same records into top tools, stop-reason
counts, estimated cost, and prompt-cache hit rate.
Notable behaviors
- Model translation — model names are rewritten per your mappings before being forwarded.
- 1M context — for Anthropic-native requests, the proxy forwards the
anthropic-beta: context-1m-2025-08-07header for models whose catalog advertises an extended context window. - Retry with backoff — upstream connection errors are retried with
exponential backoff; retryable upstream HTTP errors are also retried
(
max_connection_retries)./v1/messages/count_tokensis exempt: clients call it before every turn, so a backoff there stalls the turn only to arrive at the local estimate anyway. A429from it also pauses upstream counting for 60 seconds rather than re-asking a limiter that has already refused. - Orphaned tool-result recovery — when the upstream rejects a request for an
orphaned
tool_use_id, the proxy retries with the offending tool results stripped. - Adaptive-thinking migration — when an upstream model rejects
thinking.type = "enabled", the proxy automatically retries using the adaptive format. - Upstream errors are never disguised as streams — a non-2xx upstream
response on a
"stream": truerequest is returned as a normal error response with the upstream status code, not as a200SSE body. - Interrupted streams are reported, not silently truncated — if the upstream
connection drops mid-response, the proxy emits a protocol-appropriate
terminator (
data: {"error": …}+[DONE]for OpenAI,event: errorfor Anthropic and Responses,finishReason: "OTHER"for Gemini) and records the request as502. Anthropic streams that end without afinish_reasonare also closed withmessage_stop, so clients never block on a half-open message or keep a partial answer as a completed turn. - Byte-exact streaming — SSE parsing buffers raw bytes and decodes only
complete lines, so multi-byte characters split across network chunks are
never mangled into
U+FFFDor dropped. - SSE keepalive — a
: keepalivecomment is emitted after 15 seconds of silence so extended thinking does not trip the ~60 second idle timeout enforced by the upstream load balancer. Comments are ignored by every spec-compliant SSE client. The comment is only injected at an event boundary: if the upstream goes quiet part way through writing an event, splicing one in would corrupt it, so that case is left to the read timeout below. - Read timeout —
upstream_read_timeout_seconds(default 120) bounds how long an upstream response may stay silent between reads. It does not cap the total duration, so long answers stream normally, but a half-open connection is turned into a reported stream error instead of hanging the request forever. Set it to0to disable. anthropic-betapassthrough — the client’s beta flags are forwarded and merged with the ones the proxy derives (context-1m-2025-08-07for extended context models,context-management-2025-06-27when the request usescontext_management).- MCP tool results — a
tool_resultwhosecontentis an array of blocks is normalized before translation: text blocks are joined and image blocks becomeimage_urldata URLs. Images nested there also enable the vision header. - Parameter migration — a model that rejects
max_tokensin favour ofmax_completion_tokensis retried once with the renamed parameter, and a missingmax_tokensis filled from the model catalog. - Cost estimates use the served model — the
estimated_cost_usdfield and theghc_proxy_estimated_cost_usd_totalmetric price requests using the model actually sent upstream (after translation), not the alias the client asked for. - Graceful shutdown — on Ctrl-C (or
SIGTERMon Unix) the proxy stops accepting connections and lets in-flight requests and SSE streams finish. - Content filtering — system-prompt add/remove and tool-result suffix removal are applied per your configuration.