ZH Internals - D0n9X1n/copilot-relay GitHub Wiki
架构里画出的那些边界,背后的精确机制都在这一页。本页面向任何 需要改动中继、又不想弄坏那些代价高昂才学到的东西的人 —— 无论是人还是编码智能体。
这里给出源码路径和符号名,方便检索。行号刻意不写:行号会过期,名字不会。
src/
main.ts CLI 入口(citty):auth、start、stop、restart、status、models、replay
server.ts Hono app、请求日志、health/root 接口
start.ts 启动序列与热重载接线
stop.ts 进程发现与关停升级
restart.ts stop + start
status.ts 检测、健康探测、--deep、--json、退出码
auth.ts GitHub device login 命令
replay.ts replayCapture:校验捕获,用当前 handler 离线重放
routes/claude.ts POST /v1/messages、count_tokens、GET /v1/models
claude/
types.ts 用到的那部分 Claude Messages 类型
translate.ts 非流式 Claude <-> Copilot 翻译
stream.ts Copilot chunk -> Claude SSE 状态机
web-search.ts 由中继托管执行的 WebSearch
web-search-stream.ts resolveWebSearchStreamDecision
tool-names.ts Claude <-> Copilot 工具名规范化
utils.ts 共用翻译辅助函数
copilot/
client.ts 认证 HTTP 客户端、重试、计时
chat.ts chat 抽象、路由、think effort
models.ts 按上游地址隔离的模型目录和 token 限制
endpoint.ts 目录驱动的接口选择与协议策略
responses.ts Responses API 翻译、prompt_cache_key
native.ts 原生 Claude 传输及签名 WebSearch 历史
stream.ts 共用聚合逻辑与完整流校验
tool-schema.ts 仅用于 Responses 的工具 schema 兼容处理
types.ts 上游 payload 类型
lib/
app-config.ts readAppConfig()、写回、热重载
config.ts 运行期代理配置与请求策略快照
atomic-file.ts 文件快照检查与原子替换
address.ts 监听/客户端 URL 规范化
request-trace.ts RequestTrace、有序传输捕获及完成结果
defaults.ts 发布默认值
paths.ts ~/.copilot-relay 布局,import 时解析
auth.ts token 存储与刷新调度
models.ts 路由 + thinkEffort 校验
preflight.ts 启动期上游验证
lifecycle.ts pid 文件、findRelayOnPort、findRelayProcessIds
log.ts formatLogValue、轮转、保留
redact.ts 纯函数 URL/密钥脱敏
claude-settings.ts ~/.claude/settings.json 管理
tokenizer.ts count_tokens 启发式
upstream-diagnostics.ts 上游错误上下文采集
upstream-dispatcher.ts Copilot 与 GitHub 调用共用的 undici dispatcher、upstreamProxy
error.ts 错误整形
state.ts 运行期状态
version.ts 构建版本
src/lib/app-config.ts 中的 readAppConfig() 校验扁平标量文档并解析运行值,随后
materializeMissingKeys 仅向原始文本追加缺失键。注释、未知标量键、顺序、引号
写法及已有换行风格都会保留。重复的规范键/别名、不受支持的 YAML 和无效已知值会在
写回前报错;必填键为空不等于键缺失。新建/缺失文件使用包内模板(或旧配置)。
src/lib/atomic-file.ts 的 readFileSnapshot / writeFileSnapshot 解析符号链接,
但不替换链接本身;保留文件权限,先写私有临时文件,再原子发布完整字节。身份、目标
及内容检查拒绝已观察到的并发编辑;最初不存在的文件以排他方式发布。进程内协作写入
会串行化。这不是操作系统级 compare-and-swap rename:外部写入仍可能在最后一次
检查与替换之间竞争。applyClaudeConfig 共用这个边界,保留无关设置,并拒绝覆盖
格式错误或已经存在的空 JSON 文件。
若一次读取中唯一不一致的身份字段是 ctime,可以立即重新读取,最多共尝试三次。
每次重读必须与首次读取的字节、目标路径、权限及其他身份字段一致;其他变化或持续
不一致仍会拒绝。发布前的检查也可重新取得读取结果,但仍以包含 ctime 的完整身份
与调用方最初的快照比较。过期的写入绝不自动重试。
键一旦落盘,保存的值就优先于新的发布默认值。
不新增默认值迁移。用户固定的值属于用户;旧的 configVersion 迁移机制正是因此在
#26 中移除。
热重载 —— 对改动之后开始的工作生效:
logLevel、logRetentionDays、thinkEffort、upstreamTimeoutSeconds、
copilotBaseUrl、webSearchBackend、claudeUpstreamApi、gptModel、opusModel
需要重启:
host、port、claudeSetup、upstreamProxy
host 和 port 动不了,因为监听 socket 已经绑定。claudeSetup 在启动时读取一次,
所以改它在下次启动之前什么都不会变。upstreamProxy 在第一次上游调用之前构建一次上游
dispatcher,重载从不重建它(见出站代理)。改 gptModel 会立刻改变上游请求
路由,但不会重写已经保存在 ~/.claude/settings.json 里的模型 —— 那是启动时写的。
一次重载会记录它应用了什么:
info Config reloaded: logLevel=debug thinkEffort=xhigh upstreamTimeoutSeconds=180
ConfiguredReasoningEffort 和 configurableReasoningEfforts 将合法配置默认值与请求级
ReasoningEffort 分开。normalizeThinkEffort 在配置写回和认证之前拒绝 none 及
其他格式错误的显式默认值;startRelay 也会校验程序直接传入的默认值。只有缺失的键
使用发布默认值。无效重载只记录一次原因并保留当前设置。校验信息只指出键、行或规则,
从不回显值,因为写错键的值可能是凭据。
watchAppConfig 只读:全部已知落盘键都必须存在,包括可选但留空的搜索后端。
空文件/部分保存不会恢复默认值。应用前还会校验第二次快照,读取或应用失败不会被标为
已接受,因此修正后可以重试。每个周期都会重试,但同一文件快照与原因的组合只记录一次
(isSameFailure)。重载成功,或再次干净读到已应用的文件,都会清除该记录。读取或
校验期间落盘的新保存不算失败(readConfigSnapshot),下一个周期会读取更新后的文件。
路径不是普通文件(NotRegularFileError)时和其他失败一样记录,应用重载时抛出的任何
错误也一样。
新增一个键意味着同时更新 config.default.yaml、README,以及两种语言的
配置说明。
src/server.ts 在推理前依次检查 Host/authority、显式 Origin、可选的 apiKey 及
JSON content type。Host/authority、Origin 和 content type 是浏览器来源/本地请求检查,
不是认证;没有 apiKey 时,可从网络访问的非 loopback 监听器不受 Claude 占位
token 保护。
apiKey 在每个请求中从实时的 ProxyConfig 读取,因此热重载对下一个请求生效。
isOpenProbe 豁免 GET /healthz 和 GET/HEAD /api/hello;其他所有路由(包括未知
路由)都需要密钥。presentsApiKey 用 SHA-256 分别对配置的密钥和每个出示的值
(x-api-key 及 Authorization: Bearer token)求摘要,再用 timingSafeEqual 比较这些
定长摘要,比较不会在第一个不同的字节处提前结束。拒绝时返回 HTTP 401 authentication_error
及 WWW-Authenticate: Bearer,发生在读取正文之前,也在未知路由处理器可能记录 payload
之前;响应从不回显出示的值。src/start.ts 的 applyRuntimeConfig 在启动时和每次
重载时用 registerLogSecret 登记密钥,status 和 models 在使用前也会登记。无论
密钥由哪个 header 携带,RequestTrace 都不会把它记入捕获的 header;但捕获的正文文件
按设计不脱敏,客户端放进提示词的密钥会保存在其中。toStatusConfig 把密钥显示为
[redacted]。在 copilot-relay models 中,src/models.ts 的 printable 在缩短或补齐
一行之前替换每个模型、配置和搜索字符串中的密钥,src/lib/model-probe.ts 的 safeId
使含有密钥的 ID 既不被探测也不被打印,两条路径还会再清理打印的每一行。Claude Code 的
设置保存密钥时,applyClaudeConfig 通过 writeFileSnapshot 的 ownerOnly 选项把它们
发布为仅所有者可读写。
读取 POST 正文之前,src/lib/config.ts 的 snapshotProxyConfig 及
src/lib/state.ts 的 snapshotRuntimeState / withRuntimeState 为该请求固定路由、
base URL、超时、协议模式、搜索后端、effort 和目录视图。后续异步调用使用该请求作用域,
而非可变的全局状态。Token 和 generation getter 刻意保持实时,让重试使用刷新后的
凭据。该快照所选上游需要重新发现时,可更新自己的目录,不会切换到重载后的新策略。
接入阶段完成发现后,pinCopilotModelCatalog 为所有阶段固定该目录引用,即使发现
结果中没有目标模型也一样。输出预算处理不能在 SSE 开始后重新发现并改变能力;
token 计数仍完全本地,不会固定或刷新推理目录。
Claude Code 把一条计费归属行放在顶层 system prompt 的最前面:
x-anthropic-billing-header: cc_version=2.1.288.976; cc_entrypoint=sdk-cli;
它是给 Anthropic API 的元数据,不是给模型的指令。src/claude/billing-line.ts 的
removeBillingLine 在 POST /v1/messages 和 POST /v1/messages/count_tokens 解析
正文时移除它,让每条路由和本地 token 计数看到同一份 system prompt。它只移除以
x-anthropic-billing-header: 开头的 system block 或字符串 system 的第一行,以及
其后的空行。没有其他文本的 block 被丢弃;其余 block 保留 cache_control 和剩下的
文本。之后再出现的这段文字按普通文本处理。relay 从不记录这一行。
在 #157 之前,chat 路由把这一行并入 handleSystemPrompt 生成的唯一 system message。
使用 claude-haiku-4.5 和 claude-opus-5.5 时,Copilot 的 /chat/completions 返回
HTTP 200,但模型没有答出该消息中的暗号,token 计数也与 Copilot 省略整条消息(包括
Claude Code 的指令)一致。在没有这项修复的 relay 上,在 Claude Code 的环境中设置
CLAUDE_CODE_ATTRIBUTION_HEADER=0,Claude Code 就不再发送这一行。
每个 Copilot 调用都带 x-initiator: user 或 agent。src/claude/initiator.ts 的
getClaudeRequestInitiator 在每个 POST /v1/messages 中只判定一次,依据 Claude Code
发来的原始正文。判定发生在 removeBillingLine 之前,也在 relay 添加自己的回合之前,例如
chat 路由追加的 Continue based on the context above.。
以下情况为 agent:
- 计费行带有
cc_is_subagent=true;(subagent 的每个请求)或cc_workload=cron;(定时任务的 cron 作业); - 某个 system block 以 Claude Code 自己发起的请求的 prompt 开头:专门的压缩摘要器,或 会话标题;
- 请求以 assistant 消息结尾(prefill),或没有 user 消息。
否则,规则从回合末尾向前读,停在第一个能说明由谁发起的 block。回合是最后一条 assistant
消息之后的 user 消息。规则跳过 role: "system" 回合,并去掉 <system-reminder> 片段,
包括嵌套的片段。
- tool result 为
agent:tool 后续。 - 粘贴的图片或附加的文档为
user。 - 本人在模型工作时发来的消息,无论直接发送还是经由绑定的 thread,为
user。 - 不经本人而发起请求的文本为
agent:- 压缩指令,两部分都在时;
- 压缩后恢复会话的摘要;
- 恢复与重试提示;
- hook 反馈;
- 来自插件、其他会话、观察者、channel、teammate 和定时任务的消息。
- Claude Code 在发起内容旁附加的文本不参与判定:中断提示、hook 的附加上下文,以及
Skill 工具之后的 skill 正文。因此中断后输入的 prompt 为
user。 - 其余文本都是本人的,因此为
user,包括批准 tool 调用时输入的指令。
relay 自己的请求是 agent:
- WebSearch 执行;
- WebSearch 最终轮;
- 原生 WebSearch 后续轮;
- 启动 preflight;
-
models --deep的探测请求,它们在进程内标记自己,客户端无法这样做; -
status --deep的探测请求,它经由 HTTP 到达 relay,靠完整的正文识别:固定的 prompt、 固定的max_tokens,没有其他字段。Claude Code 总会附加 system prompt,所以本人输入同样的 文字仍读作user。
重试沿用被替换那次尝试的值。只有这个 header 会变:上游正文完全相同,因此 prompt 缓存
前缀不受影响。Model request 日志行以 initiator= 记录该值。
识别的文本来自 Claude Code 2.1.288。如果之后的版本改了其中某条的措辞,该情况按普通文本
处理。本人的文本若与 Claude Code 的某条文本开头完全相同,例如粘贴的 hook 反馈,会被读作
Claude Code 的。设置 CLAUDE_CODE_ATTRIBUTION_HEADER=0 时没有计费行,因此 subagent 的
第一个请求会被读作 user。
src/claude/translate.ts 处理双向非流式 payload:Claude 请求 -> Copilot chat 请求,
以及 Copilot 响应 -> Claude 响应。它在两种协议形状之间映射 tool call 和
thinking/text block。消息内的 system 文本保持原位置,包括工具结果之后;不能变成 assistant 发言,也不能附加
assistant 续写提示。它保持 role: system,但上游解析为 Claude 模型、发往 /chat/completions
且不会回退到 /responses 时例外:此时它变为包含 <system-reminder>…</system-reminder> 的
role: user 回合,因为不断变化的 system 回合会使 Copilot chat 路由无法缓存其后的历史
(见 Prompt 缓存下的“Chat 路由:system 回合与缓存断点”)。validateClaudeMessages
在翻译路径上只接受唯一键为 effort 的 system output_config,其值必须为
low、medium、high、xhigh、max 之一。历史标记具有不同档位是合法的。
校验和 effort 选择共用 src/claude/utils.ts 的 isEffortOnlyControl,避免已被接受的
切换被静默忽略。
上游 tool call 的 arguments 通过 src/claude/utils.ts 的 parseUpstreamToolInput
转换为 Claude 的 tool_use.input,translateToClaude 与流式的
translateChunkToClaudeEvents 共用该函数。对于不接受参数的工具,Copilot 发送空字符串
而不是 {},因此空白或仅含空白字符的文本视为空对象;流式路径随后输出
partial_json: "{}",因为客户端会解析累积文本,而 "" 不是 JSON 对象。非空但不是
JSON 对象的文本会抛出 UpstreamToolInputError:非流式路径返回 HTTP 502
api_error,流式路径在 SSE error 事件中给出同一消息。该消息只写明工具名,从不回显
参数文本,因为其中可能包含用户数据。其他流式失败仍使用通用错误消息,但 prompt 超出
模型输入上限时除外(见下文“Prompt 超出输入上限”)。
getClaudeTurnEffort 先校验初始顶层 effort,再只读遍历消息。合法的 system effort
标记先处于待生效状态,直到后续 role: user 消息将它激活;仅包含工具结果的 user
消息也算。最后激活的值生效;最新 user 之后的标记仍待生效。翻译保留 system 文本
和顺序,消化空的 effort-only 消息而不插入空 system 提示,并将当前档位映射到上游
请求字段。追加切换不会改写既有翻译前缀,也不会在工具调用与结果之间插入控制消息。
额外/未知键、null/数组控制、内联 none 和任何 clear_at 仍在翻译路径被拒绝。
未知角色、格式错误的 system 文本也会被拒绝。接入校验发生在推理请求或 SSE 之前,
即使 stream: true 也返回 HTTP 400 JSON;translateToOpenAI 对直接调用和 token
计数重复校验。原生消息保留初始设置与逐消息控制字段,由上游解释。辅助函数在原生
路径遇到无法解释的控制时跳过它,不会为了诊断而改写请求。
src/claude/tool-names.ts 在出站时把 Claude 工具名规范化成 Copilot 可接受的名字,
入站时再映射回来。Claude Code 的工具名不总是合法的上游标识符,而一个带着规范化后
名字的响应,与客户端注册的那个工具对不上。
src/lib/models.ts 中的 getRequestReasoningEffort 和 resolveReasoningEffort
保留初始字段优先级与配置回退规则。Claude 专用辅助函数解析已激活的内联控制后,
翻译层将当前档位固定到 chat payload。Chat/Responses、requested/effective 日志及
WebSearch 检索使用这个当前值,翻译路径的最终回答 payload 直接继承它。请求级运行时
快照保证配置热重载不会改变某阶段的回退值。
原生请求保留原控制字段;诊断和搜索检索使用规范化后的出站历史,包括恢复出的 user
工具结果回合。原生后续调用追加 user 工具结果时,可能激活待生效标记。即使后续标记合法,错误的
初始 effort 仍会被拒绝。见配置说明。
稳定的翻译文本前缀和 Responses 缓存键是回归约束,不是缓存命中率不变的保证:翻译 协议在请求级表达 effort,切换可能使上游缓存失效。原生逐消息控制保留原样,交由 提供方实现缓存保持语义。
src/claude/types.ts 只定义代理需要的那部分 Claude Messages API 类型。它刻意不是
完整的 Claude SDK —— 一个用不到的类型就是没有测试覆盖的维护成本。
src/copilot/chat.ts 为 routes 与启动 preflight 发送已解析好的上游 ID。
src/copilot/endpoint.ts 的 selectCopilotEndpoint 通过 getCachedCopilotModel
读取当前提供方的 supported_endpoints,探测和真实请求共用该选择器。固定的旧偏好
只用于多接口选择和元数据缺失,不再是不断扩大的模型白名单。唯一的后来条目是
gpt-5.4(不含 gpt-5.4-mini):Copilot 的 /chat/completions 对它带工具的请求返回
HTTP 400,而 /responses 接受这些请求。
requireCopilotEndpoint 在接入阶段、SSE 之前,把固定的不支持原因变成本地错误。
Claude 的显式协议策略优先;选择结果还指明是否允许仅针对特定错误码的
/chat/completions 到 /responses 恢复。被恢复的失败正文会标为 discarded 并取消,
让捕获/replay 记录一致的调用顺序。
src/copilot/models.ts 的 resolveModelReasoningEffort 区分显式请求与配置默认值。
translateToOpenAI 传递 getClaudeTurnEffort().requested,适配器只解析一次,Chat
和 Responses builder 都不会重新添加已刻意省略的值。在 parseReasoningEfforts 中,
目录的 reasoning_effort: false 和省略该键的 supports 对象都转换为
reasoningEfforts: [];缺少 supports 对象及格式错误的接口/档位数组保持未知,不会
过滤成权威的空数组。捕获/replay 使用已有数组字段,无需新格式。探测的 route_source 和白名单
接口详情说明选择过程,不会打印原始元数据。用户策略见配置说明。
src/copilot/responses.ts 为任意选中的兼容模型翻译 Copilot Responses 与
chat-completion 风格结果。检索仍是独立的 Responses web_search_preview 操作;
决策/最终阶段使用共享适配器,但仅支持 Chat 的搜索后端会产生结构化搜索失败,
不会把普通聊天伪装成检索。能力缺失保持未验证,不代表支持内置搜索。
src/copilot/native.ts 的 shouldUseNativeMessages 仅作用于路由后以 claude- 开头
的 ID。auto 要求当前上游目录公布 /v1/messages 支持,messages 强制原生,
chat-completions 固定 Chat 翻译。最后一项仍是默认值;非 Claude 模型不受该
Claude 专属设置影响,按目录选择翻译接口。
createNativeMessages 保留消息/block 结构、签名 thinking 与 redacted-thinking block、
缓存标记、原位置的 system 角色/控制字段及原生响应元数据,不经过 chat 扁平化。
它仍会路由模型、约束输出、解析 effort 并适配 relay 的 WebSearch 声明。协议 header
明确传递 anthropic-version 和可选 anthropic-beta;上游认证由共用 Copilot 客户端
负责,不使用客户端认证 header。超过原生非流式上限时请求 SSE,再聚合为 JSON。
nativeEvents / collectNative 保留签名和尾部 usage,SSE 必须有 stop reason 及
message_stop。错误或提前 EOF 不会被当作成功;也不会改走其他 API 来绕过原生
错误/拒答。
src/lib/model-probe.ts 的 probeModels 通过 reportsSelectedModel 将返回模型与
准确的目录选择比较。除原有的 GPT context 后缀规范化之外,只接受以下已观察到的
provider 行为:
- 原生拼写:接口为
/v1/messages,所选 ID 为claude-opus-5.5,返回claude-opus-5-5。 - Priority 层级:接口为
/responses,所选 ID 为gpt-5.6-sol-fast,返回gpt-5.6-sol。目录将该项命名为 "GPT-5.6 Sol Fast",实际回复报告model: gpt-5.6-sol及service_tier: priority。 - 带日期快照:不带日期的所选 ID 在返回时附加一个
-YYYY-MM-DD日期,实际回复中gpt-5.5报告gpt-5.5-2026-04-23,gpt-4o报告gpt-4o-2024-11-20。带日期的 所选 ID 必须完全一致。
这些都不是通用标点规范化、-fast 或后缀剥离,也不是新增目录别名。
claude-opus-5-5-preview、claude-opus-5、报告为 gpt-6-sol 的 gpt-6-sol-fast、
gpt-5.5-preview,以及上游改由其他模型应答的别名(gpt-4 和 gpt-4o-2024-05-13
都报告 gpt-4.1-2025-04-14)仍失败。配置和发现保留目录 ID,SENT/REPORTED 保留
两种实际拼写。接受仍需通过正常的完整文本/终止检查;不能只因为模型匹配就把拒答当作
PASS。
上游 HTTP 400 且 code 为 model_not_supported 时,单独报告为 Model not supported,
与请求内容被拒绝的情况区分。trajectory-compaction 列在目录中,但上游以此拒绝。
隐式探测 effort 为高于 none 的公布最低档。仅当 none 是唯一公布的档位时才探测它:
relay 自身从不发送 none,而 gpt-6.1-sol 公布了它却以 HTTP 400
invalid_request_body 拒绝。显式的 --effort none 仍按请求发送。
src/copilot/tool-schema.ts 中的 normalizeResponsesToolSchema 在共用的
buildResponsesRequestPayload 边界适配函数参数。Copilot 会拒绝包含 \p{Cc}、
\P{L} 等 Unicode 属性转义,以及 (?!...) 等前瞻或后顾断言的 JSON Schema
pattern 约束。Claude Code 的 Artifact 工具在 field、database 和 doc_id
参数中包含这些 pattern,即使用户从未调用该工具,也会随请求发送。仅删除 Unicode
pattern 后,上游的下一层校验仍会拒绝前瞻断言。
中继只从发往上游的副本中省略这些 pattern,保留受支持的 pattern 和其他 schema
字段。转义后的字面字符,以及字符类中形似环视断言的文本,均保持不变。
遍历仅针对承载 schema 的关键字,不会改写 default、const、enum、
examples 中的字面数据,也不会改写属性名。原始 Claude schema 和
/chat/completions 路径保持不变;客户端工具校验仍执行原始约束。所有使用 Responses
的流式、非流式以及 WebSearch 模型调用都经过同一适配。规范化是写时复制的:不需要改动的
schema 作为同一个对象发送,省略 pattern 时只复制通往它的路径上的对象,因此较长的工具
列表不会在每个请求中重建。
src/copilot/responses.ts 中的 translateTools 还会在每个 Responses 函数工具上
显式设置 strict: false。省略该字段时,上游可能把兼容的 schema 规范化为严格模式,
使原本可选的属性变为必填。显式使用非严格模式可保留 Agent.isolation、Read.pages
以及嵌套属性的可选语义,不注入 null 或默认值,也不删除或改写返回的参数。
该设置不用于内置 web_search_preview,也不影响 /chat/completions 工具。
Claude Code 把工具返回的图片(例如 Read 打开一张 PNG)放在 tool_result 内的
image block 中。src/claude/translate.ts 的 mapContent 和 handleUserMessage
把它转换为 role: "tool" 消息,其内容包含 image_url part。各路由再把图片放到其模型
读取的位置(#150):
-
/responses。 gpt-6-astra、gpt-5.4 和 gpt-5.5 从function_call_output.output内的input_image条目读取工具图片。stringifyToolOutput只保留文本 part,没有文本时 把整个结果作为 JSON 文本发送:图片要么被丢弃,要么以 base64 文本到达。因此src/copilot/responses.ts的translateToolOutput把含图片的工具消息发送为input_text与input_image条目数组,保持原有顺序。translateToolOutputPart省略 空文本 part,且不给input_image条目设置detail:Claude image block 没有该字段, 在 #150 中验证过的形状也没有。caozhiyuan/copilot-api 的 issue 361 和 362 报告 Copilot 在工具输出中拒绝low、high以外的detail,也拒绝空文本 part。 其他工具消息仍使用stringifyToolOutput的纯字符串,因此已有历史发送相同的字节, 保留其 prompt 缓存前缀。从/chat/completions发起的unsupported_api_for_model重试重新发送未适配的 payload,走同一路径。 -
/chat/completions,非 Claude 模型。 Copilot 接受工具消息中的image_urlpart, 但在 #150 中 gpt-5-mini 回复NO IMAGE,gemini-3.8-flash 说出了它从未看到的颜色。 同一张图片位于随后的 user 消息中时,两者都能读取。src/copilot/chat.ts的moveToolImagesToUserMessages在normalizeFinalAssistantPrefill之前运行。在每段 连续的工具消息中,含图片的工具消息只保留文本 part,像纯文本工具内容一样以空行连接; 没有文本时改为固定说明Image output follows in the next user message.。其图片移到 该段之后 user 消息的开头,或移到在该段之后插入的新 user 消息中,每个调用的图片前带有Image output of tool call <id>:。不含图片的工具消息保持原样。没有工具消息含图片的 payload 原样返回。 -
/chat/completions,Claude 模型。 当isClaudeModelId匹配上游模型时,payload 保持原有形状:在 #150 中 claude-opus-5.5 读取了工具消息内的图片,而 Prompt 缓存下 “Chat 路由:system 回合与缓存断点”所述的copilot_cache_control标记放在这一形状上。
src/copilot/chat.ts 的 messagesIncludeImage 检查实际发送的 payload,因此图片被移动的
请求仍带有 copilot-vision-request: true;/responses 重试检查它重新发送的 payload。
原生 /v1/messages 路由发送 Claude 自己的 block,不使用这些适配器。
src/copilot/client.ts 中的 fetchCopilot 与 src/lib/auth.ts 中的 GitHub 调用都通过
src/lib/upstream-dispatcher.ts 的 fetchUpstream 发送上游请求,它使用本进程唯一的
上游 undici dispatcher,只用 HTTP/1.1。Copilot 不发送 Keep-Alive 提示,因此 undici
默认在连接空闲超过 4 秒后关闭它,下一个请求要重新进行 TCP 和 TLS 握手。#141 中
Copilot 复用了空闲 60 秒的连接,而空闲 120 秒的连接已被关闭;dispatcher 现在把空闲
连接保留 50 秒,经过代理时也一样。
package.json 要求 undici 7.30 或更高版本。undici 7.28 在复用空闲 socket 之前,用一个
unref 的零延迟定时器检查它;在 Windows 上,这个定时器可能要等到下一次系统定时器 tick。
tests/unit/copilot-client.test.ts 覆盖这两点。
空闲连接保留得更久也有代价。undici 向空闲连接写入请求之前,会先处理该连接上已经收到的
FIN 或 RST,因此不会复用 Copilot 已关闭的连接。如果 relay 与 Copilot 之间的 NAT 或代理
丢弃了连接却没有发送这两者,这个连接看起来仍然是打开的。写入它的请求如果收到 reset,
fetchCopilot 会重试一次,与其他未被中止的 fetch 失败一样。如果没有任何回应,请求要等到
操作系统放弃该连接才失败,这种失败同样重试一次;或者先到 upstreamTimeoutSeconds 截止
时间,以 504 失败且不重试。使用 4 秒默认值时,只有丢弃空闲不足 4 秒连接的 NAT 或代理才会
造成这种情况;现在丢弃空闲 50 秒以内连接的也会。
Prompt 超出模型的 max_prompt_tokens 时,Copilot 在 /chat/completions 和
/responses 上都返回 HTTP 400:
{"error":{"message":"prompt token count of 131008 exceeds the limit of 128000","code":"model_max_prompt_tokens_exceeded"}}在 /v1/messages 上,Copilot 同样返回 HTTP 400,但用的是 Anthropic 的信封和措辞,并带上它
自己的 code。> 以 \u003e 转义到达;这里的 request id 是占位符:
{"error":{"code":"model_max_prompt_tokens_exceeded","message":"prompt is too long: 230024 tokens \u003e 200000 maximum","type":"invalid_request_error"},"request_id":"req_placeholder","type":"error"}对 claude-haiku-4.5,Copilot 在 /v1/messages 上给出的上限是 200000,而
/chat/completions 执行的是模型目录中的 136000。
Claude Code 2.1.288 按文本 prompt is too long 或 input is too long for requested model
识别超限,并用 prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+) 读取两个数字。
/chat/completions 与 /responses 上的措辞两者都不匹配。
src/copilot/client.ts 中的 toPromptTooLongError 在上游失败响应变成错误的位置识别这种
拒绝:/chat/completions 与 /responses 在 src/copilot/chat.ts 的 logUpstreamError,
/v1/messages 在 src/copilot/native.ts 的 createNativeMessages。它要求 HTTP 400,且
JSON body 的 error.code 为 model_max_prompt_tokens_exceeded;或者是 Anthropic 自己的
invalid_request_error 信封,其消息以 prompt is too long 开头。它只用锚定的模式读取
两个数字,并返回 src/lib/error.ts 中的 PromptTooLongError:一个 body 与 Anthropic API
所发内容相同的 HTTPError。
{"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 131008 tokens > 128000 maximum"}}读不出数字时,消息为 prompt is too long。其他上游文本都不会到达客户端,因此识别出的原生
body 会被重建而不是原样转发;上游措辞保留在错误的 detail 中,只有日志会显示它。
JSON 请求以 HTTP 400 收到这个 body。流式请求已经拿到 HTTP 200:接入检查和可能的模型目录
查询之后,推理调用在已打开的 SSE 响应中运行,因此慢的上游不会拖住响应头
(tests/integration/claude-routes.test.ts 中延迟上游的流式测试固定了这一点)。
src/claude/stream.ts 中的 translateErrorToClaudeErrorEvent 以 SSE error 事件发送相同的
type 和消息;路由追加 (request_id=<id>),计数模式不会读取它。Claude Code 2.1.288 依据
消息而不是 HTTP 状态行事:它的 SDK 把 SSE error 事件变成以事件 JSON 为消息的错误,超限
检查只读这条消息。它改用非流式重试的回合会收到上面的 HTTP 400。
其他上游错误的处理不变:JSON 请求收到上游 body 和状态,流式请求收到通用消息或
UpstreamToolInputError 自己的消息。已经开始的原生流中的 error 事件不做检查。覆盖测试在
tests/unit/prompt-too-long.test.ts 以及 tests/integration/claude-routes.test.ts 的 #158
测试中。
buildUpstreamDispatcher 把解析后的 upstreamProxy 变成三种 undici dispatcher 之一,
三者使用相同的 HTTP/1.1 与 keep-alive 选项:
upstreamProxy |
Dispatcher |
|---|---|
| 空 |
Agent:直连,与 #153 之前的所有版本相同 |
env |
EnvHttpProxyAgent:HTTPS_PROXY、HTTP_PROXY 与 NO_PROXY
|
| URL | 指向该代理的 ProxyAgent
|
configureUpstreamDispatcher 在每个进程中构建一次,时机是读完配置之后、第一次上游
调用之前:startRelay 中,以及 auth、models 和 usage 命令中。applyRuntimeConfig
从不调用它,所以 upstreamProxy 与 host、port 一样在重启后生效。它从不作为 undici
的全局 dispatcher。status 探测通过 src/status.ts 的 relayListener 调用 relay 自己的
监听器:undici 的 fetch 加上它们自己的直连 Agent,既不用这个 dispatcher,也不用全局
dispatcher。设置 NODE_USE_ENV_PROXY=1 时,Node 自己的 fetch 连发往 localhost 的请求
也会经过 HTTP_PROXY,除非 NO_PROXY 豁免它;经过一个拒绝连接的代理时,status 会把
健康的 relay 报告为不可用。
usage 不写任何文件,所以 src/lib/usage.ts 的 loadCopilotUsage 用
readExistingAppConfig 读取配置,而不是会创建或补全 config.yaml 的 readAppConfig。
它解析已有文件但不改写;没有文件时返回空,请求随后直连。整个命令在 withoutLogging
中运行,所以请求本会记录的日志行不会进入日志文件。
EnvHttpProxyAgent 在构建时读取代理变量,小写写法优先;未设置 HTTPS_PROXY 时,
HTTPS 目标使用 HTTP_PROXY。它对每个请求检查 NO_PROXY。变量格式错误时,undici 抛出
的错误会重复变量值,因此 buildUpstreamDispatcher 把它换成只写变量名和规则的错误。
ProxyAgent 用 CONNECT 建立隧道,HTTP 目标也一样;只有 URL 同时带用户名和密码时,
才发送 Basic Proxy-Authorization。它拒绝带 path、query 或 fragment 的代理 URL,
百分号转义格式错误的凭据在解码时失败。src/lib/app-config.ts 的
normalizeUpstreamProxy 在读取配置时就对 config.yaml 中的 URL 应用这些规则,所以错误
出现在启动时,且从不回显值。env 使用的代理变量不经过它:buildUpstreamDispatcher
构建 EnvHttpProxyAgent 时只有 undici 自己的检查,而这些检查接受只有用户名没有密码的值。
src/lib/redact.ts 的 formatUpstreamProxyForDisplay 在 status 和启动日志里只显示
代理的 origin。
configureUpstreamDispatcher 把 dispatcher 可能使用的每个代理 URL(config.yaml 中的
URL,或 EnvHttpProxyAgent 读取的每个代理变量)的凭据注册到 registerLogSecret:
user:password 的原始写法与解码后形式,以及 undici 发送的 Basic 值,各自至少 8 个字符
时才注册。它从不单独注册用户名或密码。registerLogSecret 会在每一行日志中替换该值,
较短或常见的值会切坏普通文本:用户名 copilot 会把 copilot-relay 变成
[redacted]-relay,用户名 a 会破坏 copilot-relay cache 读取的 completion path=
条目。配置重载不会注册任何值,因为它从不重建 dispatcher。
fetchUpstream 在重新抛出没有响应就失败的请求的错误之前,原地清理它
(sanitizeTransportError)。它在整个 cause 链中删除 data 和 body,undici 在这些
字段中保存来自对端的原始字节:HTTPParserError 保存回复中未解析的剩余部分,所以一个用
格式错误的回复应答 CONNECT 并回显 Proxy-Authorization 的代理,会把密码带进每一行
打印该错误的日志。它还在每条消息和每个 stack 的开头替换当前代理凭据的每种形式,包括较短
的形式以及单独的用户名或密码:只有这一个错误会被改变。错误保留它的类型、code 和
cause,调用方会读取它们,例如 src/lib/usage.ts 的 failureReason。
upstreamProxy 为空、又设置了 HTTPS_PROXY 或 HTTP_PROXY 时,如果某个上游请求在
没有响应的情况下失败,fetchUpstream 每个进程只记录一次:该变量没有被使用,以及如何
启用。在 withoutLogging 下的失败(例如 models --deep 的探测)不写任何内容,提示留给之后
的失败。默认值不是 env:readAppConfig 会把每个键写回磁盘,env 默认值会让已经为
其他工具设置这些变量的现有安装悄悄改走代理。tests/integration/upstream-proxy.test.ts
用本地记录代理覆盖每种模式。
src/copilot/models.ts 中的 loadCopilotModelCatalog 保留 preflight 读取的模型
目录。限制数据绑定到准确的上游 base URL;地址改变后会先重新发现,再使用预算。
并发刷新共用一个请求,较旧的响应不能覆盖新上游的目录。缺失或无效的限制元数据不会
被替换成猜测的容量。
boundModelOutputTokens 按实际路由模型公布的最大输出限制请求,同时不会提高客户端
明确指定的较小预算。Preflight 和深度健康检查仍保留 16-token 预算。Prompt 内容不会
为了适应限制而被切片。count_tokens 在有数据时使用受支持的已发现 tokenizer,并在这种情况下
跳过旧的 Claude 系列 15% 余量,避免已有 tokenizer 数据时仍因模型名称启发式而过早
压缩。本地模型发现返回缓存的限制,不会调用上游。
Preflight 之后、开始监听之前,start 会加载回退用的 o200k_base,以及配置模型报告的
每个受支持 tokenizer,因此第一个 count_tokens 请求不必等待构建编码器。热重载换入的
模型在首次使用时加载它的 tokenizer。
src/lib/tokenizer.ts 在任何文本编码调用之前,为每张图片分配 4096 个估算 token。
不会把 base64/URL 文本送入 tokenizer,不解码图片,也不拉取 URL。文本继续使用所选
tokenizer,消息/工具启发式及适用的旧余量仍保留。这是客户端预算估算,不是上游计费,
也不能证明多模态 prompt 一定符合容量限制。
某些模型公布的 max_non_streaming_output_tokens 小于流式上限。超过该阈值时,
createChatCompletions 会向上游请求 SSE,再由 collectChatCompletionStream 为
JSON 调用方及 WebSearch 最终模型调用返回完整 chat 响应。它复用 WebSearch 的聚合器,
保留 usage、推理、工具参数片段和 length 终止原因,并拒绝不完整的流,而不是构造
成功结果。普通流式调用方仍然实时接收 chunk。
Claude 自动预算、模型选择器处理,以及可选的无总超时设置,见
配置说明。tests/integration/model-text-limits.test.ts 在
两种响应模式下完整传递经 tokenizer 计数的 872K/936K-token prompt 和
128K/64K-token 输出;上游使用 mock,不会执行付费的百万 token 生成。
src/claude/stream.ts 把流式 Copilot chat chunk 转换成 Claude SSE 事件。它是一个
状态机,因为 Claude 要求 text、thinking、tool use 的 content block 按正确顺序显式
start/delta/stop —— 而 Copilot 的 chunk 流里没有这种分帧信息。
终止状态与 usage 是两回事:输出耗尽映射为 max_tokens,过滤/拒答保留为拒答,
failed/cancelled 的 Responses 终止事件作为错误报告,尾部 usage 不得抹掉终止原因。
交错的工具参数片段绝不能指向已关闭的 Claude content block。回归覆盖在
tests/unit/stream-terminals.test.ts 和 tests/unit/native-messages.test.ts。
Claude WebSearch 由中继托管执行:中继通过 Copilot /responses 加
web_search_preview 执行搜索,然后把检索到的上下文再送一次模型,最后返回 Claude
的 server_tool_use / web_search_tool_result block。最后那次调用保留客户端的其他
工具,所以模型可以在同一回合里对搜到的内容采取行动。
由此带来的问题是:中继必须先知道模型是否选择了 web_search,才能在普通补全和桥接
路径之间做选择。以前的做法是:只要请求声明了这个工具,就强制 stream: false
—— 而 Claude Code 每一个回合都会声明它,于是几乎所有流量都付出了"先缓冲补全、再回放
成合成 SSE"的代价。
src/claude/web-search-stream.ts 里的 resolveWebSearchStreamDecision 只把决策
阶段读到"足以判定是否会发生搜索"为止,并且边读边把消费掉的 chunk 发出去:
- 没有搜索 —— 这个回合和普通流式流没有区别
- 有搜索 —— 响应被累积起来,原样交给桥接路径
文本永远不能作为判据。 Copilot 经常在调用工具之前先写一段开场白("我这就去
搜索。")。把已经出现的内容当成"不会有搜索"的证据,会让随后的 web_search 调用逃过
拦截,以一个名为 WebSearch 的客户端 tool_use 抵达 Claude Code —— 这是一个
畸形回合,因为客户端认为这个工具本该由服务端执行。只有具名的 tool call 或
finish_reason 才能作数。tests/unit/web-search-stream.test.ts 固定了这一点。
当开场白已经流出之后才检测到搜索时,搜索 block 会接在已打开的那条消息上,而不是另
起一条,从而得到原生顺序 text → server_tool_use → web_search_tool_result →
text。
handleNativeMessages 仍通过 Copilot Responses 检索,会话本身保持原生 Claude。
原生 bridge 搜索只支持自动选择:validateNativeMessages 对声明 WebSearch 时强制
any、显式强制搜索或无法识别的 bridge 历史,在上游操作/SSE 之前返回 HTTP 400 JSON,
流式调用也一样。每回合最多执行一次搜索;多次或重复搜索调用直接失败,不循环。
决策前 text/thinking 可以流出,工具 block 则暂存,直到 relay 判断是否需要执行服务端搜索。
发往上游的续接保留决策中的签名 block 及原始 provider 工具 ID/名称。对客户端,仅把
搜索调用替换为 server_tool_use,后接 web_search_tool_result。确定性的
srvtoolu_relay_ 标记编码原始工具 ID/名称及 block/回合边界。normalizeNativeHistory
在下一请求中解码校验,重建原始决策、user 工具结果回合和可能存在的最终 assistant block。
该标记不是 provider 签名,不会伪造或改写 thinking 签名。搜索数据明确视为不可信。
同批客户端工具调用保留 ID,交给客户端处理,而不是由 relay 执行。
无法识别的旧 chat bridge 搜索历史在原生路径被拒绝,不会静默扁平化。切换协议不等于 透明迁移已有搜索会话。测试固定的是重建行为,不是实时缓存性能,也不能证明历史拒答原因。
长时间的 Claude Code 会话每次请求都会重发一大段基本不变的前缀(system prompt、工具 定义、之前的回合)。这段前缀上的 prompt 缓存命中,是输入 token 成本和延迟的主要杠杆。 应将较早的 chat/Responses 翻译路径测量,与下文 2026-09-30 的有限原生对照分开看待; 两者都不能证明所有账号或工作负载的表现。
Prompt 缓存按路由说明中继为缓存发送什么、如何测量命中率;本节保留 符号、精确机制与实测数据。
Relay 发送稳定的 prompt_cache_key 作为缓存路由提示。此前对 GPT-5.5/5.6 系列的
Copilot /responses 测试观察到,省略该 key 时缓存读取会掉到 0。但这不能证明所有模型
都需要它:下面的对照测试中,Astra 在没有 key 时也产生了缓存命中。仅有 key 既不能保证
缓存命中,也不能替代稳定的 prompt 前缀。
buildResponsesRequestPayload 派生一个按会话的 key:
- 优先使用客户端会话 id(Claude Code 会发送稳定的
metadata.user_id,在payload.user上体现); - 没有 user id 时,退化为 system prompt 的哈希。
key 本身是一个 SHA-256 摘要:来自 user id 时为 cr- 加 32 位十六进制字符,来自 system prompt
时为 cr-sys- 加 32 位十六进制字符,两者都没有时不发送 key。所以 prompt_cache_key 本身不会
暴露它是从哪个标识符派生出来的。
但这并不等于整个请求做了匿名化。
buildResponsesRequestPayload 在设置 prompt_cache_key 的同时,还会单独设置
user: sanitizeUserIdentifier(payload.user)。src/copilot/chat.ts 里的
sanitizeUserIdentifier 只是把字符串截断到 64 个字符 —— 它不做哈希 —— 因此
Claude Code 发来的那个标识符会原样出现在同一个请求的 user 字段里转发到上游。
/chat/completions 路径也是同样的转发方式。
实践建议:把 metadata.user_id 当作 GitHub Copilot 会看到的值来对待,不要在里面
放密钥或个人信息。对缓存 key 做哈希保护的是缓存路由值,不是这个标识符。
此前用 gpt-5.5、稳定 user id 和大前缀进行的端到端测量中,预热后的缓存读取约为
100%,不带 key 时为 0。这只是该模型及该工作负载的测量,不是普适的缓存前提。
2026-09-05(UTC),向 Copilot /responses 发送了六次小规模非流式请求,使用
gpt-6-astra、low effort、合成前缀及 relay 实际的
buildResponsesRequestPayload。每组使用独立的前缀标记,并连续发送三次完全相同的
请求。两组都保留稳定的合成 user;无 key 组仅从构造后的 payload 中删除
prompt_cache_key。
| 请求 | 带稳定 key:缓存 / 输入 tokens | 不带 key:缓存 / 输入 tokens |
|---|---|---|
| 第一次(冷缓存) | 0 / 9,789 | 0 / 9,789 |
| 第二次 | 9,786 / 9,789(99.97%) | 9,786 / 9,789(99.97%) |
| 第三次 | 9,786 / 9,789(99.97%) | 9,786 / 9,789(99.97%) |
六次请求均返回 HTTP 200 和 OK,每次输出 5 tokens。Relay 的 Responses 到 Claude
翻译保留了 cache_read_input_tokens: 9786,并在热缓存请求中报告 3 个未缓存输入 tokens。
这证实 Astra 接受现有 key,并能在带 key 的请求中返回缓存命中。无 key 对照组也命中了
缓存,因此本实验不能证明 Astra 必须使用该 key,也不能证明它能提高命中率。保留
稳定 key,同时测量真实工作负载:本次单账号、low effort 的短测试不代表 max、
并发、缓存过期后或接近 1M 上限时的表现。
缓存命中依赖前缀在多个回合之间逐字节稳定。Claude Code 会在 assistant 历史里回放
thinking block,中继把它们作为上游 assistant 内容转发出去。
在转发前剥掉 thinking 会重写这段前缀,从而让缓存失效。在一个超过缓存阈值的
8 回合会话上实测:转发 thinking 时命中率约 99%(每回合 130 个全价 token);剥掉之
后掉到约 88%、约 1066 个全价 token。
所以 thinking 是被刻意保留在上游历史里的。它是让前缀保持稳定的一部分,不是可
以顺手削掉的开销。翻译路径保留的是扁平化 assistant 内容,不是 provider 签名原生 block。
一次有限的实时对照使用 low effort、相同的合成 720 条参考记录格式、每条路由独立的
前缀标记,并在每条路由执行三个只追加历史的会话回合。完成的那次试验报告:
| Chat 回合 | 总输入 tokens | 缓存读取 tokens | 输出 tokens |
|---|---|---|---|
| 1(冷缓存) | 25,960 | 0 | 4 |
| 2 | 25,981 | 25,939 | 4 |
| 3 | 26,002 | 25,960 | 4 |
| Native 回合 | 非缓存 input_tokens
|
缓存写入 tokens | 缓存读取 tokens | 输出 tokens |
|---|---|---|---|---|
| 1(冷缓存) | 21 | 25,936 | 0 | 4 |
| 2 | 42 | 0 | 25,936 | 4 |
| 3 | 63 | 0 | 25,936 | 4 |
原生输入计数应相加:非缓存 input_tokens 加 cache_creation_input_tokens,再加
cache_read_input_tokens。Chat 总输入已包含缓存输入。不要拿原生缓存读取除以其
非缓存 input_tokens,也不要只比较不同 API 的这个字段。
对热回合 2–3,按 token 加权的缓存读取占比为:
- Chat:
(25,939 + 25,960) / (25,981 + 26,002)= 99.8384%。 - Native:
(25,936 + 25,936) / ((42 + 0 + 25,936) + (63 + 0 + 25,936))= 99.7980%。
在这次已完成试验中,原生占比约低 0.04 个百分点。第二次试验为平衡执行顺序,先跑 native;其首次冷请求返回拒答,试验随后停止。中断也是结果的一部分,不能隐去或声称 完成了重复对照。
这些小规模 low effort 观察只说明被测序列存在热缓存复用,不能证明跨 effort、并发、
缓存到期、长会话或接近容量上限时广泛无退化。无法确定计费成本等价:native 明确
报告冷缓存写入,chat 没有暴露对应类别。默认值继续保持
claudeUpstreamApi: chat-completions,现有证据不足以提升原生为默认。保留签名历史是
正确性要求,但仍未证明历史拒答是推理内容扁平化导致的。
真实 Claude Code 2.1.285 在原生路径上完成了两个模型回合的 Read → 工具结果 →
OK。最初两次请求因不支持 safeguards 字段收到 HTTP 400,随后是客户端自行
降低了请求能力。Relay 没有剥离 safeguards、新增拒答回退或绕过 provider 安全控制。
这只证明观察到的客户端/工具续接,不代表被拒字段或全部 Claude Code 功能都受支持。
预热后的第二回合以全部已报告输入类别为分母,缓存读取占比为
3029 / (3029 + 146 + 2) = 95.34%。这是与合成对照不同的工作负载,不能并入其
热缓存率。
冗余内联 effort 修复后,另一次独立的真实 CLI chat 路由检查也完成了两个工具回合并
返回 OK。两次独立客户端运行因此分别验证了两种协议在该流程中可用,但不是匹配的
缓存对照。验证会话使用了计划中 20 次请求预算的 18 次,HOME 状态哈希未变化;这些
操作检查不能证明通用客户端兼容性或生产就绪。
完整的已观察原始正文已私有捕获,记录的成功及拒答案例经当前 handler 离线重放都返回
MATCH。匹配的拒答仍是拒答,重放也不是新的上游验证。这些均为隔离检查,不是对
当前生产 relay 的验证,生产端口 4142 未受影响。这里只记录汇总证据,不放私有
捕获正文或凭据。
Claude Code 2.1.288 在每次请求中都发送一条会话中途的 role: "system" 消息(每回合的
token 提醒),这条消息带着最新的 cache_control 断点。system prompt 另带两个断点;工具
定义不带断点。v0.4.4 把这条消息按原位置转发为 chat system 消息。2026-10-03,隔离的
测试 relay 用 claude-opus-5.5 测量了五个连续工具回合,缓存读取以占总输入的比例表示:
| chat 路由上的提醒消息 | 请求 2 | 请求 5 | 请求 2–5 的缓存读取 |
|---|---|---|---|
| 无 | 97.68% | 98.39% | 29,242 → 30,904 |
每次请求一条 role: "system"(v0.4.4) |
97.54% | 92.78% | 每次都是 29,242 |
仅请求 1 一条 role: "system"
|
98.30% | 98.39% | 29,446 → 30,924 |
每次请求一条 role: "user"
|
98.19% | 98.27% | 29,430 → 30,982 |
在 v0.4.4 上运行真实 Claude Code 也出现同样的上限:三轮各 14 个并行 Read 调用,每个
热请求都只从缓存读取 18,444 token,而总输入从 25,177 增长到 28,457。每次请求新增一个
system 回合,会把缓存读取限制在工具加原始 system prompt;只在开头出现一次 system 回合
则不会。这与 Copilot 把 chat system 消息并入 Claude 的 system prompt、使其在历史之前
发生变化的情况相符。这是根据缓存大小作出的推断,并未观察到上游请求。
因此,当上游解析为 Claude 模型、selectCopilotEndpoint 选中 /chat/completions 且没有
/responses 回退时,translateClaudeMessagesToOpenAI 把每条会话中途的 system 消息按原位置
发送为包含 <system-reminder>…</system-reminder> 的 role: "user" 回合,即 Claude Code
传递大部分 harness 上下文所用的形式。src/routes/claude.ts 中的 translationEndpoint 在
请求和 token 计数中都把该端点传给 translateToOpenAI。若 createChatCompletions 可能把
unsupported_api_for_model 失败改在 /responses 上重试,它就不传端点,因为重试会重新发送
同一份翻译后的 payload。这只发生在 auto 模式下:模型目录中没有端点元数据的 Claude 模型,
或目录条目列出 /chat/completions 和 /responses 但没有 /v1/messages 的 Claude 模型。
这些请求与 v0.4.4 一样保留 role: "system",也不带缓存标记。在默认的
claudeUpstreamApi: chat-completions 下,Claude 模型从不回退。校验和 effort 选择先基于
原始消息执行。GPT 模型保留 role: "system":
每次请求都以 role: "system" 发送提醒时,/responses 上的 gpt-6-astra 每个热请求仍有
98.16–98.25% 的输入来自缓存。原生路由按原样转发原始角色和断点。
这有意放宽了“翻译后的历史”中所述的 system 角色保留,且仅限此路由:文本保持原位置,
不会成为 assistant 发言,也不会并入开头的 system prompt,但后来的操作者指令会以 user
回合的权限到达。上述测量只涉及缓存,不涉及模型如何权衡后来的操作者指令。
claudeUpstreamApi: messages 会保留 system 角色。
Chat Completions 没有 cache_control。在同一路由上,relay 给包含各断点所在块的那条翻译后
消息设置 copilot_cache_control: { "type": "ephemeral" },即 VS Code Copilot Chat 发送的
字段(src/claude/translate.ts 中的 markMessageBreakpoints)。handleUserMessage 把每个
tool_result 变成单独的 tool 消息,所以带标记的工具结果标记它自己的 tool 消息。合并为一条
消息的块共用该消息的标记,所以较早块上的断点会移到该消息末尾。这适用于 system prompt、
system 回合和 assistant 消息的各块,以及 user 消息中工具结果以外的块。Claude Code 2.1.288
发送的两个 system 断点中,一个位于最后一个 system 块,另一个移到合并后 prompt 的末尾。带
断点的消息翻译后为空时,标记前一条消息。有标记且无提醒时,请求 2 从缓存读取 29,422 token
而不是 29,242,因为第一条 user 消息也被缓存了。
含此改动的构建在同一天的测量:每次请求都以 role: "system" 发送提醒时,五回合 harness
的请求 2 有 98.21%、请求 5 有 98.30% 的输入来自缓存(29,470 → 31,072 token)。真实
Claude Code 2.1.288 以同样的三轮各 14 个并行 Read 调用经过该构建时,热请求分别从缓存
读取 23,534、25,195 和 26,856 token,而前一个请求的总输入分别为 23,538、25,199 和
26,860。这些请求每次都给一个较小的 prompt 增加 14 个文件结果,因此其单个请求的比例为
93.39–94.16%。
tests/unit/chat-route-cache.test.ts 重放这一 Claude Code 请求形状。若翻译后的请求在
prompt 开始后仍带 system 回合,或去掉缓存标记后某个请求的消息不再是下一个请求消息的
前缀,测试就会失败。它还固定了上述回退规则和每个断点落在哪条消息上。Claude Code 改变
harness 文本或断点的发送方式时,需重新捕获请求形状:让客户端使用占位 API key,经由捕获
代理连接一个使用独立端口的隔离测试 relay,绝不使用日常在用的 relay。该 relay 使用保存
Copilot 登录的临时 HOME,使工具循环得到真实回复。只记录角色、块类型和 cache_control
位置,不记录文本或凭据;完成后删除该临时 HOME,然后更新该测试。测试本身与其他测试一样
模拟上游。
github_token 是长期登录/刷新来源。
copilot_token.json 缓存短期 Copilot bearer token 及元数据:
{
"refreshedAt": 0,
"refreshIn": 0,
"token": "..."
}启动时,如果缓存的 Copilot token 还有超过 60 秒有效期就复用;否则用 github_token
刷新。刷新定时器必须使用 unref(),以免让短命的命令一直活着。
刷新期限尚未到达,并不能证明上游仍接受该 token。setupProxyAuth 会在运行时配置上
安装非交互刷新回调。src/copilot/client.ts 中的 fetchCopilot 对 HTTP 401,以及
正文完整内容为纯文本 forbidden 的 HTTP 403 使用该回调(忽略大小写和首尾空白,
最多 128 字节)。结构化的模型、策略或配额拒绝保持原样。provider 保留原 base URL,
但每次尝试都读取当前 token。
定时刷新和请求触发的刷新共用一个正在进行的交换操作。通过实际发送的 token 和刷新 代数识别延迟到达的拒绝,已完成的刷新会被复用,即使替换 token 的文本完全相同。 替换值先写入私有临时文件,再原子重命名覆盖缓存,随后更新内存状态并重新安排定时器。 每个上游操作最多进行一次认证恢复,加上原有瞬时故障重试额度,总计最多三次 HTTP 尝试;chat 到 Responses 的 fallback 是独立操作,但仍受同一个调用方期限约束。
取消或超时的调用方停止等待,且不会重放;其他调用方仍可使用共享交换结果。共享交换 有独立且有限的上游超时(配置禁用超时时使用 180 秒)。只恢复明确被拒绝的 HTTP 响应, 不会重放成功响应或已开始的流。刷新失败直接报告,不触发设备登录,也不会被当作网络 故障反复重试。
Token 恢复日志只包含状态码、路由和结果,不含 bearer 凭据或响应正文。这不是对原始 捕获的保证:用户提示词或上游回显本身仍可能包含密钥。
src/lib/lifecycle.ts 暴露两种检测策略,而它们必须保持不同:
| 命令 | 函数 | 策略 |
|---|---|---|
status |
findRelayOnPort |
端口匹配时用 pid 文件,否则用端口监听检查。绝不做全局进程扫描。 |
stop |
findRelayProcessIds |
做全局扫描,因为清理任意端口上的残留进程正是它的目的。 |
不要把两者"统一"。给 status 加上全局扫描,会让它报告一个"端口上根本没人监听"的
中继(#33);把 stop 收窄到单端口,则会留下残留进程。
无论 status 报告什么,pid 和地址必须来自同一条记录。把用一种方式找到的 pid 和
用另一种方式取到的地址配在一起,正是 #33 里"活的 pid 旁边打印出一个死端口"的成因。
isRelayStartProcess 同时识别 start 和长期运行的 restart 进程,严格检查可执行
文件/入口,而不是任意命令子串。发送信号及升级强制终止前,生命周期代码检查一致的
命令、工作目录和创建时间身份。身份无法取得不是退出证明,PID 被复用也不能授权终止
替代进程。未知/存活 PID 的记录会保留,不会当作清理成功而删除。有歧义的 POSIX 扁平
路径必须通过文件系统验证精确入口,并排除更早的可执行文件/脚本解释;这是保守证据,
不是操作系统级原子身份保证。初次发现状态未知会在有限宽限期内重试,之后失败而不发送
信号。status 对进程检查不确定性输出诊断并以 2 退出,不声称进程不存在。
对于入口不在 copilot-relay 或 copilot-relay-* 目录下的 node <entry> start|restart
进程,例如位于 ~/.copilot-relay/runtime/0.4.1/dist/main.js 的发布版运行时,改由包
清单识别(#113):src/lib/lifecycle.ts 的 packagedEntryCandidate 接受绝对路径或按
cwd 解析的 dist/main.js 或 src/main.ts,packageEntryProof 要求该路径是普通文件,
且其规范路径对应的 ../package.json 中 name 为 "copilot-relay"。任何无法证明的
情况都判定为 nonrelay,与这些命令此前的判定相同,因此无关的
node app/dist/main.js start 不会收到信号,也不会阻塞 stop。
配置损坏时,status 在探测前输出不回显敏感内容的诊断并以 2 退出,不表示 daemon
已停止。stop 可不依赖配置端口提示,继续只处理身份已验证的进程。重启前应修复配置。
| 退出码 | 含义 |
|---|---|
0 |
进程活着并且健康探测通过 |
1 |
没有中继在运行 |
2 |
不可用或无法建立状态 —— 健康/deep 探测失败,或配置不可读 |
一边打印 FAILED 一边以 0 退出,会让每一个脚本调用方把坏掉的中继当成正常
(#34)。
--deep 会额外经由 Copilot 发一个真实请求。它是唯一能证明中继真的可以服务 Claude
Code 的检查,因为 /healthz 和 /v1/models 都不访问上游。它是可选的,因为要花掉
一点 token。
server.close() 会等待已有连接结束,而一个空闲的 Claude Code keep-alive socket 自己
永远不会结束。于是关停会一直挂着,直到 stop 升级为 SIGKILL —— 而 SIGKILL 会跳
过 pid 文件清理,并且照样把流切断(#35)。
处理函数必须立即调用 closeIdleConnections(),并在一个短于 stopProcess 的 5 秒
超时的宽限期之后调用 closeAllConnections()。宽限期等于或超过那个超时,就等于把
这个 bug 放回去。
服务关闭后,startRelay 停止配置 watcher,清理自己的 PID 记录,再等待
flushCaptures 与 flushLogs;强制终止进程不能保证同样的落盘结果。
src/lib/request-trace.ts 的 RequestTrace 包装已接入的客户端正文和实际消费的
上游/下游流,不另开独立 tee。recordedFetch 与 recordedRefresh 保留有序尝试、
为重试丢弃的响应及刷新结果。Manifest 记录请求的策略/目录快照、chunk 长度、观察到的
字节数、正文状态和 handler 是否结束;等正文写入队列完成后,原子替换 meta.json。
私有文件 handle 保持追加目标;目录/文件身份与单链接检查拒绝已观察到的路径替换。
这不能防御同一用户下所有可能的文件系统竞争。传输/正文失败与语义上的拒答不是同一件事。
OutcomeObserver 在正文存储之外提取有界元数据:HTTP 状态、stop/finish/response
终止状态、拒答类别、未完成原因,以及已报告的输入/输出/缓存用量。缺失或过大的元数据
是未知,不代表成功完成或零缓存用量。普通 info 结果行只包含这些元数据,不含提示词
或响应文本。
withTraceObserver 为进程内调用者提供请求作用域的 handle,不新增 HTTP API,也不读取
捕获文件。回调只保存 handle:必须消费响应之后才能等待 finished。probeModels
限制诊断等待时间,之后调用 diagnosticSnapshot,而非输出整个 manifest。快照会用
全部已登记凭据(包括刷新后的 token)重新过滤允许列表中的标识符,只输出已知接口、
状态、结果和捕获状态。未知错误不能证明责任在上游。
src/lib/model-probe-output.ts 负责展示,src/lib/terminal.ts 的颜色策略不依赖模块
导入时的环境快照。Status 文字使用同一策略,但 JSON 和探测逻辑不变。深度检查启动
使用 withoutConsoleLogging 保留文件证据;推理和正文消费使用 withoutLogging
避免原始 payload 泄漏,再单独记录一条安全失败摘要。设备认证提示通过明确回调绕过
安静启动模式。
只有 debug 才生成正文文件。safeHeaders 使用不含认证 header 的允许列表,策略快照
省略 bearer token 和私有上游 URL 尾部。原始正文字节刻意不脱敏,可能包含提示词、
工具结果或上游回显中的凭据。有界异步写入队列不会让磁盘吞吐阻塞转发;过载/写入失败
会将捕获标为不完整,而不是静默截断后仍声称可以重放。操作限制、权限和安全处理见
日志与问题排查。
cleanupCaptures 与日志共用 logRetentionDays,按本地日历日期目录计龄,在启动/
重载时运行。cleanupCapturesIfDue 在非重放请求中增加每小时一次的检查。活动捕获和
owner 存活/未知的 pending 记录保留,只有 ESRCH 才证明 pending owner 已退出。
清理要求目录/manifest 身份有效,且只含已知的普通单链接文件;未知或变化中的内容
不动。flushCaptures 在优雅关停时等待清理与 pending 捕获,不能挽救崩溃或 SIGKILL。
src/replay.ts 的 replayCapture 在进程内调用 createServer(config).fetch 前校验
元数据/schema、固定正文文件名、chunk 总长、路径与大小限制。withRecordedTransport
只按原顺序提供记录的上游响应、错误和刷新结果。不创建监听器/socket,不执行设备认证、
token 交换,不写配置或新捕获。它比较当前 handler 实际生成的出站 JSON 与最终 JSON/SSE;
未消费或意外的操作被标为差异,不回退到网络。已校验 manifest 的请求 ID 通过内部
recorded transport 传入,以准确复现本地错误关联文本;客户端 header 无权选择此 ID。
当前错误文本变化后,旧错误记录合理地可能返回差异。
比较忽略传输 chunk 分界,仅规范化已知的新生成 bridge ID,并保护 provider ID 和字面
内容。Diff 只输出结构路径和固定原因,不输出值或来自 payload 的属性名。MATCH 表示
本地转换一致,不是新的模型执行、缓存基准,也不能证明历史拒答的原因。未完成/中止
捕获不能得到匹配;CLI 结果表见日志与问题排查。
单行与轮转规则来自一个涨到 9.3 GB 的日志。原始捕获文件是独立诊断通道,不是更大的普通日志条目。
src/lib/log.ts 里的 formatLogValue 同时需要 compact: true 和
breakLength: Infinity。
Node 文档读起来像是默认的 compact: 3 就够了 —— 并不够。那个数字统计的是被合并的
内层元素个数,不是阈值,所以它只会折叠嵌套深度不超过该计数的 payload。在一个真实的
4 层错误 payload 上:
| 设置 | 产生行数 |
|---|---|
compact: 3 |
10 |
compact: 1 |
22 |
compact: true |
1 |
tests/unit/log-format.test.ts 固定了这一点;不要把它"简化"掉。多行 dump 还会打断
日志与问题排查里的每一条 grep 配方 —— 因为搜索
返回的会是某个 payload 的第一个片段,而不是匹配的那条日志。
对象检查的边界是深度 6、100 个数组元素、对象内每个字符串 4000 字符。URL 和已登记
凭据脱敏后,再转义换行并按 UTF-8 边界限制最终大小:每个渲染参数 16 KiB、每条文件
日志(含时间戳等格式)64 KiB。两端收到相同的有界 payload,最终大小截断用
[truncated] 标明。这些限制不截断独立的原始捕获。
当前文件是 copilot-relay.<本地日期>.log,每条日志在记录时解析路径,因此无需定时器即可在
本地零点轮转。
保留策略按文件名里的日期判断文件年龄,对没有日期戳的文件退化为按 mtime 判断。
之所以优先用文件名,是因为 mtime 会被备份、cp 以及编辑器碰一下文件而改写,这些都
会悄悄拉长或缩短保留窗口。
在轮转存在之前,保留策略按 mtime 给唯一一个从不轮转的文件计龄,而每一次追加都会刷新 那个 mtime,于是它一次都没有够到过删除条件。
用本地日期而不是 UTC:logRetentionDays 是一个"我要保留几天"的、面向人的设置;而
UTC 戳会让格林尼治以西的人在本地下午的正中间发生文件切换。
日志体量由时间限定,而不是由大小限定。这是接受的取舍(#25)。
src/lib/log.ts 中的 wrapFileLog 在记录日志时就给每条日志加时间戳、选定它的带日期
文件,然后放入队列。同一时间只有一个 drain,它通过在批次之间保持打开的句柄,按调用顺序
追加队列中的日志;每次写入不超过 256 KiB,且总在日志条目边界结束:
FileHandle.appendFile 会把更大的缓冲区分成 512 KiB 的几段写入,另一个向同一文件追加的
进程可能插在两段之间。
一秒内没有写入新日志时,句柄会被关闭,因为文件夹中有文件打开时,Windows 无法重命名或移动
该文件夹。下一条日志会以下文的完整检查重新打开文件。关闭也经过同一条写入链,所以不会在
drain 进行中执行。它的定时器来自 node:timers,而不是全局 setTimeout:测试会替换全局
setTimeout 来捕获或执行被测代码的定时器,日志不能往其中添加定时器。
只有当路径仍指向同一个只有一个链接的私有文件、在 POSIX 上权限仍为 0600,且应用目录和
日志目录仍是打开时检查过的目录(真实目录而不是链接,在 POSIX 上权限为 0700)时,句柄
才会被复用。对文件的 lstat 会跟随父路径中的链接,所以没有目录检查时,被替换成指向已打开
文件新位置的链接的日志目录也能通过检查。重命名、删除、替换、第二个硬链接、被替换的目录或
放宽的权限,都会让下一批重新打开该路径并执行完整检查:先确认目录是真实的私有目录,再确认
不是符号链接、只有一个链接、打开前后是同一个文件,然后 chmod 0600。写入失败永远不会让请求
失败;它丢弃这一批并关闭句柄。flushLogs 等待队列中的写入完成,再关闭文件;每次调用只
等待自己排入的关闭,所以重叠的调用都会结束。
在 Windows 上,对同一个文件,已打开句柄的 stat 与其路径的 stat 可能报告不同的 dev:
Node 22.13.1 的路径 stat 报告 dev 为 0,句柄 stat 报告卷序列号,所以在那里比较 dev 会
拒绝每次打开并丢弃每一批。因此 src/lib/file-identity.ts 的 sameOpenedFile 在 Windows 上
把为 0 的 dev 视为未报告,只比较文件索引(ino),这比同时比较两者的证据弱;两个非 0 的
dev 仍必须相等,其他平台比较两者。两次路径 stat 用严格的 sameFile 比较。debug 捕获写入、
捕获保留清理和 copilot-relay replay 使用同一对函数。
#141 之前,每条日志各自执行目录检查、open、stat、chmod、追加和 close,一批突发日志可能
乱序写入文件。tests/unit/log-format.test.ts 覆盖调用顺序、调用时的时间戳和复用检查。
src/lib/redact.ts 是纯函数,覆盖那些可能在 path、query string 或 fragment 里携带
凭据的 URL。含密钥的网关尾部不能原样进入普通日志。copilotBaseUrl 校验拒绝原始引号、
尖括号、空白和控制字符,因为它们会让整条 URL 的识别出现歧义。src/lib/log.ts 的
registerLogSecret 在内存中保存已知认证凭据(包括轮换前的值,以及 upstreamProxy 的
user:password 与 Basic 值),从普通日志中移除原始或转义形式的回显。即使对象检查转义了分隔符,相邻的
嵌套 URL 也会分别脱敏。这不保证移除
任意提示词/工具中的密钥;分享前仍需审查有界摘录,绝不整份上传原始捕获。见
日志与问题排查。
npm run typecheck
npm run test:unit
npm run test:integration
npm run buildnpm 测试脚本在 tsx 及任何源码 import 之前预载 scripts/test-bootstrap.mjs。
它给每个测试进程分配私有 HOME、USERPROFILE 和临时根目录,退出时只清理自己拥有
的目录。静态 import 也因此受保护;单独跑某个测试时同样使用该预载。
需要独立日志/配置 fixture 的套件仍须在模块首次 import 前重定向,因为
src/lib/paths.ts 只解析一次 os.homedir()。例如:
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-relay-"))
process.env.HOME = tempHome
process.env.USERPROFILE = tempHome
const { readAppConfig } = await import("../../src/lib/app-config")必须同时设置 HOME 和 USERPROFILE。Node 在 Windows 上读 USERPROFILE,而 CI
会跑 windows-latest,所以只设 HOME 会让重定向在那里悄无声息地失效。不这么做的话,
测试每跑一次都会写进开发者真实的 ~/.copilot-relay/logs。
测试文件的 stdout 承载运行器读回的结果帧,因此不能写入其他内容。帧前的普通文本可能让 Node 的
运行器把两次读取按错误顺序拼接,从而丢失该文件的结果。在运行器启动的测试文件中(由
NODE_TEST_CONTEXT=child-v8 和作为 argv[1] 的文件识别),bootstrap 把中继日志和 console
都发往 stderr,运行器把它作为诊断信息报告。测试自己启动的进程保留其 stdout,因为测试把它当作
数据读取。
日志器在记录条目时按本地日期选择文件,因此午夜前刚记录的条目留在当天的文件中,而此时
getLogPath() 已经指向下一天的文件。查找自己记录的条目的测试使用 tests/fixtures/logs.ts
中的 readLogs(),它按从旧到新的顺序读取每个带日期的日志文件。测试日志写入器本身的用例按路径
操作当天的文件,因此用 t.mock.timers 固定时钟。它只 mock Date:写入器只在给条目加时间戳和
安排清理时读取时钟,空闲关闭的定时器仍是真实的。
集成测试让 Hono app 跑在本地 mock 的 Copilot HTTP server 之上。它们绝不可以调用真实 服务 —— CI 上不行,本地也不行。
GitHub 调用经过 relay 的上游 dispatcher,而不是全局 fetch,所以替换 fetch 无法伪造
它们。tests/fixtures/network.ts 提供替身:startFakeGitHub 在本地应答 GitHub,
redirectGitHubTo 给 undici 的 Agent 打补丁,让发往 github.com 和 api.github.com
的请求到达它,refuseExternalConnections 让任何其他离开本机的连接直接抛错。
withProxyEnvironment 为单个测试设置代理变量,结束后恢复。startRecordingProxy 记录
代理收到的请求,startEchoingProxy 用 undici 无法解析、并回显所收到凭据的回复应答每个
CONNECT。
这类替身要在文件中第一个 test() 之前启动。node:test 会在目前已注册的测试全部结束时
立即运行全局 after() 钩子;测试之间若有顶层 await,这可能就发生在等待期间,之后
注册的 after() 钩子永远不会运行。替身于是一直在监听,测试进程永不退出。
tests/unit/wiki-docs.test.ts 用机器强制文档契约本身:wiki/ 是扁平的、每个 EN-
页面都有对应的 ZH- 页面、每个相对链接都能解析,以及真正的代码感知
scripts/publish-wiki.py 变换不留下坏导航。它调用与 workflow 相同的脚本及 Python
fixture,不另写一份正则替代变换。它还检查每个“命令”页面都为 src/main.ts 注册的每个命令写出
一节,并写出其选项与参数;它用 TypeScript 编译器 API 读取这些源文件,而不是导入它们。套件不导入
relay 源码,Python 子进程仍隔离两个 home
变量。离线及发布后校验见开发指南。