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 settings → Transform 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;
    }
    const EXEC_MODEL = "<your-execution-model>";
    const extractText = (content) => {
        if (!content) return "";
        if (typeof content === "string") return content;
        if (Array.isArray(content)) return content.map(b => (b && b.text) || "").join("\n");
        return "";
    };
    // Claude Code injects "Plan mode is active" (entry) and "Exited Plan Mode"
    // (exit) via <system-reminder>. The entry marker stays in history, so scan
    // backward from the end and take the last-occurring marker as current state.
    const messages = body.messages || [];
    for (let i = messages.length - 1; i >= 0; i--) {
        const lower = extractText(messages[i].content).toLowerCase();
        const planIdx = lower.lastIndexOf("plan mode is active");
        const exitIdx = lower.lastIndexOf("exited plan mode");
        if (planIdx !== -1 || exitIdx !== -1) {
            if (planIdx <= exitIdx) {
                context.useModel(EXEC_MODEL);
            }
            return body;
        }
    }
    // No marker in messages — fall back to system prompt check
    const sysLower = extractText(body.system).toLowerCase();
    if (sysLower.includes("plan mode is active")) {
        return body;
    }
    // Default: switch to 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.
  • Replace EXEC_MODEL with your gateway-configured execution model name (matching the name the client requests in model), NOT "upstream-name/model-name" — useModel resolves routing by the gateway model name.
  • 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 the two markers' positions within it to determine the current state.
  • Case-insensitivity relies on .toLowerCase() + lastIndexOf: convert content to lowercase first, then search. extractText joining text blocks and .toLowerCase() each allocate a string proportional to the message size; for typical Claude Code requests (tens of KB) this is negligible.

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 (i.e. your configured EXEC_MODEL).
  • 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.