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.
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.UploadAdapter→vm.LoadAdapter) and sources edited online take effect immediately, without a restart. Directly editing a.luafile underadapter_dirrequires a process restart to reload.
Table of Contents
- Script Structure
- Static Fields
- Transform Hooks
- Optional Hooks
- Built-in Helper Functions
- meta Contract
- A Complete Minimal Adapter
- 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_thinkingandextra_bodyshould be cleaned up by the transform (openai.luadeletes 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
contentis 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 insidetransform_request; the bundledanthropic.lua/gemini.lua/ollama.luaalready do. - disable_thinking: the gateway passes
disable_thinking:truefrom the request body intotransform_request. The DeepSeek adapter maps it toextra_body.thinking = { type = "disabled" }and cleans up its own field; other adapters handle it per their protocol (implement or ignore as needed).