Files
ModelRouter/docs/lua-adapters-en.md
JianFeeeee b2183df1e8 feat(adapter): move per-source error condensing into transform_error hooks
Every upstream formats errors differently, which is adapter territory:
the protocol gains an optional transform_error(status, body) hook and all
built-in adapters implement their own envelope parsing (zen free-pool
labels, anthropic/gemini/ollama/mistral shapes, sensenova quota notes,
agentrouter WAF pages). The core keeps a single uniform fallback: when no
hook yields a reason clients get "api error <status>: unknown error" and
the raw body goes to server logs only.
2026-08-24 19:17:36 +08:00

10 KiB

Lua Adapter API

中文: lua-adapters.md

Every upstream source is mounted with a .lua adapter that transforms the unified OpenAI-format request into the upstream's native format, and converts upstream responses / stream chunks back into the unified format. The Go layer only handles scheduling, concurrency and pass-through — so adding an adapter or adapting a new protocol never requires recompiling Go.

Adapters live in two equivalent places:

  • Bundled: internal/lua/adapters/<name>.lua (embedded at build time)
  • Override: a same-named script in the configured adapter_dir (takes precedence)

When are they loaded? All adapters are loaded once at startup by lua.NewVM(adapter_dir). Adapters uploaded via the WebUI (Core.UploadAdaptervm.LoadAdapter) and sources edited online take effect immediately, without a restart. Directly editing a .lua file under adapter_dir requires a process restart to reload.

Table of Contents

  1. Script Structure
  2. Static Fields
  3. Transform Hooks
  4. Optional Hooks
  5. Built-in Helper Functions
  6. meta Contract
  7. A Complete Minimal Adapter
  8. Multimodal & disable_thinking

1. Script Structure

A script is a Lua file that returns a table — it must return a table:

local adapter = {}
adapter.name = "mysrc"
adapter.version = "1.0.0"
-- ... fields and functions ...
return adapter

Scripts are executed by LuaJIT (via golua bindings, cgo). Each adapter owns an independent VM + worker pool; adapters never interfere with each other. No mutable state may be shared across workers (log to stdout is the only side effect).

2. Static Fields

Field Type Required Description
name string yes Adapter name, shown in the WebUI
version string no Version, shown in the WebUI
endpoint string no Upstream path, default /chat/completions; overridable by source.endpoint / source.image_endpoint
headers table no Static default request headers; used as fallback when no build_headers hook is defined
transform_error(status, body) function no Error-response condensing: return a one-line reason; on nil / absence clients uniformly receive unknown error (raw body goes to server logs only)

These fields are extracted statically at load time (compile-once); reading them never occupies a pooled worker.

3. Transform Hooks

transform_request(raw_body) -> string

Input: the unified OpenAI-format JSON string built by the Go layer (the /v1/chat/completions request body). Output: the request body string to send upstream.

function adapter.transform_request(raw_body)
    local ok, req = pcall(json.decode, raw_body)
    if not ok then return raw_body end
    req.model = "upstream-model-name"      -- rewrite the model name
    req.stream = req.stream or false
    return json.encode(req)
end

Contract:

  • The returned string is POSTed verbatim to base_url + (source.endpoint or adapter.endpoint).
  • Internal gateway fields such as disable_thinking and extra_body should be cleaned up by the transform (openai.lua deletes both).

transform_response(raw_body) -> string

Input: the raw JSON string of a non-streaming upstream response. Output: a JSON string in the unified format.

Unified format fields:

Field Type Description
content string Response text
finish_reason string stop / length / tool_calls etc.
reasoning_content string (optional) Reasoning text (DeepSeek etc.)
token_usage table { prompt, completion, total } (tokens)
tool_calls array (optional) Tool calls: { id, type, name, arguments }; arguments is a decoded table
function adapter.transform_response(raw_body)
    local ok, resp = pcall(json.decode, raw_body)
    if not ok or resp == nil then return raw_body end
    local unified = {
        content = "",
        finish_reason = "",
        token_usage = { prompt = 0, completion = 0, total = 0 }
    }
    if type(resp.choices) == "table" and #resp.choices > 0 then
        local ch = resp.choices[1]
        unified.content = ch.message.content or ""
        unified.finish_reason = ch.finish_reason or ""
        if ch.message.reasoning_content then
            unified.reasoning_content = ch.message.reasoning_content
        end
    end
    return json.encode(unified)
end

transform_stream_chunk(raw_chunk) -> string

Input: the raw JSON string of each data: line in the SSE stream (without the data: prefix). Output: a unified chunk JSON string; returning "" skips that chunk.

Unified chunk format:

Field Type Description
content string Incremental text for this chunk (may be "")
done boolean (optional) true ends the stream (when finish_reason appears)
function adapter.transform_stream_chunk(raw_chunk)
    local ok, chunk = pcall(json.decode, raw_chunk)
    if not ok then return "" end
    if not chunk.choices or #chunk.choices == 0 then return "" end
    local delta = chunk.choices[1].delta or {}
    local fr = chunk.choices[1].finish_reason
    return json.encode({
        content = delta.content or "",
        done = (fr ~= nil)
    })
end

4. Optional Hooks

transform_error(status, body) -> string | nil

Condense this source's error response into a one-line reason. Every upstream formats errors differently — that is adapter territory: all built-in adapters implement their own envelope parsing (zen's {error={type,message}}, Anthropic's {type="error",error={...}}, Gemini's {error={code,message,status}}, Ollama's string {error="..."}, etc.). When the hook is absent or returns nil the core does not guess: clients get api error <status>: unknown error and the raw body is logged server-side only.

function adapter.transform_error(status, body)
    local ok, resp = pcall(json.decode, body)
    if not ok or type(resp) ~= "table" then return nil end
    if resp.error and resp.error.type == "FreeUsageLimitError" then
        return "zen free pool quota exhausted"
    end
    return resp.error and resp.error.message or nil
end

build_headers(meta) -> table<string,string>

Dynamically generate / sign request headers (e.g. KimiCode's HMAC signature). If the script does not define this function, the Go layer falls back to the static adapter.headers (or source.headers).

function adapter.build_headers(meta)
    local msg = meta.method .. meta.url .. meta.body
    return {
        ["X-App-Sign"] = hmac_sha256_hex(meta.source.meta.app_secret, msg),
        ["X-Timestamp"] = meta.timestamp,
    }
end

Returning a non-table errors; returning {} means no custom headers (no fallback to static headers).

5. Built-in Helper Functions

Global functions shared by all adapters (injected by Go):

Function Description
json.encode(v) Lua value → JSON string; returns "null" on failure
json.decode(s) JSON string → Lua value; returns nil on failure
hmac_sha256_hex(key, data) HMAC-SHA256, lowercase hex string
sha256_hex(data) SHA-256, lowercase hex string
base64_encode(s) Standard Base64 encoding
tohex(s) Bytes → lowercase hex string
log(level, msg) Prints [adapter/<name>] <msg> to stdout

Note: scripts run under LuaJIT with the full standard library (string/table/pcall etc.); os/io are not exposed (sandbox semantics).

6. meta Contract

The meta structure shared by build_headers(meta) and the request transforms:

Field Description
meta.url Full request URL
meta.method HTTP method (usually POST)
meta.body Request body string
meta.api_key The source's api_key
meta.timestamp Request timestamp
meta.source table: { name, meta = { ... } } — the source's meta field (e.g. app_secret)

7. A Complete Minimal Adapter

local adapter = {}

adapter.name = "mysrc"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
adapter.headers = { ["X-Tenant"] = "prod" }

function adapter.transform_request(raw_body)
    local ok, req = pcall(json.decode, raw_body)
    if not ok then return raw_body end
    req.disable_thinking = nil
    req.extra_body = nil
    return json.encode(req)
end

function adapter.transform_response(raw_body)
    local ok, resp = pcall(json.decode, raw_body)
    if not ok or resp == nil then return raw_body end
    local unified = {
        content = "",
        finish_reason = "",
        token_usage = { prompt = 0, completion = 0, total = 0 }
    }
    if type(resp.choices) == "table" and #resp.choices > 0 then
        local ch = resp.choices[1]
        unified.content = ch.message.content or ""
        unified.finish_reason = ch.finish_reason or ""
    end
    return json.encode(unified)
end

function adapter.transform_stream_chunk(raw_chunk)
    local ok, chunk = pcall(json.decode, raw_chunk)
    if not ok then return "" end
    if not chunk.choices or #chunk.choices == 0 then return "" end
    return json.encode({
        content = (chunk.choices[1].delta or {}).content or "",
        done = (chunk.choices[1].finish_reason ~= nil)
    })
end

return adapter

Corresponding config.yaml source:

sources:
  - name: mysrc
    base_url: https://upstream.example.com
    api_key: sk-xxx
    adapter: mysrc           # or omit = bundled adapter with the same name
    endpoint: /chat/completions
    models:
      - id: my-model
        priority: 50
        kind: chat

8. Multimodal & disable_thinking

  • Multimodal: if the request content is an array ([{type:"text"...},{type:"image_url"...}]), the Go layer passes it through verbatim to the adapter. Sources whose protocol doesn't support it (Anthropic / Gemini / Ollama) must convert inside transform_request; the bundled anthropic.lua / gemini.lua / ollama.lua already do.
  • disable_thinking: the gateway passes disable_thinking:true from the request body into transform_request. The DeepSeek adapter maps it to extra_body.thinking = { type = "disabled" } and cleans up its own field; other adapters handle it per their protocol (implement or ignore as needed).