跳到主要内容

数据截至 (上游 commit b77d61291399)

代理层与 wrap — 零改代码接进任何 agent

30 秒导读: 前四章讲的是"怎么把一段内容压小而不弄坏它"。这一章讲怎么把那套本事送到用户面前——办法是一台伪装成大模型 API 的本地反向代理,加一条 headroom wrap <agent> 命令,把编码 agent 的 base_url 指过来。agent 的代码、插件、配置逻辑一律不动。

本章覆盖两件事:

半场讲什么主要代码
服务端这台代理开了哪些门、请求怎么流、三家 wire format 怎么兼容、钱怎么记headroom/proxy/
接入端一条命令怎么"接管"一个 agent、崩了怎么自愈、怎么回滚headroom/cli/wrap.py

不重复的内容:压缩算法本身见 01-pipeline-and-router.md02-compressors.md;可逆检索见 03-ccr.md;缓存安全策略见 04-cache-safety.md


1. 这是什么(零基础也能懂)

一句话定义: 一台跑在 127.0.0.1:8787 的本地 HTTP 服务,长得跟 Anthropic / OpenAI / Gemini 的 API 一模一样;请求经过它时被压缩,再原样转发给真正的上游。

解决什么问题。 假设你天天用 Claude Code 改一个大仓库。上下文越滚越长,每一轮都要把几十万 token 的历史重发一遍,账单和延迟一起涨。你想省,但你不可能去改 Claude Code 的源码——它是别人的闭源二进制。

Headroom 的答案:插在中间。 几乎所有编码 agent 都留了一个"换 API 地址"的口子(环境变量或配置文件),因为大家都要支持 Azure / Bedrock / 自建网关。Headroom 就借这个口子进来。

用起来什么样:

$ headroom wrap claude

╔═══════════════════════════════════════════════╗
║ HEADROOM WRAP: CLAUDE ║
╚═══════════════════════════════════════════════╝

Proxy already running on port 8787
Dashboard: http://127.0.0.1:8787/dashboard
Launching Claude Code (API routed through Headroom)...
ANTHROPIC_BASE_URL=http://127.0.0.1:8787

到这里 Claude Code 已经在跑了,行为和平时一模一样,只是每个请求都先过了一遍压缩。命令行长什么样见 headroom/cli/wrap.py:4607wrap 命令组 docstring,里面逐条列了受支持的 agent 及其对应子命令。

一句话直觉: 把它当成装在你和模型厂商之间的一个变压器。两头的插头形状不变(wire format 不变),中间的电压变了(token 少了)。变压器坏了就直通——本章后面会反复出现这个"坏了就透传"的原则。


2. 顶层全景:一次请求在代理里走过的路

先看结构,再抠细节。这张图从上往下读,是一个 POST /v1/messages 的完整生命周期。

Claude Code / Codex / aider ...
│ POST /v1/messages (它以为这是 api.anthropic.com)

┌──────────────────────────────────────────────┐
│ ① 安全闸门 _security_gate │ 最外层:可选 bearer token
│ + 安全响应头 + 管理端点审计 │ 非本机无 token → 401
├──────────────────────────────────────────────┤
│ ② 打标中间件 _record_headroom_stack │ 剥 /p/<项目名> 前缀
│ 项目归属 / 客户端识别 / 入站计数 │ 认出 codex 就补 X-Client 头
├──────────────────────────────────────────────┤
│ ③ 路由匹配 │ 三层:精确路由 → 供应商路由
│ │ → 兜底 catch-all
├──────────────────────────────────────────────┤
│ ④ handler(按 wire format 分家) │ anthropic / openai / gemini
│ 读 body,同时留一份"原始字节" │ / batch / bedrock
├──────────────────────────────────────────────┤
│ ⑤ 该不该压? CompressionDecision │ bypass > 关闭 > 空消息 > 授权
├──────────────────────────────────────────────┤
│ ⑥ 压缩(丢进有界线程池,带超时+隔离) │ 失败 = 原样往下走,绝不报错
├──────────────────────────────────────────────┤
│ ⑦ 选出站字节 select_outbound_body │ 没改过 → 原字节;改过 → 规范 JSON
│ 剥掉所有 x-headroom-* │
└──────────────────────────────────────────────┘

▼ 真上游 api.anthropic.com

◄── 流式回包:字节原样回给客户端,
同时并行喂一台 SSE 状态机做记账

记账:savings_tracker / prometheus / 落盘 ledger

各部件一句话职责:

部件干什么在哪个文件
create_app组装整个 FastAPI 应用,注册全部路由与中间件headroom/proxy/server.py:2525
HeadroomProxy有状态的核心对象:HTTP 客户端、压缩执行器、指标、缓存headroom/proxy/server.py:756
register_provider_routes注册所有供应商路由 + 最后的兜底 catch-allheadroom/providers/proxy_routes.py:251
五个 handler mixin每种 wire format 一套解析/转发/回包逻辑headroom/proxy/handlers/
select_outbound_body决定"到底发哪串字节给上游"headroom/proxy/body_forwarding.py:298
SavingsTracker把省下的 token 折成钱并落盘headroom/proxy/savings_tracker.py:628
wrap 命令组起代理 + 改 agent 配置 + 拉起 agent + 退出时还原headroom/cli/wrap.py:4607

主线走一遍(不进代码): 客户端发一个它以为要去 Anthropic 的请求 → 中间件打标 → 路由把它交给 Anthropic handler → handler 决定压不压、压多少 → 压完选字节 → 转发给真上游 → 回包边流边记账。整条路上任何一步出问题,兜底动作都是"把客户端原来那串字节原样发出去"。


3. 路由表:这台服务器到底开了哪些门

create_app 一共挂了四类路由。顺序很关键,因为最后有一个吃掉一切的兜底路由。

3.1 四类门

第一类:数据面——伪装成上游 API 的那些路径。它们不在 server.py 里,而是由 register_provider_routes 在最后统一注册(headroom/proxy/server.py:5186)。

路径方法交给谁
/v1/messagesPOSThandle_anthropic_messages
/v1/messages/batches 及其子路径POST/GEThandle_anthropic_batch_*
/v1/chat/completions/chat/completionsPOSThandle_openai_chat
/v1/responsesPOSThandle_openai_responses
/v1beta/models/{model}:generateContentPOSThandle_gemini_*
/v1beta1/projects/…/publishers/{p}/models/{m}:rawPredictPOSTVertex 分流:Anthropic 走 handle_anthropic_messages,Google 走 Gemini
/model/{model_id}/invokePOSTBedrock(仅在配了 bedrock_api_url 时才挂)
/{path:path}GET/POST/PUT/DELETE/HEAD兜底透传

声明式的路由表本身在 headroom/providers/route_specs.py,PROVIDER_HANDLER_ROUTESPROVIDER_PASSTHROUGH_ROUTES 两个常量元组把"哪个路径交给哪个 handler"从代码里抽成了数据。

第二类:健康探针——/livez/readyz/health(headroom/proxy/server.py:3445:3430:3436)。

第三类:观测面——/stats/stats-lifetime/stats-history/metrics/dashboard/quota

第四类:本机专属的控制面——/v1/compress/v1/retrieve/*/v1/toin/*/debug/*/admin/upstream/transformations/feed

3.2 兜底 catch-all:为什么必须最后注册

register_provider_routes 的最后一行挂了一个吃掉一切的路由(headroom/providers/proxy_routes.py:517passthrough):

@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "HEAD"])
async def passthrough(request: Request, path: str): ...

这条路由存在的理由是代理必须对未知路径透明。Copilot 的内联补全、ChatGPT 的账号接口、各种私有网关路径,Headroom 都不认识,但也不能 404——404 会让 agent 直接报错。所以不认识的一律原样转发。

代价是:任何注册得比它晚的路由都永远匹配不上。代码里对此有两处显式注释,分别护住 dashboard 静态资源的挂载(headroom/proxy/server.py:3641)和 /favicon.ico(:3788)。

x-headroom-base-url 请求头可以在这里临时改上游地址,让同一台代理服务多个网关。

3.3 本机保护:两道闸,不是一道

这是本章最容易看漏但最重要的安全设计。/debug/*/v1/retrieve/*/v1/toin/* 这些端点只允许本机访问,判定函数是 require_loopback(headroom/proxy/loopback_guard.py:173)。它要过两道闸:

请求进来

├─ 闸① request.client.host 是回环地址吗? ── 否 ─▶ 404
│ (127.0.0.0/8、::1、::ffff:127.0.0.1)
▼ 是
├─ 闸② Host: 头也写着回环地址吗? ── 否 ─▶ 404
│ (127.0.0.1:port / [::1]:port / localhost:port)
▼ 是
放行

为什么光看 IP 不够? 这是 DNS rebinding(DNS 重绑定攻击:恶意站点先解析到自己的服务器,再把同一域名重解析到 127.0.0.1,于是受害者浏览器发出的请求 IP 上确实是本机)。此时 request.client.host 就是 127.0.0.1,闸① 直接放行。但浏览器的 Host: 头仍然写着 attacker.com,所以闸② 拦得住。模块 docstring(headroom/proxy/loopback_guard.py:20-37)把这个攻击链写得很清楚。

返回 404 而不是 403 也是刻意的:403 等于告诉扫描器"这里有东西但你没权限",404 则和"根本没这条路由"无法区分。

写操作还要再加一道 require_same_origin(headroom/proxy/loopback_guard.py:219)。它防的是另一种攻击:远程页面直接用 fetchhttp://127.0.0.1:8787/stats/reset,Host: 头写的是真实的回环地址,闸② 也过了。但浏览器会带上反映页面真实来源的 Origin: 头,于是这道闸只要 Origin 存在且不是回环就拒。CORS 拦不住这个——CORS 只阻止攻击者响应,不阻止服务器执行

3.4 同一个端点,两副面孔

有些端点既要给本机 dashboard 用,又要给网络里的监控用,于是采用了按调用方裁剪 payload 而不是一刀切 404 的做法。这就是 _request_is_loopback(headroom/proxy/server.py:2362)存在的原因:它和 require_loopback 判的是同一件事,但返回 bool 而不是抛异常。

端点本机调用方看到网络调用方看到
/health完整 payload,含上游 URL 和 backend 配置只有 status + checks(等同 /readyz)
/stats额外含 recent_requestsrequest_logsconfig只有聚合计数器,没有逐请求元数据
/stats-lifetimeprojects 明细和持久化错误剥掉 projects,错误置空

_request_is_loopback 还开了一个口子:容器化部署时浏览器经网桥网关访问,peer IP 是网关 IP 而非 127.0.0.1。运维可以用 HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS 显式白名单该网关,默认为空(即最安全)。

/stats 的敏感块另有一条更宽的授权路径 _request_can_view_dashboard_metadata(headroom/proxy/server.py:2405),它允许 CIDR 白名单内的远程 dashboard,但要求 Host: 必须是 IP 字面量,且如果带了 Origin/Referer 就必须同源——防的是"通过受害者浏览器读走敏感元数据"。

3.5 健康三兄弟:各管各的

端点检查什么谁用非 200 的含义
/livez只看事件循环回调是否健康k8s liveness503 = 进程该重启
/readyz先探一次上游,再看整体就绪k8s readiness503 = 暂时别派流量
/health/readyz,但本机调用方多拿配置块人 / headroom doctor永远 200(状态在 body 里)

/health 恒返 200 是刻意的:它是给人和诊断工具看的详情页,不是编排系统的判决依据。判决交给前两个(headroom/proxy/server.py:3468-3476 的注释直说了这一点)。

三者都在 _security_gate_AUTH_EXEMPT_PATHS 里(headroom/proxy/server.py:3364),即使配了 HEADROOM_PROXY_TOKEN 也不要求鉴权——否则绑到非回环的容器就没法被探活。

3.6 控制面:压缩、取回、模式统计

/v1/compress(headroom/proxy/server.py:5182) 是"只压不发"的端点,给 TypeScript SDK 这类想自己发请求的调用方用。它默认只允许本机,但留了 HEADROOM_COMPRESS_ALLOW_REMOTE=1 让运维把它开给内网 sidecar(Kong、LiteLLM),且只解除这一条路由的回环依赖,其他 _require_loopback 路由不受影响(headroom/proxy/server.py:5176-5180)。

handle_compress(headroom/proxy/handlers/openai.py:9427)的 config.mode 有三档:默认无标记(压完直接发给上游,不需要 CCR 回取)、ccr(写 CCR 标记和存储)、lossy_inline(先无损折叠再 Kompress)。还有个 config.frozen_message_count 把前 N 条消息钉成逐字节不变——正是 04-cache-safety.md 讲的前缀缓存保护在 HTTP 层的旋钮。

/v1/retrieve/{hash_key}(:4990)与 /v1/retrieve/tool_call(:5022) 是 CCR 的服务端(详见 03-ccr.md)。前者按 hash 直接取回原文,后者接受 Anthropic 和 OpenAI 两种 tool_call 格式,替调用方把取回结果拼成对应供应商的 tool_result。两者都是本机专属——原文里可能有整个代码库的内容,不能给网络。

/v1/toin/*(:4882:4900:4955) 暴露工具输出模式(TOIN)的学习统计。同样本机专属,而且代码里对"含用户查询原文的字段"做了额外剥离——_feedback_stats_without_query_text(headroom/proxy/server.py:2480)专门把 common_queriesqueried_fields 从 HTTP 响应里摘掉:这些 key 是拿 agent 的查询原文直接拼的,进程内用来做压缩决策没问题,出 HTTP 就不行。

3.7 项目归属:两种打标方式

/p/<项目名>/v1/messages 这种带前缀的路径会被中间件剥掉前缀并记下项目名(strip_project_path_prefix,headroom/proxy/project_context.py:45)。为什么要有这条路?因为 aider、Cursor、Copilot BYOK 这类客户端只能改 base URL,不能加自定义请求头。能加头的(claude/codex wrap)则走 X-Headroom-Project,且优先级更高(headroom/proxy/server.py:3254-3261)。

剥前缀必须发生在任何人读 request.url 之前,因为 Starlette 会缓存 URL 对象——这也是为什么它写在中间件的最前面,以及为什么 WebSocket 要单独一个 WebSocketProjectPrefixMiddleware(headroom/proxy/server.py:2509)。


4. 两个"宁可吵也不要静默降级"的设计

代理最坏的失败模式不是崩,是看起来在跑但什么也没做。有两处专门针对这一点。

4.1 启动自检:Rust 扩展没装就拒绝启动

压缩的重活在 Rust 扩展 headroom._core 里。如果它没编译进来,Python 会退回到纯 Python 路径甚至空操作——请求照常成功,省下的 token 是 0,而没人会发现。

_check_rust_core(headroom/proxy/server.py:529)在启动时导入并调用 hello(),拿返回值和常量 "headroom-core" 比对。不匹配就打印修复命令并 sys.exit(78)

为什么是 78? sysexits.h 里的 EX_CONFIG。systemd / k8s / docker 会把它当成"运维配错了"而不是"程序崩了",从而不进入重启循环(headroom/proxy/server.py:523-526)。

它还额外校验了 marker 字符串本身,专门防".so 是旧的或链错了,符号解析得到但返回垃圾"这种情况。想降级运行必须显式设 HEADROOM_REQUIRE_RUST_CORE=false

4.2 压缩隔离:超时的线程杀不掉,那就先别派新活

这是全代理最微妙的一段。问题: Python 没法抢占一个已经开跑的线程。asyncio.wait_for 超时只是取消了 asyncio 侧的 future,底下 run_in_executor 的线程仍在跑 Rust 代码,占着执行器的槽位。

如果不管它,一波慢压缩会把整个有界执行器填满超时债,后面每个请求都要等满超时才失败——延迟雪崩。

_run_compression_in_executor(headroom/proxy/server.py:1378)的解法是隔离期(quarantine):

正常 超时发生 隔离期内
──────── ────────── ─────────────
派活给执行器 ──▶ asyncio 侧放弃等待 ──▶ 新的压缩请求
worker 仍在跑 立刻抛
计入"超时债" CompressionQuarantinedError
武装 deadline (= 走既有的压缩失败策略 = 透传)


worker 终于退出 → 债清零 → 恢复

超过 deadline 上限 → 判定泄漏 → 强制恢复 + WARN

两个细节值得抄:

  • CompressionQuarantinedError 继承自 asyncio.TimeoutError 而不是内置 TimeoutError(headroom/proxy/server.py:501)。理由写在 docstring 里:这两个类在 Python 3.11 才互为别名,3.10 的调用方必须把隔离跳过和普通超时归为同一类处理,继承关系保证了这一点。
  • deadline 上限(_compression_quarantine_max_seconds)防的是一个永远不退出的 worker 把压缩永久关停。每次新超时都会重新武装 deadline,所以持续变慢会持续隔离,单个泄漏 worker 却拖不过上限。

隔离触发后,调用方走的是普通的压缩失败分支——headroom/proxy/handlers/anthropic.py:2046-2057 捕获异常、记 _compression_failed = True、把 reason 分成 timeouterror 两类打点,然后带着未压缩的消息继续往下走。请求不失败,只是这一轮没省到。


5. 三家 wire format:同一条流水线,三套外壳

压缩逻辑只有一套,但对外要说三种方言。差异被关在 headroom/proxy/handlers/ 的几个 mixin 里。

5.1 请求侧的形状差异

AnthropicOpenAI ChatGemini
消息字段messages[]messages[]contents[],内含 parts[]
系统提示顶层 system(不在 messages 里)messages 里的 role: system顶层 systemInstruction
鉴权头x-api-key(或 authorization)Authorization: Bearerx-goog-api-key
回包正文content[] blockschoices[].messagecandidates[].content.parts[]
入口handle_anthropic_messages(headroom/proxy/handlers/anthropic.py:825)handle_openai_chat(headroom/proxy/handlers/openai.py:3168)handle_gemini_generate_content(headroom/proxy/handlers/gemini.py:262)

Gemini 那三行差异是 handle_gemini_generate_content 的 docstring 自己列的(headroom/proxy/handlers/gemini.py:269-274)。其中"系统提示不在 messages 里"这条对语义缓存有直接后果——见 §8。

除三家之外还有两个特例 handler:handlers/batch.py 处理批量任务(它要先把 JSONL 输入文件下载下来、逐行压缩、重新上传,再用新 file_id 建 batch,headroom/proxy/handlers/batch.py:793),handlers/bedrock.py 处理 AWS 的 InvokeModel。

5.2 流式:字节原样回,状态机在旁边并行跑

流式回包的处理原则是不碰返回给客户端的字节。SSE 帧原样透传,同时复制一份喂给解析器提取用量。_parse_sse_usage(headroom/proxy/handlers/streaming.py:152)负责这件事,三家的用量藏在不同地方:

供应商用量出现在备注
Anthropicmessage_start(输入 token)+ message_delta(输出 token)分两处,要合并
OpenAI最后一个 chunk 的 usage需要客户端设 stream_options.include_usage=true,否则拿不到
Gemini每个 chunk 的 usageMetadata每帧都有

Anthropic 的 SSE 增量事件类型是全套里最多的,_response_to_sse(headroom/proxy/handlers/streaming.py:611)要把一个完整 JSON 回包反向重建成事件流(缓冲 CCR 路径要用),从中可以完整读出它支持哪些增量:

事件delta 类型承载
content_block_deltatext_delta普通文本
input_json_deltatool_use 的参数 JSON 片段
thinking_delta思考内容
signature_delta思考签名
citations_delta引用
message_deltastop_reason + 输出 token
message_stop结束

thinking / redacted_thinking / server_tool_use 三种 block 在重建时被特殊对待:签名必须原样带出,否则上游会拒——这直接引出下一节。

5.3 压缩失败即透传

三家 handler 的失败分支是同一个形状:捕获、打点、继续。Gemini 那边额外把结果写进响应头,headroom/proxy/handlers/gemini.py:976 会设 x-headroom-compression-failed: true

这些 x-headroom-* 响应头是允许存在的——它们是代理向客户端汇报自己干了什么,不跨上游边界(crates/headroom-proxy/src/headers.rs:32-34 明确写了这条区分)。常见几个:

响应头含义
x-headroom-tokens-saved本次省下的 token 数
x-headroom-transforms应用了哪些变换(逗号分隔)
x-headroom-cached语义缓存命中
x-headroom-compression-failed压缩失败,本次是透传

6. 原样转发的纪律 —— 这章最该带走的一条

如果本章只记一件事,记这个:代理没改过的请求,必须发出去逐字节相同的原文,而不是"解析成 dict 再 dump 回去"。

6.1 为什么重新序列化 JSON 是错的

json.loads 后再 json.dumps 出来的字节几乎肯定和原文不同:key 顺序、空格、ensure_ascii 的 Unicode 转义、浮点数格式,处处都可能变。这带来三个真实后果:

  • 签名失效。 Anthropic 的 thinking / redacted_thinking 块带签名,签的是特定的字节序列。重新序列化 = 签名对不上 = 上游拒收。
  • 前缀缓存崩掉。 供应商的 prompt cache 按前缀字节比对。多一个空格就是 miss,整段前缀白缓存。这是 04-cache-safety.md 的核心议题在 HTTP 层的映射。
  • SigV4 崩掉。 Bedrock 的 SigV4 签名覆盖请求体哈希,改了 body 签名就失效(headroom/proxy/handlers/bedrock.py:16-21)。

6.2 三条分支,一个函数说了算

select_outbound_body(headroom/proxy/body_forwarding.py:298)是唯一的裁决点:

有原始字节?

├─ 否 ─────────────────────────────▶ canonical(规范 JSON)

▼ 是
body 或原文里有签名思考块?

├─ 是 ──▶ passthrough(原字节) ← 最高优先级,连改过的也丢弃
│ 并回报 dropped_mutations
▼ 否
body 被改过?

├─ 是 ──▶ canonical(紧凑分隔符,ensure_ascii=False)

└─ 否 ──▶ passthrough(原字节)

第一条分支的"连改过的也丢弃"是个陷阱,而且代码专门为它加了一个查询函数。OutboundBody.dropped_mutations 字段的注释(headroom/proxy/body_forwarding.py:35-39)说明了危险:某个 handler 可能把 streamtrue 改成 false,好拿到一个完整回包来做缓冲式 CCR;但如果签名分支胜出,这个修改被丢弃,上游实际收到的是流式请求,handler 却在等非流式回包——wire format 对不上,直接坏掉。

所以有了 outbound_body_is_client_bytes(headroom/proxy/body_forwarding.py:379):handler 在动手改之前先问一句"我的修改会不会被丢?"。headroom/proxy/handlers/anthropic.py:3516-3519 就是这么用的。这是一个很值得抄的模式——副作用可能被上层否决时,提供一个"先问后做"的查询接口,比事后发现便宜得多。

原始字节从哪来?read_request_json_with_bytes(headroom/proxy/helpers.py:2641)同时返回 dict 和字节。注意它返回的是解压后的字节(Codex 会发 zstd/gzip 请求体),因为那才是上游最终会收到的形态。而且一旦它剥掉了非法的"仅输出块",就会重新编码 raw,免得逐字节透传把剥之前的内容泄漏出去。

配套的 BodyMutationTracker(headroom/proxy/body_forwarding.py:272)要求每次标记修改都给一个非空 reason,这些 reason 会进日志——出问题时能直接看到"这次为什么没走透传"。

还留了一个应急回滚开关 HEADROOM_PROXY_PYTHON_FORWARDER_MODE=legacy_json_kwarg,回到老的 httpx json= 行为。注释里强调这是显式运维选择,不是 fallback(headroom/proxy/body_forwarding.py:46-52)——unknown 值会直接抛异常,不静默降级。

6.3 x-headroom-* 只进不出

内部头绝不能漏给上游。原因不止一个:

  • 指纹。 x-headroom-stackx-headroom-bypass 这些头一路发到 Anthropic,等于告诉对方"这个客户端挂了个中间代理"。
  • 泄漏。 x-headroom-user-idx-headroom-base-url 含本地信息。
  • 协议污染。 上游对未知头的处理无法保证。

strip_internal_headers(headroom/proxy/internal_header_policy.py:27)按前缀 x-headroom- 大小写不敏感地整批剥掉,Python 侧封装成 _strip_internal_headers(headroom/proxy/helpers.py:1589),Rust 侧是同名的 strip_internal_headers(crates/headroom-proxy/src/headers.rs:102),两边行为对齐。

每个上游调用点都要调,而且调完要打一条结构化日志 log_outbound_headers(headroom/proxy/helpers.py:1648)记录剥了几个——这样"某个新写的转发路径忘了剥"能从日志里发现,而不是靠代码审查。handle_passthrough 里的用法见 headroom/proxy/handlers/openai.py:9870-9877

同样只在运维显式设 HEADROOM_STRIP_INTERNAL_HEADERS=disabled 时才关闭,用途是影子链路诊断。

6.4 X-Forwarded-* 只信白名单网关

反过来的方向也有纪律:入站的 X-Forwarded-For / -Proto / -Host 默认一律不信。任何客户端都能伪造这三个头,伪造出来就能冒充源 IP,绕过按 IP 做的授权。

headroom/proxy/forwarded_headers.py 的策略是三态:

白名单状态peer 在名单里?结果
未设 / 为空(默认)不适用忽略,用真实 peer IP
已配置采信
已配置忽略 + 打 forwarded_headers_rejected 事件

配置只有一个环境变量 HEADROOM_PROXY_TRUSTED_GATEWAY_CIDRS,用 ipaddress 解析(不用正则),格式错就在启动时抛 ValueError——注释里解释得很好:一个静默跳过的坏 CIDR 会让白名单悄悄变空,把配置从"严格"降级成"更严格",看起来没事却掩盖了运维的真实意图(headroom/proxy/forwarded_headers.py:96-100)。

只取 X-Forwarded-For 的最左一跳(_header_first),因为只有紧邻的那个网关的真实性是可担保的,再往前没有任何信任信号。

6.5 出站还要剥 hop-by-hop

Rust 侧的 build_forward_request_headers(crates/headroom-proxy/src/headers.rs:132)把 RFC 7230 §6.1 的逐跳头(connectionkeep-alivetetransfer-encodingupgrade 等)和客户端管理的头(hostcontent-length)一并剥掉,并额外处理 Connection: 里列出的头名。Python 侧在各 handler 里手工 headers.pop("host") / pop("accept-encoding") 做同样的事。


7. 省钱记账:数字从哪来、存到哪

Dashboard 上那个"已省 $X"是一整条链路算出来的,不是一个计数器。

handler 算出 tokens_saved

├──▶ PrometheusMetrics.record_request → /metrics 暴露
│ (proxy/prometheus_metrics.py)

├──▶ SavingsTracker.record_request → 落盘 proxy_savings.json
│ (proxy/savings_tracker.py:738) /stats /stats-history 读它

├──▶ record_savings_event → 追加 JSONL 事件账本
│ (headroom/savings_ledger.py:119) headroom savings 读它

└──▶ CostTracker → 预算与实时花费
(proxy/cost.py)

四个去处各有分工:

模块存哪活多久谁读
PrometheusMetrics进程内存重启即失/metrics 抓取
PersistentMetricsStateSavingsTracker 的状态里跨重启Dashboard 的 Lifetime 视图
SavingsTracker.headroom/proxy_savings.json跨重启,带 schema 迁移/stats/stats-history
savings_ledgerJSONL,文件锁,30 天保留跨重启,多进程安全aggregate_savingsheadroom savings CLI

为什么要 JSONL 账本而不是只用 JSON 状态文件? headroom/savings_ledger.py:1-16 的 docstring 解释了:主 MCP server、每个子 agent 的 MCP server、代理进程会同时写。共享可变状态会互相覆盖,而"追加一行 + 读时聚合"没有共享可变状态,总数永远准。写入时用 fcntl 建议锁(Windows 上跳过)。

钱是在写入时算好存下来的,不是读时算的——这样以后厂商调价,历史数字不会漂。

定价查询链:_normalize_model 归一模型名 → _resolve_litellm_model 映射到 litellm 的价目条目 → 查不到就退到混合费率常量(输入 $3/M、输出 $15/M,headroom/proxy/savings_tracker.py:43-45)。MCP 工具触发的压缩不知道 agent 用的什么模型,会记 model="unknown" 并走混合费率——记个近似值好过记 $0

Token 计数本身也是防御性的。count_tokens_offloaded(headroom/proxy/token_counting.py:69)把 tokenizer 解析和计数丢到压缩执行器上跑,因为首次解析可能要下载 HuggingFace 词表、计数一整个 Claude Code 会话是 CPU 密集的。超时或出错就降级为按字符估算,并且同一个模型只警告一次(避免刷屏)。

savings_attribution.py 处理的是扩展贡献的省量和耗时。它对每个数字都做了上界钳制,理由写得很吓人也很对(headroom/proxy/savings_attribution.py:38-52):一个 inf 混进总计,Starlette 的 JSONResponseallow_nan=False 编码会抛 ValueError,于是 /stats 直到重启为止一直是坏的;Prometheus 那边 Python 渲染成 inf 而不是 +Inf,整个抓取解析失败。而请求本身仍然返回 200——"看起来健康,dashboard 和监控全死",是最糟糕的 bug 形状。


8. 语义缓存:相似请求直接命中

SemanticCache(headroom/proxy/semantic_cache.py:25)是一个内容哈希 + LRU 的内存缓存,OrderedDict 实现,move_to_end/popitem 都是 O(1)。

真正有意思的是 key 怎么算(compute_semantic_cache_key,headroom/proxy/semantic_cache_key.py:19)。

问题一:key 里必须包含所有影响生成的字段。 只哈希 messages 是不够的——Anthropic 的 system 在顶层、不在 messages 里。两个 messages 相同但 system 不同的请求会撞 key,第二个调用方拿到第一个的回答。所以每个 handler 各自维护一份 cache_key_fields 快照,原样传进来。

问题二:cache_control 必须剥掉。 这是给上游 prompt cache 用的断点标记,它不改变生成结果。但 Claude Code 会在轮次之间挪断点位置——如果不剥,同一段对话每轮都是新 key,缓存全废。strip_cache_control 递归剥,而且连 messages 里的也剥,因为 messages 正是断点挪动最频繁的地方。

对照两条规则,能读出一个通用原则:缓存 key 要包含一切影响输出的东西,排除一切不影响输出的东西;两个方向漏一个,一个错答一个失效。


9. 模式与降级:压不压、怎么压

9.1 两种模式

headroom/proxy/proxy_mode_policy.py:7-18 定义了两个规范模式和一张别名表:

规范值优先什么接受的别名
token最大压缩率,历史可以重写token_modetoken_savingstoken_headroom
cache供应商前缀缓存稳定,冻结历史轮次cache_modecost_savings

归一化被拆成了纯函数 normalize_proxy_mode_decision(返回一个含 unknown/alias_used 标志的 dataclass)和带日志的包装 normalize_proxy_mode(headroom/proxy/modes.py:21)。这个拆法让策略可以被单测而不产生副作用——policy 后缀的模块在这个仓库里到处都是,都是同一个手法。

9.2 该不该压:四级优先

CompressionDecision.decide(headroom/proxy/compression_decision.py:72)把"要不要压"收敛成一个不可变值对象。优先级从高到低:

顺序原因为什么排这个位置
1bypass_header用户显式说"别动我的字节",这是关于前缀缓存稳定性的契约断言,压过一切
2compression_disabled运维级总开关,让代理能跑纯观测模式
3no_messages空请求上报"授权被拒"会误导人,所以排在授权前面
4license_denied商业门禁,只在真有东西可压时才有意义

这个模块的 docstring(headroom/proxy/compression_decision.py:1-25)本身就是一份很好的重构记录:同一个判断以前在四个 handler 文件的五个位置内联,而且飘了——Gemini 的三处都漏了 not _bypass,其中一处连 _license_ok 都漏了,导致显式 x-headroom-bypass: true 在所有 Gemini 路径上被静默忽略。收敛成一个工厂之后,这种分歧在结构上就不可能了。

顺带,这个 dataclass 把每个构成布尔量都暴露出来(bypass_header_setconfig_optimize_enabled…),dashboard 不用重算就能回答"当时这个决策看到了什么"。apply_to_tagspassthrough_reason 盖到 tags 上,一路流到 RequestLog 和 dashboard。

9.3 其他透传原因

headroom/proxy/handlers/anthropic.py:2022-2042 还会写入两个缓存相关的透传原因:cache_mode_prefix_mismatch(缓存模式下前缀对不上)和 cache_mode_frozen_cold_start(冷启动时恢复了冻结前缀,供应商缓存可能已有,所以别动)。这两个都不是错误,是主动选择不压,标签让 dashboard 能把它们和"压了但没省到"区分开。


10. wrap:一条命令接管一个编码 agent

服务端讲完了。现在讲怎么把 agent 送进来。

10.1 wrap 干的五件事

headroom wrap claude

├─ ① 起代理(或复用已有的) _ensure_proxy → _start_proxy
│ 端口占用就自动往上找

├─ ② 注册 MCP 工具 headroom_retrieve(给 CCR 用)
│ + Serena(代码记忆)

├─ ③ 改配置 env: ANTHROPIC_BASE_URL=http://127.0.0.1:8787
│ file: .claude/settings.local.json 的 env 块
│ + 写一个 sidecar marker 记下"改之前是什么"

├─ ④ 拉起 agent subprocess.run([claude_bin, *args], env=env)

└─ ⑤ 退出时还原 finally 块 + SIGTERM/SIGHUP handler
(代理只在最后一个客户端离开时才停)

10.2 起代理:_start_proxy 的几处硬骨头

_start_proxy(headroom/cli/wrap.py:628)把代理当子进程拉起来,细节里有好几个被真实 bug 逼出来的处理:

  • PYTHONSAFEPATH=1 python -m headroom.cli 会把当前目录塞进 sys.path 最前面。如果你恰好在 Headroom 自己的仓库目录里跑 wrap,源码树的 headroom/ 会盖住已安装的 wheel,而源码树没有编译好的 headroom._core,代理启动即死,wrap 静默降级成不带代理地拉起 agent(issue #2793)。这个环境变量关掉 cwd 注入。
  • Windows 上要脱离父进程的 console 和 Job 对象。CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB。代码里的长注释解释了为什么不用 DETACHED_PROCESS:对 python.exe 这种 console 子系统程序,它会留下一个可见的 console 窗口,用户一关窗口代理就死,反而破坏了跨 wrap 会话的引用计数。如果 Job 对象禁止 breakaway,会去掉那个 flag 重试一次。
  • stdout/stderr 写独立文件而不是管道。 避免管道写满死锁,又不和轮转的 proxy.log 抢。
  • 启动等待有超时且可调。 ML 组件(Kompress、Magika、Tree-sitter)在 uvicorn 绑端口之前同步加载,慢机器上要 20-30 秒。默认 45 秒,检测到 torch/sentence_transformers/spacy 时提到 90 秒(headroom/cli/wrap.py:300-303)。子进程中途死掉会读日志尾部 500 字节拼进异常信息。

10.3 一台代理,多个客户端

多个 agent 可以共享同一个端口上的代理。谁来决定什么时候停?基于 marker 文件的引用计数。

每个 wrap 进程在 paths.proxy_clients_dir(port) 下写一个 <pid>.json(_register_proxy_client,headroom/cli/wrap.py:4187)。_make_cleanup(:4238)返回的清理函数在退出时先摘掉自己的 marker,再看还有没有别的活客户端——有就不停代理

marker 里除了 pid 还记了进程启动时间(_proc_identity,:4122),用来防 PID 复用:进程崩了,操作系统把同一个 pid 分给了别的程序,光看"pid 还活着"会误判。_identity_mismatch(:4183)的策略是保守——只有能证明是不同进程时才判为复用,任何不确定(拿不到启动时间、来源不同)都返回 False,绝不无凭无据地丢弃状态。

10.4 marker:崩溃之后怎么收拾

这是 wrap 里工程量最大的一块,起因是一个很烦人的失败模式。

问题: wrap claudeANTHROPIC_BASE_URL=http://127.0.0.1:8787 写进了 .claude/settings.local.json(为什么要写文件见下一节)。如果代理死于硬重启或 SIGKILL,没有任何清理逻辑跑得了,这个 URL 就留在文件里。下次你直接敲 claude,它会去连一个不存在的端口,报 ConnectionRefused——而且看起来完全不像 Headroom 的锅。

解法: 写 URL 的同时写一个 sidecar marker(_write_wrap_marker,headroom/cli/wrap.py:1195),记下 {pid, start_src, start_time, port, key, previous}previous 是改之前的值,这样恢复的是真实原值而不是靠猜。marker 刻意放在 .headroom_wrap_marker.json 而不是塞进 settings 文件里,免得 Headroom 的簿记变成 Claude Code 配置解析器眼里的野字段。

然后有两个判死函数:

函数判据用在哪
_wrap_marker_is_stale(:1226)写入者进程证明已经没了(pid 无效/已死/身份不符)写新值之前清理前一次崩溃的残留
_wrap_marker_proxy_is_dead(:1259)记录的端口不再接受连接会话启动时的自愈

端口才是权威信号。 注释说得很直白:重启会把 pid 回收给不相干的活进程,所以"pid 看着活着"不能证明代理还在。反过来,端口有响应的 marker 是活会话,绝不能清——清了会让正在跑的 cc-daemon worker 中途丢掉路由。

探活本身也做了抗抖:_wrap_proxy_alive(:1241)一次 1 秒的 TCP connect 可能因为 accept 队列满或调度延迟而假失败,所以第一次成功就判活,全部 3 次都失败才判死,活代理不付延迟代价。

最后是"谁来跑自愈"。wrap 本身不装 hook,所以只跑过 wrap 没跑过 init 的会话没有任何读取者。于是 _ensure_claude_wrap_selfheal_hook(:1380)装了一个 SessionStart-only 的 hook 调用隐藏命令 wrap selfheal。注释特别强调了 绝不能用 PreToolUse:那会让自愈在会话中途每次 Bash 调用都跑一遍,一次瞬时探活抖动就可能清掉一个活会话的路由。

10.5 每个 agent 各自要改哪儿

改法分三类:环境变量(最简单)、配置文件(agent 会重读或 fork 子进程时不继承环境)、只能打印说明(配置在 GUI 里)。

agent改什么类型
Claude CodeANTHROPIC_BASE_URL 环境变量 + .claude/settings.local.jsonenv环境 + 文件
Claude Code (Foundry)ANTHROPIC_FOUNDRY_BASE_URL,代理 URL 要补 /anthropic 后缀环境 + 文件
Claude Code (Vertex)ANTHROPIC_VERTEX_BASE_URL(Vertex 模式下 ANTHROPIC_BASE_URL 被忽略)环境 + 文件
Codex CLICODEX_HOME 指向处理过的副本,并往 config.toml 注入 provider文件
aiderOPENAI_API_BASE环境
opencodeOPENCODE_CONFIG_CONTENT,兼配 OPENAI_BASE_URL / ANTHROPIC_BASE_URL 兜底环境
Copilot CLICOPILOT_PROVIDER_BASE_URL 等一组环境
VS Code Copilot改 VS Code settings.json文件
grok-build~/.grok/config.toml 注入 [model.grok-build]文件
Cursor / Cline只打印设置步骤(配置在 GUI 里)打印

为什么 Claude Code 要既设环境变量又写文件? 因为 cc-daemon 用 spawn(不是 fork)预派会话 worker,那些 worker 重新读 settings.json 而不继承 daemon 的环境。只设环境变量的话,首次启动能路由,之后新开的对话就漏出去了(issue #951,headroom/cli/wrap.py:1482)。

Codex 为什么要整个 CODEX_HOME? Codex 的 WebSocket 传输忽略 OPENAI_BASE_URL,除非配置文件里声明了一个 supports_websockets = true 的自定义 provider(headroom/cli/wrap.py:5818-5823)。所以必须改文件。改文件之前先做逐字节快照 config.toml.headroom-backup,unwrap codex 据此完整还原。

10.6 MCP 注册:两个 server,都注册到 user scope

wrap 会顺手注册两个 MCP server:

server干什么不注册的后果
headroom提供 headroom_retrieve 工具代理发出的 CCR 标记 [Retrieve more: hash=…] 指向空气,模型要不回原文
serena符号级代码记忆只是少一个能力

_setup_headroom_mcp(headroom/cli/wrap.py:1600)对所有 registrar 是通用的,拿 http://127.0.0.1:<port> 建 spec。Codex 要 force=True,因为 Codex 从 config.toml 起长驻 MCP 子进程——上一次 wrap 用的是别的端口的话,取回会悄悄指向错的代理,而模型流量走的是对的那个。

Claude Code 侧注册到 user scope。 ClaudeRegistrar._register_via_cli(headroom/mcp_registry/claude.py:127)跑的是 claude mcp add <name> -s user -- <cmd> <args>。写用 CLI(它拥有 ~/.claude.json 这个文件),读和比对直接读 JSON 文件——这样对 CLI 输出格式变化免疫(headroom/mcp_registry/claude.py:7-10)。CLI 失败会退到文件写入,而且会同时清理现代路径 ~/.claude.json 和旧路径 ~/.claude/mcp.json

账本(ledger)机制是这里的关键设计。register_server 遇到已存在但不同的条目会返回 MISMATCH拒绝覆盖。但 Headroom 自己装的旧版 spec 也会被卡住,导致老用户永远升不到新 spec。解法是查账本:headroom_installed_matching 能证明"磁盘上这条是 Headroom 装的"才强制更新;用户自己管的 Serena 永远不动(headroom/cli/wrap.py:1988-2002)。unwrap 走同一条规则,只删账本证明是自己装的(_remove_headroom_installed_serena_mcp,:2038)。

10.7 怎么回滚

两条路:自动显式

自动的是 claude 命令的 finally 块(headroom/cli/wrap.py:5099-5107):还原 tool-search 设置、还原 base_url、跑 cleanup。信号处理装了 SIGINT(转成"忽略",让 Ctrl-C 归子进程)、SIGTERM、以及 SIGHUP——关终端窗口或 tmux kill-session 发的是 SIGHUP 不是 SIGTERM,没有这一行 finally 永远不会跑(issue #1768)。

显式的是 headroom unwrap claude(:5120),做五件事:摘 MCP 注册(受账本约束)、删 Headroom 管理的 hook、删自愈 hook、按 marker 还原三种 base_url key、停代理。

还有第六件很体贴的事:_warn_if_proxy_env_leaked(:5082)。unwrap 能还原文件,但还原不了用户自己 export 到 shell 里的环境变量。那个残留会让 claude 一直连接失败。所以它显式检测并给出对应 shell 的精确修复命令(issue #2238)。

10.8 headroom doctor:诊断"静默失效"

headroom/cli/doctor.py:1-9 的模块 docstring 点明了这个命令存在的理由:Headroom 的失败模式是静默的——客户端没路由过代理(或者代理跑的是旧代码)时,一切照常工作,你只是不省 token 了,而且没有任何报错提示你。

所以 doctor 做的是关联那些没人对账的状态:

检查看什么
check_proxy_liveness/livez 通不通
check_version_drift跑着的代理版本 vs 安装的包版本
check_claude_routing / check_codex_routing各 agent 配置里的 URL 是否指向本代理端口
check_shell_env当前 shell 里的环境变量指向哪
check_wrap_marker_stalenessmarker 是不是崩溃残留
check_savings有没有真的在省
check_budget预算配置

有个很具体的例子说明它为什么有用:ollama launch claude 会写 ANTHROPIC_BASE_URL=http://127.0.0.1:11434 到子进程里,盖掉持久安装的环境块,静默绕过代理。doctor 特意认出 11434 这个端口,直接点名冲突,而不是让用户去探测那个端口(headroom/cli/doctor.py:54-59,issue #2199)。

退出码:0 全过,1 只有警告,2 有失败。


11. Rust 侧对照

crates/headroom-proxy 是同一个代理的 Rust 实现,以透明反向代理的形式跑在 Python 代理前面。对照着看能看清哪些设计是本质的(两边都有)、哪些是语言约束的产物。

关切PythonRust
应用组装create_app(headroom/proxy/server.py:2525)build_app(crates/headroom-proxy/src/proxy.rs:156)
兜底路由@app.api_route("/{path:path}")router.fallback(any(catch_all))(crates/headroom-proxy/src/proxy.rs:310)
健康/livez /readyz /health/healthz /healthz/upstream /rollout/status(crates/headroom-proxy/src/health.rs)
指标/metrics/metricshandle_metrics(crates/headroom-proxy/src/observability/prometheus.rs:208)
内部头剥离strip_internal_headers(headroom/proxy/internal_header_policy.py:27)同名函数(crates/headroom-proxy/src/headers.rs:102)
Chat/Responseshandle_openai_chat / handle_openai_responsescrates/headroom-proxy/src/handlers/chat_completions.rs / responses.rs
Bedrockheadroom/proxy/handlers/bedrock.pycrates/headroom-proxy/src/bedrock/{invoke,invoke_streaming,sigv4,eventstream}.rs
Vertex路由里按 publisher 分流handle_vertex_predict_dispatch(crates/headroom-proxy/src/vertex/mod.rs:108)
WebSocketCodex live 路由ws_handler(crates/headroom-proxy/src/websocket.rs:21)

SSE 解析是 Rust 侧最实的收益。 crates/headroom-proxy/src/sse/mod.rs:24-32 直接列了它退休掉的一批 bug,其中 P1-15 最能说明问题:Python 侧按 TCP chunk 逐块 decode(errors="ignore"),只要一个多字节字符(任何 emoji、任何非 ASCII)跨了 chunk 边界,那几个字节就被静默丢掉——生产环境 9 天记录到 1946 次解析失败,全来自这一个毛病。

Rust 的做法是分层:

TCP 字节 ──▶ SseFramer(framing.rs:93)
│ 纯字节层面找 "\n\n" 终止符
│ 一个完整事件才解码一次 UTF-8
▼ 产出 SseEvent { event_name, data }
┌────────┼────────────────┐
▼ ▼ ▼
AnthropicStreamState ChunkState ResponseState
(anthropic.rs:103) (openai_chat.rs:43) (openai_responses.rs:50)

Framer 是供应商无关的,三台状态机各自消费事件。关键约束:三台状态机都不改流向客户端的字节——它们只更新遥测用的结构化状态,字节透传和状态机是并行的两条线(crates/headroom-proxy/src/sse/mod.rs:20-22)。这和 §6 的原样转发纪律是同一条原则在回包方向上的体现。

Bedrock 的 EventStream 是二进制协议,crates/headroom-proxy/src/bedrock/eventstream_to_sse.rs 增量地把它翻成 SSE 帧,同时把翻译后的帧喂给 AnthropicStreamState 做遥测。CRC 校验默认开,关掉会在启动时打 WARN(crates/headroom-proxy/src/proxy.rs:253-260)。

Bedrock 路由只在 enable_bedrock_native 打开时才挂,关掉时会 WARN 并说明后果:请求落到 catch-all,不会重新做 SigV4 签名,所以是 fail-closed(crates/headroom-proxy/src/proxy.rs:262-268)。这个"功能开关关掉时明确告诉运维会退化成什么"的习惯,整个仓库里很一致。


12. 巧妙之处(可以直接抄的)

① 退出码选 78 而不是 1。 EX_CONFIG 让 systemd/k8s 知道这是配置问题,不进重启循环。区分"崩了"和"你配错了"能省下运维半小时(headroom/proxy/server.py:523-526)。

② 副作用可能被否决时,提供"先问后做"的查询函数。 outbound_body_is_client_bytes 让 handler 在修改 body 之前就知道这个修改会不会被签名透传分支丢弃(headroom/proxy/body_forwarding.py:379)。

③ 隔离期而不是无限等待。 杀不掉的超时线程会被记成"债",债存在期间新压缩直接走失败策略;债有时间上限,防止一个泄漏线程永久关停压缩(headroom/proxy/server.py:1378)。

④ 端口比 PID 更可信。 判断"上次那个代理还在不在",端口探活能穿过重启和 PID 回收,PID 不能(headroom/cli/wrap.py:1259)。

⑤ 保守的身份比对。 _identity_mismatch 只在能证明是不同进程时才返回 True,任何不确定都返回 False——因为调用方要拿它决定丢不丢状态,无凭无据地丢是更坏的错误(headroom/cli/wrap.py:4220)。

⑥ 账本区分"我装的"和"用户装的"。 MCP 注册永远不覆盖用户自己管的条目,但能升级自己装的旧 spec(headroom/cli/wrap.py:1988)。

⑦ 缓存 key 剥掉不影响输出的字段。 cache_control 是给上游 prompt cache 的断点标记,不改变生成结果,不剥就会因为客户端挪断点而让缓存全废(headroom/proxy/semantic_cache_key.py:19)。

⑧ 用一个值对象消灭分散判断的飘移。 CompressionDecision 收编了五个内联位置,其中三个漏判了 bypass——收敛之后这类分歧在结构上不可能了(headroom/proxy/compression_decision.py:1-25)。

⑨ 对扩展贡献的数字做上界钳制。 一个 inf 能让 /stats 和 Prometheus 抓取同时死到重启为止,而请求还照常 200(headroom/proxy/savings_attribution.py:38-52)。

⑩ 每次剥内部头都打一条结构化日志。 忘记剥的新转发路径能从日志里被发现,而不是靠人肉审查(headroom/proxy/helpers.py:1648)。


13. 边界与局限

  • 只覆盖能改 base URL 的 agent。 Cursor、Cline 这类把配置放在 GUI 里的,wrap 只能打印步骤让用户手动填。
  • 兜底 catch-all 是双刃剑。 它保证了对未知路径透明,代价是任何注册得比它晚的路由永远失效;代码里靠注释来护(headroom/providers/proxy_routes.py:517)。
  • Python 无法抢占线程。 隔离期是缓解,不是修复;超时的 worker 仍然占着执行器槽位跑到自己结束。
  • OpenAI 流式用量可能拿不到。 需要客户端设 stream_options.include_usage=true,不设就没有用量数据(headroom/proxy/handlers/streaming.py:156)。
  • 非本机绑定 + 无 token = 数据面全裸。 代码会打 event=proxy_open_bind 的 WARN(headroom/proxy/server.py:3369-3377),但不会拒绝启动。Docker 的 0.0.0.0 镜像正是这个形状。
  • 多写者账本靠 fcntl,Windows 上跳过锁。 Windows 下并发追加是尽力而为(headroom/savings_ledger.py:38-46)。
  • marker 自愈只装在 Claude 路径上。 其他 agent 的崩溃残留靠 headroom doctor 手动发现。
  • MCP 注册需要 uvx 没有就跳过 Serena 并打印提示(headroom/cli/wrap.py:2012-2014)。
  • Bedrock 原生路由不能直连 AWS。 改 body 会让调用方的 SigV4 签名失效,所以这两条路由只在显式配了 --bedrock-api-url 时才注册,且上游必须是会重签或不校验签名的网关(LiteLLM / LocalStack / 企业代理);直连 AWS 要走 --backend bedrock(headroom/proxy/handlers/bedrock.py:16-21)。

14. 代码地图(导航索引)

服务端

主题文件符号
应用组装headroom/proxy/server.pycreate_app
核心代理对象headroom/proxy/server.pyHeadroomProxy
启动自检headroom/proxy/server.py_check_rust_core_EXIT_CONFIG
压缩执行器与隔离headroom/proxy/server.py_run_compression_in_executorCompressionQuarantinedError
安全闸门 / 打标中间件headroom/proxy/server.py_security_gate_record_headroom_stack
本机判定(bool 版)headroom/proxy/server.py_request_is_loopback_request_can_view_dashboard_metadata
本机守卫(依赖版)headroom/proxy/loopback_guard.pyrequire_loopbackrequire_same_originis_loopback_host_header
供应商路由注册headroom/providers/proxy_routes.pyregister_provider_routespassthrough
声明式路由表headroom/providers/route_specs.pyPROVIDER_HANDLER_ROUTESPROVIDER_PASSTHROUGH_ROUTES
Anthropic handlerheadroom/proxy/handlers/anthropic.pyhandle_anthropic_messages
OpenAI handler / 压缩端点 / 透传headroom/proxy/handlers/openai.pyhandle_openai_chathandle_compresshandle_passthrough
Gemini handlerheadroom/proxy/handlers/gemini.pyhandle_gemini_generate_contenthandle_google_cloudcode_stream
批量headroom/proxy/handlers/batch.pyhandle_batch_create
流式与 SSE 用量headroom/proxy/handlers/streaming.py_stream_response_parse_sse_usage_response_to_sse
出站字节裁决headroom/proxy/body_forwarding.pyselect_outbound_bodyoutbound_body_is_client_bytesBodyMutationTracker
内部头剥离headroom/proxy/internal_header_policy.pystrip_internal_headers
转发头信任headroom/proxy/forwarded_headers.pytrusted_forwarded_headersload_trusted_gateway_cidrs
语义缓存headroom/proxy/semantic_cache.pysemantic_cache_key.pySemanticCachecompute_semantic_cache_keystrip_cache_control
压缩决策headroom/proxy/compression_decision.pyCompressionDecision.decide
模式归一headroom/proxy/proxy_mode_policy.pymodes.pynormalize_proxy_mode_decisionis_cache_mode
省量记账headroom/proxy/savings_tracker.pySavingsTracker.record_request_estimate_compression_savings_usd
持久聚合 / Prometheusheadroom/proxy/persistent_metrics.pyprometheus_metrics.pyPersistentMetricsStatePrometheusMetrics
事件账本headroom/savings_ledger.pyrecord_savings_eventaggregate_savings
成本与预算headroom/proxy/cost.pyCostTracker
Token 计数降级headroom/proxy/token_counting.pycount_tokens_offloaded
扩展归因与钳制headroom/proxy/savings_attribution.pybind_scopeMAX_STAGE_MS
项目归属headroom/proxy/project_context.pystrip_project_path_prefix
请求体读取headroom/proxy/helpers.pyread_request_json_with_bytes_strip_internal_headers

接入端

主题文件符号
wrap 命令组headroom/cli/wrap.pywrapclaudecodexopencode
起代理子进程headroom/cli/wrap.py_start_proxy_ensure_proxy_ensure_proxy_unlocked
客户端引用计数headroom/cli/wrap.py_register_proxy_client_live_proxy_clients_make_cleanup
wrap markerheadroom/cli/wrap.py_write_wrap_marker_wrap_marker_is_stale_wrap_marker_proxy_is_dead_check_and_clear_dead_wrap_marker
自愈 hookheadroom/cli/wrap.py_ensure_claude_wrap_selfheal_hook_selfheal_dead_wrap_base_url
配置写入与还原headroom/cli/wrap.py_write_claude_wrap_base_url_restore_claude_wrap_base_url
MCP 注册headroom/cli/wrap.py_setup_headroom_mcp_setup_serena_mcp_setup_coding_compressor
Claude MCP registrarheadroom/mcp_registry/claude.pyClaudeRegistrar._register_via_cli
Codex 配置注入headroom/cli/wrap.py_prepare_codex_wrap_state_inject_codex_provider_config
unwrapheadroom/cli/wrap.pyunwrap_claude_warn_if_proxy_env_leaked
诊断headroom/cli/doctor.pydoctorcheck_claude_routingcheck_wrap_marker_staleness
持久部署headroom/cli/install.pyinstall_build_deployment_manifest

Rust 对照

主题文件符号
路由与转发crates/headroom-proxy/src/proxy.rsbuild_appcatch_all
头过滤crates/headroom-proxy/src/headers.rsbuild_forward_request_headersstrip_internal_headers
SSE 分帧crates/headroom-proxy/src/sse/framing.rsSseFramerSseEvent
SSE 状态机crates/headroom-proxy/src/sse/AnthropicStreamStateChunkStateResponseState
Bedrockcrates/headroom-proxy/src/bedrock/handle_invokehandle_invoke_streaming
Vertexcrates/headroom-proxy/src/vertex/mod.rshandle_vertex_predict_dispatch
WebSocketcrates/headroom-proxy/src/websocket.rsws_handler
健康与指标crates/headroom-proxy/src/health.rsobservability/prometheus.rshealthzhandle_metrics