mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
feat: LuaJIT worker-pool VM, multimodal/disable-thinking passthrough, WebUI redesign, DeepSeek V4
This commit is contained in:
@ -11,13 +11,42 @@ function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
|
||||
-- 将 OpenAI 风格 content(字符串或 [{type:*}] 数组)拆成文本/图片块
|
||||
local function collect_blocks(content)
|
||||
if type(content) == "string" then
|
||||
return { { type = "text", text = content } }
|
||||
end
|
||||
local blocks = {}
|
||||
for _, p in ipairs(content or {}) do
|
||||
if p.type == "text" then
|
||||
table.insert(blocks, { type = "text", text = p.text })
|
||||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
||||
local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$")
|
||||
if b64 then
|
||||
table.insert(blocks, { type = "image", source = { type = "base64", media_type = mt or "image/png", data = b64 } })
|
||||
else
|
||||
table.insert(blocks, { type = "image", source = { type = "url", url = p.image_url.url } })
|
||||
end
|
||||
end
|
||||
end
|
||||
return blocks
|
||||
end
|
||||
local function text_of(content)
|
||||
if type(content) == "string" then return content end
|
||||
local t = ""
|
||||
for _, p in ipairs(content or {}) do
|
||||
if p.type == "text" and p.text then t = t .. p.text end
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
local msgs = {}
|
||||
local system = ""
|
||||
for _, m in ipairs(req.messages or {}) do
|
||||
if m.role == "system" then
|
||||
system = system .. m.content .. "\n"
|
||||
system = system .. text_of(m.content) .. "\n"
|
||||
else
|
||||
table.insert(msgs, { role = m.role, content = m.content })
|
||||
table.insert(msgs, { role = m.role, content = collect_blocks(m.content) })
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@ -9,7 +9,11 @@ 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"
|
||||
-- V4 已替换 legacy 模型名(deepseek-chat/reasoner 2026-07-24 停用)
|
||||
req.model = req.model or "deepseek-v4-flash"
|
||||
if req.model == "deepseek-chat" or req.model == "deepseek-reasoner" then
|
||||
req.model = "deepseek-v4-flash"
|
||||
end
|
||||
req.stream = req.stream or false
|
||||
if req.disable_thinking then
|
||||
req.extra_body = req.extra_body or {}
|
||||
|
||||
@ -11,11 +11,30 @@ function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
|
||||
-- 将 OpenAI 风格 content(字符串或 [{type:*}] 数组)拆成 Gemini parts
|
||||
local function to_parts(content)
|
||||
if type(content) == "string" then
|
||||
return { { text = content } }
|
||||
end
|
||||
local parts = {}
|
||||
for _, p in ipairs(content or {}) do
|
||||
if p.type == "text" then
|
||||
table.insert(parts, { text = p.text })
|
||||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
||||
local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$")
|
||||
if b64 then
|
||||
table.insert(parts, { inline_data = { mime_type = mt or "image/png", data = b64 } })
|
||||
end
|
||||
end
|
||||
end
|
||||
return parts
|
||||
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 } }
|
||||
parts = to_parts(m.content)
|
||||
})
|
||||
end
|
||||
|
||||
|
||||
@ -19,11 +19,29 @@ function adapter.transform_request(raw_body)
|
||||
}
|
||||
}
|
||||
|
||||
-- 转换 messages 格式(Ollama 兼容 OpenAI 的 messages 格式)
|
||||
-- 转换 messages 格式(Ollama messages 支持 images base64 数组)
|
||||
if req.messages then
|
||||
local msgs = {}
|
||||
for _, m in ipairs(req.messages) do
|
||||
table.insert(msgs, { role = m.role, content = m.content })
|
||||
local text, images
|
||||
if type(m.content) == "string" then
|
||||
text, images = m.content, nil
|
||||
else
|
||||
text = ""
|
||||
images = {}
|
||||
for _, p in ipairs(m.content or {}) do
|
||||
if p.type == "text" then
|
||||
text = text .. (p.text or "")
|
||||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
||||
local b64 = string.match(p.image_url.url, "^data:[^,]+;base64,(.+)$")
|
||||
if b64 then table.insert(images, b64) end
|
||||
end
|
||||
end
|
||||
if #images == 0 then images = nil end
|
||||
end
|
||||
local msg = { role = m.role, content = text }
|
||||
if images then msg.images = images end
|
||||
table.insert(msgs, msg)
|
||||
end
|
||||
ollama_req.messages = msgs
|
||||
end
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
// 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.
|
||||
//
|
||||
// The runtime uses LuaJIT (github.com/aarzilli/golua) so every adapter runs on
|
||||
// an independent interpreter state. Because a lua.State is NOT goroutine-safe,
|
||||
// each adapter owns a pool of workers (states); the pool size equals the sum
|
||||
// of max_concurrent over all sources that use the adapter, and a worker is
|
||||
// checked out for the duration of a single hook call.
|
||||
package lua
|
||||
|
||||
import (
|
||||
@ -13,188 +19,278 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
// golua exposes the LuaJIT C API. Build with -tags luajit so the cgo
|
||||
// LDFLAGS resolve to -lluajit-5.1 (see golua's lua.go).
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
)
|
||||
|
||||
//go:embed adapters/*.lua
|
||||
var bundledAdapters embed.FS
|
||||
|
||||
// adapterGlobal is the reserved global holding the adapter table after the
|
||||
// worker boots the script. A reserved name avoids clashing with user code.
|
||||
const adapterGlobal = "__llmsproxy_adapter"
|
||||
|
||||
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
|
||||
// worker wraps one LuaJIT interpreter state. Not goroutine-safe; each worker
|
||||
// is used by exactly one goroutine at a time, checked out from its pool.
|
||||
type worker struct {
|
||||
L *golua.State
|
||||
}
|
||||
|
||||
func newAdapterCache() *AdapterCache {
|
||||
return &AdapterCache{state: lua.NewState(), items: map[string]*lua.LTable{}}
|
||||
func (w *worker) close() {
|
||||
if w.L != nil {
|
||||
w.L.Close()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// staticInfo caches the immutable fields of an adapter script, extracted once
|
||||
// at load time. Endpoint() and the static headers fallback read from here so
|
||||
// they never churn a pooled worker.
|
||||
type staticInfo struct {
|
||||
name string
|
||||
version string
|
||||
endpoint string
|
||||
headers map[string]string
|
||||
}
|
||||
|
||||
// adapterPool owns the worker states for one adapter. Idle workers are held in
|
||||
// a slice guarded by a cond; up to target workers are created lazily.
|
||||
type adapterPool struct {
|
||||
name string
|
||||
script string
|
||||
static staticInfo
|
||||
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
idle []*worker
|
||||
created int
|
||||
target int
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newAdapterPool(name, script string, static staticInfo) *adapterPool {
|
||||
p := &adapterPool{name: name, script: script, static: static, target: 1}
|
||||
p.cond = sync.NewCond(&p.mu)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *adapterPool) setTarget(n int) {
|
||||
p.mu.Lock()
|
||||
p.target = n
|
||||
p.cond.Broadcast()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// boot creates a fresh LuaJIT state, registers the shared globals and executes
|
||||
// the adapter script, storing the returned adapter table in the state.
|
||||
func (p *adapterPool) boot() (*worker, error) {
|
||||
L := golua.NewState()
|
||||
L.OpenLibs()
|
||||
setupGlobals(L)
|
||||
if err := L.DoString(p.script); err != nil {
|
||||
L.Close()
|
||||
return nil, fmt.Errorf("compile adapter %s: %w", p.name, err)
|
||||
}
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.Close()
|
||||
return nil, fmt.Errorf("adapter %s must return a table", p.name)
|
||||
}
|
||||
L.SetGlobal(adapterGlobal)
|
||||
L.SetTop(0)
|
||||
return &worker{L: L}, nil
|
||||
}
|
||||
|
||||
func (p *adapterPool) acquire() (*worker, error) {
|
||||
p.mu.Lock()
|
||||
for {
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return nil, fmt.Errorf("adapter %s pool closed", p.name)
|
||||
}
|
||||
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
|
||||
if n := len(p.idle); n > 0 {
|
||||
w := p.idle[n-1]
|
||||
p.idle = p.idle[:n-1]
|
||||
p.mu.Unlock()
|
||||
return w, nil
|
||||
}
|
||||
L.Push(goValueToLua(L, v))
|
||||
return 1
|
||||
}))
|
||||
if p.created < p.target {
|
||||
p.created++
|
||||
break
|
||||
}
|
||||
p.cond.Wait()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
// 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)
|
||||
w, err := p.boot()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read adapter: %w", err)
|
||||
p.mu.Lock()
|
||||
p.created--
|
||||
p.cond.Signal()
|
||||
p.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
return c.PreloadSource(filepath.Base(path), string(data))
|
||||
return w, nil
|
||||
}
|
||||
|
||||
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)
|
||||
func (p *adapterPool) release(w *worker) {
|
||||
w.L.SetTop(0)
|
||||
p.mu.Lock()
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
w.close()
|
||||
return
|
||||
}
|
||||
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
|
||||
p.idle = append(p.idle, w)
|
||||
p.cond.Signal()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
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)
|
||||
func (p *adapterPool) shutdown() {
|
||||
p.mu.Lock()
|
||||
p.closed = true
|
||||
idle := p.idle
|
||||
p.idle = nil
|
||||
p.cond.Broadcast()
|
||||
p.mu.Unlock()
|
||||
for _, w := range idle {
|
||||
w.close()
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// VM wraps AdapterCache to dispatch adapter hook calls safely (single Lua
|
||||
// state is shared, so calls are serialized by a mutex).
|
||||
// VM aggregates per-adapter worker pools. All exported methods are safe for
|
||||
// concurrent use.
|
||||
type VM struct {
|
||||
mu sync.Mutex
|
||||
cache *AdapterCache
|
||||
mu sync.RWMutex
|
||||
dir string
|
||||
pools map[string]*adapterPool
|
||||
}
|
||||
|
||||
func NewVM(dir string) *VM {
|
||||
return &VM{dir: dir, cache: newAdapterCache()}
|
||||
return &VM{dir: dir, pools: map[string]*adapterPool{}}
|
||||
}
|
||||
|
||||
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 v.dir == "" {
|
||||
return nil
|
||||
}
|
||||
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.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)
|
||||
}
|
||||
if err := v.LoadAdapter(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
|
||||
pools := v.pools
|
||||
v.pools = map[string]*adapterPool{}
|
||||
v.mu.Unlock()
|
||||
for _, p := range pools {
|
||||
p.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
func (v *VM) ListAdapters() []APIAdapter { return v.cache.List() }
|
||||
// ConfigureConcurrency sets each adapter's worker target to the sum of
|
||||
// max_concurrent of every source using it. Values below one are clamped to 1.
|
||||
func (v *VM) ConfigureConcurrency(adapterConcurrency map[string]int) {
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
for name, n := range adapterConcurrency {
|
||||
if p, ok := v.pools[name]; ok {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
p.setTarget(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAdapter compiles and registers an adapter from a file (runtime safe).
|
||||
func (v *VM) LoadAdapter(path string) error { return v.cache.Preload(path) }
|
||||
func (v *VM) ListAdapters() []APIAdapter {
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
list := make([]APIAdapter, 0, len(v.pools))
|
||||
for name, p := range v.pools {
|
||||
list = append(list, APIAdapter{Name: name, Version: p.static.version})
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
|
||||
return list
|
||||
}
|
||||
|
||||
// RemoveAdapter evicts an adapter from the cache (runtime safe).
|
||||
func (v *VM) RemoveAdapter(name string) { v.cache.Remove(name) }
|
||||
// LoadAdapter compiles and registers an adapter script from a file.
|
||||
func (v *VM) LoadAdapter(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read adapter: %w", err)
|
||||
}
|
||||
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
return v.LoadAdapterSource(name, string(data))
|
||||
}
|
||||
|
||||
// LoadAdapterSource registers an adapter from source code. Replacing an
|
||||
// existing adapter shuts the old pool down and re-boots lazily.
|
||||
func (v *VM) LoadAdapterSource(name, code string) error {
|
||||
static, err := inspectScript(name, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := 1
|
||||
v.mu.RLock()
|
||||
if p, ok := v.pools[static.name]; ok {
|
||||
target = p.target
|
||||
}
|
||||
v.mu.RUnlock()
|
||||
|
||||
v.mu.Lock()
|
||||
if p, ok := v.pools[static.name]; ok {
|
||||
p.shutdown()
|
||||
}
|
||||
p := newAdapterPool(static.name, code, *static)
|
||||
p.setTarget(target)
|
||||
v.pools[static.name] = p
|
||||
v.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveAdapter evicts an adapter and shuts its workers down.
|
||||
func (v *VM) RemoveAdapter(name string) {
|
||||
v.mu.Lock()
|
||||
p := v.pools[name]
|
||||
delete(v.pools, name)
|
||||
v.mu.Unlock()
|
||||
if p != nil {
|
||||
p.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
func (v *VM) pool(name string) *adapterPool {
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
return v.pools[name]
|
||||
}
|
||||
|
||||
func (v *VM) writeBundledAdapters() error {
|
||||
known := []string{"openai", "anthropic", "deepseek", "gemini", "github", "groq", "mistral", "ollama", "kimicode"}
|
||||
@ -214,78 +310,342 @@ func (v *VM) writeBundledAdapters() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Transform runs adapter.Fn(raw) and returns the resulting string.
|
||||
func (v *VM) Transform(name, fn, raw string) (string, error) {
|
||||
adapter := v.cache.Get(name)
|
||||
if adapter == nil {
|
||||
p := v.pool(name)
|
||||
if p == 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 {
|
||||
w, err := p.acquire()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer p.release(w)
|
||||
L := w.L
|
||||
|
||||
L.SetTop(0)
|
||||
defer L.SetTop(0)
|
||||
|
||||
L.GetGlobal(adapterGlobal)
|
||||
if L.IsNil(-1) {
|
||||
return "", fmt.Errorf("adapter %s has no table", name)
|
||||
}
|
||||
L.GetField(-1, fn)
|
||||
if L.IsNil(-1) || !L.IsFunction(-1) {
|
||||
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 {
|
||||
L.PushString(raw)
|
||||
if err := L.Call(1, 1); err != nil {
|
||||
return "", fmt.Errorf("%s: %w", fn, err)
|
||||
}
|
||||
res := state.Get(-1)
|
||||
state.Pop(1)
|
||||
return res.String(), nil
|
||||
return L.ToString(-1), nil
|
||||
}
|
||||
|
||||
// BuildHeaders calls adapter.build_headers(meta). If the adapter does not
|
||||
// define build_headers, it falls back to the static adapter.headers table.
|
||||
// BuildHeaders executes adapter.build_headers(meta). If the adapter defines no
|
||||
// build_headers hook, 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 {
|
||||
p := v.pool(name)
|
||||
if p == 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
|
||||
w, err := p.acquire()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.Push(fn)
|
||||
state.Push(goValueToLua(state, meta))
|
||||
if err := state.PCall(1, 1, nil); err != nil {
|
||||
defer p.release(w)
|
||||
L := w.L
|
||||
|
||||
L.SetTop(0)
|
||||
defer L.SetTop(0)
|
||||
|
||||
L.GetGlobal(adapterGlobal)
|
||||
if L.IsNil(-1) {
|
||||
return nil, fmt.Errorf("adapter %s has no table", name)
|
||||
}
|
||||
L.GetField(-1, "build_headers")
|
||||
if !L.IsFunction(-1) {
|
||||
// no hook: fall back to the static headers captured at load time
|
||||
p.mu.Lock()
|
||||
hdr := make(map[string]string, len(p.static.headers))
|
||||
for k, h := range p.static.headers {
|
||||
hdr[k] = h
|
||||
}
|
||||
p.mu.Unlock()
|
||||
return hdr, nil
|
||||
}
|
||||
|
||||
L.SetTop(0)
|
||||
L.GetGlobal(adapterGlobal)
|
||||
L.GetField(-1, "build_headers")
|
||||
pushGoValue(L, meta)
|
||||
if err := L.Call(1, 1); 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()
|
||||
}
|
||||
})
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
return nil, fmt.Errorf("build_headers returned non-table")
|
||||
}
|
||||
headers := map[string]string{}
|
||||
luaTableToMap(L, -1, headers)
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
func (v *VM) Endpoint(name string) string {
|
||||
adapter := v.cache.Get(name)
|
||||
if adapter == nil {
|
||||
p := v.pool(name)
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
if ep := adapter.RawGetString("endpoint"); ep != nil {
|
||||
return ep.String()
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.static.endpoint
|
||||
}
|
||||
|
||||
// ---- static inspection (compile-once at load time) ----
|
||||
|
||||
func inspectScript(name, code string) (*staticInfo, error) {
|
||||
L := golua.NewState()
|
||||
L.OpenLibs()
|
||||
setupGlobals(L)
|
||||
if err := L.DoString(code); err != nil {
|
||||
L.Close()
|
||||
return nil, fmt.Errorf("compile adapter: %w", err)
|
||||
}
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.Close()
|
||||
return nil, fmt.Errorf("adapter script must return a table")
|
||||
}
|
||||
info := &staticInfo{name: name, headers: map[string]string{}}
|
||||
if n := readString(L, -1, "name"); n != "" {
|
||||
info.name = n
|
||||
}
|
||||
info.version = readString(L, -1, "version")
|
||||
info.endpoint = readString(L, -1, "endpoint")
|
||||
if getFieldBatch(L, "headers") {
|
||||
luaTableToMap(L, -1, info.headers)
|
||||
L.Pop(1)
|
||||
} else {
|
||||
L.Pop(1)
|
||||
}
|
||||
L.Close()
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// getFieldBatch fetches field of the table at the top of the stack and returns
|
||||
// true when the fetched value (now at the top) is a table.
|
||||
func getFieldBatch(L *golua.State, field string) bool {
|
||||
L.GetField(-1, field)
|
||||
return L.Type(-1) == golua.LUA_TTABLE
|
||||
}
|
||||
|
||||
func readString(L *golua.State, tableIdx int, field string) string {
|
||||
L.GetField(tableIdx, field)
|
||||
defer L.Pop(1)
|
||||
if L.Type(-1) == golua.LUA_TNIL || L.Type(-1) == golua.LUA_TNONE {
|
||||
return ""
|
||||
}
|
||||
return normalizeString(L, -1)
|
||||
}
|
||||
|
||||
func normalizeString(L *golua.State, idx int) string {
|
||||
if L.Type(idx) == golua.LUA_TNUMBER {
|
||||
n := L.ToNumber(idx)
|
||||
return strconv.FormatFloat(n, 'f', -1, 64)
|
||||
}
|
||||
return L.ToString(idx)
|
||||
}
|
||||
|
||||
// ---- shared globals installed into every worker state ----
|
||||
|
||||
// restorePcall puts the real pcall/xpcall back in place. golua's OpenLibs
|
||||
// replaces them with unsafe_ variants (it captures errors via its own panic
|
||||
// handler); the built-in adapters rely on pcall for soft error handling.
|
||||
func restorePcall(L *golua.State) {
|
||||
for _, name := range []string{"pcall", "xpcall"} {
|
||||
L.GetGlobal("unsafe_" + name)
|
||||
if !L.IsNil(-1) {
|
||||
L.SetGlobal(name)
|
||||
} else {
|
||||
L.Pop(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupGlobals(L *golua.State) {
|
||||
restorePcall(L)
|
||||
buildJSONTable(L)
|
||||
|
||||
registerFn(L, "hmac_sha256_hex", func(L *golua.State) int {
|
||||
key := L.ToString(1)
|
||||
data := L.ToString(2)
|
||||
m := hmac.New(sha256.New, []byte(key))
|
||||
m.Write([]byte(data))
|
||||
L.PushString(hex.EncodeToString(m.Sum(nil)))
|
||||
return 1
|
||||
})
|
||||
registerFn(L, "sha256_hex", func(L *golua.State) int {
|
||||
h := sha256.Sum256([]byte(L.ToString(1)))
|
||||
L.PushString(hex.EncodeToString(h[:]))
|
||||
return 1
|
||||
})
|
||||
registerFn(L, "base64_encode", func(L *golua.State) int {
|
||||
L.PushString(base64.StdEncoding.EncodeToString([]byte(L.ToString(1))))
|
||||
return 1
|
||||
})
|
||||
registerFn(L, "tohex", func(L *golua.State) int {
|
||||
L.PushString(hex.EncodeToString([]byte(L.ToString(1))))
|
||||
return 1
|
||||
})
|
||||
registerFn(L, "log", func(L *golua.State) int {
|
||||
fmt.Printf("[adapter/%s] %s\n", L.ToString(1), L.ToString(2))
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
func buildJSONTable(L *golua.State) {
|
||||
L.NewTable()
|
||||
for _, name := range []string{"encode", "decode"} {
|
||||
var fn golua.LuaGoFunction
|
||||
switch name {
|
||||
case "encode":
|
||||
fn = func(L *golua.State) int {
|
||||
b, err := jsonEncode(luaValueToGo(L, 1))
|
||||
if err != nil {
|
||||
L.PushString("null")
|
||||
return 1
|
||||
}
|
||||
L.PushString(string(b))
|
||||
return 1
|
||||
}
|
||||
case "decode":
|
||||
fn = func(L *golua.State) int {
|
||||
v, err := jsonDecode(L.ToString(1))
|
||||
if err != nil {
|
||||
L.PushNil()
|
||||
return 1
|
||||
}
|
||||
pushGoValue(L, v)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
L.PushGoClosure(fn)
|
||||
L.SetField(-2, name)
|
||||
}
|
||||
L.SetGlobal("json")
|
||||
}
|
||||
|
||||
func registerFn(L *golua.State, name string, fn golua.LuaGoFunction) {
|
||||
L.PushGoClosure(fn)
|
||||
L.SetGlobal(name)
|
||||
}
|
||||
|
||||
// ---- value conversions ----
|
||||
|
||||
func luaValueToGo(L *golua.State, idx int) interface{} {
|
||||
switch L.Type(idx) {
|
||||
case golua.LUA_TSTRING:
|
||||
return L.ToString(idx)
|
||||
case golua.LUA_TNUMBER:
|
||||
return L.ToNumber(idx)
|
||||
case golua.LUA_TBOOLEAN:
|
||||
return L.ToBoolean(idx)
|
||||
case golua.LUA_TTABLE:
|
||||
return luaTableToGoValue(L, idx)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// luaTableToGo value converts a Lua table to either []interface{} (array 1..n)
|
||||
// or map[string]interface{}.
|
||||
func luaTableToGoValue(L *golua.State, idx int) interface{} {
|
||||
abs := idx
|
||||
if idx < 0 {
|
||||
abs = L.GetTop() + idx + 1
|
||||
}
|
||||
m := map[string]interface{}{}
|
||||
allInt, n, maxK := true, 0, 0
|
||||
L.PushNil()
|
||||
for L.Next(abs) != 0 {
|
||||
kt := L.Type(-2)
|
||||
if kt == golua.LUA_TNUMBER {
|
||||
k := int(L.ToNumber(-2))
|
||||
if k > maxK {
|
||||
maxK = k
|
||||
}
|
||||
} else {
|
||||
allInt = false
|
||||
}
|
||||
m[keyToString(L, -2)] = luaValueToGo(L, -1)
|
||||
L.Pop(1)
|
||||
n++
|
||||
}
|
||||
if allInt && maxK > 0 && n == maxK {
|
||||
arr := make([]interface{}, maxK)
|
||||
for i := 1; i <= maxK; i++ {
|
||||
arr[i-1] = m[strconv.Itoa(i)]
|
||||
}
|
||||
return arr
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func luaTableToMap(L *golua.State, idx int, dst map[string]string) {
|
||||
abs := idx
|
||||
if idx < 0 {
|
||||
abs = L.GetTop() + idx + 1
|
||||
}
|
||||
L.PushNil()
|
||||
for L.Next(abs) != 0 {
|
||||
dst[keyToString(L, -2)] = normalizeString(L, -1)
|
||||
L.Pop(1)
|
||||
}
|
||||
}
|
||||
|
||||
// keyToString reads the stack value at idx as a string without modifying the
|
||||
// original (golua's ToString mutates numeric stack values in place, which
|
||||
// would break the next lua_next call), then pops the temporary copy.
|
||||
func keyToString(L *golua.State, idx int) string {
|
||||
L.PushValue(idx)
|
||||
defer L.Pop(1)
|
||||
return normalizeString(L, -1)
|
||||
}
|
||||
|
||||
func pushGoValue(L *golua.State, v interface{}) {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
L.PushNil()
|
||||
case string:
|
||||
L.PushString(x)
|
||||
case bool:
|
||||
L.PushBoolean(x)
|
||||
case int:
|
||||
L.PushNumber(float64(x))
|
||||
case int64:
|
||||
L.PushNumber(float64(x))
|
||||
case float64:
|
||||
L.PushNumber(x)
|
||||
case []interface{}:
|
||||
L.NewTable()
|
||||
for i, it := range x {
|
||||
pushGoValue(L, it)
|
||||
L.RawSeti(-2, i+1)
|
||||
}
|
||||
case map[string]interface{}:
|
||||
L.NewTable()
|
||||
for k, it := range x {
|
||||
pushGoValue(L, it)
|
||||
L.SetField(-2, k)
|
||||
}
|
||||
default:
|
||||
if b, err := json.Marshal(x); err == nil {
|
||||
var iv interface{}
|
||||
if json.Unmarshal(b, &iv) == nil {
|
||||
pushGoValue(L, iv)
|
||||
return
|
||||
}
|
||||
}
|
||||
L.PushNil()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func jsonEncode(v interface{}) ([]byte, error) { return json.Marshal(v) }
|
||||
@ -296,56 +656,3 @@ func jsonDecode(s string) (interface{}, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@ -85,3 +86,110 @@ func TestBuildHeadersCustomHook(t *testing.T) {
|
||||
t.Fatalf("timestamp = %q", hdrs["X-Timestamp"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableThinkingPassthrough(t *testing.T) {
|
||||
vm := NewVM(t.TempDir())
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
body := `{"model":"x","disable_thinking":true,"messages":[{"role":"user","content":"hi"}]}`
|
||||
out, err := vm.Transform("deepseek", "transform_request", body)
|
||||
if err != nil {
|
||||
t.Fatalf("deepseek transform: %v", err)
|
||||
}
|
||||
var req struct {
|
||||
ExtraBody map[string]interface{} `json:"extra_body"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatalf("unmarshal: %v (%s)", err, out)
|
||||
}
|
||||
thinking, ok := req.ExtraBody["thinking"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("deepseek should emit extra_body.thinking on disable_thinking: %s", out)
|
||||
}
|
||||
if thinking["type"] != "disabled" {
|
||||
t.Fatalf("thinking.type = %v", thinking["type"])
|
||||
}
|
||||
|
||||
// anthropic: disable_thinking removes the thinking block
|
||||
out2, err := vm.Transform("anthropic", "transform_request", body)
|
||||
if err != nil {
|
||||
t.Fatalf("anthropic transform: %v", err)
|
||||
}
|
||||
if strings.Contains(out2, "thinking") {
|
||||
t.Fatalf("anthropic should drop thinking when disable_thinking: %s", out2)
|
||||
}
|
||||
out3, err := vm.Transform("anthropic", "transform_request", `{"model":"x","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("anthropic transform: %v", err)
|
||||
}
|
||||
if !strings.Contains(out3, "enabled") {
|
||||
t.Fatalf("anthropic should enable thinking by default: %s", out3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultimodalTransform(t *testing.T) {
|
||||
vm := NewVM(t.TempDir())
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
body := `{"model":"x","messages":[{"role":"user","content":[
|
||||
{"type":"text","text":"what is this?"},
|
||||
{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}},
|
||||
{"type":"image_url","image_url":{"url":"https://ex.com/a.png"}}
|
||||
]}]}`
|
||||
|
||||
// anthropic: image_url -> image block (base64/url), text preserved
|
||||
out, err := vm.Transform("anthropic", "transform_request", body)
|
||||
if err != nil {
|
||||
t.Fatalf("anthropic: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"media_type":"image/png"`, `"data":"QUJD"`, `"type":"url","url":"https://ex.com/a.png"`, `"what is this?"`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("anthropic multimodal missing %s: %s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// gemini: image_url -> inline_data
|
||||
gout, err := vm.Transform("gemini", "transform_request", body)
|
||||
if err != nil {
|
||||
t.Fatalf("gemini: %v", err)
|
||||
}
|
||||
var g struct {
|
||||
Contents []struct {
|
||||
Parts []map[string]interface{} `json:"parts"`
|
||||
} `json:"contents"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(gout), &g); err != nil {
|
||||
t.Fatalf("gemini unmarshal: %v", err)
|
||||
}
|
||||
if len(g.Contents) == 0 {
|
||||
t.Fatalf("gemini no contents")
|
||||
}
|
||||
var found bool
|
||||
for _, p := range g.Contents[0].Parts {
|
||||
if v, ok := p["inline_data"].(map[string]interface{}); ok && v["data"] == "QUJD" && v["mime_type"] == "image/png" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("gemini missing inline_data image: %s", gout)
|
||||
}
|
||||
|
||||
// ollama: image_url -> images base64 array
|
||||
out, err = vm.Transform("ollama", "transform_request", body)
|
||||
if err != nil {
|
||||
t.Fatalf("ollama: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, `"images":["QUJD"]`) {
|
||||
t.Fatalf("ollama multimodal missing images: %s", out)
|
||||
}
|
||||
|
||||
// openai passthrough keeps the content array intact
|
||||
po, _ := vm.Transform("openai", "transform_request", body)
|
||||
if !strings.Contains(po, `"image_url"`) || !strings.Contains(po, `,QUJD"`) {
|
||||
t.Fatalf("openai passthrough lost multimodal content: %s", po)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user