# 脚本开发指南

[English](scripting.md) | [简体中文](scripting.zh-CN.md)

Protoflux 内置 **JavaScript 脚本引擎**（基于 QuickJS/rquickjs），支持对请求和响应的 JSON body 进行自定义转换。脚本运行在沙箱环境中，有严格的资源限制，在保持安全性的同时提供完整的编程能力。

JavaScript 引擎**保留 JSON key 的插入顺序**（ECMAScript 语言规范保证），因此可安全用于请求和响应两种变换场景——包括需要字节级前缀匹配的 Anthropic prompt caching。

## 适用场景

| 场景 | 推荐方式 |
|------|---------|
| 简单的字段增删改 | `request_payload`（声明式规则） |
| 请求体复杂变换 | **JavaScript 脚本** (`request_transform_before` / `request_transform_after`) |
| 响应后处理（如脱敏、添加元数据） | **JavaScript 脚本** (`response_transform`) |
| 协议适配（如注入 thinking blocks） | **JavaScript 脚本** 或内置 Pipeline Steps |

声明式 `request_payload` 规则覆盖约 90% 的简单场景。JavaScript 脚本处理其他所有场景——复杂逻辑、条件分支、跨字段计算——适用于请求和响应两个阶段。

## 内置 Pipeline Steps

Protoflux 提供一组内置的 Rust Pipeline Steps，直接操作 `serde_json::Value`（使用 `IndexMap` 保持插入顺序）。这些步骤在请求 pipeline 中自动注入，无需配置。

### 自动注入的步骤

| 步骤 | 注入条件 | 作用 |
|------|---------|------|
| `AnthropicThinkingNormalize` | Anthropic 协议 + DeepSeek 模型 | 为 assistant 消息注入空的 `thinking` block（DeepSeek v4-pro 要求） |
| `OpenAIReasoningContentNormalize` | base_url 包含 'dashscope' | 在 assistant 的 tool_call 消息中注入空的 `reasoning_content` 字段（DashScope 启用推理时需要） |

### 步骤注册表（StepRegistry）

内置步骤通过 `StepRegistry` 统一管理。每个步骤声明其注入条件（目标协议、模型名匹配、base_url 匹配等），pipeline builder 在路由解析时自动发现并注入适用的步骤。

```rust
// 示例：AnthropicThinkingNormalize 的注册
StepDescriptor {
    name: "AnthropicThinkingNormalize",
    priority: 10,
    target_protocol: Some(Protocol::Anthropic),
    upstream_model_contains: Some("deepseek"),
    base_url_contains: None,
    factory: || Arc::new(AnthropicThinkingNormalizeStep::new()),
}
```

**添加新的内置步骤：** 只需在 `registry.rs` 中实现 `PayloadStep` trait 并注册，无需修改 builder 或 routing 代码。

## 快速开始

脚本是一个定义 `transform` 函数的 JavaScript 模块。函数接收 body 和 context，必须返回（可能已修改的）body：

```javascript
function transform(body, context) {
    // 如果没有消息，注入默认 system prompt
    if (!body.messages || body.messages.length === 0) {
        body.messages = [{ role: "system", content: "You are a helpful assistant." }];
    }
    return body;
}
```

通过 Console API 挂载到 **upstream** 或 **model** 上。请求侧脚本有两个独立槽——`request_transform_before`（翻译前，看客户端协议 body，可触发跨协议 `useModel`）与 `request_transform_after`（翻译后，看上游协议 body，`useModel` 仅同协议）；两者可只配其一、都配、或都不配，各自接受内联字符串或 `{ "file": "path" }`：

**内联脚本**（after 槽——简单字段改写）。在 Console 里配置（不用手写 JSON）：打开上游或模型 → **转换脚本(JavaScript)** 区 → **请求** 标签 → **翻译后** 槽 → 把脚本粘进内联编辑器：

```javascript
function transform(body, context) { body.model = context.upstreamModel; return body; }
```

**文件脚本**（before 槽——翻译前变换）。在 Console 里配置：打开上游或模型 → **转换脚本(JavaScript)** → **请求** → **翻译前** 槽 → 把脚本内容粘进内联编辑器。（Console UI 仅提供内联编辑器；`{ "file": "path" }` 形态如 `scripts/inject-thinking.js` 是 Console API 侧的文件脚本形态。）

## 基础概念

### `transform` 函数

每个脚本必须定义如下签名的 `transform` 函数：

```javascript
function transform(body, context) {
    // 根据需要读取和修改 `body`
    // 读取 `context` 获取元数据（只读）
    return body;  // 必须——返回转换后的 body
}
```

| 参数 | 可修改 | 说明 |
|------|--------|------|
| `body` | 是 | JSON 请求体或响应体，JavaScript 对象 |
| `context` | 否 | 请求/响应元数据（模型名、headers 等） |

网关调用 `transform(body, context)` 并使用返回值作为新的 payload。你可以就地修改 `body` 并返回它，也可以构造并返回一个新对象。

```javascript
function transform(body, context) {
    body.temperature = 0.7;
    body.max_tokens = 4096;
    delete body.unsupported_field;
    return body;
}
```

> ⚠️ **每个 return 路径都必须返回 body。** `return;`（无值）或忘记 return 会导致脚本静默失败（`log-and-continue` 模式下请求以原始 body 继续，会记录一条 `warn` 日志，但响应不报错）。提前返回时同样需要 `return body`：
>
> ```javascript
> function transform(body, context) {
>     // ✅ 正确：提前返回时也带 body
>     if (!body.messages) return body;
>
>     // ❌ 错误：return 无值 → 返回 undefined → 脚本静默失败
>     if (!body.messages) return;
>
>     body.messages[0].content = "modified";
>     return body;
> }
> ```

### JavaScript 语法参考

脚本使用标准 ECMAScript 语法。关键模式：

```javascript
// 变量（let = 可变，const = 块级作用域常量）
let x = 42;
const name = "protoflux";

// 对象（普通 JS 对象，保留插入顺序）
const obj = { key: "value", nested: { a: 1 } };

// 数组
const arr = [1, 2, 3];
arr.push(4);

// 模板字符串
const msg = `Hello, ${name}!`;

// 条件
if (x > 10) {
    body.flag = true;
} else {
    body.flag = false;
}

// for-of 循环（遍历值）
for (const item of body.messages) {
    if (item.role === "user") {
        item.content = str_to_lower(item.content);
    }
}

// 经典 for 循环（按索引遍历）
for (let i = 0; i < body.messages.length; i++) {
    body.messages[i].processed = true;
}

// 辅助函数（脚本内允许）
function inject_field(obj, field, value) {
    if (!(field in obj)) {
        obj[field] = value;
    }
}
```

### 类型系统

JSON 值自然映射到 JavaScript 类型：

| JSON | JavaScript | 示例 |
|------|------------|------|
| `string` | `string` | `"hello"` |
| `number`（整数） | `number` | `42` |
| `number`（浮点） | `number` | `3.14` |
| `boolean` | `boolean` | `true` |
| `null` | `null` | `null` |
| `object` | `Object` | `{ key: "value" }` |
| `array` | `Array` | `[1, 2, 3]` |

使用标准 JavaScript 模式检查缺失/可选字段：

```javascript
// 检查路径是否返回值
const val = json_path(body, "temperature");
if (val === undefined) {
    body.temperature = 1.0;
}

// 或简单使用真值判断（捕获 null、undefined、0、""、false）
if (!body.temperature) {
    body.temperature = 1.0;
}

// 检查对象上是否存在某个属性
if ("temperature" in body) {
    // 字段存在
}
```

## 上下文对象（`context`）

`context` 对象提供当前请求/响应的元数据。多数字段只读；此外提供 `context.useModel(name)` 方法用于声明路由切换意图（见本节末尾）。

### 请求和响应阶段均可访问

| 字段 | 类型 | 说明 | 示例 |
|------|------|------|------|
| `context.clientModel` | string | 客户端看到的模型名 | `"gpt-4o"` |
| `context.upstreamModel` | string | 发送给上游的模型 ID | `"gpt-4o-2024-05-13"` |
| `context.upstreamName` | string | 上游分组名 | `"openai-main"` |
| `context.sourceProtocol` | string | 客户端协议 | `"OpenAIChatCompletions"` |
| `context.targetProtocol` | string | 上游协议 | `"Anthropic"` |
| `context.stream` | boolean | 是否为流式请求 | `true` |
| `context.headers` | object | 请求 headers（敏感 headers 已过滤，见下方） | `context.headers["content-type"]` |
| `context.query` | object | URL 查询参数 | `context.query["version"]` |
| `context.requestId` | string | 唯一请求 ID（UUID v4，请求和响应共享） | `"550e8400-..."` |
| `context.clientIp` | string | 客户端 IP（来自 X-Forwarded-For / X-Real-IP） | `"203.0.113.50"` |
| `context.accessKeyName` | string | 访问密钥名，未认证时为空字符串 | `"user-alice"` |
| `context.accessKeyGroup` | string | 访问密钥分组，未认证时为空字符串 | `"team-alpha"` |

### 仅响应阶段可访问

| 字段 | 类型 | 说明 |
|------|------|------|
| `context.responseStatus` | number / `null` | 上游 HTTP 状态码，请求阶段为 `null` |
| `context.responseHeaders` | object | 上游响应 headers |
| `context.elapsedMs` | number / `null` | 从请求接收到当前的耗时（毫秒），请求阶段为 `null` |

### 安全：被过滤的 Headers

以下 headers 在脚本可见之前**始终被移除**，防止凭证泄露：

- `authorization`
- `cookie` / `set-cookie`
- `x-api-key`
- `proxy-authorization`

```javascript
// 可以访问
const contentType = context.headers["content-type"];

// 返回 undefined —— 被安全机制过滤
const auth = context.headers["authorization"];  // 始终为 undefined
```

### 切换模型：`context.useModel(name)`

脚本对模型的操作分两种，语义不同，适用场景互补：

| 操作 | 写法 | gateway 行为 | 适用 |
|------|------|--------------|------|
| 改请求体 | `body.model = "X"` | route 元数据不变，仅 body 的 model 字段变 | 同端点切换（同一上游、同一 endpoint path） |
| 改模型 | `context.useModel("X")` | 重新解析目标 route，整套 `base_url`/`kind`/`direct_path`/`upstream_name`/`protocol`/`extra_config` 一起从目标 route 借用 | 跨端点切换（端点 path 不同，如文本模型 → 多模态模型） |

`body.model` 只改一个字段，不重新解析 route；`context.useModel` 是完整路由切换——按模型名重新解析必然连带 `base_url`/`kind`/`direct_path` 一起变，不能只摘其中一项。目标模型若在 model 级配了完整 URL（`direct_path = true`），只改 `body.model` 构造不出正确的上游 URL。

**`name` 参数**：传给 `useModel` 的是目标模型的 **canonical name 或 alias**（`[[models]].name` / `[[models]].aliases`，即客户端请求时用的网关侧模型名），**不是** `upstream_model_id`（provider 侧 ID）。handler 解析 route 后自动推导上游侧 ID（优先级 `upstream_inference_profile` > `upstream_model_id` > canonical name）并写入 `body.model`——脚本只需知道网关侧名，与客户端发初始请求用同名。

**双 swap 点语义**：`context.useModel(name)` 只把目标名写入控制面回写槽，不立即切换。handler 在请求管线的两个位置读取声明并交换路由，位置决定语义：

- **Before Rules（eager-swap，翻译前）**：脚本在协议翻译**之前**运行，看到的是客户端格式 body。声明 `useModel` 后，handler 在翻译**之前**交换路由——后续的翻译步骤用目标 route 的 translator 重新翻译 body。因此 Before 位置**支持跨协议切换**（如 DashScope 文本模型 → 多模态模型，端点 path 与协议都不同）。目标 route 成为活跃路由，其完整 request 侧 pipeline 都会运行：它的 before-script 也会在仍为客户端格式的 body 上执行（此时翻译尚未发生），它的 main pipeline 负责翻译与规则。若目标 route 的 before-script 再声明 `useModel`，链式继续（A→B→C），每个 route 的 before-script 至多执行一次，8 跳上限防止误配死循环（A→B→A→… 超过 8 跳以 500 ConfigError 失败）。这与 After 位置相反——After 的目标 route request 侧 pipeline 不重跑。
- **After Rules（deferred-swap，翻译后）**：脚本在协议翻译**之后**运行，看到的是上游格式 body。声明 `useModel` 后，handler 在管线结束后交换路由，但 body 已按原协议翻译且**不再重译**——目标 route 的 request 侧 pipeline 不重跑。因此 After 位置**只允许同协议切换**（跨协议会产生畸形 body）。这隐含假设：目标 route 的 request 侧 pipeline 与原 route 产出兼容（同 protocol 且目标 route 无额外请求侧规则），否则 body 格式可能不符——`useModel` 是 endpoint/auth 切换，非请求侧重翻译。

每个 route 只有一个脚本位置（Before 或 After，二选一）。eager（Before）swap 点可在链 A→B→C 中连发（受 MAX_EAGER_SWAPS 限制），deferred（After）swap 点至多触发一次；故同一请求既可能跑完 eager 链再触发一次 deferred swap（当链落到 After 位置路由且其 after-script 声明 useModel 时），但同一位置绝不会触发两次 swap。

**控制面 / 数据面分离**：`useModel` 的值只存在于控制面，永不进入请求 body，永不发给上游，因此无需剥离。这与把控制信号塞进 body 字段（事后要剥离否则泄漏上游）的做法相反。

**位置决定语义**：Before 与 After 位置对 `useModel` 语义不同——Before 在翻译前交换路由（客户端格式 body，支持跨协议）；After 在翻译后交换路由（上游格式 body，仅同协议）。脚本挂在哪个位置，决定 swap 发生在翻译前还是翻译后。

**Protocol 守卫（仅 After 位置）**：After Rules 声明的 `useModel`，目标 route 的 protocol 必须与当前 route 一致——body 已按当前协议翻译且不再重译，跨协议切换会产生畸形 body。这意味着 After 位置的目标模型必须在 `[[models]]`/`[[upstreams]]` 里配成与运行脚本的 route **相同协议**（如脚本挂在 DashScope route 下，目标也得是 DashScope 协议模型）。不一致时请求以 500 ConfigError 失败。Before 位置无此守卫——翻译前交换路由，翻译步骤会用目标 route 的协议重新翻译 body，跨协议是其设计目的。

**访问控制**：normal 与 failover 路径对目标模型执行与初始请求相同的访问控制检查（`check_user_model_access`）。**`hide_name` 限制被绕过**——`useModel` 是脚本内部路由切换，不是客户端请求，始终走 `resolve_including_hidden` 路径，脚本可用任何 canonical name 或 alias 切换到配置中**已启用**的模型（包括 `hide_name` 的模型）。`enabled = false` 的模型在路由表构造时即被过滤出 `model_map`，`resolve_including_hidden` 也无法解析——故禁用的模型不可经 `useModel` 切换。console try-me 诊断路径不执行访问控制检查（admin 诊断上下文）。

**failover 语义**：failover 路径内同轮 swap，不中断循环，走正常 dispatch 复用全部错误处理与响应翻译。failoverable 错误时循环到下一 LB node，重跑原 pipeline，脚本重新声明 `useModel`，重新 swap。swap 目标不在原 LB entries 内，下一轮 `select_lb_entry` 不会选中它（它不在候选集里）；目标上游故障时每轮重新 swap 至同一上游直至 max_attempts 耗尽。这是显式切模型接受的权衡（`useModel` 是脚本/管理员显式决策，非 LB 自动容灾）。

**api_key 不变**：swap 不改 `api_key`。`api_key` 是客户端身份（用于 LB sticky binding 与 `access_key_hash` 模板变量），与上游 HTTP 鉴权无关——上游凭证由 dispatch 层按 `route.upstream_name` 从 auth_store 查，swap 后 `upstream_name` 已更新，dispatch 自动取新上游凭证。

**可观测性**：swap 完成后，访问日志记录一条 `translation_notice`（`reason = script_use_model_swap`，`detail = "原模型 → 目标模型"`），标明该请求的上游模型是脚本经 `useModel` 切换的，而非客户端原始请求的模型——因为日志的 model 字段显示的是切换后的目标模型，单看字段无法区分"客户端直接请求"与"脚本切换"。该通知随日志进入 DB `extensions` 与实时 SSE，console 日志详情页顶部"通知"栏展示。

```javascript
function transform(body, context) {
  // 检测多模态输入（图片），跨端点切到多模态模型。
  // body 形状取决于客户端协议，此处为 DashScope input.messages 形态。
  const messages = body.input?.messages || [];
  const hasImage = messages.some(m =>
    Array.isArray(m.content) && m.content.some(c => c.image || c.type === "image"));
  if (hasImage) context.useModel("qwen3.7-plus");
  return body;
}
```

## 内置函数参考

### JSON Path 操作

用点号路径读写嵌套 JSON 结构。

```javascript
// 读取嵌套值
const temp = json_path(body, "generation_config.temperature");

// 设置值（自动创建中间对象）
set_path(body, "generation_config.max_output_tokens", 4096);

// 删除字段
remove_path(body, "deprecated_field");

// 仅当字段不存在时添加
add_if_absent_path(body, "temperature", 1.0);

// 向数组追加元素（数组不存在时自动创建）
append_path(body, "messages", { role: "system", content: "Be concise." });
```

| 函数 | 签名 | 说明 |
|------|------|------|
| `json_path` | `(body, path) → value` | 读取点号路径的值，未找到返回 `undefined` |
| `set_path` | `(body, path, value) → void` | 设置/覆盖值，自动创建中间对象 |
| `remove_path` | `(body, path) → void` | 删除字段，不存在时不报错 |
| `add_if_absent_path` | `(body, path, value) → void` | 仅在路径不存在时设置 |
| `append_path` | `(body, path, value) → void` | 向数组追加，数组不存在时创建 |

### 字符串函数

所有字符串函数使用**字符语义**（非字节），正确处理中文、日文、emoji 等多字节字符。

```javascript
const s = "Hello, 世界!";
str_len(s);            // 9（字符数，非字节数）
str_slice(s, 0, 5);    // "Hello"
str_slice(s, 7, 9);    // "世界"

str_trim("  hello  ");                    // "hello"
str_to_upper("hello");                    // "HELLO"
str_to_lower("HELLO");                    // "hello"
str_contains("hello world", "world");     // true
str_starts_with("hello", "he");           // true
str_ends_with("hello", "lo");             // true
str_replace("foo-bar", "-", "_");         // "foo_bar"
str_split("a,b,c", ",");                  // ["a", "b", "c"]
```

| 函数 | 签名 | 返回值 |
|------|------|--------|
| `str_trim` | `(s) → string` | 去除首尾空白 |
| `str_to_upper` | `(s) → string` | 转大写 |
| `str_to_lower` | `(s) → string` | 转小写 |
| `str_contains` | `(s, needle) → boolean` | 是否包含子串 |
| `str_starts_with` | `(s, prefix) → boolean` | 是否以 prefix 开头 |
| `str_ends_with` | `(s, suffix) → boolean` | 是否以 suffix 结尾 |
| `str_replace` | `(s, from, to) → string` | 替换所有匹配 |
| `str_split` | `(s, delimiter) → array` | 按分隔符拆分为数组 |
| `str_len` | `(s) → number` | **字符数**（非字节数） |
| `str_slice` | `(s, start, end) → string` | 按**字符索引**截取子串 `[start, end)` |

### 正则表达式函数

正则模式编译后缓存（LRU，256 条，5 分钟 TTL）。模式最大长度 4096 字符。

```javascript
// 匹配测试
const hasDigits = regex_match("abc123", "[0-9]+");  // true

// 替换所有匹配
const cleaned = regex_replace("foo123bar456", "[0-9]+", "N");  // "fooNbarN"

// 提取捕获组
const keys = regex_extract("a=1 b=2 c=3", "([a-z]+)=");  // ["a", "b", "c"]
```

| 函数 | 签名 | 返回值 |
|------|------|--------|
| `regex_match` | `(text, pattern) → boolean` | 文本是否匹配模式 |
| `regex_replace` | `(text, pattern, replacement) → string` | 替换所有匹配后的文本 |
| `regex_extract` | `(text, pattern) → array` | 所有匹配的第 1 个捕获组 |

### 编码与哈希

```javascript
// Base64
const encoded = base64_encode("hello");    // "aGVsbG8="
const decoded = base64_decode(encoded);    // "hello"

// URL 编码
const safe = url_encode("hello world");    // "hello%20world"
const original = url_decode(safe);         // "hello world"

// 哈希
const hash = sha256("hello");   // "2cf24dba5fb0..."
const md = md5("hello");        // "5d41402abc4b..."

// JSON 序列化
const jsonStr = json_encode(body.messages);   // 序列化为字符串
const parsed = json_parse(jsonStr);           // 从字符串解析回对象
```

| 函数 | 签名 | 返回值 |
|------|------|--------|
| `base64_encode` | `(data) → string` | Base64 编码字符串 |
| `base64_decode` | `(data) → string` | 解码后的 UTF-8 字符串 |
| `url_encode` | `(s) → string` | URL 百分号编码 |
| `url_decode` | `(s) → string` | URL 解码 |
| `sha256` | `(data) → string` | SHA-256 十六进制摘要 |
| `md5` | `(data) → string` | MD5 十六进制摘要 |
| `json_encode` | `(value) → string` | JSON 序列化字符串 |
| `json_parse` | `(text) → value` | 从 JSON 解析的 JavaScript 值 |

### 数组函数

```javascript
// 排序 —— 数值优先（整数和浮点数按数值排，字符串按字典序排）
array_sort([3, 1, 2]);                // [1, 2, 3]
array_sort(["banana", "apple"]);      // ["apple", "banana"]

// 去重（基于 JSON 序列化的类型安全比较）
array_unique([1, 2, 2, 3, 3]);        // [1, 2, 3]

// 基础操作
array_reverse([1, 2, 3]);             // [3, 2, 1]
array_flatten([[1, 2], [3, 4]]);      // [1, 2, 3, 4]

// 按字段值过滤/排除（类型安全比较）
const active = array_filter_by(body.items, "active", true);
const rejected = array_reject_by(body.items, "status", "deleted");

// 查找第一个匹配（返回元素或 null）
const user = array_find(body.users, "name", "alice");
if (user !== null) {
    body.found_user = user;
}

// 从每个元素中提取某个字段
const names = array_pluck(body.users, "name");  // ["alice", "bob", ...]

// 按字段分组为对象
const grouped = array_group_by(body.items, "category");
// grouped = { "electronics": [...], "books": [...], ... }

// 分块
array_chunk([1, 2, 3, 4, 5], 2);      // [[1, 2], [3, 4], [5]]

// 合并
array_merge([1, 2], [3, 4]);          // [1, 2, 3, 4]

// 集合运算（类型安全比较）
array_diff([1, 2, 3], [2, 3, 4]);     // [1]
array_intersect([1, 2, 3], [2, 3, 4]); // [2, 3]

// 数值求和
array_sum([1.5, 2.5, 3.0]);           // 7.0
```

| 函数 | 签名 | 返回值 |
|------|------|--------|
| `array_sort` | `(arr) → array` | 排序后的数组（数值优先） |
| `array_unique` | `(arr) → array` | 去重后的数组 |
| `array_reverse` | `(arr) → array` | 反转后的数组 |
| `array_flatten` | `(arr) → array` | 展平一层 |
| `array_filter_by` | `(arr, field, value) → array` | 保留 `field == value` 的元素 |
| `array_reject_by` | `(arr, field, value) → array` | 排除 `field == value` 的元素 |
| `array_find` | `(arr, field, value) → value` | 第一个匹配的元素，或 `null` |
| `array_pluck` | `(arr, field) → array` | 每个元素中指定字段的值 |
| `array_group_by` | `(arr, field) → object` | 按字段值分组的对象 |
| `array_chunk` | `(arr, size) → array` | 拆分为子数组 |
| `array_merge` | `(a, b) → array` | 两个数组的拼接 |
| `array_diff` | `(a, b) → array` | 在 `a` 中但不在 `b` 中的元素 |
| `array_intersect` | `(a, b) → array` | 同时在 `a` 和 `b` 中的元素 |
| `array_sum` | `(arr) → number` | 数值之和 |

### JSONPath 与 JSON Patch

处理比简单点号路径更复杂的查询。

```javascript
// JSONPath —— 查找 items 中所有 name
const names = jsonpath_query(body, "$.items[*].name");

// 仅取第一个匹配
const first = jsonpath_query_first(body, "$.messages[?(@.role=='user')].content");

// 统计匹配数量
const count = jsonpath_query_count(body, "$.messages[*]");

// RFC 6902 JSON Patch
const patches = [
    { op: "add", path: "/metadata", value: { version: "1.0" } },
    { op: "remove", path: "/deprecated_field" },
    { op: "replace", path: "/model", value: "gpt-4o" }
];
body = json_patch(body, patches);
```

| 函数 | 签名 | 返回值 |
|------|------|--------|
| `jsonpath_query` | `(value, path) → array` | 所有 JSONPath 匹配结果 |
| `jsonpath_query_first` | `(value, path) → value` | 第一个匹配，或 `null` |
| `jsonpath_query_count` | `(value, path) → number` | 匹配数量 |
| `json_patch` | `(value, patches) → value` | 应用 RFC 6902 补丁后的值 |

### 深拷贝与深合并

```javascript
// 深拷贝 —— 独立的副本（修改不影响原始数据）
const backup = deep_clone(body);
body.messages = [];
// backup.messages 仍然保留原始数据

// 深合并 —— 递归合并（source 覆盖 target，数组替换而非拼接）
const defaults = { temperature: 1.0, top_p: 0.9, stop: ["\n"] };
const overrides = { temperature: 0.5, stop: ["END"] };
const merged = deep_merge(defaults, overrides);
// merged = { temperature: 0.5, top_p: 0.9, stop: ["END"] }
```

| 函数 | 签名 | 返回值 |
|------|------|--------|
| `deep_clone` | `(value) → value` | 独立的深拷贝 |
| `deep_merge` | `(target, source) → value` | 递归合并（数组替换，非拼接） |

### UUID 与时间

```javascript
// 生成 UUID
const id = uuid();   // "550e8400-e29b-41d4-a716-446655440000"

// 时间戳
const ts = now();      // 1748620800（秒级 Unix 时间戳）
const ms = now_ms();   // 1748620800000（毫秒级 Unix 时间戳）

// 格式化时间戳（chrono 格式字符串）
const formatted = format_time(ts, "%Y-%m-%d %H:%M:%S");
// "2025-05-31 00:00:00"

const dateOnly = format_time(ts, "%Y-%m-%d");
// "2025-05-31"
```

| 函数 | 签名 | 返回值 |
|------|------|--------|
| `uuid` | `() → string` | UUID v4 字符串 |
| `now` | `() → number` | Unix 时间戳（秒） |
| `now_ms` | `() → number` | Unix 时间戳（毫秒） |
| `format_time` | `(timestamp, format) → string` | 使用 `chrono` 格式化时间 |

### 数学函数

```javascript
min(3, 7);           // 3
max(3, 7);           // 7
clamp(15, 0, 10);    // 10（截断到最大值）
abs(-42);            // 42
```

| 函数 | 签名 | 返回值 |
|------|------|--------|
| `min` | `(a, b) → number` | 较小值 |
| `max` | `(a, b) → number` | 较大值 |
| `clamp` | `(value, min, max) → number` | 截断到 `[min, max]` 范围内 |
| `abs` | `(value) → number` | 绝对值 |

## 执行顺序

脚本作为 **Payload 管线** 的一部分运行。管线顺序在路由解析时确定。请求侧脚本拆为两个独立槽：`request_transform_before` 在协议翻译**之前**运行（作用于客户端协议 body），`request_transform_after` 在翻译**之后**运行（作用于上游协议 body）。同一路由两槽都配时，两槽都运行——before 槽先跑，after 槽后跑。

### 请求侧

请求管线在协议翻译处分为两段：**翻译前管线**（Anthropic sanitize + Before 脚本，作用于客户端格式 body）与**主管线**（协议转换 → 自动归一化 → 声明式规则 → After 脚本 → 缓存注入，作用于上游格式 body）。

**`request_transform_before`**（在翻译前运行，看到客户端协议 body）：
```
[翻译前] Anthropic sanitize → Upstream 脚本 → Model 脚本 → (eager useModel swap ──┐
                                                          ↑                      │ 目标 route 有 before-script
                                                          └──────────────────────┘ 则回到 [翻译前] 跑目标的（链式 A→B→C，8 跳上限）
→ [主管线] 协议转换 → 自动归一化 → 声明式规则 → 缓存注入
```
eager swap 在翻译前交换路由，目标 route 成为活跃路由——其 before 槽与 main pipeline 都会运行。若目标的 before 槽再声明 `useModel`，循环回到 [翻译前] 跑目标的目标，链式推进直至某 route 不再声明 `useModel`，再进入 [主管线]。注意：一旦 before 槽触发 eager swap，**原路由的 after 槽不再执行**——整套 pipeline 换成目标路由的（其 before 与 after 槽都会跑）。这是有意语义。

**`request_transform_after`**（在翻译后运行，看到上游协议 body）：
```
[翻译前] Anthropic sanitize → [主管线] 协议转换 → 自动归一化 → 声明式规则 → Upstream 脚本 → Model 脚本 → (deferred useModel swap) → 缓存注入
```

before 槽脚本在协议转换**之前**运行，body 是客户端协议格式，可触发跨协议 `useModel` 切换；after 槽脚本在协议转换**之后**运行，body 是上游协议格式，`useModel` 仅限同协议切换。

**自动归一化步骤** 由 `StepRegistry` 根据路由上下文自动注入（详见上方"内置 Pipeline Steps"）。例如：
- Anthropic + DeepSeek 路由自动注入 thinking blocks、过滤 web_search 工具
- DashScope 图片内容过滤可通过配置驱动的 `filter-content-types` payload 规则实现（非自动注入）
- `x-anthropic-billing-header:` 等文本行过滤通过声明式 `strip-lines` payload 规则处理（无需硬编码步骤）

### 响应侧（仅非流式）

```
上游响应 → 响应脚本 → 反向协议转换
```

> **注意**：流式请求的响应脚本暂不支持。

### 脚本级别

脚本可以在两个级别设置：

| 级别 | 作用范围 | 适用场景 |
|------|---------|---------|
| **Upstream** | 经过此 upstream 的所有模型 | 协议适配、公共字段注入 |
| **Model** | 仅此模型 | 模型特定调整、参数调优 |

两个级别共享相同的 4 个配置字段：

| 字段 | 说明 | 默认值 |
|------|------|--------|
| `request_transform_before` | 翻译前请求体脚本 | 无 |
| `request_transform_after` | 翻译后请求体脚本 | 无 |
| `response_transform` | 响应体脚本 | 无 |
| `script_error_mode` | `log-and-continue` 或 `log-and-reject` | `log-and-continue` |

## 配置方式

脚本通过 Console UI 或 Console API 配置。业务配置存储在数据库中（非 TOML 文件）。

### 挂载脚本

**Upstream 级别脚本**（适用于经过此 upstream 的所有模型）。在 Console 里配置（不用手写 JSON）：打开上游 `openai` → **转换脚本(JavaScript)** 区 → **请求** 标签 → **翻译后** 槽 → 把脚本粘进内联编辑器：

```javascript
function transform(body, context) { body.model = context.upstreamModel; return body; }
```

**Model 级别脚本**（仅适用于此模型）。在 Console 里配置（不用手写 JSON）：打开模型 `gpt-4o` → **转换脚本(JavaScript)** 区 → **请求** 标签 → **翻译后** 槽 → 把请求转换脚本粘进内联编辑器：

```javascript
function transform(body, context) { body.temperature = 0.7; return body; }
```

切到 **响应** 标签 → 把响应转换脚本粘进内联编辑器：

```javascript
function transform(body, context) { body.usage.gateway_request_id = context.requestId; return body; }
```

### 文件脚本

对于较大的脚本，可引用外部 `.js` 文件。在 Console 里配置：打开模型 `claude-thinking` → **转换脚本(JavaScript)** 区 → **请求** 标签 → **翻译前** 槽 → 把脚本内容粘进内联编辑器。将**脚本错误模式**设为 `log-and-reject`。（Console UI 仅提供内联编辑器；`{ "file": "path" }` 形态如 `scripts/inject-thinking.js` 是 Console API 侧的文件脚本形态。）

`scripts/inject-thinking.js`：
```javascript
// 为 assistant 消息注入空的 thinking block
function transform(body, context) {
    for (let i = 0; i < body.messages.length; i++) {
        if (body.messages[i].role === "assistant") {
            let hasThinking = false;
            for (const c of body.messages[i].content) {
                if (c.type === "thinking") {
                    hasThinking = true;
                }
            }
            if (!hasThinking) {
                const newContent = [{ type: "thinking", thinking: "" }];
                for (const c of body.messages[i].content) {
                    newContent.push(c);
                }
                body.messages[i].content = newContent;
            }
        }
    }
    return body;
}
```

### 通过 Console API 测试

```bash
# 部署前先测试脚本
curl -X POST http://localhost:3000/console/api/scripts/test \
  -H "Content-Type: application/json" \
  -d '{
    "script": "function transform(body, context) { body.model = context.upstreamModel; body.added = true; return body; }",
    "body": {"model": "gpt-4o", "messages": []},
    "context": {
      "clientModel": "gpt-4o",
      "upstreamModel": "gpt-4o-2024-05-13",
      "upstreamName": "openai",
      "sourceProtocol": "openai",
      "targetProtocol": "openai",
      "stream": false
    }
  }'
```

响应：
```json
{
  "result_body": {"model": "gpt-4o-2024-05-13", "messages": [], "added": true},
  "execution_time_ms": 0.12
}
```

## 错误处理

### `log-and-continue`（默认）

脚本出错时记录警告日志，请求**以原始未修改的 body 继续**。适用于脚本失败不应阻断请求的场景。

### `log-and-reject`

脚本出错时记录日志并**拒绝请求**，返回网关错误。适用于脚本正确执行至关重要的场景（如安全脱敏）。

### 常见错误

| 错误 | 原因 | 解决方法 |
|------|------|---------|
| `TooManyOperations` | 脚本超过 `max_operations`（默认 2000） | 优化循环，减少迭代 |
| `StackOverflow` | 递归深度过大 | 减少递归调用，改用迭代 |
| `DataTooLarge` | 字符串 > 10MB 或数组 > 10,000 个元素 | 分块处理数据 |
| `TypeError` | 访问 `null`/`undefined` 的属性 | 使用 `typeof`、`in` 运算符或 `json_path()` 检查 |
| `InvalidRegex` | 正则表达式语法错误 | 修正正则语法 |
| `ReferenceError` | 使用未定义的变量或函数 | 检查拼写，确保所有辅助函数已定义 |
| `transform() must return a value` | 函数末尾缺少 `return body`，或提前 `return;` 没有带值 | **每个 return 路径都必须返回 body**（见下方"提前返回"示例） |

## 沙箱与安全

### 脚本可以做什么

- 读取和修改 `body` 参数
- 读取 `context` 对象（元数据）
- 使用上述所有内置函数
- 在脚本内定义辅助函数
- 使用标准 JavaScript 结构（循环、条件、闭包、解构等）

### 脚本不能做什么

| 被禁止 | 原因 |
|--------|------|
| `console.log` / `print` | 防止信息泄露 |
| 文件 I/O | 无文件系统访问权限 |
| 网络 | 无 HTTP 调用、无 `fetch`、无 socket |
| `import` / `require` / 模块 | 不能加载外部代码 |
| `eval` / `Function` | 不能动态执行代码 |
| `globalThis` 修改 | 沙箱全局对象已冻结 |
| 进程访问 | 无 `exec`、无环境变量 |

### 资源限制

| 限制 | 值 | 可配置 |
|------|------|--------|
| 最大操作数 | 10,000 | `server.toml` 中的 `server.script_max_operations` |
| 最大字符串 | 10 MB | 固定（适配 base64 截图） |
| 最大数组/对象 | 10,000 个元素 | 固定 |
| 正则缓存 | 256 条，5 分钟 TTL | 固定 |
| 最大正则模式 | 4,096 字符 | 固定 |
| 栈大小 | 1024 帧 | 固定 |

### 敏感 Headers 黑名单

以下 headers 会从 `context.headers` 中被移除，防止凭证泄露：
`authorization`、`cookie`、`set-cookie`、`x-api-key`、`proxy-authorization`

## 常用模式

### 覆盖模型名

```javascript
function transform(body, context) {
    body.model = context.upstreamModel;
    return body;
}
```

### 注入 System Prompt

```javascript
function transform(body, context) {
    if (body.messages.length === 0 || body.messages[0].role !== "system") {
        const systemMsg = { role: "system", content: "You are a helpful assistant." };
        body.messages = [systemMsg, ...body.messages];
    }
    return body;
}
```

### 剥离图片内容（降低成本）

使用 `filter-content-types` Request Payload Rule 代替脚本进行简单的内容过滤。在 Console 里配置（不用手写 JSON）：打开**模型详情**（或上游的「默认请求体规则」）→ 找到**请求体规则** → 点 **+ 添加规则** → 在模式下拉里选 **`filter-content-types`** → **路径** 填 `messages`、**值** 填 `image`。

> **注意**：对于 DashScope 路由，图片内容过滤可通过配置驱动的 `filter-content-types` payload 规则实现（非自动注入）。

移除图片块时会在原位自动注入一段占位文本（默认提示"用户附带了图片但当前模型不支持图片输入"），可用 `hint` 字段自定义。详见[配置参考 — 过滤内容类型](configuration.zh-CN.md#过滤内容类型)。

对于更复杂的过滤逻辑，使用脚本：

```javascript
function transform(body, context) {
    for (const msg of body.messages) {
        if (Array.isArray(msg.content)) {
            msg.content = msg.content.filter(block => block.type !== "image");
        }
    }
    return body;
}
```

### 在响应中添加请求元数据

```javascript
function transform(body, context) {
    if (body.usage) {
        body.usage.gateway_request_id = context.requestId;
        body.usage.gateway_elapsed_ms = context.elapsedMs;
    }
    return body;
}
```

### 根据访问密钥分组执行条件逻辑

```javascript
function transform(body, context) {
    if (context.accessKeyGroup === "premium") {
        body.max_tokens = 8192;
    } else {
        body.max_tokens = 4096;
    }
    return body;
}
```

### 响应中脱敏 PII

```javascript
function transform(body, context) {
    // 替换邮箱地址
    for (let i = 0; i < body.choices.length; i++) {
        if (body.choices[i].message.content) {
            body.choices[i].message.content = regex_replace(
                body.choices[i].message.content,
                "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}",
                "[REDACTED]"
            );
        }
    }
    return body;
}
```

### 转换 Anthropic Thinking Blocks

```javascript
function transform(body, context) {
    // 为 Anthropic API 兼容性注入空的 thinking block
    for (let i = 0; i < body.messages.length; i++) {
        if (body.messages[i].role === "assistant") {
            let hasThinking = false;
            for (const c of body.messages[i].content) {
                if (c.type === "thinking") { hasThinking = true; }
            }
            if (!hasThinking) {
                const newContent = [{ type: "thinking", thinking: "" }];
                for (const c of body.messages[i].content) {
                    newContent.push(c);
                }
                body.messages[i].content = newContent;
            }
        }
    }
    return body;
}
```

> **注意**：对于 Anthropic 协议 + DeepSeek 模型，`AnthropicThinkingNormalize` Pipeline Step 会自动注入 thinking blocks，无需手动编写此脚本。此示例仅适用于其他需要手动处理的场景。

### 协议自适应参数映射

```javascript
function transform(body, context) {
    // 当协议不同时，将 OpenAI 风格参数映射为 Anthropic 风格
    if (context.targetProtocol === "Anthropic") {
        if (body.max_tokens !== undefined) {
            body.max_tokens = clamp(body.max_tokens, 1, 8192);
        }
        // Anthropic 在某些场景下使用 top_k 而非 top_p
        if (body.top_p !== undefined && body.top_k === undefined) {
            body.top_k = Math.round(body.top_p * 100);
            delete body.top_p;
        }
    }
    return body;
}
```

### 通过 body 添加时间戳元数据

```javascript
function transform(body, context) {
    body.metadata = {
        request_id: context.requestId,
        timestamp: format_time(now(), "%Y-%m-%dT%H:%M:%SZ"),
        client_ip: context.clientIp
    };
    return body;
}
```

### 多模态模型切换 + System Prompt 过滤（After Rules）

一个综合性的实际场景示例：检测图片输入或联网搜索参数并切换到多模态模型，同时过滤掉 Claude Code 的 system prompt。该脚本运行在 After 位置（默认值），挂载于 DashScope upstream，其中 `body.input.messages` 是消息路径，`parameters.enable_search` 表示声明式 `request_payload` 规则已将 `web_search` 转换为该参数。

```javascript
function transform(body, context) {
    const msgs = body.input?.messages;

    // 1. 检测图片内容。DashScope 多模态 block 格式为 {"image": "<url>"}
    //    （没有 "type" 字段）——openai_chat_completions_dashscope 翻译器
    //    以此格式输出，并通过 block.image 检测。
    let hasImage = false;
    if (Array.isArray(msgs)) {
        for (const msg of msgs) {
            if (!Array.isArray(msg.content)) continue;
            if (msg.content.some(b => b.image)) { hasImage = true; break; }
        }
    }

    // 2. 联网搜索或图片 → 切换到多模态模型。qwen3.7-plus 同时支持
    //    图片输入和联网搜索，一个目标覆盖两种触发条件。使用
    //    context.useModel（而非 body.model =），因为 qwen3.7-plus 挂载在
    //    不同的端点（multimodal-generation）上：仅修改 body.model 会将
    //    多模态模型名发送到 text-generation URL——错误。适配器已将
    //    web_search 转换为 parameters.enable_search。
    if (hasImage || body.parameters?.enable_search) {
        context.useModel("aliyun-china/qwen3.7-plus");
    }

    // 3. 过滤 Claude Code 的 system prompt。Claude Code 的 system prompt 是
    //    整条 `role: "system"` 消息——纯字符串或文本块数组，首个块以
    //    PREFIX 开头。识别后整条丢弃：旧版按行剥离只删了 PREFIX 行，
    //    保留了多行提示的其余内容；纯字符串 system 消息则完全未被过滤。
    const PREFIX = "You are Claude Code, Anthropic's official CLI";
    if (!Array.isArray(msgs)) return body;
    for (let i = msgs.length - 1; i >= 0; i--) {
        const msg = msgs[i];
        if (msg.role !== "system") continue;
        const isClaudeCode = typeof msg.content === "string"
            ? msg.content.startsWith(PREFIX)
            : Array.isArray(msg.content) && msg.content.some(b =>
                b.type === "text" && typeof b.text === "string" && b.text.startsWith(PREFIX));
        if (isClaudeCode) msgs.splice(i, 1);
    }
    return body;
}
```

### 多模态模型切换 + System Prompt 过滤（Before Rules，Anthropic 协议）

上面是 After 位置下的 DashScope 示例。若客户端走 **Anthropic 协议**（`/v1/messages`），把脚本改挂在 Before 位置即可——Before 在协议翻译**之前**运行，看到的是客户端原始 Anthropic body。Anthropic 与 DashScope 的关键结构差异有三处，脚本须分别适配：

- **图片 block**：`body.messages[].content[]` 里的图片块是 `{"type":"image", source:{...}}`（DashScope 是无 `type` 字段的 `{"image":"<url>"}`）。
- **联网搜索**：Anthropic 没有类似 `parameters.enable_search` 的开关字段，web search 是 `body.tools` 里一个内置 server tool，条目形如 `{"type":"web_search_20250305", "name":"web_search", ...}`——`name === "web_search"` 即为信号。
- **system prompt**：Anthropic 把系统提示放在顶层 `body.system`（字符串或 block 数组），**不在** `messages` 里（DashScope/OpenAI 在 `messages` 里放 `role:"system"`）。少数客户端也会在 `messages` 塞 `role:"system"`，故两处都处理。

由于 Before 位置走 eager-swap，`useModel` 在翻译前交换路由并**用目标 route 的 translator 重新翻译 body**，因此这里可把 Anthropic 客户端的请求切换到一个挂在不同端点/协议上的多模态模型（如 DashScope 的 `multimodal-generation`）——这是 Before 相对 After 的核心能力（After 仅同协议）。

```javascript
function transform(body, context) {
    // 该脚本挂在 Before 位置、Anthropic 协议 route 上，看到的是客户端原始
    // /v1/messages body。Before 支持 cross-protocol useModel，适合切到挂在不同
    // 端点/协议上的多模态模型。

    // 1. 检测图片输入。Anthropic 图片块为 {"type":"image", source:{...}}。
    let hasImage = false;
    const msgs = body.messages;
    if (Array.isArray(msgs)) {
        for (const msg of msgs) {
            if (!Array.isArray(msg.content)) continue;
            if (msg.content.some(b => b.type === "image")) { hasImage = true; break; }
        }
    }

    // 2. 检测联网搜索。Anthropic 把 web search 作为内置 server tool 放在 body.tools
    //    里（type "web_search_20250305", name "web_search"），没有 enable_search
    //    开关——条目本身即信号。
    let hasSearch = false;
    if (Array.isArray(body.tools)) {
        hasSearch = body.tools.some(t => t.name === "web_search");
    }

    // 3. 图片或搜索 → 切到多模态模型。用 context.useModel（而非 body.model =）：
    //    目标模型挂在不同端点（multimodal-generation），只改 body.model 会保留
    //    Anthropic 端点/协议。Before 位置的 useModel 重新解析完整 route
    //    （base_url/kind/protocol/...）并用目标协议重新翻译 body，cross-protocol
    //    由此生效。qwen3.7-plus 同时覆盖图片与搜索，一个目标处理两种触发。
    if (hasImage || hasSearch) {
        context.useModel("aliyun-china/qwen3.7-plus");
    }

    // 4. 过滤 Claude Code 的 system prompt。Anthropic 的 system prompt 在顶层
    //    body.system（字符串或 block 数组），不在 messages。识别后整条丢弃：
    //    按行剥离只删首行会泄漏多行提示的其余内容；纯字符串则完全未过滤。
    const PREFIX = "You are Claude Code, Anthropic's official CLI";
    const isCC = (text) => typeof text === "string" && text.startsWith(PREFIX);

    // 4a. body.system —— 字符串形态
    if (isCC(body.system)) {
        delete body.system;
    }
    // 4b. body.system —— block 数组形态：只删 CC 块，保留其余
    else if (Array.isArray(body.system)) {
        body.system = body.system.filter(b => !(b.type === "text" && isCC(b.text)));
        if (body.system.length === 0) delete body.system;
    }

    // 4c. body.messages —— 少数客户端在此塞 role:"system"，一并处理
    if (Array.isArray(msgs)) {
        for (let i = msgs.length - 1; i >= 0; i--) {
            const msg = msgs[i];
            if (msg.role !== "system") continue;
            const claudeCode = typeof msg.content === "string"
                ? isCC(msg.content)
                : Array.isArray(msg.content) && msg.content.some(b => b.type === "text" && isCC(b.text));
            if (claudeCode) msgs.splice(i, 1);
        }
    }

    return body;
}
```

**配置**：此脚本放在 before 槽（需读取客户端原始 Anthropic body）：

```toml
request_transform_before = { inline = """...""" }
```

**与 After（DashScope）版本的差异**：

| 维度 | After（DashScope） | Before（Anthropic） |
|------|-------------------|--------------|
| body 格式 | 已翻译成 DashScope 上游格式 | 客户端原始 Anthropic 格式 |
| 图片检测 | `b.image`（无 type 字段） | `b.type === "image"` |
| 搜索检测 | `body.parameters.enable_search` | `body.tools[].name === "web_search"` |
| system prompt 位置 | `messages` 里 `role:"system"` | 顶层 `body.system`（字符串或 block 数组） |
| useModel 限制 | 仅同协议（DashScope→DashScope） | 支持 cross-protocol（Anthropic→DashScope 多模态） |

下一节给出按 `sourceProtocol` 分支、一次覆盖全部 5 种客户端协议的 Before 通用写法。

### 跨协议多模态切换 + System Prompt 过滤（Before Rules）

若脚本需要支持**多种客户端协议**（OpenAI ChatCompletions、Anthropic、Google、DashScope、OpenAI Response），按 `context.sourceProtocol` 分别处理各协议的 body 结构即可——下例把上一节的 Anthropic 分支扩展为全协议。

Before 位置在协议翻译**之前**运行，看到的是客户端原始格式 body。`useModel` 走 eager-swap 路径，主 pipeline 会用目标 route 的 translator 重新翻译，因此**支持跨协议切换**。

```javascript
function transform(body, context) {
  const proto = context.sourceProtocol;

  // ── 1. 检测图片内容（按协议分支）──
  let hasImage = false;

  if (proto === "DashScope") {
    const msgs = body.input?.messages;
    if (Array.isArray(msgs)) {
      for (const msg of msgs) {
        if (Array.isArray(msg.content) && msg.content.some(b => b.image)) {
          hasImage = true;
          break;
        }
      }
    }
  } else if (proto === "OpenAIChatCompletions") {
    const msgs = body.messages;
    if (Array.isArray(msgs)) {
      for (const msg of msgs) {
        if (Array.isArray(msg.content) && msg.content.some(b => b.type === "image_url")) {
          hasImage = true;
          break;
        }
      }
    }
  } else if (proto === "Anthropic") {
    const msgs = body.messages;
    if (Array.isArray(msgs)) {
      for (const msg of msgs) {
        if (Array.isArray(msg.content) && msg.content.some(b => b.type === "image")) {
          hasImage = true;
          break;
        }
      }
    }
  } else if (proto === "Google") {
    const contents = body.contents;
    if (Array.isArray(contents)) {
      for (const c of contents) {
        if (Array.isArray(c.parts) && c.parts.some(p => p.inlineData || p.fileData)) {
          hasImage = true;
          break;
        }
      }
    }
  } else if (proto === "OpenAIResponse") {
    const items = body.input;
    if (Array.isArray(items)) {
      for (const item of items) {
        if (item.type === "message" && Array.isArray(item.content)) {
          if (item.content.some(b => b.type === "input_image")) {
            hasImage = true;
            break;
          }
        }
      }
    }
  }

  // ── 2. 检测联网搜索（按协议分支）──
  let hasSearch = false;

  if (proto === "DashScope") {
    hasSearch = !!body.parameters?.enable_search;
  } else if (proto === "OpenAIChatCompletions" || proto === "OpenAIResponse") {
    const tools = body.tools;
    if (Array.isArray(tools)) {
      hasSearch = tools.some(t =>
        t.type === "web_search_preview" ||
        t.type === "web_search" ||
        t.function?.name === "web_search"
      );
    }
  } else if (proto === "Anthropic") {
    const tools = body.tools;
    if (Array.isArray(tools)) {
      hasSearch = tools.some(t => t.name === "web_search");
    }
  } else if (proto === "Google") {
    const tools = body.tools;
    if (Array.isArray(tools)) {
      hasSearch = tools.some(t => t.googleSearch || t.googleSearchRetrieval);
    }
  }

  // ── 3. 切换模型（搜索优先：qwen3.7-plus 同时支持图片+搜索）──
  if (hasImage) {
    context.useModel("aliyun-china/qwen3.7-max");
  }
  if (hasSearch) {
    context.useModel("aliyun-china/qwen3.7-plus");
  }

  // ── 4. 过滤 Claude Code 的 system prompt（按协议分支）──
  const PREFIX = "You are Claude Code, Anthropic's official CLI";

  const filterTextBlock = (block) => {
    if (block.type !== "text" || typeof block.text !== "string") return true;
    if (!block.text.startsWith(PREFIX)) return true;
    const lines = block.text.split("\n");
    const remaining = lines.filter(l => !l.startsWith(PREFIX));
    block.text = remaining.join("\n");
    return block.text.trim().length > 0;
  };

  if (proto === "Anthropic") {
    // Anthropic 的 system prompt 在 body.system（字符串或 block 数组）
    if (typeof body.system === "string") {
      if (body.system.startsWith(PREFIX)) {
        const lines = body.system.split("\n");
        const remaining = lines.filter(l => !l.startsWith(PREFIX));
        body.system = remaining.join("\n").trim();
        if (!body.system) delete body.system;
      }
    } else if (Array.isArray(body.system)) {
      body.system = body.system.filter(filterTextBlock);
      if (body.system.length === 0) delete body.system;
    }
    // 部分客户端也会把 system 放在 messages 里
    const msgs = body.messages;
    if (Array.isArray(msgs)) {
      for (let i = msgs.length - 1; i >= 0; i--) {
        const msg = msgs[i];
        if (msg.role !== "system" || !Array.isArray(msg.content)) continue;
        msg.content = msg.content.filter(filterTextBlock);
        if (msg.content.length === 0) msgs.splice(i, 1);
      }
    }
  } else if (proto === "Google") {
    // Google 的 system prompt 在 body.systemInstruction
    if (typeof body.systemInstruction === "string") {
      if (body.systemInstruction.startsWith(PREFIX)) {
        const lines = body.systemInstruction.split("\n");
        const remaining = lines.filter(l => !l.startsWith(PREFIX));
        body.systemInstruction = remaining.join("\n").trim();
        if (!body.systemInstruction) delete body.systemInstruction;
      }
    } else if (body.systemInstruction && typeof body.systemInstruction === "object") {
      if (Array.isArray(body.systemInstruction.parts)) {
        body.systemInstruction.parts = body.systemInstruction.parts.filter(filterTextBlock);
        if (body.systemInstruction.parts.length === 0) delete body.systemInstruction;
      }
    }
  } else if (proto === "OpenAIResponse") {
    const items = body.input;
    if (Array.isArray(items)) {
      for (let i = items.length - 1; i >= 0; i--) {
        const item = items[i];
        if (item.type !== "system") continue;
        if (typeof item.content === "string") {
          if (item.content.startsWith(PREFIX)) {
            const lines = item.content.split("\n");
            const remaining = lines.filter(l => !l.startsWith(PREFIX));
            item.content = remaining.join("\n").trim();
            if (!item.content) items.splice(i, 1);
          }
        } else if (Array.isArray(item.content)) {
          item.content = item.content.filter(filterTextBlock);
          if (item.content.length === 0) items.splice(i, 1);
        }
      }
    }
  } else {
    // OpenAIChatCompletions / DashScope：system prompt 在 messages 里 role="system"
    let msgs;
    if (proto === "DashScope") {
      msgs = body.input?.messages;
    } else {
      msgs = body.messages;
    }

    if (Array.isArray(msgs)) {
      for (let i = msgs.length - 1; i >= 0; i--) {
        const msg = msgs[i];
        if (msg.role !== "system") continue;
        if (!Array.isArray(msg.content)) continue;
        msg.content = msg.content.filter(filterTextBlock);
        if (msg.content.length === 0) msgs.splice(i, 1);
      }
    }
  }

  return body;
}
```

**配置**：此脚本放在 before 槽（需读取客户端原始协议 body）：

```toml
request_transform_before = { inline = """...""" }
```

**与 after 槽版本的差异**：

| 维度 | after 槽 | before 槽 |
|------|-------------------|--------------|
| body 格式 | 已翻译成上游协议（如 DashScope） | 客户端原始协议（OpenAI/Anthropic/Google/DashScope） |
| 协议支持 | 仅脚本挂载的协议 | 所有协议（按 `sourceProtocol` 分支） |
| useModel 限制 | 仅同协议切换 | 支持跨协议切换 |
| 图片检测 | 只看 `b.image`（DashScope 格式） | 5 种协议各有不同的 image block 结构 |
| 搜索检测 | 只看 `parameters.enable_search` | 5 种协议各有不同的 tool 结构 |
| system prompt | 只在 messages 里 | Anthropic 在 `body.system`，Google 在 `body.systemInstruction`，OpenAIResponse 用 `type="system"` item |

**`sourceProtocol` 值**：是 `Protocol` 枚举变体名的 Debug 输出（PascalCase），如 `"OpenAIChatCompletions"`、`"Anthropic"`、`"DashScope"`、`"Google"`、`"OpenAIResponse"`。不是 serde 名（如 `"openai"`）也不是 strum 名（如 `"OpenAI Chat Completions"`）。


## 性能注意事项

脚本执行的开销主要来自 **JSON 序列化/反序列化往返**，而非脚本逻辑本身：

```
Rust Value → JSON 字符串 → JS JSON.parse → 脚本执行 → JS JSON.stringify → JSON 字符串 → Rust Value
```

| body 大小 | 典型耗时 | 瓶颈 |
|-----------|---------|------|
| < 10 KB | < 1 ms | 可忽略 |
| ~50 KB | ~5 ms | 轻微 |
| ~250 KB | ~30 ms | 显著（长对话场景） |
| > 1 MB | > 100 ms | 需关注 |

**建议：**

- 简单字段操作优先用 `request_payload` 声明式规则（Rust 直接操作 `serde_json::Value`，无序列化开销）
- 脚本中避免不必要的 `deep_clone` / `json_encode` + `json_parse`（额外序列化）
- 大 body 场景（长对话、多工具定义）注意耗时随 body 线性增长

## `strip-lines` 路径速查

`strip-lines` 模式的 `path` 取决于**上游协议**（脚本在协议翻译之后执行）。常见协议的路径：

| 上游协议 | System Prompt 路径 | 示例 |
|---------|-------------------|------|
| Anthropic | `system` | `body.system` 是字符串或 content block 数组 |
| OpenAI Chat | `messages.*.content` | `body.messages[].content` 字符串或数组 |
| OpenAI Response | `input.*.content` | `body.input[].content` |
| DashScope | `input.messages.*.content` | `body.input.messages[].content` 数组 |
| Google | `contents.*.parts` | `body.contents[].parts` 数组 |

示例（移除 DashScope 上游的特定文本行）。在 Console 里配置（不用手写 JSON）：打开**模型详情**（或上游的「默认请求体规则」）→ 找到**请求体规则** → 点 **+ 添加规则** → 在模式下拉里选 **`strip-lines`** → **路径** 填 `input.messages.*.content`、**匹配** 选 `starts_with`、**值** 填 `x-anthropic-billing-header:`。
