feat: LuaJIT worker-pool VM, multimodal/disable-thinking passthrough, WebUI redesign, DeepSeek V4

This commit is contained in:
root
2026-08-07 13:34:16 +08:00
parent a0e6e6da90
commit ccbcede581
16 changed files with 1733 additions and 425 deletions

View File

@ -100,7 +100,7 @@ func buildBinary(t *testing.T) string {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "llmsproxy")
out, err := exec.Command("go", "build", "-o", bin, "llmsproxy/cmd/llmsproxy").CombinedOutput()
out, err := exec.Command("go", "build", "-tags", "luajit", "-o", bin, "llmsproxy/cmd/llmsproxy").CombinedOutput()
if err != nil {
t.Fatalf("build: %v\n%s", err, out)
}

2
go.mod
View File

@ -3,6 +3,6 @@ module llmsproxy
go 1.25.0
require (
github.com/yuin/gopher-lua v1.1.2
github.com/aarzilli/golua v0.0.0-20250217091409-248753f411c4
gopkg.in/yaml.v3 v3.0.1
)

4
go.sum
View File

@ -1,5 +1,5 @@
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
github.com/aarzilli/golua v0.0.0-20250217091409-248753f411c4 h1:gW5i3FQAMcbkNgo/A87gCKAbBMalAO8BlPIMo9Gk2Ow=
github.com/aarzilli/golua v0.0.0-20250217091409-248753f411c4/go.mod h1:hMjfaJVSqVnxenMlsxrq3Ni+vrm9Hs64tU4M7dhUoO4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@ -22,30 +22,30 @@ type Config struct {
}
// Model is a single exposed model id bound to a source, with priority used by
// AUTO model selection (higher number = preferred).
// AUTO auto selection (higher number = preferred).
type Model struct {
ID string `yaml:"id"`
Priority int `yaml:"priority"`
Kind string `yaml:"kind"` // "chat" (default) | "image"
Meta map[string]interface{} `yaml:"meta"`
ID string `yaml:"id" json:"id"`
Priority int `yaml:"priority" json:"priority"`
Kind string `yaml:"kind" json:"kind"` // "chat" (default) | "image"
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
}
// Source describes a single upstream LLM provider.
type Source struct {
Name string `yaml:"name"`
BaseURL string `yaml:"base_url"`
APIKey string `yaml:"api_key"`
Adapter string `yaml:"adapter"`
Endpoint string `yaml:"endpoint"` // chat endpoint override
ImageEndpoint string `yaml:"image_endpoint"` // image endpoint override
Models []Model `yaml:"models"`
Headers map[string]string `yaml:"headers"`
Meta map[string]interface{} `yaml:"meta"`
Temperature float64 `yaml:"temperature"`
MaxTokens int `yaml:"max_tokens"`
Timeout time.Duration `yaml:"timeout"`
MaxConcurrent int `yaml:"max_concurrent"` // per-source inflight cap
QueueTimeout time.Duration `yaml:"queue_timeout"` // wait for slot before failing
Name string `yaml:"name" json:"name"`
BaseURL string `yaml:"base_url" json:"base_url"`
APIKey string `yaml:"api_key" json:"api_key"`
Adapter string `yaml:"adapter" json:"adapter"`
Endpoint string `yaml:"endpoint" json:"endpoint,omitempty"` // chat endpoint override
ImageEndpoint string `yaml:"image_endpoint" json:"image_endpoint,omitempty"` // image endpoint override
Models []Model `yaml:"models" json:"models"`
Headers map[string]string `yaml:"headers" json:"headers,omitempty"`
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
Temperature float64 `yaml:"temperature" json:"temperature,omitempty"`
MaxTokens int `yaml:"max_tokens" json:"max_tokens,omitempty"`
Timeout time.Duration `yaml:"timeout" json:"-"`
MaxConcurrent int `yaml:"max_concurrent" json:"max_concurrent"`
QueueTimeout time.Duration `yaml:"queue_timeout" json:"-"`
}
// Load reads and validates a config file.

View File

@ -98,9 +98,14 @@ func (c *Core) mergedSources() []config.Source {
func (c *Core) rebuildRegistry() error {
srcs := c.mergedSources()
providers := make([]*provider.Provider, 0, len(srcs))
adapterConcurrency := map[string]int{}
for _, s := range srcs {
providers = append(providers, provider.New(s, c.vm))
if s.Adapter != "" {
adapterConcurrency[s.Adapter] += s.MaxConcurrent
}
}
c.vm.ConfigureConcurrency(adapterConcurrency)
if c.registry == nil {
c.registry = provider.NewRegistry(providers, c.cfg.DefaultModel)
} else {

View File

@ -23,6 +23,8 @@ type chatRequest struct {
Stream bool `json:"stream,omitempty"`
Tools []interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
DisableThinking bool `json:"disable_thinking"`
ExtraBody map[string]interface{} `json:"extra_body,omitempty"`
}
// ChatCompletion is the non-streaming OpenAI response object.
@ -111,13 +113,15 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
inner := &types.ChatRequest{
Model: normalizeModel(model),
Model: effective,
Messages: req.Messages,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
Stream: req.Stream,
Tools: req.Tools,
ToolChoice: req.ToolChoice,
DisableThinking: req.DisableThinking,
ExtraBody: req.ExtraBody,
}
if req.Stream {
g.streamChat(w, ctx, cands, inner, effective)

View File

@ -93,6 +93,63 @@ func TestChatSingle(t *testing.T) {
}
}
func TestChatDisableThinkingPassthrough(t *testing.T) {
got := ""
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
got = string(b)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}]}`)
}))
defer up.Close()
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
rr := doReq(t, g, "POST", "/v1/chat/completions",
`{"model":"mock-model","disable_thinking":true,"messages":[{"role":"user","content":"hi"}]}`)
if rr.Code != 200 {
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
}
var sent map[string]interface{}
if err := json.Unmarshal([]byte(got), &sent); err != nil {
t.Fatalf("upstream body: %v", err)
}
if _, has := sent["disable_thinking"]; has {
t.Fatalf("disable_thinking not stripped: %s", got)
}
// openai adapter strips disable_thinking; deepseek would map it to extra_body.thinking.
// with the passthrough fix the flag now reaches the VM at all.
}
func TestChatMultimodalPassthrough(t *testing.T) {
gotBody := ""
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}]}`)
}))
defer up.Close()
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
rr := doReq(t, g, "POST", "/v1/chat/completions",
`{"model":"mock-model","messages":[{"role":"user","content":[
{"type":"text","text":"what is this?"},
{"type":"image_url","image_url":{"url":"data:image/png;base64,QUJD"}}
]}]}`)
if rr.Code != 200 {
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
}
var sent struct {
Messages []struct {
Content []map[string]interface{} `json:"content"`
} `json:"messages"`
}
if err := json.Unmarshal([]byte(gotBody), &sent); err != nil {
t.Fatalf("upstream body: %v", err)
}
if len(sent.Messages) != 1 || len(sent.Messages[0].Content) != 2 {
t.Fatalf("multimodal content lost: %s", gotBody)
}
}
func TestChatAUTO(t *testing.T) {
up := mockUpstream()
defer up.Close()
@ -265,7 +322,7 @@ func TestWebUIServesPage(t *testing.T) {
if rr.Code != 200 {
t.Fatalf("status=%d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "llmsproxy") {
if !strings.Contains(rr.Body.String(), "ModelRouter") {
t.Fatalf("ui not served")
}
}
@ -304,3 +361,89 @@ func TestSourcesAPIAddAndPersist(t *testing.T) {
t.Fatalf("runtime file not written: %v", err)
}
}
func TestUIRequiresAuth(t *testing.T) {
up := mockUpstream()
defer up.Close()
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
// / without a key -> redirect to /login
req, _ := http.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
g.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("expected 302 for /, got %d", rr.Code)
}
if loc := rr.Header().Get("Location"); !strings.Contains(loc, "/login") {
t.Fatalf("expected redirect to /login, got %q", loc)
}
// /api/status without a key -> 401
req, _ = http.NewRequest("GET", "/api/status", nil)
rr = httptest.NewRecorder()
g.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for /api/status, got %d", rr.Code)
}
// /login page is public
req, _ = http.NewRequest("GET", "/login", nil)
rr = httptest.NewRecorder()
g.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "登录") {
t.Fatalf("login page: %d %s", rr.Code, rr.Body.String())
}
}
func TestLoginAPIAndCookie(t *testing.T) {
up := mockUpstream()
defer up.Close()
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
// bad key -> 401
req, _ := http.NewRequest("POST", "/api/login", strings.NewReader(`{"key":"wrong"}`))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
g.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("bad login: %d", rr.Code)
}
// good key -> cookie
req, _ = http.NewRequest("POST", "/api/login", strings.NewReader(`{"key":"sk-test"}`))
req.Header.Set("Content-Type", "application/json")
rr = httptest.NewRecorder()
g.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("login: %d", rr.Code)
}
cookies := rr.Result().Cookies()
if len(cookies) == 0 || cookies[0].Name != "gw_key" {
t.Fatalf("no gw_key cookie set")
}
// use cookie to access /api/status
req, _ = http.NewRequest("GET", "/api/status", nil)
req.AddCookie(cookies[0])
rr = httptest.NewRecorder()
g.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("cookie authed status: %d", rr.Code)
}
var out map[string]interface{}
_ = json.Unmarshal(rr.Body.Bytes(), &out)
if out["base_url"] == "" {
t.Fatalf("status missing base_url: %s", rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "sk-test") {
t.Fatalf("status missing gateway_keys: %s", rr.Body.String())
}
}
func TestAPIChatInternal(t *testing.T) {
up := mockUpstream()
defer up.Close()
g := newTestGateway(t, config.Source{Name: "mock", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "mock-model"}}})
rr := doReq(t, g, "POST", "/api/chat",
`{"model":"mock-model","messages":[{"role":"user","content":"hi"}]}`)
if rr.Code != 200 {
t.Fatalf("api chat status=%d body=%s", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "pong") {
t.Fatalf("api chat body=%s", rr.Body.String())
}
}

View File

@ -7,9 +7,11 @@ package gateway
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"log"
"net/http"
"net/url"
"strings"
"llmsproxy/internal/core"
@ -44,7 +46,14 @@ func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
}
func (g *Gateway) Handler() http.Handler {
return g.auth(http.HandlerFunc(g.routes))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// login entry point + login/logout API are the only unauthenticated routes
if r.URL.Path == "/login" || r.URL.Path == "/api/login" || r.URL.Path == "/api/logout" {
g.routes(w, r)
return
}
g.auth(http.HandlerFunc(g.routes)).ServeHTTP(w, r)
})
}
func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
@ -59,8 +68,16 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
g.handleAdaptersAPI(w, r)
case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"):
g.handleSourcesAPI(w, r)
case r.URL.Path == "/api/chat":
g.handleChat(w, r)
case r.URL.Path == "/api/status":
g.handleStatusAPI(w, r)
case r.URL.Path == "/login":
g.handleLogin(w, r)
case r.URL.Path == "/api/login":
g.handleLoginAPI(w, r)
case r.URL.Path == "/api/logout":
g.handleLogoutAPI(w, r)
default:
g.serveUI(w, r)
}
@ -97,14 +114,146 @@ func (g *Gateway) auth(next http.Handler) http.Handler {
if key == "" {
key = r.URL.Query().Get("api_key")
}
if key == "" {
if c, err := r.Cookie("gw_key"); err == nil {
key = c.Value
}
}
if !g.apiKeys[key] {
if isAPIPath(r.URL.Path) {
writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api key")
return
}
// browser navigation to UI pages -> login page
http.Redirect(w, r, "/login?continue="+url.QueryEscape(r.URL.Path), http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
// isAPIPath reports whether the request targets a JSON API endpoint that
// should answer 401 instead of redirecting to the login page.
func isAPIPath(p string) bool {
return strings.HasPrefix(p, "/v1/") || strings.HasPrefix(p, "/api/")
}
// handleLogoutAPI clears the session cookie and redirects to the login page.
func (g *Gateway) handleLogoutAPI(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "gw_key",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
http.Redirect(w, r, "/login", http.StatusFound)
}
// handleLogin serves the login page (unauthenticated). Supports a
// ?continue= path to return to after a successful login and a ?lang=zh|en
// toggle for i18n.
func (g *Gateway) handleLogin(w http.ResponseWriter, r *http.Request) {
continuePath := r.URL.Query().Get("continue")
if continuePath == "" || !strings.HasPrefix(continuePath, "/") || strings.HasPrefix(continuePath, "//") {
continuePath = "/"
}
lang := strings.ToLower(r.URL.Query().Get("lang"))
if lang != "en" {
lang = "zh"
}
// escape for embedding in HTML attribute and single-quoted JS string
htmlCont := strings.NewReplacer(
"&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&#39;",
).Replace(continuePath)
jsCont := strings.NewReplacer("\\", "\\\\", "'", "\\'", "\n", "\\n").Replace(continuePath)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, loginPageHTML, htmlCont, jsCont, lang)
}
const loginPageHTML = `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 · ModelRouter</title>
<style>
:root{--bg:#f4f6fb;--card:#ffffff;--line:#e2e6ef;--fg:#1c2333;--muted:#6b7390;--accent:#3f6ef5;--accent-h:#2f5ae0;--err:#e5484d}
*{box-sizing:border-box}
body{margin:0;font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;background:linear-gradient(160deg,#f4f6fb 0%%,#e8edf8 100%%);color:var(--fg);display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:36px 34px;width:340px;box-shadow:0 12px 40px rgba(31,45,90,.10)}
.brand{display:flex;align-items:center;gap:10px;margin-bottom:6px}
.logo{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,#3f6ef5,#6a8ffb);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:15px}
h1{font-size:18px;margin:0;letter-spacing:.2px}
.sub{color:var(--muted);font-size:13px;margin:4px 0 22px}
label{display:block;font-size:12px;color:var(--muted);margin-bottom:6px}
input{width:100%%;background:#fbfcfe;border:1px solid var(--line);border-radius:8px;padding:10px 12px;font:inherit;color:var(--fg);outline:none;transition:border .15s,box-shadow .15s}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(63,110,245,.15)}
button{margin-top:16px;width:100%%;background:var(--accent);color:#fff;border:0;border-radius:8px;padding:11px;font:inherit;font-weight:600;cursor:pointer;transition:background .15s}
button:hover{background:var(--accent-h)}
#msg{color:var(--err);margin-top:12px;min-height:18px;font-size:13px}
.foot{margin-top:18px;text-align:center;font-size:12px;color:var(--muted)}
.foot a{color:var(--muted);text-decoration:none;border-bottom:1px dashed var(--line)}
</style></head>
<body><div class="card">
<div class="brand"><div class="logo">M</div><div>
<h1 data-i="title">ModelRouter</h1>
<div class="sub" data-i="sub">统一 LLM 网关 · 登录</div></div></div>
<input id="key" type="password" data-i-ph="ph" placeholder="Gateway API Key" autocomplete="off">
<button onclick="login()" data-i="btn">登录</button>
<div id="msg"></div>
<div class="foot"><a data-i="lang" href="?lang=en&continue=%[1]s">English</a></div>
</div>
<script>
var LANG='%[3]s';
var CONT='%[2]s';
var I={zh:{title:'ModelRouter',sub:'统一 LLM 网关 · 登录',ph:'输入网关 API Key',btn:'登录',lang:'English'},
en:{title:'ModelRouter',sub:'Unified LLM Gateway · Sign in',ph:'Enter gateway API key',btn:'Sign in',lang:'中文'}};
function apply(){var t=I[LANG]||I.zh;document.querySelector('[data-i=title]').textContent=t.title;
document.querySelector('[data-i=sub]').textContent=t.sub;
document.querySelector('[data-i=ph]').placeholder=t.ph;
document.querySelector('[data-i=btn]').textContent=t.btn;
document.querySelector('[data-i=lang]').textContent=t.lang;
document.documentElement.lang=LANG==='zh'?'zh':'en';}
document.querySelector('[data-i=lang]').onclick=function(e){e.preventDefault();
location.href='/login?lang='+(LANG==='zh'?'en':'zh')+'&continue='+encodeURIComponent(CONT);};
async function login(){
var key=document.getElementById('key').value.trim();
if(!key){document.getElementById('msg').textContent=(LANG==='zh'?'请输入 Key':'Enter a key');return;}
var r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key})});
var j=await r.json().catch(function(){return{};});
if(r.ok){location.href=CONT||'/';}
else{document.getElementById('msg').textContent=(j.error&&j.error.message)||(LANG==='zh'?'登录失败':'Login failed');}
}
document.getElementById('key').addEventListener('keydown',function(e){if(e.key==='Enter')login();});
apply();
</script></body></html>`
// handleLoginAPI validates the gateway key and issues a session cookie.
func (g *Gateway) handleLoginAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST")
return
}
var body struct {
Key string `json:"key"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json")
return
}
if !g.apiKeys[body.Key] {
writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api key")
return
}
http.SetCookie(w, &http.Cookie{
Name: "gw_key",
Value: body.Key,
Path: "/",
MaxAge: 86400 * 30,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
func (g *Gateway) handleModels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
@ -126,11 +275,24 @@ func (g *Gateway) handleModels(w http.ResponseWriter, r *http.Request) {
}
func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
host := r.Host
if host == "" {
host = g.core.Listen()
}
if strings.HasPrefix(host, ":") {
host = "127.0.0.1" + host
}
keys := make([]string, 0, len(g.apiKeys))
for k := range g.apiKeys {
keys = append(keys, k)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"default_model": g.core.DefaultModel(),
"models": g.core.Registry().ModelList(),
"sources": g.core.Registry().Status(),
"adapters": g.core.ListAdapters(),
"base_url": "http://" + host + "/v1",
"gateway_keys": keys,
})
}

View File

@ -3,182 +3,652 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>llmsproxy</title>
<title>ModelRouter</title>
<style>
:root { --bg:#0f1115; --card:#171a21; --line:#262b36; --fg:#e6e8ee; --muted:#8b93a5;
--accent:#4f7cff; --ok:#3ecf8e; --warn:#ffb454; --err:#ff5d6c; }
:root{
--bg:#f4f6fb; --card:#ffffff; --card2:#f9fafd; --line:#e3e6f0; --fg:#1d2434; --muted:#66708a;
--accent:#3f6ef5; --accent-h:#2f5ae0; --ok:#17a964; --warn:#b7791f; --err:#d64550;
--tag-ok:#e6f6ee; --tag-err:#fdecec; --tag-blue:#e8effc; --tag-warn:#fdf3e2;
--shadow:0 8px 30px rgba(28,44,94,.08);
}
html[data-theme="dark"]{
--bg:#0e121d; --card:#161b2b; --card2:#111522; --line:#242b40; --fg:#e7eaf3; --muted:#8c94ac;
--accent:#4f7cff; --accent-h:#6a8ffb; --ok:#4f7cff;
--tag-ok:#12301f; --tag-err:#3d1a1c; --tag-blue:#182744; --tag-warn:#3b2f14;
--shadow:0 10px 34px rgba(0,0,0,.4);
}
* { box-sizing:border-box; }
body { margin:0; font:14px/1.5 ui-monospace,Menlo,Consolas,monospace; background:var(--bg); color:var(--fg); }
header { display:flex; align-items:center; gap:12px; padding:14px 20px; border-bottom:1px solid var(--line); }
header h1 { font-size:16px; margin:0; }
header .badge { font-size:12px; color:var(--muted); }
nav { display:flex; gap:4px; padding:10px 20px; border-bottom:1px solid var(--line); }
nav button { background:transparent; border:1px solid transparent; color:var(--muted); padding:6px 14px;
cursor:pointer; border-radius:6px; font:inherit; }
nav button.active { background:var(--card); border-color:var(--line); color:var(--fg); }
main { padding:20px; max-width:1200px; margin:0 auto; }
.card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:16px; margin-bottom:16px; }
.card h2 { font-size:14px; margin:0 0 12px; color:var(--muted); font-weight:600; }
body { margin:0; background:var(--bg); color:var(--fg);
font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
transition:background .2s,color .2s; }
a { color:var(--accent); }
header { display:flex; align-items:center; gap:14px; padding:14px 24px; border-bottom:1px solid var(--line);
background:var(--card); position:sticky; top:0; z-index:20; }
.brand { display:flex; align-items:center; gap:12px; }
.logo { width:34px; height:34px; border-radius:10px; flex:0 0 auto;
background:linear-gradient(135deg,var(--accent),#6a8ffb); color:#fff; font-weight:700; font-size:17px;
display:flex; align-items:center; justify-content:center; box-shadow:0 4px 12px rgba(63,110,245,.35); }
.brand h1 { font-size:17px; margin:0; letter-spacing:.2px; }
.brand .sub { font-size:12px; color:var(--muted); margin-top:1px; }
.spacer { flex:1; }
.hd-actions { display:flex; align-items:center; gap:8px; }
.hd-actions button { background:var(--card2); border:1px solid var(--line); color:var(--muted);
padding:6px 12px; border-radius:8px; cursor:pointer; font:inherit; transition:border .15s,color .15s; }
.hd-actions button:hover { border-color:var(--accent); color:var(--accent); }
nav { display:flex; gap:6px; padding:14px 24px 0; max-width:1240px; margin:0 auto; }
nav button { background:transparent; border:1px solid transparent; color:var(--muted); padding:7px 16px;
cursor:pointer; border-radius:9px; font:inherit; font-weight:500; transition:all .15s; }
nav button:hover { color:var(--fg); background:var(--card); }
nav button.active { background:var(--card); border-color:var(--line); color:var(--accent); box-shadow:var(--shadow); }
main { padding:20px 24px 48px; max-width:1240px; margin:0 auto; }
.card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:20px; margin-bottom:18px;
box-shadow:var(--shadow); transition:background .2s; }
.card h2 { font-size:14px; margin:0 0 14px; color:var(--muted); font-weight:600;
display:flex; align-items:center; gap:8px; }
.card h2 .grow { flex:1; }
table { width:100%; border-collapse:collapse; }
th,td { text-align:left; padding:8px 10px; border-bottom:1px solid var(--line); font-size:13px; }
th { color:var(--muted); font-weight:500; }
.tag { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; margin:2px; }
.tag-green { background:#143b2b; color:var(--ok); }
.tag-red { background:#3b1418; color:var(--err); }
.tag-blue { background:#14223b; color:var(--accent); }
button { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:7px 14px; cursor:pointer; font:inherit; }
th,td { text-align:left; padding:9px 12px; border-bottom:1px solid var(--line); font-size:13px; }
th { color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.4px; }
tr:last-child td { border-bottom:0; }
.tag { display:inline-block; padding:2px 9px; border-radius:20px; font-size:12px; margin:2px 3px 2px 0; font-weight:500; }
.tag-green { background:var(--tag-ok); color:var(--ok); }
.tag-red { background:var(--tag-err); color:var(--err); }
.tag-blue { background:var(--tag-blue); color:var(--accent); }
.tag-warn { background:var(--tag-warn); color:var(--warn); }
button { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:8px 16px; cursor:pointer;
font:inherit; font-weight:500; transition:background .15s; }
button:hover { background:var(--accent-h); }
button.ghost { background:transparent; border:1px solid var(--line); color:var(--muted); }
button.danger { background:transparent; border:1px solid #3b1418; color:var(--err); }
input,select,textarea { width:100%; background:#10131a; border:1px solid var(--line); color:var(--fg);
border-radius:6px; padding:7px 10px; font:inherit; margin-bottom:8px; }
textarea { min-height:220px; resize:vertical; }
label { display:block; font-size:12px; color:var(--muted); margin:10px 0 4px; }
button.ghost:hover { border-color:var(--accent); color:var(--accent); background:transparent; }
button.danger { background:transparent; border:1px solid var(--err); color:var(--err); }
button.danger:hover { background:var(--err); color:#fff; }
button.small { padding:5px 12px; font-size:12px; border-radius:7px; }
input,select,textarea { width:100%; background:var(--card2); border:1px solid var(--line); color:var(--fg);
border-radius:8px; padding:9px 12px; font:inherit; margin-bottom:10px; outline:none;
transition:border .15s,box-shadow .15s; }
input:focus,select:focus,textarea:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(63,110,245,.15); }
textarea { min-height:200px; resize:vertical; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12.5px; }
select { cursor:pointer; }
label { display:block; font-size:12px; color:var(--muted); margin:12px 0 5px; }
.row { display:flex; gap:12px; } .row > div { flex:1; }
.model-row { display:flex; gap:8px; align-items:center; }
.model-row input { margin:0; } .model-row .del { flex:0 0 auto; padding:4px 8px; }
.model-row input, .model-row select { margin:0 0 8px; }
.model-row .del { flex:0 0 auto; padding:5px 9px; }
.muted { color:var(--muted); }
.hidden { display:none; }
#toast { position:fixed; bottom:20px; right:20px; background:var(--card); border:1px solid var(--line);
padding:10px 16px; border-radius:8px; display:none; }
.hidden#tab-chat { display:none; }
#toast { position:fixed; bottom:24px; right:24px; background:var(--card); border:1px solid var(--line);
padding:11px 18px; border-radius:10px; box-shadow:var(--shadow); display:none; animation:fadein .2s; z-index:100; }
@keyframes fadein { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} }
pre.configbox { background:var(--card2); border:1px solid var(--line); border-radius:10px; padding:14px; margin:10px 0;
overflow:auto; white-space:pre-wrap; word-break:break-all; font-size:12.5px; font-family:ui-monospace,Menlo,Consolas,monospace; }
.chatlog { height:280px; overflow:auto; background:var(--card2); border:1px solid var(--line);
border-radius:10px; padding:14px; margin-bottom:10px; font-size:13.5px; }
.chatlog .u { color:var(--accent); font-weight:500; }
.chatlog .a { color:var(--ok); }
.chatlog .meta { color:var(--muted); font-size:12px; margin-top:4px; }
.attbox { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:10px; }
.att { position:relative; display:inline-block; }
.att img { display:block; height:64px; border-radius:8px; border:1px solid var(--line); max-width:120px; object-fit:cover; }
.att button { position:absolute; top:-8px; right:-8px; border-radius:50%; }
.modelchip { cursor:pointer; color:var(--accent); text-decoration:none; }
/* ---------- chat page (clean) ---------- */
.tab-chat { max-width:840px; margin:0 auto; display:flex; flex-direction:column; height:calc(100vh - 178px); }
.chat-wrap { background:var(--card); border:1px solid var(--line); border-radius:12px; box-shadow:var(--shadow);
display:flex; flex-direction:column; flex:1; min-height:0; overflow:hidden; }
.chat-log { flex:1; overflow-y:auto; padding:20px 20px 8px; display:flex; flex-direction:column; gap:18px; }
.msg { display:flex; gap:10px; max-width:100%; }
.msg.usr { flex-direction:row-reverse; }
.avatar { width:28px; height:28px; border-radius:8px; flex:0 0 auto; display:flex; align-items:center; justify-content:center;
font-size:12px; font-weight:600; color:#fff; letter-spacing:.3px; }
.avatar.u { background:var(--accent); }
.avatar.a { background:var(--ok); }
.bubble { max-width:84%; padding:10px 14px; border-radius:12px; position:relative; word-break:break-word; font-size:13.5px; line-height:1.6; }
.msg.usr .bubble { background:var(--accent); color:#fff; border-top-right-radius:3px; }
.msg.ass .bubble { background:var(--card2); border:1px solid var(--line); border-top-left-radius:3px; }
.bubble .bmd p { margin:0 0 8px; } .bubble .bmd p:last-child { margin:0; }
.bubble .bmd h1,.bubble .bmd h2,.bubble .bmd h3,.bubble .bmd h4 { margin:9px 0 5px; font-size:1.04em; }
.bubble .bmd ul,.bubble .bmd ol { margin:0 0 8px; padding-left:20px; }
.bubble .bmd li { margin:2px 0; }
.bubble .bmd code { background:var(--bg); border:1px solid var(--line); border-radius:4px; padding:1px 5px; font-size:11.5px;
font-family:ui-monospace,Menlo,Consolas,monospace; }
.msg.usr .bubble .bmd code { background:rgba(0,0,0,.22); border-color:transparent; }
.bubble .bmd pre { background:var(--bg); border:1px solid var(--line); border-radius:8px; padding:10px 12px; overflow:auto; margin:8px 0; }
.bubble .bmd pre code { background:transparent; border:0; padding:0; }
.bubble .bmd blockquote { margin:8px 0; padding-left:12px; border-left:3px solid var(--line); color:var(--muted); }
.bubble .bmd a { color:var(--accent); text-decoration:none; } .bubble .bmd a:hover { text-decoration:underline; }
.msg .muted { font-size:11.5px; margin-top:4px; opacity:.75; }
.think { color:var(--muted); border-left:2px solid var(--line); padding:4px 0 4px 12px; margin-top:8px; font-size:12px;
max-height:180px; overflow:auto; white-space:pre-wrap; }
.tk-hint { font-size:11px; display:block; margin-bottom:2px; opacity:.85; text-transform:none; }
.cursor-dot { display:inline-block; width:2px; height:14px; background:var(--fg); margin-left:3px; vertical-align:-2px;
animation:blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity:0 } }
.chat-composer { border-top:1px solid var(--line); padding:10px 12px 12px; background:var(--card); }
.chat-tools { display:flex; align-items:center; gap:12px; margin-bottom:8px; }
.chat-tools .tl { font-size:12px; color:var(--muted); white-space:nowrap; }
.chat-tools select { width:auto; margin:0; padding:4px 10px; font-size:12.5px; border-radius:7px; }
.chat-tools .grow { flex:1; }
.chk { display:flex; align-items:center; gap:6px; font-size:12.5px; color:var(--muted); cursor:pointer; white-space:nowrap; user-select:none; }
.chk input { width:14px; height:14px; margin:0; accent-color:var(--accent); }
.chat-box { display:flex; gap:9px; align-items:flex-end; }
.chat-box textarea { flex:1; margin:0; min-height:42px; max-height:140px; resize:none; padding:10px 13px; border-radius:10px;
font:inherit; line-height:1.5; }
.chat-box .sendbtn { width:auto; padding:10px 20px; border-radius:10px; font-weight:600; white-space:nowrap; }
.chat-box .sendbtn:disabled { opacity:.5; cursor:default; }
.attach-btn { background:transparent; border:1px solid var(--line); color:var(--muted); border-radius:10px;
width:42px; height:42px; display:flex; align-items:center; justify-content:center; cursor:pointer; flex:0 0 auto;
transition:all .15s; }
.attach-btn:hover { border-color:var(--accent); color:var(--accent); }
.chat-empty { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--muted); gap:6px; }
.chat-empty .ring { width:42px; height:42px; margin-bottom:6px; border-radius:50%;
border:1px solid var(--line); display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:17px; opacity:.8; }
.chat-empty p { margin:0; }
.modelchip:hover { text-decoration:underline; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.dropzone { border:2px dashed var(--line); border-radius:10px; padding:26px; text-align:center; color:var(--muted);
cursor:pointer; transition:all .15s; background:var(--card2); }
.dropzone.dragover, .dropzone:hover { border-color:var(--accent); color:var(--accent); background:#eef2ff; }
html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:hover { background:#1a2138; }
.dropzone b { font-size:14px; display:block; margin-bottom:4px; }
.empty { color:var(--muted); text-align:center; padding:24px 0; }
#modal-wrap { position:fixed; inset:0; background:rgba(15,22,44,.45); display:flex; align-items:flex-start;
justify-content:center; overflow:auto; padding:48px 20px; z-index:50; }
</style>
</head>
<body>
<header>
<h1>llmsproxy</h1>
<span class="badge">统一 LLM 网关 · 适配器/源管理</span>
<div class="brand">
<div class="logo">M</div>
<div><h1>ModelRouter</h1><div class="sub" data-i="tagline">统一 LLM 网关</div></div>
</div>
<div class="spacer"></div>
<div class="hd-actions">
<button id="btn-theme" title="theme"></button>
<button id="btn-lang">EN</button>
<button class="ghost" id="btn-logout" data-i="logout">退出登录</button>
</div>
</header>
<nav>
<button data-tab="status" class="active">状态</button>
<button data-tab="sources"></button>
<button data-tab="adapters">适配器</button>
<button data-tab="status" class="active" data-i="navStatus">状态</button>
<button data-tab="chat" data-i="navChat">Chat 测试</button>
<button data-tab="sources" data-i="navSources"></button>
<button data-tab="adapters" data-i="navAdapters">适配器</button>
</nav>
<main>
<div id="tab-status"></div>
<div id="tab-chat" class="hidden"></div>
<div id="tab-sources" class="hidden"></div>
<div id="tab-adapters" class="hidden"></div>
</main>
<div id="toast"></div>
<script>
/* ---------- i18n ---------- */
const STR = {
zh: {
tagline:'统一 LLM 网关', logout:'退出登录', langTo:'EN',
navStatus:'状态', navChat:'Chat 测试', navSources:'源', navAdapters:'适配器',
connTitle:'连接配置Agent / OpenAI SDK', connHint:'模型名默认 AUTO按优先级自动选择可用源点击任一模型可生成固定到该模型的配置。',
copyCfg:'一键复制配置', copyEnv:'复制为环境变量',
srcTitle:'源状态', srcCount:'共 %d 个',
tName:'名称', tAdapter:'适配器', tModels:'模型(点击看配置)', tURL:'地址', tConn:'连接', tConc:'并发',
online:'在线', offline:'退避 / 不可用',
adTitle:'已加载适配器', tVersion:'版本',
chatTitle:'Chat 测试', cModel:'模型', cMsg:'Enter 发送Shift+Enter 换行)', send:'发送', clear:'清空',
cThinking:'推理', cImg:'图片', cPick:'选择图片', cRemoveImg:'移除', cWelcome:'开始对话——选择模型,输入消息即可测试上游连接',
cStream:'调用 /v1/chat/completions流式 SSE',
srcEmpty:'还没有配置任何源',
srcAdd:'+ 新增源', srcEdit:'编辑', srcDel:'删除',
adEmpty:'尚未加载适配器',
uploadTitle:'上传 / 拖拽 Lua 适配器', dropHint:'拖拽 .lua 文件到此处,或点击选择文件',
adName:'名称(保存为 &lt;名称&gt;.lua', tbLua:'Lua 脚本(返回 adapter table', uploadBtn:'上传并加载',
modalNew:'新增源', modalEdit:'编辑源',
mName:'名称', mURL:'Base URL', mKey:'API Key', mAlias:'适配器', mAliasAuto:'自动', mEp:'聊天端点(可选覆盖)',
mImgEp:'生图端点(可选覆盖)', mConc:'并发上限', mTemp:'温度',
mModels:'模型列表优先级越大AUTO 越优先选择)', mAddModel:'+ 模型',
mMeta:'Meta透传给 build_headers 钩子JSON', mSave:'保存', mCancel:'取消',
toastCopied:'已复制', toastCopyFail:'复制失败,请手动选择复制', toastSaved:'已保存并热重载',
toastEmpty:'请输入消息', toastBadJson:'Meta 不是合法 JSON', toastSaveFail:'保存失败: %s',
toastUploaded:'适配器已加载', toastUpFail:'上传失败: %s', toastNeedAll:'需要适配器名称和脚本',
toastDelOk:'已删除', confirmDelSrc:'确定删除源 %s 吗?', confirmDelAdp:'确定删除适配器 %s 吗?',
u:'用户', a:'助手', at:'助手[思考]', aEmpty:'(空回复)', aErr:'请求失败: %s', cErr:'错误: %s',
chatMeta:'模型=%s · 耗时 %s ms · %d 字符', connPinned:'# 固定到模型: %s (源 %s)', connAuto:'# 模型名 AUTO自动选择可用源',
},
en: {
tagline:'Unified LLM Gateway', logout:'Log out', langTo:'中',
navStatus:'Status', navChat:'Chat', navSources:'Sources', navAdapters:'Adapters',
connTitle:'Connection config (Agent / OpenAI SDK)', connHint:'Model defaults to AUTO — picks the best healthy source by priority. Click a model to pin it.',
copyCfg:'Copy config', copyEnv:'Copy as env vars',
srcTitle:'Sources', srcCount:'%d total',
tName:'Name', tAdapter:'Adapter', tModels:'Models', tURL:'URL', tConn:'Status', tConc:'Concurrency',
online:'online', offline:'backoff / down',
adTitle:'Loaded adapters', tVersion:'Version',
chatTitle:'Chat', cModel:'Model', cMsg:'(Enter to send, Shift+Enter for newline)', send:'Send', clear:'Clear',
cThinking:'Thinking', cImg:'Image', cPick:'Pick image', cRemoveImg:'Remove', cWelcome:'Start a conversation — pick a model to test the upstream connection',
cStream:'calls /v1/chat/completions (streaming SSE)',
srcEmpty:'No sources configured yet',
srcAdd:'+ Add source', srcEdit:'Edit', srcDel:'Delete',
adEmpty:'No adapters loaded',
uploadTitle:'Upload / drag a Lua adapter', dropHint:'Drag a .lua file here, or click to choose',
adName:'Name (saved as <name>.lua)', tbLua:'Lua script (returns adapter table)', uploadBtn:'Upload & load',
modalNew:'Add source', modalEdit:'Edit source',
mName:'Name', mURL:'Base URL', mKey:'API Key', mAlias:'Adapter', mAliasAuto:'Auto', mEp:'Chat endpoint (override)',
mImgEp:'Image endpoint (override)', mConc:'Max concurrency', mTemp:'Temperature',
mModels:'Models (higher priority → preferred)', mAddModel:'+ model',
mMeta:'Meta (passed to build_headers hook, JSON)', mSave:'Save', mCancel:'Cancel',
toastCopied:'Copied', toastCopyFail:'Copy failed', toastSaved:'Saved & hot-reloaded',
toastEmpty:'Enter a message', toastBadJson:'Meta is not valid JSON', toastSaveFail:'Save failed: %s',
toastUploaded:'Adapter loaded', toastUpFail:'Upload failed: %s', toastNeedAll:'Adapter name and code required',
toastDelOk:'Deleted', confirmDelSrc:'Delete source %s?', confirmDelAdp:'Delete adapter %s?',
u:'You:', a:'Assistant:', at:'Assistant [thinking]', aEmpty:'(empty)', aErr:'Request failed: %s',
cErr:'Error: %s', chatMeta:'Model=%s · %s ms · %d chars', connPinned:'# Pinned to model: %s (source %s)', connAuto:'# Model "AUTO" picks healthy source by priority',
}
};
let LANG = localStorage.getItem('llms-proxy.lang') || ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
function t(key){ return (STR[LANG] || STR.zh)[key] !== undefined ? (STR[LANG] || STR.zh)[key] : key; }
function tFmt(key){ const s = String(t(key)); const args = Array.prototype.slice.call(arguments, 1);
return s.replace(/%s|%d/g, (m) => { const v = args.shift(); return (v === undefined) ? (m === '%d' ? '0' : '') : v; }); }
function applyI18n() {
document.querySelectorAll('[data-i]').forEach(el => {
const s = t(el.dataset.i);
if (el.dataset.i === 'logout') el.textContent = s;
else el.textContent = s;
});
document.getElementById('btn-lang').textContent = LANG === 'zh' ? 'EN' : '中';
document.documentElement.lang = LANG;
}
let lastTab = 'status';
function activeTab(){ return lastTab; }
/* ---------- theme & header ---------- */
const THEME = localStorage.getItem('llms-proxy.theme') || 'light';
document.documentElement.dataset.theme = THEME;
document.getElementById('btn-theme').onclick = () => {
const nx = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = nx;
localStorage.setItem('llms-proxy.theme', nx);
};
document.getElementById('btn-lang').onclick = () => {
LANG = LANG === 'zh' ? 'en' : 'zh';
localStorage.setItem('llms-proxy.lang', LANG);
applyI18n(); refresh(lastTab);
};
document.getElementById('btn-logout').onclick = () => { location.href = '/api/logout'; };
document.querySelectorAll('nav button').forEach(b => {
b.onclick = () => {
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('active', x === b));
['status','chat','sources','adapters'].forEach(tn => $('#tab-' + tn).classList.toggle('hidden', tn !== b.dataset.tab));
lastTab = b.dataset.tab; refresh(lastTab);
};
});
applyI18n();
/* ---------- helpers ---------- */
const $ = s => document.querySelector(s);
const tab = () => document.querySelector('nav button.active').dataset.tab;
const api = (p, o) => fetch(p, o).then(async r => {
if (r.status === 401) { location.href = '/login?continue=' + encodeURIComponent(location.pathname); throw new Error('unauthorized'); }
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error((j.error && j.error.message) || r.statusText);
return j;
});
function toast(m) { const t = $('#toast'); t.textContent = m; t.style.display = 'block'; setTimeout(() => t.style.display = 'none', 3000); }
document.querySelectorAll('nav button').forEach(b => b.onclick = () => {
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('active', x === b));
['status','sources','adapters'].forEach(t => $('#tab-' + t).classList.toggle('hidden', t !== b.dataset.tab));
refresh(b.dataset.tab);
});
async function refresh(t) {
if (t === 'status') return renderStatus();
if (t === 'sources') return renderSources();
return renderAdapters();
let toastTimer;
function toast(m) { const el = $('#toast'); el.textContent = m; el.style.display = 'block';
clearTimeout(toastTimer); toastTimer = setTimeout(() => el.style.display = 'none', 2600); }
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function escAttr(s) { return esc(s).replace(/"/g, '&quot;'); }
function copyText(txt, okMsg) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(() => toast(okMsg || t('toastCopied')), () => toast(t('toastCopyFail')));
} else { toast(t('toastCopyFail')); }
}
/* ---------- status tab ---------- */
async function renderStatus() {
const s = await api('/api/status');
const src = s.sources.map(x => `<tr><td>${esc(x.name)}</td><td>${esc(x.adapter)}</td>
<td>${x.models.map(m => `<span class="tag tag-blue">${esc(m)}</span>`).join('')}</td>
<td>${x.available ? '<span class="tag tag-green">可用</span>' : '<span class="tag tag-red">退避/不可用</span>'}</td>
const base = window._base = s.base_url || location.origin + '/v1';
const key = window._key = (s.gateway_keys && s.gateway_keys[0]) || '';
const srcRows = s.sources.map(x =>
`<tr><td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
<td>${x.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join('')}</td>
<td><span class="muted">${esc(x.base_url || '')}</span></td>
<td>${x.available ? `<span class="tag tag-green">${t('online')}</span>` : `<span class="tag tag-red">${t('offline')}</span>`}</td>
<td>${x.max_concurrent}</td></tr>`).join('');
$('#tab-status').innerHTML = `
<div class="card"><h2>网关</h2>
<div class="muted">默认模型: ${esc(s.default_model)}</div>
<div class="muted">模型列表: ${s.models.map(esc).join(', ')}</div>
<div class="card"><h2>${t('connTitle')}</h2>
<div class="muted">${t('connHint')}</div>
<pre class="configbox" id="conncfg"></pre>
<p><button onclick="copyCfg()">${t('copyCfg')}</button>
<button class="ghost" onclick="copyEnv()">${t('copyEnv')}</button></p>
<label>${t('tModels')}</label>
<div class="chips" id="model-chips"></div>
</div>
<div class="card"><h2>源状态 (${s.sources.length})</h2>
<table><tr><th>名称</th><th>适配器</th><th>模型</th><th>健康</th><th>并发</th></tr>${src}</table>
<div class="card"><h2>${t('srcTitle')} (${s.sources.length})</h2>
<table><tr><th>${t('tName')}</th><th>${t('tAdapter')}</th><th>${t('tModels')}</th><th>${t('tURL')}</th><th>${t('tConn')}</th><th>${t('tConc')}</th></tr>${srcRows || `<tr><td colspan="6" class="empty">${t('srcEmpty')}</td></tr>`}</table>
</div>
<div class="card"><h2>已加载适配器 (${s.adapters.length})</h2>
<table><tr><th>名称</th><th>版本</th></tr>
${s.adapters.map(a => `<tr><td>${esc(a.name)}</td><td>${esc(a.version || '')}</td></tr>`).join('')}
</table></div>`;
<div class="card"><h2>${t('adTitle')} (${s.adapters.length})</h2>
<table><tr><th>${t('tName')}</th><th>${t('tVersion')}</th></tr>
${s.adapters.map(a => `<tr><td>${esc(a.name)}</td><td>${esc(a.version || '')}</td></tr>`).join('')}</table>
</div>`;
$('#conncfg').textContent = '';
showModelConfig(null, '');
$('#model-chips').innerHTML = s.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig(null,'${escAttr(m)}')">${esc(m)}</span>`).join('');
}
function showModelConfig(srcName, model) {
const el = $('#conncfg'); if (!el) return;
const base = window._base, key = window._key;
const m = model || 'AUTO';
const json = JSON.stringify({ base_url: base, api_key: key || 'YOUR_GATEWAY_KEY', model: m }, null, 2);
let head;
if (model) head = tFmt('connPinned', model, srcName || '');
else head = tFmt('connAuto');
el.textContent = head + '\n' + json;
window._lastCfg = {
json,
env: 'OPENAI_BASE_URL=' + base + '\nOPENAI_API_KEY=' + (key || 'YOUR_GATEWAY_KEY') + '\nOPENAI_MODEL=' + m,
};
}
function copyCfg() { copyText(window._lastCfg ? window._lastCfg.json : ''); }
function copyEnv() { copyText(window._lastCfg ? window._lastCfg.env : ''); }
/* ---------- chat tab ---------- */
let chatHistory = [];
let chatImages = [];
async function renderChat() {
const s = await api('/api/status');
const opts = ['AUTO', ...(s.models || [])].map(m => `<option value="${escAttr(m)}">${esc(m)}</option>`).join('');
$('#tab-chat').innerHTML = `
<div class="tab-chat">
<div class="chat-wrap">
<div class="chat-log" id="ct-log">
<div class="chat-empty" id="ct-empty">
<div class="ring">M</div>
<p>${esc(t('cWelcome'))}</p>
<p class="muted" style="font-size:12px">${esc(t('cStream'))}</p>
</div>
</div>
<div class="chat-composer">
<div class="chat-tools">
<span class="tl">${t('cModel')}</span>
<select id="ct-model">${opts}</select>
<span class="grow"></span>
<label class="chk"><input id="ct-thinking" type="checkbox" checked>${t('cThinking')}</label>
</div>
<div class="attbox" id="ct-imgs"></div>
<div class="chat-box">
<button class="attach-btn" id="ct-pin" title="${escAttr(t('cPick'))}" onclick="$('#ct-file').click()">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
</button>
<input id="ct-file" type="file" accept="image/*" multiple hidden onchange="addChatImgs(this.files)">
<textarea id="ct-msg" rows="1" placeholder="${escAttr(t('cMsg'))}"></textarea>
<button class="sendbtn" id="ct-send" onclick="sendChat()">${t('send')}</button>
</div>
</div>
</div>
</div>`;
chatHistory = [];
chatImages = [];
updateGrow();
$('#ct-msg').addEventListener('input', updateGrow);
$('#ct-msg').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); } });
$('#ct-imgs').style.display = 'none';
$('#ct-msg').focus();
}
function updateGrow() {
const ta = $('#ct-msg');
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 150) + 'px';
}
function addMsg(role, text, reason) {
const empty = $('#ct-empty'); if (empty) empty.remove();
const log = $('#ct-log');
const row = document.createElement('div');
row.className = 'msg ' + (role === 'user' ? 'usr' : 'ass');
const av = role === 'user' ? 'u' : 'a';
const label = role === 'user' ? '你' : 'AI';
let inner = `<div class="avatar ${av}">${label}</div>`;
if (role === 'user') {
inner += `<div class="bubble"><div class="bmd">&nbsp;</div></div>`;
} else {
inner += `<div class="bubble"><div class="bmd"></div>` +
(reason ? `<div class="think"><div class="tk-hint"></div></div>` : ``) + `</div>`;
}
row.innerHTML = inner;
log.appendChild(row);
scrollChat();
return row;
}
function scrollChat() { const log = $('#ct-log'); log.scrollTop = log.scrollHeight; }
function renderMD(el, text) {
// minimal markdown: code blocks, inline code, bold, italic, links, lists, headings, paragraphs
const blocks = text.split(/\n{2,}/);
const out = [];
let inCode = false;
let codeBuf = [];
for (const b of blocks) {
const cm = b.match(/^```([\w-]*)\r?\n([\s\S]*?)(?:```|$)/);
if (cm) {
out.push(`<pre><code>${esc(cm[2].replace(/\n$/, ''))}</code></pre>`);
continue;
}
if (b.trim().startsWith('```')) { inCode = !inCode; codeBuf.push(b); continue; }
if (inCode) { codeBuf.push(b); continue; }
if (codeBuf.length) {
out.push(`<pre><code>${esc(codeBuf.join('\n').replace(/^```.*\r?\n?/, '').replace(/\n?```$/, ''))}</code></pre>`);
inCode = false; codeBuf = [];
}
let line = b.trim();
if (!line) continue;
let html = esc(line);
// headings
html = html.replace(/^(#{1,4})\s+(.+)$/gm, (_, h, t) => `<h${h.length}>${t}</h${h.length}>`);
// bullets / lists
html = html.replace(/^(\s*)[-*]\s+(.+)$/gm, (_, ind, t) => `${ind}<li>${t}</li>`);
html = html.replace(/(?:^|\n)<li>/g, '<li>');
out.push(`<p>${linksAndBold(html)}</p>`);
}
if (codeBuf.length) {
out.push(`<pre><code>${esc(codeBuf.join('\n').replace(/^```.*\n?/, '').replace(/\n?```$/, ''))}</code></pre>`);
}
el.innerHTML = out.join('');
}
function linksAndBold(s) {
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
s = s.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
s = s.replace(/https?:\/\/[^\s<)]+/g, '<a href="$&" target="_blank" rel="noopener">$&</a>');
return s;
}
async function sendChat() {
const model = $('#ct-model').value || 'AUTO';
const msgTa = $('#ct-msg');
const msg = msgTa.value.trim();
if (!msg && chatImages.length === 0) return toast(t('toastEmpty'));
let content = msg;
if (chatImages.length > 0) {
content = [{ type: 'text', text: msg }];
chatImages.forEach(img => content.push({ type: 'image_url', image_url: { url: img } }));
}
chatHistory.push({ role: 'user', content });
msgTa.value = '';
msgTa.style.height = 'auto';
clearChatImg();
const urow = addMsg('user', msg);
const uimgBox = urow.querySelector('.bmd');
uimgBox.innerHTML = '';
if (Array.isArray(content)) {
const tx = content.filter(p => p.type === 'text').map(p => p.text).join(' ');
uimgBox.innerHTML = esc(tx);
content.filter(p => p.type === 'image_url').forEach(p => {
const img = document.createElement('img');
img.src = p.image_url.url; img.style.maxWidth = '120px'; img.style.borderRadius = '8px';
img.style.display = 'block'; img.style.marginTop = '6px';
uimgBox.appendChild(img);
});
} else {
uimgBox.textContent = msg;
}
const arow = addMsg('assistant', '');
const abuf = arow.querySelector('.bmd');
const thinkEl = arow.querySelector('.think');
const hintEl = arow.querySelector('.tk-hint');
const cursor = document.createElement('span'); cursor.className = 'cursor-dot';
abuf.appendChild(cursor);
const sendBtn = $('#ct-send');
sendBtn.disabled = true;
const started = Date.now(); let text = '', thought = '', firstChunk = Date.now();
try {
const resp = await fetch('/api/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, stream: true, disable_thinking: !$('#ct-thinking').checked, messages: chatHistory })});
if (resp.status === 401) { location.href = '/login'; return; }
if (!resp.ok) {
const j = await resp.json().catch(() => ({}));
cursor.remove();
abuf.innerHTML = esc(tFmt('cErr', j.error.message || resp.statusText || ''));
return;
}
const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf = '';
let firstDelta = true;
while (true) {
const { done, value } = await reader.read(); if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const ev = buf.slice(0, i); buf = buf.slice(i + 2);
const line = ev.split('\n').find(l => l.startsWith('data:'));
if (!line) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') continue;
const ck = JSON.parse(data);
const delta = (ck.choices && ck.choices[0] && ck.choices[0].delta) || {};
if (firstDelta) { firstDelta = false; firstChunk = Date.now(); }
if (delta.reasoning_content) {
thought += delta.reasoning_content;
hintEl.textContent = t('at') + ' · ' + Math.round(thought.length / 3) + '…';
thinkEl.style.display = 'block';
thinkEl.textContent = thought.trim();
scrollChat();
}
if (delta.content) {
text += delta.content;
cursor.remove();
renderMD(abuf, text);
scrollChat();
}
}
}
cursor.remove();
const ms = Date.now() - started;
if (text) {
renderMD(abuf, text);
chatHistory.push({ role: 'assistant', content: text });
} else {
abuf.textContent = (thought ? '(仅思考) ' : '') + t('aEmpty');
}
const meta = document.createElement('div'); meta.className = 'muted';
meta.textContent = tFmt('chatMeta', model, ms, text.length);
abuf.appendChild(meta);
} catch (e) {
cursor.remove();
abuf.textContent = esc(tFmt('aErr', e.message));
} finally {
sendBtn.disabled = false;
}
}
function clearChat() { chatHistory = []; chatImages = []; const log = $('#ct-log'); if (log) log.innerHTML = ''; clearChatImg(); }
function clearChatImg() {
chatImages = [];
const box = $('#ct-imgs');
if (box) { box.innerHTML = ''; box.style.display = 'none'; }
}
function renderChatImgs() {
const box = $('#ct-imgs'); if (!box) return;
box.style.display = chatImages.length ? 'flex' : 'none';
box.innerHTML = chatImages.map((src, i) =>
`<span class="att"><img src="${escAttr(src)}"><button class="ghost small" title="${escAttr(t('cRemoveImg'))}" onclick="removeChatImg(${i})">&times;</button></span>`).join('');
}
function addChatImgs(files) {
for (const f of files) {
const rd = new FileReader();
rd.onload = () => { chatImages.push(rd.result); renderChatImgs(); };
rd.readAsDataURL(f);
}
const inp = $('#ct-file'); if (inp) inp.value = '';
}
function removeChatImg(i) { chatImages.splice(i, 1); renderChatImgs(); }
/* ---------- sources tab ---------- */
async function renderSources() {
const j = await api('/api/sources');
const rows = j.sources.map(s => `<tr><td>${esc(s.name)}</td><td>${esc(s.base_url)}</td><td>${esc(s.adapter)}</td>
<td>${s.models.map(m => `<span class="tag tag-blue">${esc(m.id)}<span class="muted">·${m.priority||0}</span></span>`).join('')}</td>
<td><button class="ghost" onclick="editSource(${JSON.stringify(s.name).replace(/"/g,'&quot;')})">编辑</button>
<button class="danger" onclick="delSource('${escAttr(s.name)}')">删除</button></td></tr>`).join('');
const rows = j.sources.map(s => `<tr><td><b>${esc(s.name)}</b></td><td>${esc(s.base_url)}</td><td>${esc(s.adapter)}</td>
<td>${s.models.map(m => `<span class="tag tag-blue">${esc(m.id)}<span class="muted"> ·${m.priority||0}</span></span>`).join('')}</td>
<td><button class="ghost small" onclick="editSource(${JSON.stringify(s.name).replace(/"/g,'&quot;')})">${t('srcEdit')}</button>
<button class="danger small" onclick="delSource('${escAttr(s.name)}')">${t('srcDel')}</button></td></tr>`).join('');
$('#tab-sources').innerHTML = `
<div class="card"><h2>源 (${j.sources.length})</h2>
<table><tr><th>名称</th><th>地址</th><th>适配器</th><th>模型 (优先级)</th><th></th></tr>${rows}</table>
<p><button onclick="editSource('')">+ 新增源</button></p>
<div class="card"><h2><span>${t('srcTitle')}</span><span class="grow"></span>
<button class="small" onclick="editSource('')">${t('srcAdd')}</button></h2>
<table><tr><th>${t('tName')}</th><th>${t('tURL')}</th><th>${t('tAdapter')}</th><th>${t('tModels')}</th><th></th></tr>
${rows || `<tr><td colspan="5" class="empty">${t('srcEmpty')}</td></tr>`}</table>
</div>`;
}
async function renderAdapters() {
const j = await api('/api/status');
const rows = j.adapters.map(a => `<tr><td>${esc(a.name)}</td><td>${esc(a.version || '')}</td>
<td><button class="danger" onclick="delAdapter('${escAttr(a.name)}')">删除</button></td></tr>`).join('');
$('#tab-adapters').innerHTML = `
<div class="card"><h2>已加载适配器 (${j.adapters.length})</h2>
<table><tr><th>名称</th><th>版本</th><th></th></tr>${rows}</table>
</div>
<div class="card"><h2>上传 Lua 适配器</h2>
<label>名称(脚本保存为 <code>&lt;name&gt;.lua</code></label>
<input id="adp-name" placeholder="如 mysrc">
<label>Lua 脚本(返回 adapter table支持 transform_request/response/stream_chunk/build_headers</label>
<textarea id="adp-code" spellcheck="false" placeholder="return { name='mysrc', endpoint='/chat/completions', transform_request=function(raw) return raw end, transform_response=function(raw) return raw end }"></textarea>
<p><button onclick="uploadAdapter()">上传并加载</button></p>
</div>`;
}
function editSource(name) {
const modal = document.createElement('div');
const existing = name ? null : null;
// We'll re-fetch and prefill
api('/api/sources').then(j => {
const s = j.sources.find(x => x.name === name) || { name: name, models: [{ id: '', priority: 0, kind: 'chat' }] };
const modelInputs = (s.models || []).map((m, i) => modelRow(m, i)).join('');
modal.innerHTML = `<div class="card"><h2>${name ? '编辑源: ' + esc(name) : '新增源'}</h2>
<label>名称</label><input id="s-name" value="${escAttr(s.name)}" ${name ? 'disabled' : ''}>
<label>Base URL</label><input id="s-url" value="${escAttr(s.base_url || '')}">
<label>API Key</label><input id="s-key" type="password" value="${escAttr(s.api_key || '')}">
<label>适配器(对应已加载的 Lua 适配器名)</label><input id="s-adapter" value="${escAttr(s.adapter || 'openai')}">
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
Promise.all([api('/api/sources'), api('/api/status')]).then(([src, st]) => {
const s = src.sources.find(x => x.name === name) || { name: name, models: [{ id: '', priority: 0, kind: 'chat' }] };
const cur = s.adapter || 'openai';
const apps = ['', ...(st.adapters || []).map(a => a.name)];
if (cur && !apps.includes(cur)) apps.push(cur);
const adSel = `<select id="s-adapter"><option value="" ${!cur ? 'selected' : ''}>${esc(t('mAliasAuto'))}</option>` +
apps.filter(n => n).map(n => `<option value="${escAttr(n)}" ${n === cur ? 'selected' : ''}>${esc(n)}</option>`).join('') + '</select>';
wrap.innerHTML = `<div class="card"><h2>${esc(name ? t('modalEdit') + ': ' + name : t('modalNew'))}</h2>
<div class="row">
<div><label>聊天端点 (可选覆盖)</label><input id="s-ep" value="${escAttr(s.endpoint || '')}"></div>
<div><label>生图端点 (可选覆盖)</label><input id="s-img" value="${escAttr(s.image_endpoint || '')}"></div>
<div><label>${t('mName')}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? 'disabled' : ''}></div>
<div><label>${t('mAlias')}</label>${adSel}</div>
</div>
<label>${t('mURL')}</label><input id="s-url" value="${escAttr(s.base_url || '')}">
<label>${t('mKey')}</label><input id="s-key" type="password" value="${escAttr(s.api_key || '')}">
<div class="row">
<div><label>${t('mEp')}</label><input id="s-ep" value="${escAttr(s.endpoint || '')}"></div>
<div><label>${t('mImgEp')}</label><input id="s-img" value="${escAttr(s.image_endpoint || '')}"></div>
</div>
<div class="row">
<div><label>并发上限</label><input id="s-conc" type="number" value="${s.max_concurrent || 8}"></div>
<div><label>温度</label><input id="s-temp" type="number" step="0.1" value="${s.temperature || 0.7}"></div>
<div><label>${t('mConc')}</label><input id="s-conc" type="number" value="${s.max_concurrent || 8}"></div>
<div><label>${t('mTemp')}</label><input id="s-temp" type="number" step="0.1" value="${s.temperature ?? 0.7}"></div>
</div>
<label>模型列表(优先级数字越大越优先被 AUTO 选中)</label>
<div id="s-models">${modelInputs}</div>
<button class="ghost" onclick="addModelRow()">+ 模型</button>
<label>Meta透传给 build_headers 钩子JSON</label>
<label>${t('mModels')}</label>
<div id="s-models"></div>
<button class="ghost small" onclick="addModelRow()">${t('mAddModel')}</button>
<label>${t('mMeta')}</label>
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
<p><button onclick="saveSource(this)">保存</button> <button class="ghost" onclick="modal.remove()">取消</button></p>
<p><button onclick="saveSource(this)">${t('mSave')}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
</div>`;
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:40px 20px;z-index:50';
modal.id = 'modal';
document.body.appendChild(modal);
window._modal = modal;
window._models = s.models || [];
});
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
document.body.appendChild(wrap);
const box = $('#s-models');
(s.models && s.models.length ? s.models : [{ id: '', priority: 0, kind: 'chat' }]).forEach((m, i) => box.insertAdjacentHTML('beforeend', modelRow(m, i)));
}).catch(() => {});
}
function modelRow(m, i) {
return `<div class="model-row">
<input data-mi="${i}" class="m-id" placeholder="模型 id如 deepseek-v4-flash" value="${escAttr(m.id)}">
<input data-mi="${i}" class="m-prio" type="number" placeholder="优先级" value="${m.priority || 0}" style="width:90px">
<input data-mi="${i}" class="m-id" placeholder="model-id" value="${escAttr(m.id)}">
<input data-mi="${i}" class="m-prio" type="number" placeholder="priority" value="${m.priority || 0}" style="width:90px">
<select data-mi="${i}" class="m-kind"><option ${(m.kind==='image')?'':'selected'} value="chat">chat</option><option ${(m.kind==='image')?'selected':''} value="image">image</option></select>
<button class="ghost del" data-mi="${i}" onclick="this.closest('.model-row').remove()">×</button>
<button class="ghost del small" onclick="this.closest('.model-row').remove()">&times;</button>
</div>`;
}
function addModelRow() {
const div = $('#s-models');
div.insertAdjacentHTML('beforeend', modelRow({ id: '', priority: 0, kind: 'chat' }, div.children.length));
}
async function saveSource(btn) {
const models = [...document.querySelectorAll('#s-models .model-row')].map(row => ({
id: row.querySelector('.m-id').value.trim(),
@ -186,55 +656,92 @@ async function saveSource(btn) {
kind: row.querySelector('.m-kind').value,
})).filter(m => m.id);
let meta = {};
try { meta = JSON.parse($('#s-meta').value || '{}'); } catch (e) { toast('Meta 不是合法 JSON'); return; }
try { meta = JSON.parse($('#s-meta').value || '{}'); } catch (e) { toast(t('toastBadJson')); return; }
const payload = {
name: $('#s-name').value.trim(),
base_url: $('#s-url').value.trim(),
api_key: $('#s-key').value.trim(),
adapter: $('#s-adapter').value.trim(),
endpoint: $('#s-ep').value.trim(),
image_endpoint: $('#s-img').value.trim(),
max_concurrent: parseInt($('#s-conc').value) || 8,
temperature: parseFloat($('#s-temp').value) || 0,
name: $('#s-name').value.trim(), base_url: $('#s-url').value.trim(),
api_key: $('#s-key').value.trim(), adapter: $('#s-adapter').value.trim(),
endpoint: $('#s-ep').value.trim(), image_endpoint: $('#s-img').value.trim(),
max_concurrent: parseInt($('#s-conc').value) || 8, temperature: parseFloat($('#s-temp').value) || 0,
models, meta,
};
btn.disabled = true;
try {
await api('/api/sources', { method: 'POST', body: JSON.stringify(payload) });
toast('已保存并热重载');
window._modal && window._modal.remove();
toast(t('toastSaved'));
const w = $('#modal-wrap'); if (w) w.remove(); else if (window._modal) window._modal.remove();
renderSources();
} catch (e) { toast('保存失败: ' + e.message); btn.disabled = false; }
} catch (e) { toast(tFmt('toastSaveFail', e.message)); btn.disabled = false; }
}
async function delSource(name) {
if (!confirm('删除源 ' + name + '?')) return;
if (!confirm(tFmt('confirmDelSrc', name))) return;
await api('/api/sources/' + encodeURIComponent(name), { method: 'DELETE' });
toast('已删除');
renderSources();
toast(t('toastDelOk')); renderSources();
}
/* ---------- adapters tab ---------- */
async function renderAdapters() {
const j = await api('/api/status');
const rows = j.adapters.map(a => `<tr><td><b>${esc(a.name)}</b></td><td>${esc(a.version || '')}</td>
<td><button class="danger small" onclick="delAdapter('${escAttr(a.name)}')">${t('srcDel')}</button></td></tr>`).join('');
$('#tab-adapters').innerHTML = `
<div class="card"><h2>${t('adTitle')} (${j.adapters.length})</h2>
<table><tr><th>${t('tName')}</th><th>${t('tVersion')}</th><th></th></tr>
${rows || `<tr><td colspan="3" class="empty">${t('adEmpty')}</td></tr>`}</table>
</div>
<div class="card"><h2>${t('uploadTitle')}</h2>
<div class="dropzone" id="dz">${t('dropHint')}</div>
<input type="file" id="adp-file" accept=".lua,text/x-lua" class="hidden">
<label>${t('adName')}</label><input id="adp-name" placeholder="mysrc">
<label>${t('tbLua')}</label>
<textarea id="adp-code" spellcheck="false" placeholder="return { name='mysrc', endpoint='/chat/completions', transform_request=function(raw) return raw end, transform_response=function(raw) return raw end }"></textarea>
<p><button onclick="uploadAdapter()">${t('uploadBtn')}</button></p>
</div>`;
bindDropzone();
}
function bindDropzone() {
const dz = $('#dz'), file = $('#adp-file'), name = $('#adp-name'), code = $('#adp-code');
['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('dragover'); }));
['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('dragover'); }));
dz.addEventListener('drop', e => {
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) loadFile(f);
});
dz.onclick = () => file.click();
file.onchange = () => { if (file.files[0]) { loadFile(file.files[0]); file.value = ''; } };
function loadFile(f) {
if (!f.name.toLowerCase().endsWith('.lua')) { toast(tFmt('toastUpFail', 'not a .lua')); return; }
const reader = new FileReader();
reader.onload = () => {
name.value = f.name.replace(/\.lua$/i, '');
code.value = String(reader.result || '');
toast(f.name);
};
reader.readAsText(f);
}
}
async function uploadAdapter() {
const name = $('#adp-name').value.trim();
const code = $('#adp-code').value;
if (!name || !code) return toast('需要名称和脚本');
const name = $('#adp-name').value.trim(), code = $('#adp-code').value;
if (!name || !code) return toast(t('toastNeedAll'));
try {
await api('/api/adapters', { method: 'POST', body: JSON.stringify({ name, code }) });
toast('适配器已加载');
toast(t('toastUploaded'));
$('#adp-name').value = ''; $('#adp-code').value = '';
renderAdapters();
} catch (e) { toast('上传失败: ' + e.message); }
} catch (e) { toast(tFmt('toastUpFail', e.message)); }
}
async function delAdapter(name) {
if (!confirm('删除适配器 ' + name + '?')) return;
if (!confirm(tFmt('confirmDelAdp', name))) return;
await api('/api/adapters/' + encodeURIComponent(name), { method: 'DELETE' });
toast('已删除');
renderAdapters();
toast(t('toastDelOk')); renderAdapters();
}
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function escAttr(s) { return esc(s).replace(/"/g, '&quot;'); }
/* ---------- boot ---------- */
function refresh(tab) {
if (tab === 'status') return renderStatus();
if (tab === 'chat') return renderChat();
if (tab === 'sources') return renderSources();
return renderAdapters();
}
refresh('status');
</script>
</body>

View File

@ -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

View File

@ -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 {}

View File

@ -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

View File

@ -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

View File

@ -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,149 +19,170 @@ 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
}
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
}))
// 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
}
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))
}
// 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
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
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)
}
if n := len(p.idle); n > 0 {
w := p.idle[n-1]
p.idle = p.idle[:n-1]
p.mu.Unlock()
return w, nil
}
if p.created < p.target {
p.created++
break
}
p.cond.Wait()
}
p.mu.Unlock()
w, err := p.boot()
if err != nil {
p.mu.Lock()
p.created--
p.cond.Signal()
p.mu.Unlock()
return nil, err
}
return w, nil
}
func (p *adapterPool) release(w *worker) {
w.L.SetTop(0)
p.mu.Lock()
if p.closed {
p.mu.Unlock()
w.close()
return
}
p.idle = append(p.idle, w)
p.cond.Signal()
p.mu.Unlock()
}
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()
}
}
// VM aggregates per-adapter worker pools. All exported methods are safe for
// concurrent use.
type VM struct {
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 v.dir == "" {
return nil
}
if err := os.MkdirAll(v.dir, 0755); err != nil {
return fmt.Errorf("mkdir adapter dir: %w", err)
}
@ -170,31 +197,100 @@ func (v *VM) Start() error {
if filepath.Ext(e.Name()) != ".lua" {
continue
}
if err := v.cache.Preload(filepath.Join(v.dir, e.Name())); err != nil {
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() })
w, err := p.acquire()
if err != nil {
return nil, err
}
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)
}
return headers, nil
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
}
state.Push(fn)
state.Push(goValueToLua(state, meta))
if err := state.PCall(1, 1, nil); err != nil {
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)
if L.Type(-1) != golua.LUA_TTABLE {
return nil, fmt.Errorf("build_headers returned non-table")
}
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()
}
})
}
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()
}
}
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
}
}

View File

@ -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)
}
}

View File

@ -145,6 +145,7 @@ func (r *Registry) Default() *Provider {
type SourceStatus struct {
Name string `json:"name"`
Adapter string `json:"adapter"`
BaseURL string `json:"base_url"`
Models []string `json:"models"`
Available bool `json:"available"`
Healthy bool `json:"healthy"`
@ -159,6 +160,7 @@ func (r *Registry) Status() []SourceStatus {
s := SourceStatus{
Name: p.Name(),
Adapter: p.Adapter(),
BaseURL: p.Config().BaseURL,
Models: p.Models(),
Available: p.Available(),
Healthy: p.Available(),