# Scripting Guide

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

Protoflux supports **JavaScript scripts** (powered by QuickJS/rquickjs) for custom request and response body transformations. Scripts run inside a sandboxed engine with resource limits, giving you full programmatic control over payloads while keeping the gateway safe.

The JavaScript engine **preserves JSON key insertion order** (an ECMAScript language guarantee), making it safe for both request and response transforms — including requests targeting Anthropic prompt caching, which relies on byte-identical prefix matching.

## When to Use Scripts

| Scenario | Recommended Method |
|----------|-------------------|
| Simple field add/remove/overwrite | `request_payload` (declarative rules) |
| Complex request transforms | **JavaScript script** (`request_transform_before` / `request_transform_after`) |
| Response post-processing (e.g. redact PII, add metadata) | **JavaScript script** (`response_transform`) |
| Protocol-specific adaptation (e.g. inject thinking blocks) | **JavaScript script** or built-in Pipeline Steps |

Declarative `request_payload` rules handle ~90% of simple cases. JavaScript scripts handle everything else — complex logic, conditional branching, cross-field computation — for both request and response phases.

## Built-in Pipeline Steps

Protoflux provides a set of built-in Rust Pipeline Steps that operate directly on `serde_json::Value` (using `IndexMap` to preserve insertion order). These steps are automatically injected into the request pipeline — no configuration needed.

### Auto-Injected Steps

| Step | Injection Condition | Purpose |
|------|-------------------|---------|
| `AnthropicThinkingNormalize` | Anthropic protocol + DeepSeek model | Injects empty `thinking` blocks into assistant messages (required by DeepSeek v4-pro) |
| `OpenAIReasoningContentNormalize` | base_url contains 'dashscope' | Injects empty `reasoning_content` field on assistant tool-call messages (required by DashScope when reasoning is enabled) |

### Step Registry

Built-in steps are managed via the `StepRegistry`. Each step declares its injection conditions (target protocol, model name match, base_url match, etc.). The pipeline builder automatically discovers and injects applicable steps during route resolution.

```rust
// Example: AnthropicThinkingNormalize registration
StepDescriptor {
    name: "AnthropicThinkingNormalize",
    priority: 10,
    target_protocol: Some(Protocol::Anthropic),
    upstream_model_contains: Some("deepseek"),
    base_url_contains: None,
    factory: || Arc::new(AnthropicThinkingNormalizeStep::new()),
}
```

**Adding new built-in steps:** Simply implement the `PayloadStep` trait and register it in `registry.rs` — no changes needed to the builder or routing code.

## Quick Start

A script is a JavaScript module that defines a `transform` function. The function receives the body and context, and must return the (possibly modified) body:

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

Attach it to an **upstream** or **model** via the Console API. Request transforms come in two independent slots — `request_transform_before` (runs before protocol translation, sees the client-protocol body, can trigger a cross-protocol `useModel` switch) and `request_transform_after` (runs after translation, sees the upstream-protocol body, `useModel` is same-protocol only). Set either, both, or neither; each slot accepts an inline string or `{ "file": "path" }`.

**Inline script** (after slot — a simple field tweak). To configure it in the Console (no hand-written JSON): open the upstream or model → **Transform (JavaScript)** section → **Request** tab → **After** slot → paste the script into the inline editor:

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

**File-based script** (before slot — a pre-translation transform). To configure it in the Console: open the upstream or model → **Transform (JavaScript)** → **Request** → **Before** slot → paste the script contents into the inline editor. (The Console UI exposes an inline editor per slot; a `{ "file": "path" }` reference such as `scripts/inject-thinking.js` is the Console-API shape for a file-backed script.)

## Script Basics

### The `transform` Function

Every script must define a `transform` function with this signature:

```javascript
function transform(body, context) {
    // Read and modify `body` as needed
    // Read `context` for metadata (read-only)
    return body;  // REQUIRED — return the transformed body
}
```

| Parameter | Mutable | Description |
|-----------|---------|-------------|
| `body` | Yes | The JSON request or response body as a JavaScript object |
| `context` | No | Request/response metadata (model name, headers, etc.) |

The gateway calls `transform(body, context)` and uses the returned value as the new payload. You may mutate `body` in place and return it, or construct and return a new object.

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

> ⚠️ **Every return path must return body.** A bare `return;` (no value) or missing return causes the script to fail silently — under `log-and-continue` mode the request proceeds with the original unmodified body (a `warn` is logged, but the response shows no error). Early returns must also carry `return body`:
>
> ```javascript
> function transform(body, context) {
>     // ✅ Correct: early return carries body
>     if (!body.messages) return body;
>
>     // ❌ Wrong: bare return → returns undefined → silent failure
>     if (!body.messages) return;
>
>     body.messages[0].content = "modified";
>     return body;
> }
> ```

### JavaScript Syntax Reference

Scripts use standard ECMAScript syntax. Key patterns:

```javascript
// Variables (let = mutable, const = block-scoped constant)
let x = 42;
const name = "protoflux";

// Objects (plain JS objects, preserve insertion order)
const obj = { key: "value", nested: { a: 1 } };

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

// Template literals
const msg = `Hello, ${name}!`;

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

// for-of loop (iterate values)
for (const item of body.messages) {
    if (item.role === "user") {
        item.content = str_to_lower(item.content);
    }
}

// Classic for loop (iterate by index)
for (let i = 0; i < body.messages.length; i++) {
    body.messages[i].processed = true;
}

// Helper functions (allowed in scripts)
function inject_field(obj, field, value) {
    if (!(field in obj)) {
        obj[field] = value;
    }
}
```

### Type System

JSON values map to JavaScript types naturally:

| JSON | JavaScript | Example |
|------|------------|---------|
| `string` | `string` | `"hello"` |
| `number` (integer) | `number` | `42` |
| `number` (float) | `number` | `3.14` |
| `boolean` | `boolean` | `true` |
| `null` | `null` | `null` |
| `object` | `Object` | `{ key: "value" }` |
| `array` | `Array` | `[1, 2, 3]` |

Check for missing/optional fields with standard JavaScript patterns:

```javascript
// Check if a path returns a value
const val = json_path(body, "temperature");
if (val === undefined) {
    body.temperature = 1.0;
}

// Or simply use truthiness (catches null, undefined, 0, "", false)
if (!body.temperature) {
    body.temperature = 1.0;
}

// Check if a property exists on an object
if ("temperature" in body) {
    // field exists
}
```

## Context Object (`context`)

The `context` object provides metadata about the current request or response. Most fields are read-only; it also exposes a `context.useModel(name)` method for declaring a route-switch intent (see the end of this section).

### Available in All Phases

| Field | Type | Description | Example |
|-------|------|-------------|---------|
| `context.clientModel` | string | Client-facing model name | `"gpt-4o"` |
| `context.upstreamModel` | string | Model ID sent to the upstream provider | `"gpt-4o-2024-05-13"` |
| `context.upstreamName` | string | Upstream group name | `"openai-main"` |
| `context.sourceProtocol` | string | Client protocol | `"OpenAIChatCompletions"` |
| `context.targetProtocol` | string | Upstream protocol | `"Anthropic"` |
| `context.stream` | boolean | Whether this is a streaming request | `true` |
| `context.headers` | object | Request headers (sensitive headers **blocked** — see below) | `context.headers["content-type"]` |
| `context.query` | object | URL query parameters | `context.query["version"]` |
| `context.requestId` | string | Unique request ID (UUID v4, same for request + response) | `"550e8400-..."` |
| `context.clientIp` | string | Client IP (from X-Forwarded-For / X-Real-IP) | `"203.0.113.50"` |
| `context.accessKeyName` | string | Access key name, empty string if unauthenticated | `"user-alice"` |
| `context.accessKeyGroup` | string | Access key group, empty string if unauthenticated | `"team-alpha"` |

### Available in Response Phase Only

| Field | Type | Description |
|-------|------|-------------|
| `context.responseStatus` | number / `null` | HTTP status code from upstream, `null` in request phase |
| `context.responseHeaders` | object | Upstream response headers |
| `context.elapsedMs` | number / `null` | Wall-clock time since request ingress (ms), `null` in request phase |

### Security: Blocked Headers

These headers are **always stripped** from `context.headers` before the script sees them, preventing credential leakage:

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

```javascript
// This works
const contentType = context.headers["content-type"];

// These return undefined — blocked for security
const auth = context.headers["authorization"];  // always undefined
```

### Switching models: `context.useModel(name)`

Scripts can act on the model in two distinct ways, with complementary semantics and use cases:

| Operation | Syntax | Gateway behavior | Use case |
|-----------|--------|------------------|----------|
| Change request body | `body.model = "X"` | Route metadata unchanged; only the body's `model` field changes | Same-endpoint switch (same upstream, same endpoint path) |
| Switch model | `context.useModel("X")` | Re-resolves the target route and borrows the full set `base_url`/`kind`/`direct_path`/`upstream_name`/`protocol`/`extra_config` from it | Cross-endpoint switch (different endpoint path, e.g. text model → multimodal model) |

`body.model` changes a single field without re-resolving the route; `context.useModel` is a full route switch — re-resolving by model name necessarily changes `base_url`/`kind`/`direct_path` together, so you cannot pick just one. If the target model configures a full URL at the model level (`direct_path = true`), changing only `body.model` cannot construct a correct upstream URL.

**The `name` argument**: `useModel` takes the target model's **canonical name or alias** (`[[models]].name` / `[[models]].aliases` — the gateway-facing name a client sends), **not** the `upstream_model_id` (the provider-side ID). After resolving the route, the handler derives the upstream-side ID (priority: `upstream_inference_profile` > `upstream_model_id` > canonical name) and writes it to `body.model` — the script only needs the gateway-facing name, the same one a client sends for the initial request.

**Dual swap-point semantics**: `context.useModel(name)` only writes the target name to a control-plane writeback slot; it does not switch immediately. The handler reads the declaration and swaps the route at one of two positions in the request pipeline, and the position determines the semantics:

- **Before Rules (eager-swap, pre-translation)**: the script runs **before** protocol translation and sees the client-format body. When `useModel` is declared, the handler swaps the route **before** translation — the subsequent translation step then re-translates the body using the target route's translator. The Before position therefore **supports cross-protocol switches** (e.g. DashScope text model → multimodal model, where both the endpoint path and protocol differ). The target route becomes the active route and its full request-side pipeline runs: its before-script also executes on the still-client-format body (translation has not happened yet), and its main pipeline performs translation and rules. If the target route's before-script declares another `useModel`, the chain continues (A→B→C); each route's before-script executes at most once, and an 8-hop cap guards against a misconfigured loop (A→B→A→… fails with a 500 ConfigError beyond 8 hops). This is the opposite of the After position, where the target route's request-side pipeline is not re-run.
- **After Rules (deferred-swap, post-translation)**: the script runs **after** protocol translation and sees the upstream-format body. When `useModel` is declared, the handler swaps the route after the pipeline finishes, but the body has already been translated under the original protocol and is **not re-translated** — the target route's request-side pipeline is not re-run. The After position therefore **allows same-protocol switches only** (cross-protocol would produce a malformed body). This implies an assumption: the target route's request-side pipeline produces output compatible with the original route's (same protocol, and the target route has no extra request-side rules). Otherwise the body shape may not match — `useModel` is an endpoint/auth switch, not a request-side re-translation.

Each route has a single script position (Before or After, not both). The eager swap point (Before) may fire repeatedly across a chain A→B→C (bounded by MAX_EAGER_SWAPS), and the deferred swap point (After) fires at most once; a single request can therefore trigger both an eager chain and a final deferred swap (when the chain lands on an After-position route whose after-script declares useModel), but never two swaps at the same position.

**Control-plane / data-plane separation**: the `useModel` value lives only in the control plane — it never enters the request body and is never sent upstream, so no stripping is needed. This is the opposite of stuffing a control signal into a body field (which must be stripped afterward or it leaks the upstream).

**Position determines semantics**: the Before and After positions give `useModel` different semantics — Before swaps the route before translation (client-format body, supports cross-protocol); After swaps after translation (upstream-format body, same-protocol only). Which position the script is attached to determines whether the swap happens before or after translation.

**Protocol guard (After position only)**: for a `useModel` declared from After Rules, the target route's protocol must match the current route's — the body has already been translated under the current protocol and is not re-translated, so a cross-protocol switch would produce a malformed body. This means the After position's target model must be configured (in `[[models]]`/`[[upstreams]]`) under the **same protocol** as the route whose pipeline runs the script (e.g. if the script is attached to a DashScope route, the target must also be a DashScope-protocol model). On mismatch the request fails with a 500 ConfigError. The Before position has no such guard — the route is swapped before translation, and the translation step re-translates the body under the target route's protocol; cross-protocol is its intended purpose.

**Access control**: the normal and failover paths apply the same access-control check to the target model as to the initial request (`check_user_model_access`). **The `hide_name` restriction is bypassed** — `useModel` is an internal script route switch, not a client request, and always uses the `resolve_including_hidden` path. Scripts can switch to any **enabled** model in the configuration (including `hide_name` models) by canonical name or alias. Models with `enabled = false` are filtered out of `model_map` at route-table construction, so `resolve_including_hidden` cannot resolve them either — disabled models cannot be reached via `useModel`. The console try-me diagnostic path does not perform the access-control check (admin diagnostic context).

**Failover semantics**: within the failover path the swap happens in the same iteration without breaking the loop, flowing into normal dispatch and reusing all error handling and response translation. On a failoverable error the loop continues to the next LB entry, re-runs the original pipeline, the script re-declares `useModel`, and the swap repeats. The swap target is not an LB entry, so it is intentionally outside the failover `excluded` list — `select_lb_entry` only excludes LB entries, never swap targets; if the target upstream is failing, each iteration swaps back to the same failing upstream until `max_attempts` is exhausted. This is the accepted tradeoff for an explicit model switch (`useModel` is an explicit script/admin decision, not LB automatic failover).

**`api_key` is unchanged**: the swap does not modify `api_key`. `api_key` is the client identity (used for LB sticky binding and `access_key_hash` template variables), unrelated to upstream HTTP authentication — the upstream credential is resolved by the dispatch layer from `auth_store` by `route.upstream_name`; after the swap `upstream_name` is updated, so dispatch automatically picks up the new upstream's credential.

**Observability**: after the swap completes, the access log records a `translation_notice` (`reason = script_use_model_swap`, `detail = "from → to"`) marking the upstream model as switched by a script via `useModel` rather than the model the client originally requested — the log's `model` field shows the swapped target, so the field alone cannot distinguish "client asked for this" from "script swapped to this". The notice flows with the log into the DB `extensions` and the live SSE stream, and renders in the "Notices" bar atop the console log detail view.

```javascript
function transform(body, context) {
  // Detect multimodal input (an image) and switch across endpoints to a
  // multimodal model. The body shape depends on the client protocol; this is
  // the DashScope input.messages form.
  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;
}
```

## Built-in Functions Reference

### JSON Path Operations

Navigate and modify nested JSON structures using dot-notation paths.

```javascript
// Read a nested value
const temp = json_path(body, "generation_config.temperature");

// Set a value (creates intermediate objects)
set_path(body, "generation_config.max_output_tokens", 4096);

// Remove a field
remove_path(body, "deprecated_field");

// Add only if the field doesn't exist
add_if_absent_path(body, "temperature", 1.0);

// Append to an array (creates the array if missing)
append_path(body, "messages", { role: "system", content: "Be concise." });
```

| Function | Signature | Description |
|----------|-----------|-------------|
| `json_path` | `(body, path) → value` | Read value at dot-path. Returns `undefined` if not found |
| `set_path` | `(body, path, value) → void` | Set/overwrite value, creating intermediate objects |
| `remove_path` | `(body, path) → void` | Remove field. No-op if absent |
| `add_if_absent_path` | `(body, path, value) → void` | Set only if path doesn't exist |
| `append_path` | `(body, path, value) → void` | Append to array, creating it if missing |

### String Functions

All string functions use **character semantics** (not byte), so they work correctly with multi-byte characters like Chinese, Japanese, emoji, etc.

```javascript
const s = "Hello, 世界!";
str_len(s);            // 9 (characters, not bytes)
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"]
```

| Function | Signature | Returns |
|----------|-----------|---------|
| `str_trim` | `(s) → string` | Whitespace-trimmed string |
| `str_to_upper` | `(s) → string` | Uppercased string |
| `str_to_lower` | `(s) → string` | Lowercased string |
| `str_contains` | `(s, needle) → boolean` | Whether `s` contains `needle` |
| `str_starts_with` | `(s, prefix) → boolean` | Whether `s` starts with `prefix` |
| `str_ends_with` | `(s, suffix) → boolean` | Whether `s` ends with `suffix` |
| `str_replace` | `(s, from, to) → string` | All occurrences of `from` replaced with `to` |
| `str_split` | `(s, delimiter) → array` | Array of substrings |
| `str_len` | `(s) → number` | **Character** count (not bytes) |
| `str_slice` | `(s, start, end) → string` | Substring by **character** indices `[start, end)` |

### Regex Functions

Patterns are compiled once and cached (LRU, 256 entries, 5-min TTL). Max pattern length: 4096 chars.

```javascript
// Test match
const hasDigits = regex_match("abc123", "[0-9]+");  // true

// Replace all matches
const cleaned = regex_replace("foo123bar456", "[0-9]+", "N");  // "fooNbarN"

// Extract capture groups
const keys = regex_extract("a=1 b=2 c=3", "([a-z]+)=");  // ["a", "b", "c"]
```

| Function | Signature | Returns |
|----------|-----------|---------|
| `regex_match` | `(text, pattern) → boolean` | Whether text matches pattern |
| `regex_replace` | `(text, pattern, replacement) → string` | Text with all matches replaced |
| `regex_extract` | `(text, pattern) → array` | Capture group 1 from all matches |

### Encoding & Hashing

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

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

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

// JSON serialization
const jsonStr = json_encode(body.messages);   // serialize to string
const parsed = json_parse(jsonStr);           // parse back to object
```

| Function | Signature | Returns |
|----------|-----------|---------|
| `base64_encode` | `(data) → string` | Base64-encoded string |
| `base64_decode` | `(data) → string` | Decoded UTF-8 string |
| `url_encode` | `(s) → string` | URL percent-encoded string |
| `url_decode` | `(s) → string` | URL-decoded string |
| `sha256` | `(data) → string` | SHA-256 hex digest |
| `md5` | `(data) → string` | MD5 hex digest |
| `json_encode` | `(value) → string` | JSON-serialized string |
| `json_parse` | `(text) → value` | Parsed JavaScript value from JSON |

### Array Functions

```javascript
// Sort — numeric-first (integers and floats sort numerically, strings lexicographically)
array_sort([3, 1, 2]);                // [1, 2, 3]
array_sort(["banana", "apple"]);      // ["apple", "banana"]

// Deduplicate (type-safe comparison via JSON serialization)
array_unique([1, 2, 2, 3, 3]);        // [1, 2, 3]

// Basic operations
array_reverse([1, 2, 3]);             // [3, 2, 1]
array_flatten([[1, 2], [3, 4]]);      // [1, 2, 3, 4]

// Filter/reject by field value (type-safe comparison)
const active = array_filter_by(body.items, "active", true);
const rejected = array_reject_by(body.items, "status", "deleted");

// Find first match (returns element or null)
const user = array_find(body.users, "name", "alice");
if (user !== null) {
    body.found_user = user;
}

// Extract a field from each element
const names = array_pluck(body.users, "name");  // ["alice", "bob", ...]

// Group into object by field
const grouped = array_group_by(body.items, "category");
// grouped = { "electronics": [...], "books": [...], ... }

// Chunk into sub-arrays
array_chunk([1, 2, 3, 4, 5], 2);      // [[1, 2], [3, 4], [5]]

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

// Set operations (type-safe comparison)
array_diff([1, 2, 3], [2, 3, 4]);     // [1]
array_intersect([1, 2, 3], [2, 3, 4]); // [2, 3]

// Sum numeric values
array_sum([1.5, 2.5, 3.0]);           // 7.0
```

| Function | Signature | Returns |
|----------|-----------|---------|
| `array_sort` | `(arr) → array` | Sorted array (numeric-first) |
| `array_unique` | `(arr) → array` | Deduplicated array |
| `array_reverse` | `(arr) → array` | Reversed array |
| `array_flatten` | `(arr) → array` | Flattened one level |
| `array_filter_by` | `(arr, field, value) → array` | Elements where `field == value` |
| `array_reject_by` | `(arr, field, value) → array` | Elements where `field != value` |
| `array_find` | `(arr, field, value) → value` | First match or `null` |
| `array_pluck` | `(arr, field) → array` | Field values from each element |
| `array_group_by` | `(arr, field) → object` | Object of field-value → elements |
| `array_chunk` | `(arr, size) → array` | Array of sub-arrays |
| `array_merge` | `(a, b) → array` | Concatenation of two arrays |
| `array_diff` | `(a, b) → array` | Elements in `a` not in `b` |
| `array_intersect` | `(a, b) → array` | Elements in both `a` and `b` |
| `array_sum` | `(arr) → number` | Sum of numeric values |

### JSONPath & JSON Patch

For complex queries beyond simple dot-paths.

```javascript
// JSONPath — find all names in items
const names = jsonpath_query(body, "$.items[*].name");

// First match only
const first = jsonpath_query_first(body, "$.messages[?(@.role=='user')].content");

// Count matches
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);
```

| Function | Signature | Returns |
|----------|-----------|---------|
| `jsonpath_query` | `(value, path) → array` | All JSONPath matches |
| `jsonpath_query_first` | `(value, path) → value` | First match or `null` |
| `jsonpath_query_count` | `(value, path) → number` | Number of matches |
| `json_patch` | `(value, patches) → value` | Value after applying RFC 6902 patches |

### Deep Operations

```javascript
// Deep clone — independent copy (modifications don't affect original)
const backup = deep_clone(body);
body.messages = [];
// backup.messages still has the original data

// Deep merge — recursive merge (source overwrites target, arrays are replaced)
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"] }
```

| Function | Signature | Returns |
|----------|-----------|---------|
| `deep_clone` | `(value) → value` | Independent deep copy |
| `deep_merge` | `(target, source) → value` | Recursive merge (arrays replaced, not concatenated) |

### UUID & Time

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

// Timestamps
const ts = now();      // 1748620800 (seconds since epoch)
const ms = now_ms();   // 1748620800000 (milliseconds since epoch)

// Format timestamp (chrono format strings)
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"
```

| Function | Signature | Returns |
|----------|-----------|---------|
| `uuid` | `() → string` | UUID v4 string |
| `now` | `() → number` | Unix timestamp (seconds) |
| `now_ms` | `() → number` | Unix timestamp (milliseconds) |
| `format_time` | `(timestamp, format) → string` | Formatted time using `chrono` format specifiers |

### Math

```javascript
min(3, 7);           // 3
max(3, 7);           // 7
clamp(15, 0, 10);    // 10 (clamped to max)
abs(-42);            // 42
```

| Function | Signature | Returns |
|----------|-----------|---------|
| `min` | `(a, b) → number` | Smaller of two values |
| `max` | `(a, b) → number` | Larger of two values |
| `clamp` | `(value, min, max) → number` | Value clamped to `[min, max]` |
| `abs` | `(value) → number` | Absolute value |

## Execution Order

Scripts run as part of the **payload pipeline**. The pipeline order is determined at route resolution time. Request transforms are split into two independent slots: `request_transform_before` runs **before** protocol translation (on the client-protocol body) and `request_transform_after` runs **after** translation (on the upstream-protocol body). When both slots are configured on the same route, both run — before-slot first, then after-slot.

### Request Side

The request pipeline is split into two phases at the translation boundary: a **pre-translation pipeline** (Anthropic sanitize + Before script, operating on the client-format body) and a **main pipeline** (Protocol Translation → Auto-Normalization → Declarative Rules → After script → Cache injection, operating on the upstream-format body).

**`request_transform_before`** (runs before translation, sees the client-protocol body):
```
[Pre-translation] Anthropic sanitize → Upstream Script → Model Script → (eager useModel swap ──┐
                                                       ↑                                        │ if the target route has a
                                                       └────────────────────────────────────────┘ before-script, loop back to [Pre-translation] (chain A→B→C, 8-hop cap)
→ [Main] Protocol Translation → Auto-Normalization → Declarative Rules → Cache injection
```
The eager swap swaps the route before translation; the target route becomes active and both its before-slot and main pipeline run. If the target's before-slot declares another `useModel`, the loop returns to [Pre-translation] for the new target, chaining forward until some route declares no `useModel`, then enters [Main]. Note that once a before-slot triggers an eager swap, the **original route's after-slot does not run** — the whole pipeline is replaced by the swapped route's (whose before- and after-slots both run). This is intentional.

**`request_transform_after`** (runs after translation, sees the upstream-protocol body):
```
[Pre-translation] Anthropic sanitize → [Main] Protocol Translation → Auto-Normalization → Declarative Rules → Upstream Script → Model Script → (deferred useModel swap) → Cache injection
```

A before-slot script runs **before** protocol translation on a client-protocol body and can trigger a cross-protocol `useModel` switch; an after-slot script runs **after** protocol translation on an upstream-protocol body, where `useModel` is same-protocol only.

**Auto-Normalization Steps** are automatically injected by the `StepRegistry` based on route context (see "Built-in Pipeline Steps" above). For example:
- Anthropic + DeepSeek routes auto-inject thinking blocks and filter web_search tools
- DashScope image content filtering is available via the config-driven `filter-content-types` payload rule (not auto-injected)
- `x-anthropic-billing-header:` and similar text line filtering is handled via declarative `strip-lines` payload rules (no hardcoded step needed)

### Response Side (non-streaming only)

```
Upstream Response → Response Script → Reverse Protocol Translation
```

> **Note**: Response scripts for streaming requests are not supported.

### Script Levels

Scripts can be set at two levels:

| Level | Scope | Use Case |
|-------|-------|----------|
| **Upstream** | All models routed through this upstream | Protocol adaptation, common field injection |
| **Model** | Only this specific model | Model-specific tweaks, parameter tuning |

Both levels share the same 4 configuration fields:

| Field | Description | Default |
|-------|-------------|---------|
| `request_transform_before` | Request transform script (before translation) | none |
| `request_transform_after` | Request transform script (after translation) | none |
| `response_transform` | Script for response body | none |
| `script_error_mode` | `log-and-continue` or `log-and-reject` | `log-and-continue` |

## Configuration

Scripts are configured via the Console UI or Console API. Business config is stored in the database (not TOML files).

### Attaching Scripts

**Upstream-level script** (applies to all models through this upstream). To configure it in the Console (no hand-written JSON): open the upstream `openai` → **Transform (JavaScript)** section → **Request** tab → **After** slot → paste the script into the inline editor:

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

**Model-level script** (applies to this specific model). To configure it in the Console (no hand-written JSON): open the model `gpt-4o` → **Transform (JavaScript)** section → **Request** tab → **After** slot → paste the request transform into the inline editor:

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

Switch to the **Response** tab → paste the response transform into the inline editor:

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

### File-based Script

For larger scripts, reference an external `.js` file. To configure it in the Console: open the model `claude-thinking` → **Transform (JavaScript)** section → **Request** tab → **Before** slot → paste the script contents into the inline editor. Set **Script Error Mode** to `log-and-reject`. (The Console UI exposes an inline editor per slot; a `{ "file": "path" }` reference such as `scripts/inject-thinking.js` is the Console-API shape for a file-backed script.)

`scripts/inject-thinking.js`:
```javascript
// Inject empty thinking blocks into assistant messages
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;
}
```

### Testing via Console API

```bash
# Test a script before deploying
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
    }
  }'
```

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

## Error Handling

### `log-and-continue` (default)

Script errors are logged as warnings. The request proceeds with the **original unmodified body**. Use this when script failure should not block the request.

### `log-and-reject`

Script errors are logged and the request is **rejected** with a gateway error. Use this when correct script execution is critical (e.g., security redaction).

### Common Errors

| Error | Cause | Fix |
|-------|-------|-----|
| `TooManyOperations` | Script exceeded `max_operations` (default 2000) | Optimize loops, reduce iterations |
| `StackOverflow` | Excessive recursion depth | Reduce recursive calls, use iteration instead |
| `DataTooLarge` | String > 10MB or array > 10,000 elements | Process data in chunks |
| `TypeError` | Accessing property of `null`/`undefined` | Check with `typeof`, `in` operator, or `json_path()` |
| `InvalidRegex` | Bad regex pattern | Fix the regex syntax |
| `ReferenceError` | Using undefined variable or function | Check spelling, ensure all helpers are defined |
| `transform() must return a value` | Missing `return body` at end of function, or bare `return;` in early exit | **Every return path must carry `return body`** (see "early return" example above) |

## Sandbox & Security

### What Scripts Can Do

- Read and mutate the `body` parameter
- Read the `context` object (metadata)
- Use all built-in functions listed above
- Define helper functions within the script
- Use standard JavaScript constructs (loops, conditionals, closures, destructuring, etc.)

### What Scripts Cannot Do

| Disabled | Why |
|----------|-----|
| `console.log` / `print` | Prevents information leakage |
| File I/O | No filesystem access |
| Network | No HTTP calls, no `fetch`, no sockets |
| `import` / `require` / modules | No external code loading |
| `eval` / `Function` | No dynamic code execution |
| `globalThis` mutation | Sandbox globals are frozen |
| Process access | No `exec`, no environment variables |

### Resource Limits

| Limit | Value | Configurable |
|-------|-------|-------------|
| Max operations | 10,000 | `server.script_max_operations` in `server.toml` |
| Max string size | 10 MB | Fixed (for base64 screenshots) |
| Max array/object size | 10,000 elements | Fixed |
| Regex cache | 256 entries, 5-min TTL | Fixed |
| Max regex pattern | 4,096 chars | Fixed |
| Stack size | 1024 frames | Fixed |

### Sensitive Headers Blocklist

These headers are stripped from `context.headers` to prevent credential leakage:
`authorization`, `cookie`, `set-cookie`, `x-api-key`, `proxy-authorization`

## Common Patterns

### Override Model Name

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

### Inject 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;
}
```

### Strip Image Content (Cost Optimization)

Use the `filter-content-types` Request Payload Rule instead of a script for simple content filtering. To configure it in the Console (no hand-written JSON): open the **model detail** (or the upstream's *default request payload rules*) → find **Request Payload Rules** → click **+ Add Rule** → pick **`filter-content-types`** in the mode dropdown → set **path** to `messages` and **value** to `image`.

> **Note**: For DashScope routes, image content filtering is available via the config-driven `filter-content-types` payload rule (not auto-injected).

Removing image blocks auto-injects a placeholder text in their place (a default note that the user attached an image but the model does not support image input), customizable via `hint`. See [Configuration — Filter Content Types](configuration.md#filter-content-types).

For more complex filtering logic, use a script:

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

### Add Request Metadata to Response

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

### Conditional Logic Based on Access Key

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

### Remove PII from Response

```javascript
function transform(body, context) {
    // Redact email addresses
    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;
}
```

### Convert Anthropic Thinking Blocks

```javascript
function transform(body, context) {
    // Inject empty thinking blocks for Anthropic API compatibility
    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;
}
```

> **Note**: For Anthropic protocol + DeepSeek model, the `AnthropicThinkingNormalize` Pipeline Step automatically injects thinking blocks — no manual script needed. This example is for other scenarios requiring manual handling.

### Protocol-Adaptive Parameter Mapping

```javascript
function transform(body, context) {
    // Map OpenAI-style params to Anthropic-style when protocol differs
    if (context.targetProtocol === "Anthropic") {
        if (body.max_tokens !== undefined) {
            body.max_tokens = clamp(body.max_tokens, 1, 8192);
        }
        // Anthropic uses top_k instead of top_p in some cases
        if (body.top_p !== undefined && body.top_k === undefined) {
            body.top_k = Math.round(body.top_p * 100);
            delete body.top_p;
        }
    }
    return body;
}
```

### Add Timestamp Metadata via 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;
}
```

### Multimodal Model Switch + System Prompt Filter (After Rules)

A comprehensive real-world example: detect image input or web search parameters and switch to a multimodal model, while filtering out Claude Code's system prompt. This script runs in the After position (default) on a DashScope upstream, where `body.input.messages` is the message path and `parameters.enable_search` indicates the declarative `request_payload` rule already converted `web_search` into it.

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

    // 1. Detect image content. DashScope multimodal blocks are {"image": "<url>"}
    //    (no "type" field) — the openai_chat_completions_dashscope translator
    //    emits them this way and detects them via 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. Search OR image → switch to the multimodal model. qwen3.7-plus handles
    //    both image input and search, so one target covers both triggers. Use
    //    context.useModel (not body.model =) because qwen3.7-plus lives on a
    //    DIFFERENT endpoint (multimodal-generation): body.model alone would send
    //    the multimodal model name to the text-generation URL — wrong. The
    //    adapter already turned web_search into parameters.enable_search.
    if (hasImage || body.parameters?.enable_search) {
        context.useModel("aliyun-china/qwen3.7-plus");
    }

    // 3. Filter Claude Code's system prompt. Claude Code sends its system
    //    prompt as the whole `role: "system"` message — a plain string or an
    //    array of text blocks, the first beginning with PREFIX. Drop the
    //    entire message when identified: the previous line-stripping filter
    //    only removed the PREFIX line and kept the rest of the multi-line
    //    prompt, and a plain-string system message was not filtered at all.
    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;
}
```

### Multimodal Model Switch + System Prompt Filter (Before Rules, Anthropic protocol)

The example above runs in the After position on a DashScope route. If the client speaks **Anthropic** (`/v1/messages`), move the script to the Before position — Before runs **before** protocol translation and sees the client's original Anthropic body. Three structural differences from DashScope must be handled separately:

- **Image blocks**: images in `body.messages[].content[]` are `{"type":"image", source:{...}}` (DashScope uses `{"image":"<url>"}` with no `type` field).
- **Web search**: Anthropic has no `parameters.enable_search` flag; web search is a built-in server tool in `body.tools`, shaped like `{"type":"web_search_20250305", "name":"web_search", ...}` — `name === "web_search"` is the signal.
- **system prompt**: Anthropic carries it at top-level `body.system` (string or block array), **not** in `messages` (DashScope/OpenAI put `role:"system"` in `messages`). A few clients also embed `role:"system"` in `messages`, so both spots are handled.

Because Before takes the eager-swap path, `useModel` swaps the route before translation and **re-translates the body with the target route's translator**, so an Anthropic client's request can be switched to a multimodal model living on a different endpoint/protocol (e.g. DashScope's `multimodal-generation`) — this is the core capability Before has over After (After is same-protocol only).

```javascript
function transform(body, context) {
    // Mounted in the Before position on an Anthropic-protocol route; sees the
    // client's original /v1/messages body. Before supports cross-protocol
    // useModel, so the target can be a multimodal model on another endpoint.

    // 1. Detect image input. Anthropic image blocks are {"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. Detect web search. Anthropic exposes it as a built-in server tool in
    //    body.tools (type "web_search_20250305", name "web_search"); there is no
    //    enable_search flag — the entry itself is the signal.
    let hasSearch = false;
    if (Array.isArray(body.tools)) {
        hasSearch = body.tools.some(t => t.name === "web_search");
    }

    // 3. Image or search → switch to a multimodal model. Use context.useModel
    //    (not body.model =): the target lives on a different endpoint
    //    (multimodal-generation), so body.model alone would keep the Anthropic
    //    endpoint/protocol. Before-position useModel re-resolves the full route
    //    (base_url/kind/protocol/...) and re-translates the body under the
    //    target protocol — that is why cross-protocol works here. qwen3.7-plus
    //    covers both image and search, one target for both triggers.
    if (hasImage || hasSearch) {
        context.useModel("aliyun-china/qwen3.7-plus");
    }

    // 4. Filter Claude Code's system prompt. Anthropic carries it at top-level
    //    body.system (string or block array), not in messages. Identify by the
    //    PREFIX and drop the whole entry: line-by-line stripping only removes the
    //    first line and leaks the rest of a multi-line prompt; a plain string is
    //    not filtered at all.
    const PREFIX = "You are Claude Code, Anthropic's official CLI";
    const isCC = (text) => typeof text === "string" && text.startsWith(PREFIX);

    // 4a. body.system — string form
    if (isCC(body.system)) {
        delete body.system;
    }
    // 4b. body.system — block-array form: drop only the CC block, keep the rest
    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 — a few clients embed role:"system" here, handle too
    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;
}
```

**Configuration**: this script goes in the before slot (it reads the client's original Anthropic body):

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

**Differences from the After (DashScope) version**:

| Dimension | After (DashScope) | Before (Anthropic) |
|------|-------------------|--------------|
| Body format | Already translated to DashScope upstream format | Client's original Anthropic format |
| Image detection | `b.image` (no type field) | `b.type === "image"` |
| Search detection | `body.parameters.enable_search` | `body.tools[].name === "web_search"` |
| system prompt location | `role:"system"` in `messages` | top-level `body.system` (string or block array) |
| useModel limit | Same-protocol only (DashScope→DashScope) | Supports cross-protocol (Anthropic→DashScope multimodal) |

The next section generalizes this into a single Before script that branches on `sourceProtocol` to cover all 5 client protocols.

### Cross-Protocol Multimodal Switch + System Prompt Filter (Before Rules)

To support **multiple client protocols** (OpenAI ChatCompletions, Anthropic, Google, DashScope, OpenAI Response), branch on `context.sourceProtocol` to handle each protocol's body structure — the example below expands the Anthropic branch above into all protocols.

The Before position runs **before** protocol translation and sees the client's original-format body. `useModel` takes the eager-swap path, so the main pipeline re-translates using the target route's translator — **cross-protocol switches are supported**.

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

  // ─ 1. Detect image content (protocol-specific) ──
  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. Detect web_search (protocol-specific) ──
  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. Switch model (search takes priority: qwen3.7-plus handles both image+search) ──
  if (hasImage) {
    context.useModel("aliyun-china/qwen3.7-max");
  }
  if (hasSearch) {
    context.useModel("aliyun-china/qwen3.7-plus");
  }

  // ── 4. Filter Claude Code system prompt (protocol-specific) ──
  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 is at body.system (string or array of blocks)
    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;
    }
    // Some clients also put system in 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 is at 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 in messages as 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;
}
```

**Configuration**: this script belongs in the before slot (it reads the client's original protocol body):

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

**Differences from the after-slot version**:

| Dimension | After slot | Before slot |
|-----------|----------------------|--------------|
| Body format | Already translated to upstream protocol (e.g. DashScope) | Client's original protocol (OpenAI/Anthropic/Google/DashScope) |
| Protocol support | Only the protocol the script is attached to | All protocols (branched by `sourceProtocol`) |
| useModel restriction | Same-protocol only | Cross-protocol supported |
| Image detection | Only checks `b.image` (DashScope format) | 5 protocols, each with different image block structure |
| Search detection | Only checks `parameters.enable_search` | 5 protocols, each with different tool structure |
| System prompt | Only in messages | Anthropic at `body.system`, Google at `body.systemInstruction`, OpenAIResponse uses `type="system"` item |

**`sourceProtocol` values**: these are the Debug output of `Protocol` enum variant names (PascalCase), e.g. `"OpenAIChatCompletions"`, `"Anthropic"`, `"DashScope"`, `"Google"`, `"OpenAIResponse"`. Not the serde name (e.g. `"openai"`) and not the strum name (e.g. `"OpenAI Chat Completions"`).


## Performance Considerations

The dominant cost of script execution is the **JSON serialization round-trip**, not the script logic itself:

```
Rust Value → JSON string → JS JSON.parse → script execution → JS JSON.stringify → JSON string → Rust Value
```

| Body size | Typical latency | Impact |
|-----------|----------------|--------|
| < 10 KB | < 1 ms | Negligible |
| ~50 KB | ~5 ms | Minor |
| ~250 KB | ~30 ms | Noticeable (long conversations) |
| > 1 MB | > 100 ms | Needs attention |

**Recommendations:**

- Prefer `request_payload` declarative rules for simple field operations (Rust operates on `serde_json::Value` directly — no serialization overhead)
- Avoid unnecessary `deep_clone` / `json_encode` + `json_parse` inside scripts (extra serialization)
- For large bodies (long conversations, many tool definitions), expect latency to scale linearly with body size

## `strip-lines` Path Reference

The `path` for `strip-lines` mode depends on the **upstream protocol** (scripts run after protocol translation). Common paths:

| Upstream Protocol | System Prompt Path | Notes |
|-------------------|-------------------|-------|
| Anthropic | `system` | `body.system` is a string or content block array |
| OpenAI Chat | `messages.*.content` | `body.messages[].content` string or array |
| OpenAI Response | `input.*.content` | `body.input[].content` |
| DashScope | `input.messages.*.content` | `body.input.messages[].content` array |
| Google | `contents.*.parts` | `body.contents[].parts` array |

Example (remove a specific text line from DashScope upstream). To configure it in the Console (no hand-written JSON): open the **model detail** (or the upstream's *default request payload rules*) → find **Request Payload Rules** → click **+ Add Rule** → pick **`strip-lines`** in the mode dropdown → set **path** to `input.messages.*.content`, **match** to `starts_with`, and **value** to `x-anthropic-billing-header:`.
