# Lua Adapter API > **中文**: [lua-adapters.md](./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/.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 `.lua` file under > `adapter_dir` requires a process restart to reload. ## Table of Contents 1. [Script Structure](#1-script-structure) 2. [Static Fields](#2-static-fields) 3. [Transform Hooks](#3-transform-hooks) 4. [Optional Hooks](#4-optional-hooks) 5. [Built-in Helper Functions](#5-built-in-helper-functions) 6. [meta Contract](#6-meta-contract) 7. [A Complete Minimal Adapter](#7-a-complete-minimal-adapter) 8. [Multimodal & disable_thinking](#8-multimodal--disable_thinking) --- ## 1. Script Structure A script is a Lua file that returns a table — it **must** `return` a table: ```lua 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 | 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. ```lua 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 | ```lua 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) | ```lua 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 ### `build_headers(meta) -> table` 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`). ```lua 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/] ` 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 ```lua 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: ```yaml 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).