Skip to content
This page is a translation of the authoritative Chinese source and may lag behind.View the original

Switch model by Claude Code plan mode with a script

Claude Code plans in plan mode and executes after exiting. Many teams want the plan phase to run on a strong reasoning model and the execution phase on a cost-effective model — but the Claude Code client itself has no "select model by mode" switch. On the gateway side you can use a script to recognize the plan-mode marker injected into the conversation and call context.useModel to switch routing accordingly: stay on the current (high-quality) model while planning, and switch to the execution model after exiting plan.

Prerequisite: read Write your first script transform first to understand the basic structure, mounting, and Context API.

How to determine the plan-mode marker

Claude Code's plan mode injects markers into the conversation: on entry it injects Plan mode is active into the last message of the current turn via <system-reminder>, and on exit it injects Exited Plan Mode. Once plan mode has been entered, the entry marker stays in the conversation history permanently — so you cannot judge the current state by "does the string appear" (after exiting, the old entry marker still matches in the history and would forever be misjudged as plan mode). The correct approach is to scan backward from the last message and take the last-occurring marker as the current state.

Plan mode is a concept specific to Claude Code (the Anthropic protocol). If the entry model also serves clients of other protocols, you must first check context.sourceProtocol — non-Anthropic requests will not contain these two markers, and a full-text scan would misjudge them as "non-plan" and divert the traffic. Let non-Anthropic requests pass at the top of the script.

Where to mount: the entry model's before slot

This example mounts the script on the entry model (the model used for plan mode) in the before slot (request_transform_before): during plan mode it does not switch and keeps the current model; when exiting plan mode it useModel-switches to the execution model.

Mount in the before slot because the script needs to read the marker text in the client's original Anthropic body (the after slot receives the already-translated upstream format, where the marker position has changed), and plan→exec is a cross-model routing switch (the before slot supports cross-protocol switching, while the after slot's useModel is same-protocol only). See Scripting API reference for the full description of the before/after slots.

Console operation: Upstreams & Models → edit the entry model (the model used in the plan phase) → Advanced settingsTransform script (JavaScript)Request tab → set Runs on Before translation, paste the script below.

The actual console UI (the screenshot is the English UI; the Chinese UI corresponds to "Advanced settings / Transform script (JavaScript) / Request / Before translation"):

Transform (JavaScript) panel: Runs set to Before, tab set to Request, script pasted in the editor; Script Error Mode keeps the default Log and Continue

Note that Runs must be on Before and the tab on Request — the after slot receives the translated upstream body and cannot read the client-injected plan marker.

Complete script

javascript
function transform(body, context) {
    if (context.sourceProtocol !== "Anthropic") {
        return body;
    }
    // Execution model: a model name configured in the gateway (matching the
    // name the client requests in `model`), NOT "upstream-name/model-name" —
    // useModel resolves routing by the gateway model name.
    const EXEC_MODEL = "qwen3.7-max";
    const PLAN = /plan mode is active/i;
    const EXIT = /exited plan mode/i;
    // Position of the last occurrence of the marker in a string; when PLAN and
    // EXIT both appear, the later one represents the current state.
    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;
    };
    // Take the first block from the end that contains a marker — it must
    // contain the globally last marker; then compare the two markers' order.
    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;
    };
    // The marker is injected into the newest message's <system-reminder>: entry
    // is "Plan mode is active", exit is "Exited Plan Mode". The entry marker
    // stays in history permanently, so the current state is decided by the last
    // message containing a marker — take the first marker-bearing message from
    // the end and compare the two markers' positions within it.
    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;
        }
    }
    // No message has a marker — fall back to the system prompt check.
    if (lastMark(body.system) === "plan") return body;
    // Fallback: no marker anywhere ⇒ not in plan mode ⇒ switch to the execution model.
    context.useModel(EXEC_MODEL);
    return body;
}

Key points:

  • Must be in the before slot (request_transform_before): the script needs to read the marker text in the client's original Anthropic body, and plan→exec is a cross-model routing switch.
  • context.sourceProtocol takes camelCase values like "Anthropic" / "OpenAIChatCompletions" / "Google" (not lowercase like anthropic). The script's opening !== "Anthropic" passes non-Claude-Code traffic through.
  • When mounted on the entry model, the plan state does not call useModel, so the request lands on this model and this model's request_payload rules (e.g. prompt_cache_key) take effect exactly; the exec state switches away and applies the target model's rules, so such rules must be configured on both models.
  • Claude Code also sends bypass requests (e.g. conversation-title generation) that carry no plan marker and are not plan-conversation turns; by "no marker = non-plan" they switch to the execution model, which is normal and harmless.
  • Scan message-by-message from the end: the marker is injected only into the last message of the current turn, so take the first marker-bearing message from the end and compare only the marker positions within that message to determine the current state.
  • Case-insensitivity relies on the /i regex flag: /plan mode is active/i matches the injected text directly; .test/.exec scan in place without copying the whole message. lastIdx uses an exec loop to take the last match position, comparing the order of the two markers within the same message to get the current state.

Script memory limit and OOM

Each script runs in an independent QuickJS sandbox; the heap limit is controlled by SCRIPT_MEMORY_LIMIT_MB (default 64MB; changing it requires a restart, not hot-applied). A script exceeding the limit throws out of memory, and the transform falls back per the error mode — the default log-and-continue silently discards all side effects of this script run (including context.useModel), and the request continues with the original body, appearing externally as "the script didn't take effect / the routing didn't switch".

The script layer cannot fix the inherent body→JS conversion memory: when the gateway sends the request body into the sandbox, structures like tools / messages (e.g. the dozens of tool definitions from Claude Code) occupy a chunk of memory first — this is a floor the script layer cannot remove. If an extra-large body still OOMs, first raise SCRIPT_MEMORY_LIMIT_MB; to completely bypass the JS memory wall and do conditional routing on text markers, a native capability in the execution engine is needed (not yet implemented, on the roadmap).

Verification

After configuring, send one plan-state request and one post-plan-exit request, then look at the model column in Console → Logs:

  • Requests switched away by useModel after exiting plan get an SW tag before the model name (script useModel swap); hovering shows the switch source; the model column shows the execution model (e.g. qwen3.7-max).
  • Plan-state requests do not switch; the model column is still the entry model with no SW tag.

If the landing of the two kinds of requests matches expectations, the script works. If a request that should switch doesn't, first check whether it OOM'd and was silently discarded (see above).

FAQ

Q: After exiting plan mode it doesn't switch away / it's always judged as plan state? Most likely you used a "does it contain Plan mode is active" check, or mounted the script in the after slot. The entry marker stays in history permanently, so after exiting, the old marker still matches and would forever be misjudged as plan state. Change to take the last marker from the end; also confirm the script is in the before slot — the after slot receives the upstream-format body and cannot read the position of the client-injected marker text.

Q: Non-Claude-Code client traffic is also switched to the execution model? The context.sourceProtocol !== "Anthropic" passthrough branch at the top is missing. Other protocols' bodies won't contain these two markers and would be judged "non-plan" by the fallback logic and diverted. First check the protocol; non-Anthropic should directly return body.

Q: The script seems to have no effect and the log shows no error? On OOM the script is silently discarded under log-and-continue, including context.useModel — appearing externally as "routing didn't switch". Claude Code's dozens of tool definitions occupy the inherent body→JS conversion memory first, a floor the script layer cannot remove. First raise SCRIPT_MEMORY_LIMIT_MB; see the memory-limit note on this page and Scripting config.

Next: Fix protocol translation with scripts for the remaining before / after slot practical examples; Scripting API reference for context.useModel and the full built-in function tables; Script performance & memory for cost sources and after-slot OOM anti-patterns.