# Rule Flags 设计与实操

> 适用对象：tingly-box 后端 / 前端贡献者。
> 本文档描述**当前**的 rule flag 系统的最终设计与实操。

---

## 1. 为什么需要 rule 级 flag

三层语义：

| 维度 | 粒度 | 例子 |
|------|------|------|
| Provider | provider 实例 | `user_agent`、`api_base`、`proxy_url`、`timeout` |
| Scenario flags | scenario | `disable_stream_usage` 等 |
| **Rule flags** | **单条 rule** | **`cursor_compat`、`skip_usage`、`use_max_completion_tokens`、`use_max_tokens`、`custom_user_agent` …** |

很多行为只对**某类客户端 / 某类模型 / 某种调试目的**有意义。塞到 Provider
配置里会污染默认；做成 Scenario flag 又过于粗粒度。Rule flag 是这类
"局部、可选、可叠加开关"的归宿。

设计原则：

1. **可发现**：UI 列出所有可选 flag 及其语义。
2. **可叠加**：同一条 rule 可同时启用多个 flag。
3. **可扩展**：新 flag 只在**一处**注册元数据（`flag_registry.go`），后端
   行为代码 + 前端 UI 不应硬编码 flag 列表。
4. **不污染默认**：未启用时必须是 zero-cost no-op。

---

## 2. 架构图

```
              ┌───────────────────────────────────────────────────┐
              │                    Frontend                        │
              │  FlagCatalogDialog  ◄── GET /rule/flags/registry  │
              │       │                                            │
              │       ▼ Switch / TextField per FlagSpec            │
              │  RuleExtensionsCard  ─── POST /rule/:uuid (flags)  │
              └─────────────────────┬─────────────────────────────┘
                                    │
              ┌─────────────────────▼─────────────────────────────┐
              │                    Backend                          │
              │                                                     │
              │  internal/typ/flag_registry.go                      │
              │      RuleFlagRegistry() []FlagSpec  ◄── 唯一可信源 │
              │                                                     │
              │  internal/typ/type.go                               │
              │      Rule.Flags = RuleFlags{ ...typed fields... }   │
              │                                                     │
              │  Persisted as JSON column on the rule row.          │
              └─────────────────────┬───────────────────────────────┘
                                    │ rule resolved at request time
                                    ▼
   ┌───────────────────────────────────────────────────────────────┐
   │  inbound handler (openai.go / openai_responses.go /            │
   │                   anthropic_v1.go / anthropic_beta.go)         │
   │                                                                │
   │   flags := resolveRuleFlags(c, rule)                           │
   │   ├─ WithCustomUserAgent(ctx, flags.CustomUserAgent)           │  Type 2
   │   ├─ reqCtx.Extra["skip_usage"] = flags.SkipUsage              │  Type 3
   │   ├─ preBase := rulePreBaseTransforms(flags)                   │  Type 1b-pre
   │   │   → [transform.OpenAICursorCompatTransform{}]              │
   │   └─ extras := ruleExtraTransforms(flags)                      │  Type 1b-post
   │       → [transform.OpenAIMaxTokensRewriteTransform{...}]       │
   └───────────────────────────┬───────────────────────────────────┘
                               │
                               ▼
   ┌───────────────────────────────────────────────────────────────┐
   │   transformXxxx(..., preBase, extras...) :                     │
   │     chain := BuildTransformChain(...)                          │
   │     prependPreBaseTransforms(chain, preBase)                   │
   │     appendExtraTransforms(chain, extras)                       │
   │     chain.Execute(ctx)                                         │
   │                                                                │
   │     preBase → Base → MCP → Consistency → Vendor → extras       │
   │   (pre-base stages)                       (post-base stages)   │
   └───────────────────────────┬───────────────────────────────────┘
                               ▼
                       upstream provider
```

---

## 3. Flag Registry — 唯一可信源

```go
// internal/typ/flag_registry.go
type FlagSpec struct {
    Key         string        // 与 RuleFlags 上的 json tag 完全对应
    Label       string        // UI 展示用人话
    Description string        // hover 详细说明
    Type        FlagValueType // bool | string | enum
    Category    FlagCategory  // compatibility | request | response
    Placeholder string        // string 类型的输入框 hint
    Options     []FlagOption  // enum 类型的可选值，按显示顺序
}

type FlagOption struct {
    Value string // 持久化到 RuleFlags 的字符串
    Label string // UI 展示
}

func RuleFlagRegistry() []FlagSpec { … }
```

**约束**（`flag_registry_test.go` 强制）：

- 每个 `FlagSpec.Key` **必须**对应 `RuleFlags` struct 上的某个 json
  tag。这条测试挡住"加了 spec 忘了加字段"或反过来。
- `Label` / `Description` 非空。
- `Type` 必须在已知枚举内。
- `enum` 类型必须声明 ≥ 2 个 `Options`，每个 Option 的 `Value` / `Label`
  非空且不重复。首个 Option 视为默认值（UI 显示为"未启用"状态）。

---

## 4. 当前已注册 flag

| Key | Type | 类别 | 作用 | 注入点 |
|-----|------|------|------|--------|
| `cursor_compat` | bool | compatibility | Cursor IDE 内容归一化 + stream usage 抑制 | `transform.OpenAICursorCompatTransform` → `ops.ApplyCursorCompatContentNormalization`（Type 1b-pre：pre-Base chain stage）|
| `cursor_compat_auto` | bool | compatibility | 通过请求头识别 Cursor，自动折叠进 `cursor_compat` | `resolveRuleFlags(c, rule)` 在 handler 入口合并 |
| `skip_usage` | bool | response | 剥离响应中的 `usage`（流式 + 非流式 + Anthropic 转 OpenAI 路径） | `shouldStripUsage(reqCtx.Extra)`（Type 3）|
| `use_max_completion_tokens` | bool | request | 把 `max_tokens` 字段名重写为 `max_completion_tokens`（OpenAI o1/o3/gpt-5 系列必需） | `transform.OpenAIMaxTokensRewriteTransform` → `ops.ApplyMaxCompletionTokensRewrite`（Type 1b）|
| `use_max_tokens` | bool | request | 反向：把 `max_completion_tokens` 写回旧字段 `max_tokens`（用于拒绝新字段的 provider/模型）| 同上 → `ops.ApplyMaxTokensRewrite`（Type 1b）|
| `custom_user_agent` | string | request | 覆盖出站 User-Agent header | `customUserAgentTransport` + `WithCustomUserAgent(ctx, ...)`（Type 2）|
| `openai_endpoint_override` | enum (`auto`/`chat`/`responses`) | request | 强制单条 rule 的 OpenAI 出口走 Chat 或 Responses；与 provider 声明的 `OpenAIEndpointMode` 冲突时 provider 赢（见 `.design/openai-endpoint-routing.md`）| `ParseEndpointOverride` → `ResolveOpenAIEndpoint`（Type 4：路由层决策）|
| `block_tools` | string (逗号分隔) | request | 按名字从请求 tool list 中剔除指定工具（发出前），跨 OpenAI Chat / Responses / Anthropic / Google 入站形态生效 | `transform.ToolBlockTransform` → `ops.ApplyToolBlock*`（Type 1b-pre：pre-Base chain stage）|

---

## 5. 五种 flag 注入手法

```
Type 1b-pre  Request body, pre-Base Transform
         作为 Transform 接口的实现 prepend 到 chain 最前（BaseTransform
         之前）运行；type-switch 决定是否对 inbound request 形态生效。
         例：OpenAICursorCompatTransform —— 在 Base 把 OpenAI Chat 转
         成其他形态之前 flatten 富文本内容。聚合点：
         `internal/server/rule_flags.go::rulePreBaseTransforms`。

Type 1b-post Request body, post-Base Transform（推荐用于跨协议 rewrite）
         作为 Transform 接口的实现 append 到 chain 末尾，在 BaseTransform
         完成协议转换之后运行；type-switch 决定是否对目标 request 形态
         生效。例：OpenAIMaxTokensRewriteTransform。聚合点：
         `internal/server/rule_flags.go::ruleExtraTransforms`。

Type 2   Per-request context hint
         handler 把 c.Request 的 ctx 替换成带 hint 的；transport /
         round-tripper 等深层组件读 ctx。例：custom_user_agent。

Type 3   Response post-processing
         handler 把 flag 写进 reqCtx.Extra；protocol_dispatch.go 的派
         发分支用 shouldStripUsage 这类统一判定来决定剥/改字段。
         例：skip_usage。

Type 4   Routing-decision input
         handler 把 flag 解析后传给 ResolveOpenAIEndpoint；路由层在
         Transform chain 构造**之前**就决定走哪条出口。Provider 声明
         的 OpenAIEndpointMode 优先于 rule flag 冲突时（详见
         .design/openai-endpoint-routing.md）。不进 ExtraFields、不进
         ctx。例：openai_endpoint_override。
```

任何新 flag 都应归入这几类之一。pre-Base 和 post-Base Transform 都是同一
个 `Transform` 接口的实现，区别只在 chain 中的位置：pre 在 BaseTransform
之前（看见 inbound 形态），post 在之后（看见目标形态）。聚合点
`rulePreBaseTransforms` / `ruleExtraTransforms` 决定 flag 装哪边。

---

## 6. op vs Transform —— Type 1b 内部的分层

Type 1b（pre-Base 与 post-Base 同形）涉及两层抽象，**职责必须分开**：

| 层 | 位置 | 职责 | 例子 |
|----|------|------|------|
| **op（操作原语）** | `internal/protocol/transform/ops/` | 纯函数。对某个具体 request 类型做无副作用的字段改写。**不感知链路、不感知 rule、不感知 ctx**。 | `ops.ApplyMaxCompletionTokensRewrite(*openai.ChatCompletionNewParams)`、`ops.ApplyCursorCompatContentNormalization(*openai.ChatCompletionNewParams)` |
| **Transform（链路阶段，协议层）** | `internal/protocol/transform/` | 实现 `Transform` 接口。构造期接受配置；`Apply()` 里 type-switch `ctx.Request`，匹配目标类型时调 op。**仅依赖协议层 / SDK 类型**。pre/post 之分由聚合点（`rulePreBaseTransforms` / `ruleExtraTransforms`）决定，与 Transform 实现本身无关。 | `transform.OpenAIMaxTokensRewriteTransform`、`transform.OpenAICursorCompatTransform` |
| **Transform（链路阶段，server-domain）** | `internal/server/transform_*.go` | 同 Transform 接口，但需要 server-domain 类型（如 `*typ.ScenarioConfig`）。 | `ThinkingModeTransform`、`MaxTokensTransform`（Anthropic 上限）、`CleanHeaderTransform` |

**为什么必须分两层？**

- `BaseTransform` 负责跨协议转换（Anthropic ↔ OpenAI / Responses /
  Google）。如果在链外直接改 inbound 请求，遇到 "Anthropic inbound →
  OpenAI Chat target" 的路径，改的还是 Anthropic 形态，rewrite 在
  Base 转换之后就丢失。
- 把 rewrite 包成一个 Transform 并加在 Base **之后**（post-Base），它
  看见的是已经转换好的 `*openai.ChatCompletionNewParams`——无论 inbound
  是什么形态，目标是 OpenAI Chat 时就能命中。`max_tokens` 走这条。
- 反过来，如果 mutation 的语义就是"在 inbound 形态上做归一化"，则放
  Base **之前**（pre-Base）。`cursor_compat` 走这条：cursor IDE 只发
  OpenAI Chat，归一化只在 inbound=Chat 时有意义；放 post-Base 反而要
  对每个目标形态各写一遍 op。
- op 是纯函数：可以独立单测（含 wire 序列化）、可以被多个 Transform
  复用、可以被多端调用而不被 chain 绑死。

**为什么再分协议层 vs server-domain？**

- 协议层 Transform 只依赖 SDK 类型，可以放在 `protocol/transform/`，与
  `BaseTransform`、`ConsistencyTransform`、`VendorTransform` 同包。
- 一旦 Transform 需要读 `*typ.ScenarioConfig`、`*typ.Rule` 等 server
  类型，就放到 `internal/server/transform_*.go`，避免反向依赖。

---

## 7. 链路 wiring

```
handler                            transformXxxx                       chain
  │                                     │                                │
  │ flags  := resolveRuleFlags(c, rule) │                                │
  │ preBase:= rulePreBaseTransforms(    │                                │
  │             flags)                  │                                │
  │  (e.g. [OpenAICursorCompat])        │                                │
  │ extras := ruleExtraTransforms(      │                                │
  │             flags)                  │                                │
  │  (e.g. [OpenAIMaxTokensRewrite])    │                                │
  ├──────── preBase, extras... ────────►│                                │
  │                                     │  chain := BuildTransformChain  │
  │                                     │           (...)                │
  │                                     ├──────────────────────────────►│
  │                                     │  prependPreBaseTransforms(     │
  │                                     │      chain, preBase)           │
  │                                     │  appendExtraTransforms(        │
  │                                     │      chain, extraTransforms)   │
  │                                     │  chain.Execute(ctx)            │
  │                                     ├──────────────────────────────►│
```

关键约束：

- `BuildTransformChain` 不感知 rule flag。它的输入是 scenario / provider
  / recording——把哪些 rule Transform 要 prepend / append 的决策留给
  handler。
- 所有 4 个 `transformXxxx`（`transformAnthropicV1`、
  `transformAnthropicBeta`、`transformOpenAIChat`、
  `transformOpenAIResponses`）都接受一个 `preBaseTransforms []transform.Transform`
  位置参数 + variadic `extraTransforms ...transform.Transform`，分别用
  `prependPreBaseTransforms(chain, preBase)` 和
  `appendExtraTransforms(chain, extras)` 装到 chain 两端。
- `rulePreBaseTransforms(flags)` 和 `ruleExtraTransforms(flags)`
  （`internal/server/rule_flags.go`）是 rule→Transform 的两个聚合点。
  新增 rule-driven Transform 时，按"作用于 inbound 形态" / "作用于目标
  形态"二选一，往对应聚合点追加 case；handler 端不需要改动。

---

## 8. UA 链层 — 实操中最易踩坑的部分

User-Agent 优先级（每一层都是"set then delegate"，**innermost wins**）：

```
   ┌────────────────────────────────────┐
   │  outer adapter (e.g. claudeRT)     │ ← vendor 硬编码 UA，先 set
   │     │  set "claude-cli/2.1.86"     │
   │     ▼ delegates to                 │
   │  wrapWithUserAgent(provider)       │ ← provider.UserAgent（调试 override）
   │     │  if non-empty: set provider  │   非空时覆盖 vendor 硬编码
   │     ▼                              │
   │  customUserAgentTransport          │ ← rule-level custom_user_agent
   │     │  if ctx has UA: set rule UA  │   非空时覆盖一切（innermost wins）
   │     ▼                              │
   │  base http.Transport (sends wire)  │
   └────────────────────────────────────┘
```

最终结果：

| 场景 | rule UA | provider UA | vendor 硬编码 | 实际发出 |
|------|---------|-------------|--------------|---------|
| 默认 | 空 | 空 | claude-cli/… | claude-cli/… |
| Provider 配了 | 空 | "MyOrg/1.0" | claude-cli/… | MyOrg/1.0 |
| Rule 配了 | "Bench/1" | "MyOrg/1.0" | claude-cli/… | Bench/1 |
| Rule 配了 | "Bench/1" | 空 | — | Bench/1 |

⚠️ `provider.UserAgent` 一旦设置就会覆盖 vendor 硬编码——这是**有意为之**
的调试通道（见 `ai/provider.go` 该字段的 doc comment）。Claude Code OAuth
等端点对 UA 有真实校验，错配会让 OAuth 直接被拒；设置该字段时要清楚自己
在干嘛。

并非所有 client 都接入这条链：

| Client | Provider UA | Rule UA |
|--------|-------------|---------|
| 通用 OpenAI (`client/openai.go`) | ✅ | ✅ |
| 通用 Anthropic 非-OAuth (`client/anthropic.go` else 分支) | ✅ | ✅ |
| Claude Code OAuth (`claudeRoundTripper`) | ✅ | ❌ |
| Codex / Gemini / Google | ✅ | ❌ |

vendor-specialized 路径不接 rule UA：它们 UA 跟整个 OAuth/握手协议绑
定，rule 级覆盖反而会破坏 vendor 校验。这条边界写进了
`flag_registry.go` 中 `custom_user_agent` 的 description 里。

---

## 9. 前端 UI

### 卡片位置

```
┌─────────────────────────────────────────────────────────────────────┐
│ Rule Card                                            [⚙ settings]    │
│                                                                      │
│  ┌──────────────────────────────── horizontal scroll ──→  ┐ ┌─────┐  │
│  │ ModelNode → ArrowNode → ProviderNode → ProviderNode … │ │ Ext │  │
│  │                                                        │ │Card │  │
│  └────────────────────────────────────────────────────────┘ └─────┘  │
│           ↑                                                    ↑     │
│   随 provider 增多可横向滚动                          常驻右侧不滚动 │
└─────────────────────────────────────────────────────────────────────┘
```

关键 CSS 决策：

- 外层 `display: flex`，graph 在 `flexGrow:1, minWidth:0` 的 box 里启用
  `overflowX:auto`；`minWidth:0` 是让 flex item 能收缩到内容宽以下的关
  键，否则会把 Extensions 推出视口。
- Extensions Card `flexShrink:0`——滚动条出现时它**不缩**。
- Card 高度 = `PROVIDER_NODE_STYLES.height`（72px），视觉与 provider 行对
  齐；内容溢出时 card 内 `overflowY:auto`。

### Catalog 弹窗

按 `FlagSpec.Category` 分组（compatibility / request / response）。每个
flag：

- `type: bool` → 一个 `Switch`。
- `type: string` → `Switch`（占位，未来可改成独立 enable 控制）+
  `TextField`。当前空字符串视为未启用。
- 已启用的 flag 在边框 / 背景上有高亮。

### 路由图齿轮菜单

齿轮菜单只保留通用项（Test Probe、Export、Activate/Deactivate、Edit
flag、Delete）。**Cursor 专用菜单项已删除**——所有 flag 都通过右侧
Extensions Card 操作。

---

## 10. 新增一个 flag — 操作手册

按以下顺序，**勿乱序**：

```
1. internal/typ/type.go
   ├─ 给 RuleFlags struct 加一个字段（snake_case json tag）
   └─ yaml tag 与 json 保持一致

2. internal/typ/flag_registry.go
   └─ 在 RuleFlagRegistry() 追加一个 FlagSpec，
      key 必须与上一步的 json tag 一字不差

3. 选定 §5 的注入类型，落地行为：

   ┌─────────────────────────────────────────────────────────────┐
   │ Type 1b (Transform — 推荐)                                   │
   │   ① internal/protocol/transform/ops/<xxx>.go：写 op 原语，    │
   │      签名形如 ApplyXxx(*openai.ChatCompletionNewParams) 或    │
   │      其他具体 request 类型。op 必须纯函数、无 rule 感知。     │
   │   ② internal/protocol/transform/<xxx>.go：写 Transform，      │
   │      构造期接受配置，Apply() 里 type-switch ctx.Request，     │
   │      匹配目标类型时调 op。如需 server-domain 类型才放到       │
   │      internal/server/ 下。                                    │
   │   ③ pre-Base / post-Base 二选一：                             │
   │      • 作用于 inbound 形态（cursor_compat 类）→               │
   │        internal/server/rule_flags.go::rulePreBaseTransforms   │
   │      • 作用于目标形态（max_tokens 类）→                       │
   │        internal/server/rule_flags.go::ruleExtraTransforms     │
   │   ④ handler 端无需改动——4 个 handler 都已调                  │
   │      rulePreBaseTransforms(flags) / ruleExtraTransforms(flags)│
   │                                                              │
   │ Type 2 (context-passed hint)                                 │
   │   ① 在 internal/typ/id.go 加 contextKey + 一对                │
   │      WithXxx / GetXxx helper。                                │
   │   ② handler 入口 c.Request = c.Request.WithContext(WithXxx)。 │
   │   ③ 消费方（transport / round-tripper）读 GetXxx。            │
   │                                                              │
   │ Type 3 (response 后置加工)                                    │
   │   ① handler 把 flag 值写进 reqCtx.Extra。                    │
   │   ② internal/server/protocol_dispatch.go：在派发分支调用      │
   │      shouldStripUsage(...) 这类聚合判定。                     │
   └─────────────────────────────────────────────────────────────┘

4. 测试位置随注入类型走：
   ├─ op 单元测试 → 与 op 同包 (`internal/protocol/transform/ops/`)
   ├─ Transform 行为测试 → 与 Transform 同包
   │     必备 case：在目标 request 类型上启用 / 在其他类型上 no-op /
   │              chain 中配合 stub BaseTransform 验证位置正确：
   │              • post-Base：跟在 stub Base 之后，验证目标形态生效
   │                （max_tokens 模式）。
   │              • pre-Base：跟在 stub Base 之前，验证 inbound 形态
   │                生效（cursor_compat 模式）。
   └─ wire 序列化测试（仅对改字段名的 op 有意义）：marshal 前后断言
       JSON 顶层 key 出现/消失，挡 SDK omitzero tag 失效。

5. frontend/src/components/RoutingGraphTypes.ts
   ├─ RuleFlags 接口加 camelCase 字段
   └─ RuleFlagsApi 接口加 snake_case 字段（与后端 json tag 对齐）

6. frontend/src/components/rule-card/utils.ts
   ├─ ruleToConfigRecord：snake_case → camelCase 映射
   ├─ formatRuleFlags / parseRuleFlags：扩展 string-key 兼容路径
   └─ countActiveFlags：新字段计入

7. frontend/src/components/rule-card/RuleExtensionsCard.tsx
   └─ flagBoolValue / flagStringValue switch 增加 case

8. frontend/src/components/rule-card/FlagCatalogDialog.tsx
   └─ flagToBool / flagToString / setBool / setString switch 增加 case

9. frontend/src/components/rule-card/useRuleCardHooks.ts
   └─ autoSave 的 flags 序列化：camelCase → snake_case payload

10. (可选) 重跑 frontend `npm run gen:api`（CLAUDE.md 约定）
```

`TestRuleFlagRegistry_KeysMatchStructFields` 会在第 1、2 步任一步漏改
时立即红——这是链路最稳的安全网。

---

## 11. 设计取舍

| 选项 | 已采纳 | 备择 | 取舍理由 |
|------|--------|------|----------|
| `RuleFlags` 用 typed struct vs `map[string]any` | typed struct | map | 编译期检查；JSON 兼容性靠 `omitempty` 与零值 |
| Registry 由后端 owner | ✅ | 前端硬编码 + 后端硬编码两边对 | 单一可信源，新 flag 只动一处元数据 |
| 卡片放路由图内 vs 固定右侧 | 固定右侧 | 卡片随路由图滚动 | flag 是 rule 级而非 provider 级，常驻可见更符合心智模型 |
| string flag 的"启用"语义 | 空 = 未启用 | 独立 enable Switch + 文本 | 一个字段一个状态，UI 更简单；权衡是无法区分"空字符串"和"未配置" |
| UA 链 vendor pin 是否不可覆盖 | 否，可被 provider/rule 覆盖 | vendor pin 强制 | 把 `provider.UserAgent` 当调试 override 更灵活；用 doc comment 规约 |
| 请求字段重写：handler pre-chain mutate vs post-base Transform | post-base Transform | handler 链外直改 | 链外直改在跨协议路径（Anthropic→OpenAI）失效；Transform 在 Base 之后看到的是最终形态，所有 inbound 类型都能命中 |
| op vs Transform 是否合并 | 分两层 | 把 op 直接做成 Transform | op 是纯函数原语（可独立测、可复用、可多端调用）；Transform 才感知 rule 与链路位置。合并会让原语难复用 |
| Transform 放协议层包 vs server 包 | 视依赖而定 | 全部塞 server | 只依赖 SDK / 协议类型的放 `internal/protocol/transform/`；需要 server-domain 类型的放 `internal/server/`。避免反向依赖 |
| `BuildTransformChain` 是否感知 rule | 否 | 把 ruleFlags 传入 | chain builder 只懂 scenario/provider/recording；rule 级关心点放在 handler，通过 `extraTransforms ...transform.Transform` variadic 注入 |
| 取消路由图齿轮菜单中的 Cursor 专用项 | ✅ | 同时保留菜单项和 Extensions 卡片 | 两套入口对同一字段会引发混淆 |

---

## 12. 未做 / 后续可做

- **UI**：string flag 加独立 enable Switch，让"空"与"未启用"可区分。
- **UI**：Catalog 加搜索框 / category collapse（flag 数量超过 ~8 个时
  会拥挤）。
- ~~**后端**：`cursor_compat` 内容归一化目前是 Type 1a（pre-chain）~~。
  **已完成**：cursor_compat 现在是 Type 1b-pre（pre-Base Transform，
  `transform.OpenAICursorCompatTransform`），通过
  `rulePreBaseTransforms` prepend 到 chain 最前；handler 入口的 pre-chain
  mutation 已移除。Inbound 形态非 OpenAI Chat 时 type-switch 自然 no-op，
  与 "cursor 归一化只对 OpenAI 入站才有意义" 的设计意图一致。
- **后端**：`internal/client/openai.go` 通用 OpenAI client 用
  `http.DefaultTransport` 而非 `createSessionBoundTransport`，没有走
  transport pool / session 隔离——独立问题，不在 rule flag 范畴，但
  补齐时记得保留 §8 的两层 UA 包装顺序。
- **后端**：部分 ScenarioFlags 可下沉成 rule flag（例如
  `disable_stream_usage` 已与 `skip_usage` 高度重叠）。
- **测试**：当前覆盖 op / Transform / handler helper / wire 序列化四
  层，缺一个 "rule 配了 skip_usage → 真实 HTTP 响应里没有 usage" 的端
  到端 case，待 mock provider fixture 成熟后补。
