mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
feat: ModelRouter — unified OpenAI-compatible multi-source LLM gateway
- Lua adapters per upstream (transform_request/response/stream_chunk, build_headers signing hooks) - AUTO priority routing with per-model kind (chat/image), explicit source/model routing - Per-source concurrency caps with queueing, exponential backoff, AUTO failover - OpenAI-compatible API: chat completions, SSE streaming, image generations, models - Gateway key auth, web UI for adapter/source management, runtime persistence - e2e test running the real binary against mocked upstreams
This commit is contained in:
86
internal/lua/adapters/anthropic.lua
Normal file
86
internal/lua/adapters/anthropic.lua
Normal file
@ -0,0 +1,86 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "anthropic"
|
||||
adapter.version = "2.0.0"
|
||||
adapter.endpoint = "/v1/messages"
|
||||
adapter.headers = {
|
||||
["anthropic-version"] = "2023-06-01"
|
||||
}
|
||||
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
|
||||
local msgs = {}
|
||||
local system = ""
|
||||
for _, m in ipairs(req.messages or {}) do
|
||||
if m.role == "system" then
|
||||
system = system .. m.content .. "\n"
|
||||
else
|
||||
table.insert(msgs, { role = m.role, content = m.content })
|
||||
end
|
||||
end
|
||||
|
||||
local anthropic_req = {
|
||||
model = req.model or "claude-sonnet-4-20250514",
|
||||
max_tokens = req.max_tokens or 4096,
|
||||
messages = msgs,
|
||||
stream = req.stream or false,
|
||||
}
|
||||
|
||||
if not req.disable_thinking then
|
||||
anthropic_req.thinking = { type = "enabled", budget_tokens = 4096 }
|
||||
end
|
||||
|
||||
if system ~= "" then
|
||||
anthropic_req.system = system
|
||||
end
|
||||
|
||||
return json.encode(anthropic_req)
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
local ok, resp = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = "",
|
||||
token_usage = { prompt = 0, completion = 0, total = 0 }
|
||||
}
|
||||
|
||||
if resp.usage then
|
||||
unified.token_usage.prompt = resp.usage.input_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.output_tokens or 0
|
||||
unified.token_usage.total = (resp.usage.input_tokens or 0) + (resp.usage.output_tokens or 0)
|
||||
end
|
||||
|
||||
if resp.content and #resp.content > 0 then
|
||||
for _, block in ipairs(resp.content) do
|
||||
if block.type == "text" then
|
||||
unified.content = unified.content .. (block.text or "")
|
||||
end
|
||||
end
|
||||
end
|
||||
unified.finish_reason = resp.stop_reason or ""
|
||||
|
||||
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 chunk.type == "message_start" then return "" end
|
||||
if chunk.type == "message_delta" then
|
||||
return json.encode({ content = "", done = (chunk.delta and chunk.delta.stop_reason ~= nil) })
|
||||
end
|
||||
if chunk.type == "content_block_delta" and chunk.delta then
|
||||
return json.encode({ content = chunk.delta.text or "", done = false })
|
||||
end
|
||||
if chunk.type == "message_stop" then
|
||||
return json.encode({ content = "", done = true })
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
return adapter
|
||||
78
internal/lua/adapters/deepseek.lua
Normal file
78
internal/lua/adapters/deepseek.lua
Normal file
@ -0,0 +1,78 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "deepseek"
|
||||
adapter.version = "2.1.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
|
||||
req.model = req.model or "deepseek-chat"
|
||||
req.stream = req.stream or false
|
||||
if req.disable_thinking then
|
||||
req.extra_body = req.extra_body or {}
|
||||
req.extra_body.thinking = { type = "disabled" }
|
||||
end
|
||||
req.disable_thinking = 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.usage) == "table" then
|
||||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
unified.content = ch.message.content or ""
|
||||
if ch.message.reasoning_content then
|
||||
unified.reasoning_content = ch.message.reasoning_content
|
||||
end
|
||||
if type(ch.message.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
||||
if not args_ok then args = {} end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = tc["function"].name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
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
|
||||
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
|
||||
|
||||
return adapter
|
||||
89
internal/lua/adapters/gemini.lua
Normal file
89
internal/lua/adapters/gemini.lua
Normal file
@ -0,0 +1,89 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "gemini"
|
||||
adapter.version = "2.0.0"
|
||||
adapter.endpoint = "/v1/models"
|
||||
adapter.headers = {}
|
||||
|
||||
-- Gemini API: POST /v1/models/{model}:generateContent
|
||||
-- Auth: API key in query param ?key=XXX or Authorization: Bearer XXX
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
|
||||
local contents = {}
|
||||
for _, m in ipairs(req.messages or {}) do
|
||||
table.insert(contents, {
|
||||
role = (m.role == "assistant") and "model" or m.role,
|
||||
parts = { { text = m.content } }
|
||||
})
|
||||
end
|
||||
|
||||
local gemini_req = {
|
||||
contents = contents,
|
||||
generationConfig = {
|
||||
temperature = req.temperature or 0.7,
|
||||
maxOutputTokens = req.max_tokens or 4096,
|
||||
}
|
||||
}
|
||||
|
||||
if req.stream then
|
||||
gemini_req.stream = true
|
||||
end
|
||||
|
||||
return json.encode(gemini_req)
|
||||
end
|
||||
|
||||
-- Gemini 的 endpoint 动态拼接:/v1/models/{model}:generateContent
|
||||
function adapter.transform_response(raw_body)
|
||||
local ok, resp = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = "",
|
||||
token_usage = { prompt = 0, completion = 0, total = 0 }
|
||||
}
|
||||
|
||||
if resp.usageMetadata then
|
||||
unified.token_usage.prompt = resp.usageMetadata.promptTokenCount or 0
|
||||
unified.token_usage.completion = resp.usageMetadata.candidatesTokenCount or 0
|
||||
unified.token_usage.total = resp.usageMetadata.totalTokenCount or 0
|
||||
end
|
||||
|
||||
if resp.candidates and #resp.candidates > 0 then
|
||||
local cand = resp.candidates[1]
|
||||
if cand.content and cand.content.parts then
|
||||
for _, part in ipairs(cand.content.parts) do
|
||||
if part.text then
|
||||
unified.content = unified.content .. part.text
|
||||
end
|
||||
end
|
||||
end
|
||||
if cand.finishReason then
|
||||
unified.finish_reason = cand.finishReason
|
||||
end
|
||||
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.candidates or #chunk.candidates == 0 then return "" end
|
||||
local cand = chunk.candidates[1]
|
||||
local content = ""
|
||||
if cand.content and cand.content.parts then
|
||||
for _, part in ipairs(cand.content.parts) do
|
||||
content = content .. (part.text or "")
|
||||
end
|
||||
end
|
||||
return json.encode({
|
||||
content = content,
|
||||
done = (cand.finishReason ~= nil)
|
||||
})
|
||||
end
|
||||
|
||||
return adapter
|
||||
75
internal/lua/adapters/github.lua
Normal file
75
internal/lua/adapters/github.lua
Normal file
@ -0,0 +1,75 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "github"
|
||||
adapter.version = "2.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
-- GitHub Models: Azure-like endpoint, auth via Bearer token (PAT)
|
||||
-- BaseURL example: https://models.inference.ai.azure.com
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
req.model = req.model or "gpt-4o"
|
||||
req.temperature = req.temperature or 0.7
|
||||
req.max_tokens = req.max_tokens or 4096
|
||||
req.stream = req.stream or false
|
||||
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.usage) == "table" then
|
||||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
unified.content = ch.message.content or ""
|
||||
if type(ch.message.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
||||
if not args_ok then args = {} end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = tc["function"].name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
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
|
||||
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
|
||||
|
||||
return adapter
|
||||
74
internal/lua/adapters/groq.lua
Normal file
74
internal/lua/adapters/groq.lua
Normal file
@ -0,0 +1,74 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "groq"
|
||||
adapter.version = "2.0.0"
|
||||
adapter.endpoint = "/openai/v1/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
-- Groq API is OpenAI-compatible
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
req.model = req.model or "llama3-70b-8192"
|
||||
req.temperature = req.temperature or 0.7
|
||||
req.max_tokens = req.max_tokens or 4096
|
||||
req.stream = req.stream or false
|
||||
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.usage) == "table" then
|
||||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
unified.content = ch.message.content or ""
|
||||
if type(ch.message.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
||||
if not args_ok then args = {} end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = tc["function"].name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
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
|
||||
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
|
||||
|
||||
return adapter
|
||||
107
internal/lua/adapters/kimicode.lua
Normal file
107
internal/lua/adapters/kimicode.lua
Normal file
@ -0,0 +1,107 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "kimicode"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/v1/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
-- KimiCode / Kimi K2 属于 OpenAI 兼容协议;但部分云端 API 会校验调用方
|
||||
-- "app"(只放行特定 agent),要求每次请求带上按 secret 计算的应用签名。
|
||||
-- 这里演示 build_headers 钩子:基于 timestamp + 请求体哈希生成签名头。
|
||||
--
|
||||
-- 配置要求(source.meta):
|
||||
-- meta:
|
||||
-- app_id: <申请到的 app id>
|
||||
-- app_key: <你的 key(由网关的 base_url 复用 api_key 亦可)>
|
||||
-- app_secret: <签名密钥>
|
||||
-- app_agent: code-agent # 若云端要求声明 agent 身份
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
req.model = req.model or "kimi-k2"
|
||||
req.disable_thinking = nil
|
||||
req.extra_body = nil
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- 可选的动态签名钩子。meta 由 Go 注入:
|
||||
-- meta.url / meta.method / meta.body / meta.api_key / meta.timestamp / meta.source.meta
|
||||
function adapter.build_headers(meta)
|
||||
local h = {
|
||||
["Content-Type"] = "application/json",
|
||||
["X-App-Id"] = tostring((meta.source.meta or {}).app_id or ""),
|
||||
["X-Timestamp"] = tostring(meta.timestamp),
|
||||
}
|
||||
local agent = (meta.source.meta or {}).app_agent
|
||||
if agent and agent ~= "" then
|
||||
h["X-Agent"] = agent
|
||||
end
|
||||
-- 校验 app:通常要求 Authorization 用 app secret 派生签名
|
||||
local secret = (meta.source.meta or {}).app_secret
|
||||
local api_key = meta.source.meta and meta.source.meta.api_key or meta.api_key
|
||||
if secret and secret ~= "" then
|
||||
local body_hash = sha256_hex(meta.body)
|
||||
local sign_string = tostring(meta.timestamp) .. meta.method .. meta.url .. body_hash
|
||||
local sign = hmac_sha256_hex(secret, sign_string)
|
||||
h["Authorization"] = "Bearer " .. api_key
|
||||
h["X-App-Sign"] = sign
|
||||
else
|
||||
h["Authorization"] = "Bearer " .. api_key
|
||||
end
|
||||
return h
|
||||
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.usage) == "table" then
|
||||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||||
end
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
unified.content = ch.message.content or ""
|
||||
if ch.message.reasoning_content then
|
||||
unified.reasoning_content = ch.message.reasoning_content
|
||||
end
|
||||
if type(ch.message.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
||||
if not args_ok then args = {} end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = tc["function"].name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
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
|
||||
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
|
||||
|
||||
return adapter
|
||||
74
internal/lua/adapters/mistral.lua
Normal file
74
internal/lua/adapters/mistral.lua
Normal file
@ -0,0 +1,74 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "mistral"
|
||||
adapter.version = "2.0.0"
|
||||
adapter.endpoint = "/v1/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
-- Mistral API is OpenAI-compatible, just passes through
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
req.model = req.model or "mistral-large-latest"
|
||||
req.temperature = req.temperature or 0.7
|
||||
req.max_tokens = req.max_tokens or 4096
|
||||
req.stream = req.stream or false
|
||||
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.usage) == "table" then
|
||||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
unified.content = ch.message.content or ""
|
||||
if type(ch.message.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
||||
if not args_ok then args = {} end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = tc["function"].name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
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
|
||||
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
|
||||
|
||||
return adapter
|
||||
63
internal/lua/adapters/ollama.lua
Normal file
63
internal/lua/adapters/ollama.lua
Normal file
@ -0,0 +1,63 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "ollama"
|
||||
adapter.version = "2.0.0"
|
||||
adapter.endpoint = "/api/chat"
|
||||
adapter.headers = {}
|
||||
|
||||
-- Ollama API 格式:{ model, messages, stream, options:{temperature,num_predict} }
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
|
||||
local ollama_req = {
|
||||
model = req.model or "llama3",
|
||||
stream = req.stream or false,
|
||||
options = {
|
||||
temperature = req.temperature or 0.7,
|
||||
num_predict = req.max_tokens or 2048
|
||||
}
|
||||
}
|
||||
|
||||
-- 转换 messages 格式(Ollama 兼容 OpenAI 的 messages 格式)
|
||||
if req.messages then
|
||||
local msgs = {}
|
||||
for _, m in ipairs(req.messages) do
|
||||
table.insert(msgs, { role = m.role, content = m.content })
|
||||
end
|
||||
ollama_req.messages = msgs
|
||||
end
|
||||
|
||||
return json.encode(ollama_req)
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
local ok, resp = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
|
||||
local unified = {
|
||||
content = "",
|
||||
finish_reason = resp.done_reason or "",
|
||||
tool_calls = {},
|
||||
usage = { prompt = 0, completion = 0, total = 0 }
|
||||
}
|
||||
|
||||
if resp.message then
|
||||
unified.content = resp.message.content 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.message then return "" end
|
||||
|
||||
return json.encode({
|
||||
content = chunk.message.content or "",
|
||||
done = chunk.done or false
|
||||
})
|
||||
end
|
||||
|
||||
return adapter
|
||||
80
internal/lua/adapters/openai.lua
Normal file
80
internal/lua/adapters/openai.lua
Normal file
@ -0,0 +1,80 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "openai"
|
||||
adapter.version = "2.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||
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
|
||||
if req.messages then
|
||||
for _, msg in ipairs(req.messages) do
|
||||
msg.reasoning_content = nil
|
||||
end
|
||||
end
|
||||
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.usage) == "table" then
|
||||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
local ch = resp.choices[1]
|
||||
if type(ch.message) == "table" then
|
||||
unified.content = ch.message.content or ""
|
||||
if ch.message.reasoning_content then
|
||||
unified.reasoning_content = ch.message.reasoning_content
|
||||
end
|
||||
if type(ch.message.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
||||
if not args_ok then args = {} end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = tc["function"].name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
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
|
||||
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
|
||||
|
||||
return adapter
|
||||
351
internal/lua/vm.go
Normal file
351
internal/lua/vm.go
Normal file
@ -0,0 +1,351 @@
|
||||
// Package lua implements the adapter runtime: bundles/loads *.lua adapter
|
||||
// scripts, exposes json/string helpers, and lets Go call the protocol
|
||||
// transform functions plus a build_headers signature hook.
|
||||
package lua
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
//go:embed adapters/*.lua
|
||||
var bundledAdapters embed.FS
|
||||
|
||||
type APIAdapter struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type AdapterCache struct {
|
||||
mu sync.RWMutex
|
||||
state *lua.LState
|
||||
items map[string]*lua.LTable
|
||||
}
|
||||
|
||||
func newAdapterCache() *AdapterCache {
|
||||
return &AdapterCache{state: lua.NewState(), items: map[string]*lua.LTable{}}
|
||||
}
|
||||
|
||||
func (c *AdapterCache) setupGlobals() {
|
||||
s := c.state
|
||||
jsonTable := s.NewTable()
|
||||
s.SetGlobal("json", jsonTable)
|
||||
s.SetField(jsonTable, "encode", s.NewFunction(func(L *lua.LState) int {
|
||||
b, err := jsonEncode(luaValueToGo(L.CheckAny(1)))
|
||||
if err != nil {
|
||||
L.Push(lua.LString("null"))
|
||||
return 1
|
||||
}
|
||||
L.Push(lua.LString(string(b)))
|
||||
return 1
|
||||
}))
|
||||
s.SetField(jsonTable, "decode", s.NewFunction(func(L *lua.LState) int {
|
||||
v, err := jsonDecode(L.CheckString(1))
|
||||
if err != nil {
|
||||
L.Push(lua.LNil)
|
||||
return 1
|
||||
}
|
||||
L.Push(goValueToLua(L, v))
|
||||
return 1
|
||||
}))
|
||||
|
||||
// signature/crypto helpers (app verification, timing-safe auth)
|
||||
s.SetGlobal("hmac_sha256_hex", s.NewFunction(func(L *lua.LState) int {
|
||||
key := L.CheckString(1)
|
||||
data := L.CheckString(2)
|
||||
m := hmac.New(sha256.New, []byte(key))
|
||||
m.Write([]byte(data))
|
||||
L.Push(lua.LString(hex.EncodeToString(m.Sum(nil))))
|
||||
return 1
|
||||
}))
|
||||
s.SetGlobal("sha256_hex", s.NewFunction(func(L *lua.LState) int {
|
||||
h := sha256.Sum256([]byte(L.CheckString(1)))
|
||||
L.Push(lua.LString(hex.EncodeToString(h[:])))
|
||||
return 1
|
||||
}))
|
||||
s.SetGlobal("base64_encode", s.NewFunction(func(L *lua.LState) int {
|
||||
L.Push(lua.LString(base64.StdEncoding.EncodeToString([]byte(L.CheckString(1)))))
|
||||
return 1
|
||||
}))
|
||||
s.SetGlobal("tohex", s.NewFunction(func(L *lua.LState) int {
|
||||
L.Push(lua.LString(hex.EncodeToString([]byte(L.CheckString(1)))))
|
||||
return 1
|
||||
}))
|
||||
|
||||
s.SetGlobal("log", s.NewFunction(func(L *lua.LState) int {
|
||||
level := L.ToString(1)
|
||||
msg := L.ToString(2)
|
||||
fmt.Printf("[adapter/%s] %s\n", level, msg)
|
||||
return 0
|
||||
}))
|
||||
}
|
||||
|
||||
func (c *AdapterCache) Preload(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read adapter: %w", err)
|
||||
}
|
||||
return c.PreloadSource(filepath.Base(path), string(data))
|
||||
}
|
||||
|
||||
func (c *AdapterCache) PreloadSource(name, code string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if err := c.state.DoString(code); err != nil {
|
||||
return fmt.Errorf("compile adapter: %w", err)
|
||||
}
|
||||
tbl, ok := c.state.Get(-1).(*lua.LTable)
|
||||
c.state.Pop(1)
|
||||
if !ok {
|
||||
return fmt.Errorf("adapter script must return a table")
|
||||
}
|
||||
if n := tbl.RawGetString("name"); n != nil && n.String() != "" {
|
||||
name = n.String()
|
||||
}
|
||||
c.items[name] = tbl
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AdapterCache) Get(name string) *lua.LTable {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.items[name]
|
||||
}
|
||||
|
||||
// Remove deletes an adapter from the cache.
|
||||
func (c *AdapterCache) Remove(name string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.items, name)
|
||||
}
|
||||
|
||||
func (c *AdapterCache) List() []APIAdapter { c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
list := make([]APIAdapter, 0, len(c.items))
|
||||
for name, tbl := range c.items {
|
||||
a := APIAdapter{Name: name}
|
||||
if v := tbl.RawGetString("version"); v != nil {
|
||||
a.Version = v.String()
|
||||
}
|
||||
list = append(list, a)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// VM wraps AdapterCache to dispatch adapter hook calls safely (single Lua
|
||||
// state is shared, so calls are serialized by a mutex).
|
||||
type VM struct {
|
||||
mu sync.Mutex
|
||||
cache *AdapterCache
|
||||
dir string
|
||||
}
|
||||
|
||||
func NewVM(dir string) *VM {
|
||||
return &VM{dir: dir, cache: newAdapterCache()}
|
||||
}
|
||||
|
||||
func (v *VM) Start() error {
|
||||
if v.dir != "" {
|
||||
if err := os.MkdirAll(v.dir, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir adapter dir: %w", err)
|
||||
}
|
||||
if err := v.writeBundledAdapters(); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(v.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if filepath.Ext(e.Name()) != ".lua" {
|
||||
continue
|
||||
}
|
||||
if err := v.cache.Preload(filepath.Join(v.dir, e.Name())); err != nil {
|
||||
fmt.Printf("[lua] preload %s: %v\n", e.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
v.cache.setupGlobals()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VM) Stop() {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if v.cache.state != nil {
|
||||
v.cache.state.Close()
|
||||
v.cache.state = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (v *VM) ListAdapters() []APIAdapter { return v.cache.List() }
|
||||
|
||||
// LoadAdapter compiles and registers an adapter from a file (runtime safe).
|
||||
func (v *VM) LoadAdapter(path string) error { return v.cache.Preload(path) }
|
||||
|
||||
// RemoveAdapter evicts an adapter from the cache (runtime safe).
|
||||
func (v *VM) RemoveAdapter(name string) { v.cache.Remove(name) }
|
||||
|
||||
func (v *VM) writeBundledAdapters() error {
|
||||
known := []string{"openai", "anthropic", "deepseek", "gemini", "github", "groq", "mistral", "ollama", "kimicode"}
|
||||
for _, name := range known {
|
||||
dst := filepath.Join(v.dir, name+".lua")
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
continue
|
||||
}
|
||||
data, err := bundledAdapters.ReadFile("adapters/" + name + ".lua")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(dst, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VM) Transform(name, fn, raw string) (string, error) {
|
||||
adapter := v.cache.Get(name)
|
||||
if adapter == nil {
|
||||
return "", fmt.Errorf("adapter %s not loaded", name)
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
f := adapter.RawGetString(fn)
|
||||
if f == nil || f == lua.LNil {
|
||||
return "", fmt.Errorf("adapter %s missing %s", name, fn)
|
||||
}
|
||||
state := v.cache.state
|
||||
state.Push(f)
|
||||
state.Push(lua.LString(raw))
|
||||
if err := state.PCall(1, 1, nil); err != nil {
|
||||
return "", fmt.Errorf("%s: %w", fn, err)
|
||||
}
|
||||
res := state.Get(-1)
|
||||
state.Pop(1)
|
||||
return res.String(), nil
|
||||
}
|
||||
|
||||
// BuildHeaders calls adapter.build_headers(meta). If the adapter does not
|
||||
// define build_headers, it falls back to the static adapter.headers table.
|
||||
func (v *VM) BuildHeaders(name string, meta map[string]interface{}) (map[string]string, error) {
|
||||
adapter := v.cache.Get(name)
|
||||
if adapter == nil {
|
||||
return nil, fmt.Errorf("adapter %s not loaded", name)
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
state := v.cache.state
|
||||
|
||||
fn := adapter.RawGetString("build_headers")
|
||||
if fn == nil || fn == lua.LNil {
|
||||
// fall back to static headers table
|
||||
headers := map[string]string{}
|
||||
if ht := adapter.RawGetString("headers"); ht != nil {
|
||||
if tbl, ok := ht.(*lua.LTable); ok {
|
||||
tbl.ForEach(func(key, val lua.LValue) { headers[key.String()] = val.String() })
|
||||
}
|
||||
}
|
||||
return headers, nil
|
||||
}
|
||||
state.Push(fn)
|
||||
state.Push(goValueToLua(state, meta))
|
||||
if err := state.PCall(1, 1, nil); err != nil {
|
||||
return nil, fmt.Errorf("build_headers: %w", err)
|
||||
}
|
||||
res := state.Get(-1)
|
||||
state.Pop(1)
|
||||
headers := map[string]string{}
|
||||
if tbl, ok := res.(*lua.LTable); ok {
|
||||
tbl.ForEach(func(key, val lua.LValue) {
|
||||
k := key.String()
|
||||
if k != "" && k != "n" {
|
||||
headers[k] = val.String()
|
||||
}
|
||||
})
|
||||
}
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
func (v *VM) Endpoint(name string) string {
|
||||
adapter := v.cache.Get(name)
|
||||
if adapter == nil {
|
||||
return ""
|
||||
}
|
||||
if ep := adapter.RawGetString("endpoint"); ep != nil {
|
||||
return ep.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func jsonEncode(v interface{}) ([]byte, error) { return json.Marshal(v) }
|
||||
func jsonDecode(s string) (interface{}, error) {
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func luaValueToGo(lv lua.LValue) interface{} {
|
||||
switch x := lv.(type) {
|
||||
case lua.LString:
|
||||
return string(x)
|
||||
case lua.LNumber:
|
||||
return float64(x)
|
||||
case lua.LBool:
|
||||
return bool(x)
|
||||
case *lua.LTable:
|
||||
if x.MaxN() > 0 {
|
||||
arr := make([]interface{}, 0, x.MaxN())
|
||||
x.ForEach(func(_, val lua.LValue) { arr = append(arr, luaValueToGo(val)) })
|
||||
return arr
|
||||
}
|
||||
m := map[string]interface{}{}
|
||||
x.ForEach(func(key, val lua.LValue) { m[key.String()] = luaValueToGo(val) })
|
||||
return m
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func goValueToLua(L *lua.LState, val interface{}) lua.LValue {
|
||||
switch x := val.(type) {
|
||||
case string:
|
||||
return lua.LString(x)
|
||||
case float64:
|
||||
return lua.LNumber(x)
|
||||
case int:
|
||||
return lua.LNumber(x)
|
||||
case int64:
|
||||
return lua.LNumber(x)
|
||||
case bool:
|
||||
return lua.LBool(x)
|
||||
case nil:
|
||||
return lua.LNil
|
||||
case []interface{}:
|
||||
t := L.NewTable()
|
||||
for i, item := range x {
|
||||
t.RawSetInt(i+1, goValueToLua(L, item))
|
||||
}
|
||||
return t
|
||||
case map[string]interface{}:
|
||||
t := L.NewTable()
|
||||
for k, item := range x {
|
||||
t.RawSetString(k, goValueToLua(L, item))
|
||||
}
|
||||
return t
|
||||
default:
|
||||
return lua.LNil
|
||||
}
|
||||
}
|
||||
87
internal/lua/vm_test.go
Normal file
87
internal/lua/vm_test.go
Normal file
@ -0,0 +1,87 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadBundledAdapters(t *testing.T) {
|
||||
vm := NewVM(t.TempDir())
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
adapters := vm.ListAdapters()
|
||||
if len(adapters) == 0 {
|
||||
t.Fatal("no adapters loaded")
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, a := range adapters {
|
||||
names[a.Name] = true
|
||||
}
|
||||
for _, want := range []string{"openai", "deepseek", "anthropic", "gemini", "ollama", "kimicode"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing adapter %s (got %v)", want, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformRequest(t *testing.T) {
|
||||
vm := NewVM(t.TempDir())
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
out, err := vm.Transform("openai", "transform_request", `{"model":"x","disable_thinking":true,"messages":[]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("transform: %v", err)
|
||||
}
|
||||
if strings.Contains(out, "disable_thinking") {
|
||||
t.Fatalf("disable_thinking not stripped: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHeadersFallbackStatic(t *testing.T) {
|
||||
vm := NewVM(t.TempDir())
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
hdrs, err := vm.BuildHeaders("anthropic", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build headers: %v", err)
|
||||
}
|
||||
if hdrs["anthropic-version"] != "2023-06-01" {
|
||||
t.Fatalf("static header missing: %v", hdrs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHeadersCustomHook(t *testing.T) {
|
||||
vm := NewVM(t.TempDir())
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
hdrs, err := vm.BuildHeaders("kimicode", map[string]interface{}{
|
||||
"timestamp": int64(12345),
|
||||
"api_key": "k",
|
||||
"body": "{}",
|
||||
"method": "POST",
|
||||
"url": "http://x/chat",
|
||||
"source": map[string]interface{}{"meta": map[string]interface{}{
|
||||
"app_id": "app-9", "app_secret": "s", "api_key": "k",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("build headers: %v", err)
|
||||
}
|
||||
if hdrs["X-App-Id"] != "app-9" {
|
||||
t.Fatalf("x-app-id = %q", hdrs["X-App-Id"])
|
||||
}
|
||||
if hdrs["X-App-Sign"] == "" {
|
||||
t.Fatal("expected signature header")
|
||||
}
|
||||
if hdrs["X-Timestamp"] != "12345" {
|
||||
t.Fatalf("timestamp = %q", hdrs["X-Timestamp"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user