跳到正文

用脚本做协议翻译修正

跨协议翻译是网关的核心能力,但有时候你需要做些超出默认翻译的事情——按客户端协议分支处理、按会话标记切换路由、修正某协议的方言差异等。本章通过两个实战例子说明怎么用 before 槽脚本做这类翻译修正。

前置:先读 写第一个脚本变换 了解基本结构、挂载方式、Context API。

例 1:按 Claude Code 计划模式切换模型(before 槽)

Claude Code 的 plan 模式(计划模式)会在对话里注入标记:进入时通过 <system-reminder>当前轮末尾消息注入 Plan mode is active,退出时注入 Exited Plan Mode。一旦进入过 plan 模式,进入标记会永久留在对话历史里——所以不能用"是否包含该字符串"判断当前状态(退出后历史里仍能匹配到旧的进入标记,会永远误判为 plan 模式)。正确做法是从末尾消息向前扫描,取最后出现的标记作为当前状态。

plan 模式是 Claude Code(Anthropic 协议)特有的概念。如果入口模型同时服务其他协议的客户端,必须先判断 context.sourceProtocol——非 Anthropic 请求里不会有这两个标记,全文扫描会误判为"非 plan"而把流量切走。脚本开头先放过非 Anthropic 请求。

本例把脚本挂在入口模型(plan 模式所用的模型)的 before 槽request_transform_before):plan 模式中不切换、保持当前模型;退出 plan 模式时 useModel 切到执行模型。

javascript
function transform(body, context) {
    if (context.sourceProtocol !== "Anthropic") {
        return body;
    }
    const EXEC_MODEL = "aliyun-china/qwen3.7-max";
    const PLAN = /plan mode is active/i;
    const EXIT = /exited plan mode/i;
    // 标记在串中末次出现的位置;PLAN 与 EXIT 同现时,靠后者代表当前状态。
    const lastIdx = (s, re) => {
        let idx = -1, m;
        const g = new RegExp(re.source, "gi");
        while ((m = g.exec(s))) idx = m.index;
        return idx;
    };
    // 从末尾取首个含标记的块——它必含全局最后的标记,块内再比两标记先后。
    const lastMark = (content) => {
        const blocks = typeof content === "string" ? [content]
            : Array.isArray(content) ? content.map(b => (b && typeof b.text === "string") ? b.text : "") : [];
        for (let i = blocks.length - 1; i >= 0; i--) {
            const s = blocks[i];
            if (!s) continue;
            const p = PLAN.test(s), e = EXIT.test(s);
            if (!p && !e) continue;
            return (p && e) ? (lastIdx(s, PLAN) > lastIdx(s, EXIT) ? "plan" : "exit") : (p ? "plan" : "exit");
        }
        return null;
    };
    // 标记注入在最新一条消息的 <system-reminder>:进入为 "Plan mode is active",
    // 退出为 "Exited Plan Mode"。进入标记永久留在历史里,故当前状态由最后一个含
    // 标记的消息决定——从末尾向前取首个含标记的消息,比较其内两标记的位置判定。
    const messages = body.messages || [];
    for (let i = messages.length - 1; i >= 0; i--) {
        const m = lastMark(messages[i].content);
        if (m) {
            if (m === "exit") context.useModel(EXEC_MODEL);
            return body;
        }
    }
    // 所有消息都没有标记——回退到 system prompt 判断。
    if (lastMark(body.system) === "plan") return body;
    // 兜底:任何地方都没有标记 ⇒ 不在 plan 模式 ⇒ 切执行模型。
    context.useModel(EXEC_MODEL);
    return body;
}

要点:

  • 必须放在 before 槽request_transform_before):脚本要读客户端原始 Anthropic body 里的标记文本,且 plan→exec 是跨模型路由切换。
  • context.sourceProtocol 的取值是 "Anthropic" / "OpenAIChatCompletions" / "Google" 这种驼峰写法(不是 anthropic 这种小写形式)。脚本开头用 !== "Anthropic" 放过非 Claude Code 流量。
  • 挂在入口模型上时,plan 态不调 useModel,请求落在本模型,本模型的 request_payload 规则(如 prompt_cache_key)正好生效;exec 态切走后应用的是目标模型的规则,所以这类规则要在两个模型上各配一份。
  • Claude Code 还会发旁路请求(如对话标题生成),这类请求不携带 plan 标记、也不是 plan 对话轮,按"无标记=非 plan"切到执行模型,属正常且无害。
  • 从末尾向前逐消息扫描:标记只注入在当前轮末尾消息里,故从末尾向前取首个含标记的消息、只比较该消息内的标记位置即可判定当前状态。
  • 大小写不敏感靠正则 /i 标志:/plan mode is active/i 直接匹配注入文本;.test/.exec 原地扫描,不复制整条消息。lastIdxexec 循环取末次匹配位置,比较同消息内两标记先后即得当前状态。

脚本内存上限与 OOM

每个脚本在独立的 QuickJS 沙箱里执行,堆上限由 SCRIPT_MEMORY_LIMIT_MB 控制(默认 64MB改动需重启,不热生效)。脚本超过该上限即抛 out of memory,变换按错误模式兜底——默认的 log-and-continue静默丢弃本次脚本的全部副作用(包括 context.useModel),请求按原 body 继续,外在表现就是"脚本没生效 / 路由没切"。

脚本层救不了 body → JS 的固有转换内存:网关把请求体送进沙箱时,tools / messages 等结构(例如 Claude Code 的几十个工具定义)会先占一份内存,这是脚本层无法消除的 floor。若超大 body 仍 OOM,先调高 SCRIPT_MEMORY_LIMIT_MB;若希望彻底绕开 JS 内存墙、按文本标记做条件路由,需要执行引擎层面的原生能力(尚未实现,列入后续规划)。

例 2:多模态检测 + 路由切换(before 槽)

检测图片输入或联网搜索参数,切换到多模态模型。放在 before 槽request_transform_before):

javascript
function transform(body, context) {
    const proto = context.sourceProtocol;
    let hasImage = false;

    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;
                }
            }
        }
    }

    if (hasImage) {
        context.useModel("multimodal-model");
    }
    return body;
}

要点:

  • 必须放在 before 槽:要读客户端原始格式的图片字段,且不同协议字段名不同(OpenAI image_url、Anthropic image、Google inlineData / fileData
  • context.sourceProtocol 分支处理,否则跨协议客户端会漏判
  • context.useModel 做完整路由切换(base_url、协议、凭证一起换)

图片/联网切路由:优先用原生 switch-route(非脚本)

上面的 before 槽脚本适用于需要复杂分支 / 跨字段判定的场景。若判定只是「请求里有没有图片块 / 有没有打开联网」这类结构检查,应改用原生 request_payloadswitch-route 规则——它不读写 body 内容,只在 when 谓词成立时把本次请求改投到 use_model同协议或跨协议皆可(它在协议转换前按客户端形状判定并触发 立即切换,跨协议目标会被重翻译),且因为谓词永不解引用二进制字节,大请求体的磁盘暂存保持开启,不会像 after 槽脚本那样在多模态大 body 上 OOM 或被静默跳过。

配置(控制台,不用写 JSON):模型详情 → 请求体规则 → 添加规则 → 模式选 switch-route,「切换到模型 (use_model)」下拉选目标模型,加三条判定——

  • 路径 messages.*.content.*.image_url,「等于」留空(OpenAI Chat 客户端图像块,存在性)
  • 路径 messages.*.content.*.type,「等于」填 image(Anthropic 客户端图像块)
  • 路径 tools.*.type,「等于」填 web_search_20250305(联网搜索工具)

when 是 OR 语义:任一谓词成立即切到 use_model,都不成立则维持原路由。谓词两种形态——省略 eq 表示字段存在性(如 image_url 块存在即命中),给出 eq 表示相等(小值,如块 type)。path* 通配取存在性语义(数组中任一元素该子路径存在即视为存在)。switch-route客户端形状评估,故路径要按调用方协议写——上例并列了 OpenAI Chat(messages.*.content.*.image_url)与 Anthropic(messages.*.content.*.type 等于 "image")两种客户端形态,DashScope 客户端为 input.messages.*.content.*.image,用 OR 并列即可兼容多种客户端。use_model 目标可与当前路由同协议或跨协议。切到的目标视为管理员指定的内部路由:网关不再use_model 目标重新校验调用方所属分组的模型 ACL(模型 ACL 只在请求入口对客户端原始请求的模型校验一次),所以能把只有 A 模型权限的用户透明改路到 B 模型——这正是该特性的用途,而非权限漏洞(脚本 context.useModel(...) 同理,swap 目标同样不走调用方组 ACL)。

这条规则通常挂在模型上(哪个模型需要"遇图切多模态"是模型级策略)。与其它改写规则不同,switch-route 在协议转换之前应用,故 when 路径写的是客户端 body 形态。完整字段说明见 上游与模型字段

例 3:按访问密钥分组设置参数

根据访问密钥分组(context.accessKeyGroup)设置不同的请求参数:

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

挂在 after 槽(request_transform_after),按分组差异化配额。

例 4:注入 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;
}

挂在 after 槽。简单的"不存在则注入"逻辑也可以用 request_payloadappend-if-missing 规则(性能更好),脚本适合需要复杂判断的场景。

常见问题

Q:脚本切路由后,原路由的 after 槽还会跑吗? 不会。一旦 before 槽触发了 立即 useModel 切换路由,整套 pipeline 换成目标路由的,原路由的 after 槽不执行。所以原路由和目标路由上的 request_payload 规则要各配一份。

Q:context.useModel 切换的目标模型与当前模型协议不同,能切吗? 能。before 槽支持跨协议切换。但 after 槽的 useModel 只能同协议切换——after 槽拿到的是上游格式 body,跨协议会报错。

Q:脚本跑得太慢? 脚本执行的开销主要来自 JSON 序列化/反序列化。body 越大越慢。建议简单字段操作优先用 request_payload(无序列化开销),避免不必要的 deep_clone / json_encode + json_parse

Q:脚本报 TooManyOperations 超过操作数上限(默认 2000)。优化循环,把 O(n²) 改成 O(n)。SCRIPT_MAX_OPERATIONS 可调(但建议先优化脚本)。

下一步脚本 API 参考 看完整内置函数表;脚本配置 看运行限制与配置;写第一个脚本变换 看入门结构。