mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
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.
294 lines
10 KiB
Markdown
294 lines
10 KiB
Markdown
# 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/<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 `.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 |
|
|
| `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.
|
|
|
|
```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
|
|
|
|
### `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.
|
|
|
|
```lua
|
|
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`).
|
|
|
|
```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/<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
|
|
|
|
```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).
|