# 架构 ## 一句话 插件在 **`globalThis.fetch` 这一层**按目标 host 做两个决定:**走哪条通路**、**以什么客户端身份发出去**。它不改 provider 的 `baseURL`,不起本地中继端口,不写系统代理。 ## 为什么是 fetch | 候选拦截点 | 问题 | |---|---| | provider profile / `baseURL` | 表达不了“同一个 provider 换通路”;改一次要重启;`user-agent` 会被 `attributionHeaders()` 的冲突过滤丢掉 | | `@deepseek-ai/dsh-http-proxy` | 策略是进程级的,装成 undici 全局 dispatcher;`NO_PROXY` 只能按地址打补丁 | | `llm/stream` 事件 | 太晚:URL 与最终请求头都已确定,且拿不到“这次请求实际走了哪条 socket” | | **`globalThis.fetch`** | 入参上同时能看到最终 URL 与拼好的头;pi-ai 提供方栈与模型发现都在调用点解析它 | ## 请求生命周期 ```mermaid flowchart TD A["pi-ai / provider stack
await fetch(url, init)"] --> B["router.wrapped(input, init)"] B --> C["normalizeRequestUrl(url)
修掉紧贴 / 的空白"] C --> D{"findRule(rules, host)"} D -->|"未命中 且 defaults=inherit"| N["原生 fetch
(不记录决策)"] D -->|"命中 或 defaults≠inherit"| E["plan()
指纹头 + 通路"] E --> F{"route.kind"} F -->|inherit| G["原生 fetch
只改头"] F -->|direct| H["httpclient: 直连 socket"] F -->|proxy| I["httpclient: HTTP(S) / SOCKS5 隧道"] H --> J["Response"] I --> J G --> J H -. "连接/协议失败" .-> K["回退原生 fetch
保留已改写头"] I -. "连接/协议失败" .-> K K --> J E --> L["决策环
origin+path / 命中 / 通路 / 状态 / 耗时"] ``` ### 决策细节 1. **URL 归一化只对命中规则的请求做**:`baseURL` 尾部空白是常见事故,探测路径 trim 过,所以它只在真实调用时炸。归一化把“紧贴 `/` 或位于路径末尾”的空白抹掉,段内空白保留。 2. **头改写先读后写**:先取请求上已有的头,再生成指纹头。`anthropic-beta` 是多值累加头,把「原有 beta + `headers` 显式写的 beta + `context-1m-2025-08-07`」去重合并,避免打开 1M 时静默关掉 thinking / tool-streaming。 3. **`inherit` 只改头**:请求仍交给原生 fetch,因此与全局代理策略、HTTP/2、其它传输插件共存。 4. **失败回退不丢指纹**:自有通路失败时回退原生 fetch,但改写后的头仍然保留。`ProxyConfigError`(代理地址非法)与 `UnsupportedBodyError`(FormData/Blob)不计入错误计数,因为它们不是链路故障。 5. **日志脱敏**:决策只记录 `origin + path`,query 与 fragment 一律丢弃。 ## 模块 | 文件 | 职责 | 关键导出 | |---|---|---| | `lib/index.js` | 插件入口:配置收敛、规则装载与合并、安装拦截、注册回环 API | `name`、`inject`、`apply`、`normalizeConfig` | | `lib/router.js` | `globalThis.fetch` 包装:匹配 → 改头 → 选通路 → 决策环 | `createFetchRouter` | | `lib/httpclient.js` | 零依赖传输层:直连、HTTP(S) 代理(绝对 URI / `CONNECT`)、SOCKS5;TLS、重定向、`AbortSignal` | `requestThroughRoute`、`resolveRoute`、`normalizeRequestUrl` | | `lib/probe.js` | 探测引擎:代理可用性、通路×指纹矩阵、推理端点、分类与推荐 | `probeProxy`、`probeMatrix`、`inferencePlan`、`classify`、`recommend` | | `lib/store.js` | 规则归一化、host 匹配、原子持久化(临时文件 + rename、BOM 容忍) | `normalizeRule`、`normalizeRules`、`findRule`、`matchHost`、`loadRules`、`saveRules` | | `lib/fingerprints.js` | 指纹表与响应分类常量 | `FINGERPRINT_CATALOG`、`fingerprintHeaders` | | `lib/api.js` | 宿主 API(界面唯一通道)、provider 发现、凭据软解析 | `createApi`、`API_PATH`、`discoverProviders` | | `client.js` | 浏览器半侧:`window.__ModuleLoader__` bundle,注册 `settings.section` | — | | `scripts/probe-cli.mjs` | 命令行探测:可驱动运行中的插件,也可独立探测端点 | — | ## 规则合并 ```mermaid flowchart LR Y["cordis.patch.yml
config.providers"] --> N1["normalizeRules"] F["$DSH_HOME/net-router/rules.json"] --> N2["normalizeRules"] N1 --> M{"按 provider id 合并"} N2 --> M M -->|"文件里出现的键"| W["文件值优先"] M -->|"文件里没出现的键"| P["patch 值"] W --> L["生效规则"] P --> L ``` - 冲突判断只看 **own key 是否存在**,不看值——否则用户把默认策略从 `direct` 改回 `inherit` 会被 patch 静默压回去。 - 代理池是并集(patch 池 + 文件池),去重。 - 命中优先级:`match` 串更长者优先;同长度按 provider id 字典序,结果稳定可复现。 ## 探测与分类 `probeMatrix` 对每个「通路 × 指纹」格子发一次请求,分类结果: | 分类 | 判据 | 推荐动作 | |---|---|---| | `ok` | 2xx | — | | `client-gate` | 响应体含 `unauthorized client` 等客户端校验标识 | 换指纹 | | `context-gate` | 400 且含 `1m 上下文` / `context-1m` | 开 1M | | `auth` | 401/403 但不是客户端校验 | 查 key | | `model-error` | 模型不存在 | 换模型 | | `timeout` | 未在超时内完成 | **未知,不等于被拦**:调大超时复测 | | `network-error` | 连接层失败 | 换通路 / 查代理 | | `http-error` | 其它状态码 | 看上游原文 | 三条刻意保留的判定原则: 1. 只有观测到明确 `client-gate`,才把“必须伪装”说成结论;超时只作为 caveat。 2. 同一通路上“有的行成功、有的行断连”单独提示——那可能是拒绝而不是抖动。 3. 指纹生效但上游仍报错(400/402)时,结论是“客户端校验已过,问题在上游”,而不是“三种指纹都没放行”。 ## 安全边界 - API 走同源校验:本机回环放行;非回环要求 `Origin` 与 `Host` 同源。 - 规则文件原子替换;读取容忍 BOM;损坏时按空规则继续而不是崩掉插件。 - 凭据只在探测时按引用读取,不落规则文件、不落决策日志、不进错误信息。 - 只包装 `globalThis.fetch`;`node:http` / `node:https` 直连流量(例如遥测)不受影响。 - 卸载只在顶层 fetch 仍是自己时还原,避免踩掉后来安装的包装。 ## 已知边界 - 只实现 HTTP/1.1;不支持 HTTP/2、PAC、SOCKS4。 - FormData / Blob 请求体不承载,会回退原生 fetch。 - 规则按 host 匹配:同一 host 上的多个 provider 共享通路与指纹;需要不同协议时用 `api` 字段区分路由,而不是规则。 - 同一进程里不要同时挂多个“接管 fetch”的插件;共存时把本插件设为 `mode: inherit` + 所需指纹。