EN Prompt Caching - D0n9X1n/copilot-relay GitHub Wiki
Prompt caching
Every Claude Code request resends the whole conversation: the system prompt, the tool definitions and every earlier turn. Most of it repeats the previous request. When Copilot serves that repeated prefix from its prompt cache, those input tokens are read from cache instead of being processed again. In long sessions that is the main lever for input-token cost and latency.
This page explains who caches, what Claude Code marks for caching, what the relay sends on each upstream route, what keeps a conversation's prefix stable, and how to measure the hit rate. The precise mechanics and the measurements are under "Prompt caching" in Internals.
Who caches
Copilot does. The relay keeps no prompt cache of its own, and it cannot make
Copilot cache a request or keep an entry. It decides only what it forwards: on some
routes it passes on Claude Code's cache marks, on /responses it sends a cache key,
and on the rest it sends no cache hint. It learns how much input was read from cache
only from the usage Copilot reports for each call. None of this guarantees a cache
hit.
What Claude Code marks
Claude Code marks the end of each prefix it wants cached with cache_control on a
content block. In the request shape recorded from Claude Code 2.1.288, in
tests/unit/chat-route-cache.test.ts, three blocks carry it: two in the system
prompt, and the token reminder Claude Code sends as the last message of every
request, a mid-conversation role: "system" message. Earlier reminders are replayed
as plain strings without a mark, and the tool definitions carry none. Other Claude
Code versions may mark other blocks.
What the relay sends on each route
Which route a request takes depends on the model, its catalog entry and
claudeUpstreamApi; see claudeUpstreamApi in Configuration.
| Route | Cache hint the relay sends | Mid-conversation system message |
|---|---|---|
/v1/messages |
Claude Code's cache_control, forwarded as sent |
Kept as role: "system" |
/chat/completions, Claude model whose request cannot fall back to /responses |
copilot_cache_control on each message that holds a marked block |
Sent as a role: "user" turn holding <system-reminder>, in its original position |
/chat/completions, a request that can fall back, or a model that is not Claude |
None | Kept as role: "system" |
/responses |
prompt_cache_key, when there is a source for it |
Kept as role: "system" |
On every route, the relay first removes Claude Code's billing attribution line from
the top-level system prompt. A block that held only that line is dropped; the other
blocks keep their cache_control. See "Claude Code's billing line" in
Internals.
/chat/completions
Chat Completions has no cache_control. For a Claude model whose request cannot
fall back to /responses, the relay sets
copilot_cache_control: { "type": "ephemeral" } on the translated message that
holds each marked block. Blocks that join into one message share its mark, so a mark
on an earlier block moves to the end of that message.
On the same route, a mid-conversation system message is sent as a role: "user"
turn holding <system-reminder>…</system-reminder>, in its original position.
Measured with claude-opus-5.5, a new role: "system" message on every request
capped cache reads at the tools plus the original system prompt, and a user turn did
not; see "Chat route: system turns and cache breakpoints" in
Internals. The cost is authority: a later operator instruction
arrives with user-turn authority. The measurements cover caching, not how the model
weighs that instruction. claudeUpstreamApi: messages keeps the system role.
A Claude request can fall back only in auto mode, for a model with no endpoint
metadata in the catalog, or whose catalog entry lists /chat/completions and
/responses but not /v1/messages. When Copilot answers /chat/completions with
unsupported_api_for_model, the relay sends the same translated payload to
/responses, so that payload keeps role: "system" and carries no cache marks. A
model that is not Claude is translated the same way. With the default
claudeUpstreamApi: chat-completions, a Claude model never falls back.
/responses
The relay sends no cache marks to /responses. It sends prompt_cache_key, a
cache-routing hint derived with SHA-256:
cr-plus 32 hex characters, from the request'suseridentifier, which the relay takes from Claude Code'smetadata.user_id;cr-sys-plus 32 hex characters, from the system prompt text, when there is nouseridentifier;- no key when there is neither.
The key does not guarantee a cache hit. Earlier Copilot tests with the GPT-5.5/5.6 family found cache reads dropping to 0 without it, while GPT-6 Astra also cached requests without it.
Hashing the key does not hide the identifier: the relay also sends the identifier
itself, truncated to 64 characters, in the request's user field, on /responses
and on /chat/completions. Treat metadata.user_id as a value Copilot sees.
How cached input is reported
Claude Code receives usage in the Messages API form: input_tokens for uncached
input, plus cache_read_input_tokens and cache_creation_input_tokens. Copilot
reports usage differently per route, and the relay maps it:
| Route | Copilot reports | Claude Code receives |
|---|---|---|
/v1/messages |
Messages API usage | The same fields |
/chat/completions |
prompt_tokens, which includes cached input, and prompt_tokens_details.cached_tokens |
input_tokens = prompt_tokens minus cached_tokens; cache_read_input_tokens = cached_tokens |
/responses |
input_tokens, which includes cached input, and input_tokens_details.cached_tokens |
input_tokens = input_tokens minus cached_tokens; cache_read_input_tokens = cached_tokens |
Only /v1/messages reports cache writes. When Copilot reports no cached count, the
relay leaves cache_read_input_tokens out rather than sending 0. The exception is
a /v1/messages request whose WebSearch the relay runs itself: it adds up the usage
of that request's passes and always sends both cache fields, counting a missing
value as 0. The relay's own
log keeps each route's counts as Copilot reported them, so its input_tokens
includes cached input on /chat/completions and /responses but not on
/v1/messages; copilot-relay cache accounts for that, as "What is counted" below
explains.
What keeps the prefix stable
A cache hit needs the start of a request to match, byte for byte, what an earlier request sent. These keep it stable on the relay's side:
- Assistant
thinkingstays in the upstream history. Claude Code replays it, and stripping it before forwarding would rewrite the prefix and invalidate the cache; see "Assistantthinkingstays in upstream history" in Internals. - On
/chat/completions, for a Claude model that cannot fall back, a reminder stays a user turn in its original position rather than becoming a new system message on every request. tests/unit/chat-route-cache.test.tsreplays Claude Code's request shape. It fails if a translated request carries a system turn after the prompt starts, or if, with cache marks removed, a request's messages stop being a prefix of the next request's messages.- On
/responses, the key stays the same while theuseridentifier, or the system prompt when there is no identifier, stays the same.
Anything that changes an earlier part of the request starts a new prefix from that point: a different system prompt, a changed tool list, or edited or compacted history. Copilot decides what to cache and when an entry expires, so a stable prefix makes a hit possible, not certain.
Measure the hit rate
copilot-relay cache reports how much of each model's input the prompt cache
served, per upstream route. It reads only the relay's log files: it contacts neither
the relay nor Copilot, and writes nothing. Its options and exit codes are under
cache in Commands. To find the hour a hit rate changed, or one
cache-read size repeated across many requests, see "Finding a regression" in
Logs and troubleshooting.
copilot-relay cache # last 24 hours, one row per model and route
copilot-relay cache --hourly # hourly trend over the last 24 hours
copilot-relay cache --daily # daily trend across the retained logs
copilot-relay cache --since 6h # a duration, or an ISO date or time
copilot-relay cache --model opus # names containing "opus", ignoring case
copilot-relay cache --goal 97.5 # rates below 97.5% in red (default 95)
copilot-relay cache --json # an array of rows, for scripts
Prompt-cache hit rate since 2026-10-02 17:30 local time, goal 95%
MODEL ROUTE REQUESTS HIT RATE
claude-opus-5-5 /v1/messages 1 98.36%
claude-opus-5.5 /chat/completions 2 98.38%
gpt-5.5-2026-04-23 /responses 1 92.86%
HIT RATE leaves out 1 call that logged no cache_read_input_tokens.
| Column | Meaning |
|---|---|
MODEL, ROUTE |
The model the upstream reported, or unknown when the entry has none, and the upstream path. One model can be reported under different names on different routes, such as claude-opus-5.5 and claude-opus-5-5. |
REQUESTS |
Upstream calls that answered HTTP 200 and logged input_tokens, including calls cut off after their usage arrived and calls whose caching is unknown. |
HIT RATE |
Input tokens read from the prompt cache, divided by total input, truncated to two decimals. On a color terminal it is green at or above --goal and red below it. It is -, in gray, when no call in the row reported its caching. |
A call whose entry carried no cache_read_input_tokens has unknown caching, not
zero. It counts in REQUESTS but not in HIT RATE, and the line under the table
says how many such calls there are; with none, the line is left out. The token
counts behind the rate (total input, cache read, uncached and cache write), and
the counts of calls with unknown caching and with a cache read of 0, are in
--json.
The color of HIT RATE is the table's only mark of a missed goal. Without color
(NO_COLOR, or output to a pipe), compare the rate with the goal in the title, or
read belowGoal in --json. HIT RATE is truncated rather than rounded, so a rate
below the goal never prints as the goal. --goal takes at most two decimals, and a
rate is red exactly when the printed HIT RATE is below the goal.
--hourly and --daily add an HOUR or DAY column in local time, matching
the dates in the log file names. When clocks go back, a local hour that happens
twice gets one row per real hour, each ending in its UTC offset, such as
2026-11-01 01:00 UTC-04:00 and 2026-11-01 01:00 UTC-05:00. Without --since,
the summary and the hourly trend cover the last 24 hours and the daily trend
covers every retained day. A duration counts back from now; a date, or a time
without Z or an offset, is local.
--json prints one object per row with bucket, model, route, requests,
unknownCacheRequests, zeroCacheReadRequests, totalInputTokens,
cacheReadTokens, uncachedInputTokens, cacheWriteTokens, hitRate and
belowGoal. hitRate is a fraction from 0 to 1, or null; bucket is null
in the summary, and cacheWriteTokens is null when no call in the row reported
it. With no data it prints [].
What is counted
The command reads the completion entry the relay logs at info for each
upstream call, from the dated files in ~/.copilot-relay/logs/. An entry counts
only with http_status=200, a numeric input_tokens, and a route of
/chat/completions, /responses or /v1/messages. Its body and terminal
values do not matter, so a call cut off after its usage arrived still shows what
it read from cache. The request outcome entry reports usage again, for the
client request (both entries are described under "HTTP requests" in
Logs and troubleshooting), so it is
never read: counting it would count calls twice. Malformed lines, and a last line
the relay is still writing, are skipped.
input_tokens means different things per route, so the total input that HIT RATE
divides by (totalInputTokens in --json) is normalized:
| Route | Total input |
|---|---|
/chat/completions, /responses |
input_tokens, which already includes cached input |
/v1/messages |
input_tokens + cache_read_input_tokens + cache_creation_input_tokens |
On /v1/messages, an absent cache_creation_input_tokens counts as 0. Hours and
days are local time.
How far back the command can see depends on logRetentionDays (default 3).
completion entries are written at info, so a relay running with
logLevel: error leaves nothing to read, and the command says it found no data
rather than printing an empty table.