feat: ModelRouter — unified OpenAI-compatible multi-source LLM gateway

- Lua adapters per upstream (transform_request/response/stream_chunk, build_headers signing hooks)
- AUTO priority routing with per-model kind (chat/image), explicit source/model routing
- Per-source concurrency caps with queueing, exponential backoff, AUTO failover
- OpenAI-compatible API: chat completions, SSE streaming, image generations, models
- Gateway key auth, web UI for adapter/source management, runtime persistence
- e2e test running the real binary against mocked upstreams
This commit is contained in:
root
2026-08-05 15:24:51 +08:00
parent 8631b08253
commit f7f76e097d
32 changed files with 4382 additions and 2 deletions

116
internal/gateway/api.go Normal file
View File

@ -0,0 +1,116 @@
package gateway
import (
"encoding/json"
"io"
"net/http"
"strings"
"llmsproxy/internal/config"
)
type adapterPayload struct {
Name string `json:"name"`
Code string `json:"code"`
}
func (g *Gateway) handleAdaptersAPI(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/adapters")
path = strings.Trim(path, "/")
switch r.Method {
case http.MethodGet:
if path == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{"adapters": g.core.ListAdapters()})
return
}
writeError(w, http.StatusNotFound, "not_found", "adapter code not exposed; edit in UI")
case http.MethodPost:
var p adapterPayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if err := g.core.UploadAdapter(p.Name, p.Code); err != nil {
writeError(w, http.StatusBadRequest, "adapter_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": p.Name})
case http.MethodDelete:
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "adapter name required")
return
}
if err := g.core.RemoveAdapter(path); err != nil {
writeError(w, http.StatusBadRequest, "adapter_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
// sourcePayload mirrors config.Source for JSON web UI editing.
type sourcePayload struct {
Name string `json:"name"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Adapter string `json:"adapter"`
Endpoint string `json:"endpoint"`
ImageEndpoint string `json:"image_endpoint"`
Models []config.Model `json:"models"`
Headers map[string]string `json:"headers"`
Meta map[string]interface{} `json:"meta"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
MaxConcurrent int `json:"max_concurrent"`
}
func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/sources")
path = strings.Trim(path, "/")
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{"sources": g.core.Sources()})
case http.MethodPost:
var p sourcePayload
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &p); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
src := config.Source{
Name: p.Name,
BaseURL: p.BaseURL,
APIKey: p.APIKey,
Adapter: p.Adapter,
Endpoint: p.Endpoint,
ImageEndpoint: p.ImageEndpoint,
Models: p.Models,
Headers: p.Headers,
Meta: p.Meta,
Temperature: p.Temperature,
MaxTokens: p.MaxTokens,
MaxConcurrent: p.MaxConcurrent,
}
if err := g.core.AddSource(src); err != nil {
writeError(w, http.StatusBadRequest, "source_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
case http.MethodDelete:
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "source name required")
return
}
if err := g.core.RemoveSource(path); err != nil {
writeError(w, http.StatusBadRequest, "source_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}

283
internal/gateway/chat.go Normal file
View File

@ -0,0 +1,283 @@
package gateway
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync/atomic"
"time"
"llmsproxy/internal/provider"
"llmsproxy/internal/scheduler"
"llmsproxy/internal/types"
)
// chatRequest mirrors the OpenAI chat completions request the gateway accepts.
type chatRequest struct {
Model string `json:"model"`
Messages []types.ChatMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
}
// ChatCompletion is the non-streaming OpenAI response object.
type ChatCompletion struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChatChoice `json:"choices"`
Usage *types.TokenUsage `json:"usage,omitempty"`
}
type ChatChoice struct {
Index int `json:"index"`
Message RespMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type RespMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []types.ToolCall `json:"tool_calls,omitempty"`
}
type ChatChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChunkChoice `json:"choices"`
}
type ChunkChoice struct {
Index int `json:"index"`
Delta RespMessage `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
var seq int64
func newID() string {
n := atomic.AddInt64(&seq, 1)
return fmt.Sprintf("chatcmpl-%d", n)
}
func isAuto(m string) bool {
m = strings.TrimSpace(m)
return m == "" || strings.EqualFold(m, "AUTO")
}
// resolveCands picks the ordered candidate providers for a requested model.
func (g *Gateway) resolveCands(model string) ([]*provider.Provider, string) {
if model == "" || isAuto(model) {
return g.core.Registry().Resolve("AUTO"), ""
}
return g.core.Registry().Resolve(model), model
}
func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST")
return
}
var req chatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if len(req.Messages) == 0 {
writeError(w, http.StatusBadRequest, "invalid_request", "messages is required")
return
}
model := req.Model
if model == "" {
model = g.core.DefaultModel()
}
cands, effective := g.resolveCands(model)
if len(cands) == 0 {
writeError(w, http.StatusServiceUnavailable, "no_provider", "no LLM source configured")
return
}
if effective == "" {
effective = firstModel(cands[0])
}
ctx := r.Context()
inner := &types.ChatRequest{
Model: normalizeModel(model),
Messages: req.Messages,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
Stream: req.Stream,
Tools: req.Tools,
ToolChoice: req.ToolChoice,
}
if req.Stream {
g.streamChat(w, ctx, cands, inner, effective)
return
}
g.singleChat(w, ctx, cands, inner, effective)
}
func normalizeModel(m string) string {
if isAuto(m) {
return ""
}
return m
}
func firstModel(p *provider.Provider) string {
ms := p.Models()
if len(ms) > 0 {
return ms[0]
}
return "auto"
}
// imageOnly keeps providers exposing at least one image-kind model.
func imageOnly(cands []*provider.Provider) []*provider.Provider {
var out []*provider.Provider
for _, p := range cands {
for _, id := range p.Models() {
if m := p.ModelByID(id); m != nil && m.Kind == "image" {
out = append(out, p)
break
}
}
}
return out
}
func (g *Gateway) singleChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string) {
resp, err := g.core.Scheduler().Chat(ctx, scheduler.FromRegistry(cands), req)
if err != nil {
writeError(w, http.StatusBadGateway, "upstream_error", err.Error())
return
}
msg := RespMessage{Role: "assistant", Content: resp.Content}
if resp.ReasoningContent != "" {
msg.ReasoningContent = resp.ReasoningContent
}
if len(resp.ToolCalls) > 0 {
msg.ToolCalls = resp.ToolCalls
}
out := ChatCompletion{
ID: newID(),
Object: "chat.completion",
Created: time.Now().Unix(),
Model: effective,
Choices: []ChatChoice{{Index: 0, Message: msg, FinishReason: resp.FinishReason}},
}
if resp.TokenUsage.Total > 0 || resp.TokenUsage.Prompt > 0 || resp.TokenUsage.Completion > 0 {
out.Usage = &resp.TokenUsage
}
writeJSON(w, http.StatusOK, out)
}
func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands []*provider.Provider, req *types.ChatRequest, effective string) {
chunks, err := g.core.Scheduler().ChatStream(ctx, scheduler.FromRegistry(cands), req)
if err != nil {
writeError(w, http.StatusBadGateway, "upstream_error", err.Error())
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
id := newID()
created := time.Now().Unix()
send := func(obj interface{}) bool {
b, err := json.Marshal(obj)
if err != nil {
return false
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
return false
}
if flusher != nil {
flusher.Flush()
}
return true
}
if !send(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: effective,
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{Role: "assistant"}}},
}) {
return
}
for ck := range chunks {
chunk := ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: effective,
}
delta := RespMessage{Content: ck.Content}
if ck.ReasoningContent != "" {
delta.ReasoningContent = ck.ReasoningContent
}
if len(ck.ToolCalls) > 0 {
delta.ToolCalls = ck.ToolCalls
}
choice := ChunkChoice{Index: 0, Delta: delta}
if ck.Done {
stop := "stop"
choice.FinishReason = &stop
}
chunk.Choices = []ChunkChoice{choice}
if !send(chunk) {
return
}
}
stop := "stop"
send(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: effective,
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &stop}},
})
fmt.Fprintf(w, "data: [DONE]\n\n")
if flusher != nil {
flusher.Flush()
}
}
func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use POST")
return
}
var req types.ImageGenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if req.Prompt == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "prompt is required")
return
}
model := req.Model
if model == "" {
model = g.core.DefaultModel()
}
cands, _ := g.resolveCands(model)
cands = imageOnly(cands)
if len(cands) == 0 {
writeError(w, http.StatusServiceUnavailable, "no_provider", "no image source configured")
return
}
resp, err := g.core.Scheduler().Image(r.Context(), scheduler.FromRegistry(cands), &req)
if err != nil {
writeError(w, http.StatusBadGateway, "upstream_error", err.Error())
return
}
writeJSON(w, http.StatusOK, types.ImageGenResponse{
Created: time.Now().Unix(),
Data: resp.ImageData,
})
}

View File

@ -0,0 +1,306 @@
package gateway
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/config"
"llmsproxy/internal/core"
)
func mockUpstream() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
_ = json.Unmarshal(body, &req)
if stream, _ := req["stream"].(bool); stream {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"Hel"}}]}`)
fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"lo"}}]}`)
fmt.Fprintln(w, `data: {"choices":[{"delta":{},"finish_reason":"stop"}]}`)
fmt.Fprintln(w, "data: [DONE]")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
fmt.Fprintf(w, `{"choices":[{"message":{"content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
}))
}
func newTestGateway(t *testing.T, srcs ...config.Source) *Gateway {
t.Helper()
cfg := &config.Config{
AdapterDir: filepath.Join(t.TempDir(), "adapters"),
RuntimeFile: filepath.Join(t.TempDir(), "runtime.json"),
Sources: srcs,
}
if err := cfg.ApplyDefaults(); err != nil {
t.Fatal(err)
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatalf("core: %v", err)
}
t.Cleanup(c.Close)
g, err := New(c, []string{"sk-test"})
if err != nil {
t.Fatalf("gateway: %v", err)
}
return g
}
func doReq(t *testing.T, g *Gateway, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
req, _ := http.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer sk-test")
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rr := httptest.NewRecorder()
g.Handler().ServeHTTP(rr, req)
return rr
}
func TestChatSingle(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", "/v1/chat/completions",
`{"model":"mock-model","messages":[{"role":"user","content":"hi"}]}`)
if rr.Code != 200 {
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
}
var cc ChatCompletion
if err := json.Unmarshal(rr.Body.Bytes(), &cc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cc.Choices[0].Message.Content != "pong" {
t.Fatalf("content = %q", cc.Choices[0].Message.Content)
}
if cc.Usage == nil || cc.Usage.Total != 4 {
t.Fatalf("usage = %+v", cc.Usage)
}
if cc.Model != "mock-model" {
t.Fatalf("model = %q", cc.Model)
}
}
func TestChatAUTO(t *testing.T) {
up := mockUpstream()
defer up.Close()
g := newTestGateway(t,
config.Source{Name: "low", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "low-m", Priority: 10}}},
config.Source{Name: "high", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "high-m", Priority: 100}}},
)
// no model -> AUTO -> picks the highest priority source
rr := doReq(t, g, "POST", "/v1/chat/completions",
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
if rr.Code != 200 {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var cc ChatCompletion
_ = json.Unmarshal(rr.Body.Bytes(), &cc)
if cc.Model != "high-m" {
t.Fatalf("AUTO picked %q, want high-m", cc.Model)
}
}
func TestChatAuthRequired(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"}}})
req, _ := http.NewRequest("POST", "/v1/chat/completions",
strings.NewReader(`{"messages":[{"role":"user","content":"hi"}]}`))
rr := httptest.NewRecorder()
g.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}
func TestChatStream(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", "/v1/chat/completions",
`{"model":"mock-model","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
body := rr.Body.String()
if !strings.Contains(body, "data: [DONE]") {
t.Fatalf("missing DONE, body=%s", body)
}
if !strings.Contains(body, "Hel") || !strings.Contains(body, "lo") {
t.Fatalf("missing content chunks, body=%s", body)
}
}
func TestImageGeneration(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"created":1,"data":[{"b64_json":"QUJD"}]}`)
}))
defer up.Close()
g := newTestGateway(t, config.Source{Name: "img", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "flux-1", Kind: "image"}}})
rr := doReq(t, g, "POST", "/v1/images/generations",
`{"model":"flux-1","prompt":"a cat"}`)
if rr.Code != 200 {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var out map[string]interface{}
_ = json.Unmarshal(rr.Body.Bytes(), &out)
data, _ := out["data"].([]interface{})
if len(data) != 1 {
t.Fatalf("image data len = %d", len(data))
}
}
func TestImageAutoFallsOnlyToImageProviders(t *testing.T) {
imageHits := 0
chatHits := 0
img := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
imageHits++
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"created":1,"data":[{"b64_json":"QUJD"}]}`)
}))
defer img.Close()
chatUp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
chatHits++
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]}`)
}))
defer chatUp.Close()
g := newTestGateway(t,
config.Source{Name: "chat", BaseURL: chatUp.URL, Adapter: "openai", Models: []config.Model{{ID: "chat-m", Priority: 100}}},
config.Source{Name: "img", BaseURL: img.URL, Adapter: "openai", Models: []config.Model{{ID: "flux", Kind: "image", Priority: 1}}},
)
rr := doReq(t, g, "POST", "/v1/images/generations",
`{"model":"AUTO","prompt":"a cat"}`)
if rr.Code != 200 {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
if chatHits != 0 {
t.Fatalf("image AUTO hit chat-only provider: %d chat hits", chatHits)
}
if imageHits == 0 {
t.Fatalf("image AUTO did not hit image provider")
}
}
func TestKimicodeSigning(t *testing.T) {
var gotAuth, gotSign string
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.ReadAll(r.Body)
gotAuth = r.Header.Get("Authorization")
gotSign = r.Header.Get("X-App-Sign")
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`)
}))
defer up.Close()
g := newTestGateway(t, config.Source{
Name: "kimi",
BaseURL: up.URL,
Adapter: "kimicode",
APIKey: "sk-kimi",
Models: []config.Model{{ID: "kimi-k2"}},
Meta: map[string]interface{}{"app_id": "app-1", "app_secret": "s3cr3t", "app_agent": "code-agent", "api_key": "sk-kimi"},
})
rr := doReq(t, g, "POST", "/v1/chat/completions",
`{"model":"kimi-k2","messages":[{"role":"user","content":"hi"}]}`)
if rr.Code != 200 {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
if gotAuth == "" || !strings.Contains(gotAuth, "sk-kimi") {
t.Fatalf("expected signed auth, got %q", gotAuth)
}
if gotSign == "" {
t.Fatalf("expected app signature header")
}
}
func TestModelRoutingPrefix(t *testing.T) {
up := mockUpstream()
defer up.Close()
g := newTestGateway(t,
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "model-a"}}},
config.Source{Name: "b", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "model-b"}}},
)
rr := doReq(t, g, "POST", "/v1/chat/completions",
`{"model":"model-b","messages":[{"role":"user","content":"hi"}]}`)
if rr.Code != 200 {
t.Fatalf("status=%d", rr.Code)
}
}
func TestModelsEndpoint(t *testing.T) {
up := mockUpstream()
defer up.Close()
g := newTestGateway(t,
config.Source{Name: "a", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "model-a"}}},
config.Source{Name: "b", BaseURL: up.URL, Adapter: "openai", Models: []config.Model{{ID: "model-b"}}},
)
rr := doReq(t, g, "GET", "/v1/models", "")
if rr.Code != 200 {
t.Fatalf("status=%d", rr.Code)
}
var out map[string]interface{}
_ = json.Unmarshal(rr.Body.Bytes(), &out)
if !strings.Contains(rr.Body.String(), "model-a") || !strings.Contains(rr.Body.String(), "model-b") {
t.Fatalf("missing models: %s", rr.Body.String())
}
}
func TestWebUIServesPage(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, "GET", "/", "")
if rr.Code != 200 {
t.Fatalf("status=%d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "llmsproxy") {
t.Fatalf("ui not served")
}
}
func TestAdaptersAPIUpload(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"}}})
// upload adapter
rr := doReq(t, g, "POST", "/api/adapters",
`{"name":"testadp","code":"return {name='testadp',endpoint='/chat/completions',transform_request=function(raw) return raw end,transform_response=function(raw) return raw end}"}`)
if rr.Code != 200 {
t.Fatalf("upload status=%d body=%s", rr.Code, rr.Body.String())
}
rr = doReq(t, g, "GET", "/api/status", "")
if !strings.Contains(rr.Body.String(), "testadp") {
t.Fatalf("adapter not listed: %s", rr.Body.String())
}
}
func TestSourcesAPIAddAndPersist(t *testing.T) {
up := mockUpstream()
defer up.Close()
g := newTestGateway(t)
rr := doReq(t, g, "POST", "/api/sources",
fmt.Sprintf(`{"name":"added","base_url":"%s","adapter":"openai","models":[{"id":"new-m","priority":5}]}`, up.URL))
if rr.Code != 200 {
t.Fatalf("add source status=%d body=%s", rr.Code, rr.Body.String())
}
rr = doReq(t, g, "GET", "/v1/models", "")
if !strings.Contains(rr.Body.String(), "new-m") {
t.Fatalf("new model not live: %s", rr.Body.String())
}
// verify persistence file exists
if _, err := os.Stat(g.core.Config().RuntimeFile); err != nil {
t.Fatalf("runtime file not written: %v", err)
}
}

149
internal/gateway/server.go Normal file
View File

@ -0,0 +1,149 @@
// Package gateway exposes an OpenAI-compatible HTTP API over the provider
// registry: POST /v1/chat/completions (SDK + SSE), POST /v1/images/generations,
// GET /v1/models, protected by shared gateway API keys, plus a web UI and
// management API for adapters and sources.
package gateway
import (
"embed"
"encoding/json"
"io/fs"
"log"
"net/http"
"strings"
"llmsproxy/internal/core"
)
//go:embed ui/*
var uiFS embed.FS
// Gateway is the HTTP handler for the OpenAI-compatible endpoint + web UI.
type Gateway struct {
core *core.Core
apiKeys map[string]bool
ui http.Handler
}
func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
keys := map[string]bool{}
for _, k := range gatewayKeys {
if k != "" {
keys[k] = true
}
}
sub, err := fs.Sub(uiFS, "ui")
if err != nil {
return nil, err
}
return &Gateway{
core: c,
apiKeys: keys,
ui: http.FileServer(http.FS(sub)),
}, nil
}
func (g *Gateway) Handler() http.Handler {
return g.auth(http.HandlerFunc(g.routes))
}
func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v1/chat/completions":
g.handleChat(w, r)
case r.URL.Path == "/v1/images/generations":
g.handleImage(w, r)
case r.URL.Path == "/v1/models":
g.handleModels(w, r)
case r.URL.Path == "/api/adapters" || strings.HasPrefix(r.URL.Path, "/api/adapters/"):
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/status":
g.handleStatusAPI(w, r)
default:
g.serveUI(w, r)
}
}
func (g *Gateway) serveUI(w http.ResponseWriter, r *http.Request) {
// serve index.html directly for the root path (FileServer would 301 it)
if r.URL.Path == "/" || r.URL.Path == "/ui" {
data, err := uiFS.ReadFile("ui/index.html")
if err != nil {
http.Error(w, "ui missing", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(data)
return
}
g.ui.ServeHTTP(w, r)
}
func (g *Gateway) auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(g.apiKeys) == 0 {
next.ServeHTTP(w, r)
return
}
key := ""
if h := r.Header.Get("Authorization"); h != "" {
parts := strings.SplitN(h, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
key = parts[1]
}
}
if key == "" {
key = r.URL.Query().Get("api_key")
}
if !g.apiKeys[key] {
writeError(w, http.StatusUnauthorized, "invalid_api_key", "invalid gateway api key")
return
}
next.ServeHTTP(w, r)
})
}
func (g *Gateway) handleModels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
return
}
models := g.core.Registry().ModelList()
type modelObj struct {
ID string `json:"id"`
Object string `json:"object"`
}
objs := make([]modelObj, 0, len(models))
for _, m := range models {
objs = append(objs, modelObj{ID: m, Object: "model"})
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"object": "list",
"data": objs,
})
}
func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
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(),
})
}
func writeError(w http.ResponseWriter, code int, errType, msg string) {
writeJSON(w, code, map[string]interface{}{
"error": map[string]interface{}{"type": errType, "message": msg},
})
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("[gateway] write json: %v", err)
}
}

View File

@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>llmsproxy</title>
<style>
:root { --bg:#0f1115; --card:#171a21; --line:#262b36; --fg:#e6e8ee; --muted:#8b93a5;
--accent:#4f7cff; --ok:#3ecf8e; --warn:#ffb454; --err:#ff5d6c; }
* { 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; }
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; }
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; }
.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; }
.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; }
</style>
</head>
<body>
<header>
<h1>llmsproxy</h1>
<span class="badge">统一 LLM 网关 · 适配器/源管理</span>
</header>
<nav>
<button data-tab="status" class="active">状态</button>
<button data-tab="sources"></button>
<button data-tab="adapters">适配器</button>
</nav>
<main>
<div id="tab-status"></div>
<div id="tab-sources" class="hidden"></div>
<div id="tab-adapters" class="hidden"></div>
</main>
<div id="toast"></div>
<script>
const $ = s => document.querySelector(s);
const tab = () => document.querySelector('nav button.active').dataset.tab;
const api = (p, o) => fetch(p, o).then(async r => {
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();
}
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>
<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>
<div class="card"><h2>源状态 (${s.sources.length})</h2>
<table><tr><th>名称</th><th>适配器</th><th>模型</th><th>健康</th><th>并发</th></tr>${src}</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>`;
}
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('');
$('#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>`;
}
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')}">
<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>
<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>模型列表(优先级数字越大越优先被 AUTO 选中)</label>
<div id="s-models">${modelInputs}</div>
<button class="ghost" onclick="addModelRow()">+ 模型</button>
<label>Meta透传给 build_headers 钩子JSON</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>
</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 || [];
});
}
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">
<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>
</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(),
priority: parseInt(row.querySelector('.m-prio').value) || 0,
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; }
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,
models, meta,
};
btn.disabled = true;
try {
await api('/api/sources', { method: 'POST', body: JSON.stringify(payload) });
toast('已保存并热重载');
window._modal && window._modal.remove();
renderSources();
} catch (e) { toast('保存失败: ' + e.message); btn.disabled = false; }
}
async function delSource(name) {
if (!confirm('删除源 ' + name + '?')) return;
await api('/api/sources/' + encodeURIComponent(name), { method: 'DELETE' });
toast('已删除');
renderSources();
}
async function uploadAdapter() {
const name = $('#adp-name').value.trim();
const code = $('#adp-code').value;
if (!name || !code) return toast('需要名称和脚本');
try {
await api('/api/adapters', { method: 'POST', body: JSON.stringify({ name, code }) });
toast('适配器已加载');
renderAdapters();
} catch (e) { toast('上传失败: ' + e.message); }
}
async function delAdapter(name) {
if (!confirm('删除适配器 ' + name + '?')) return;
await api('/api/adapters/' + encodeURIComponent(name), { method: 'DELETE' });
toast('已删除');
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;'); }
refresh('status');
</script>
</body>
</html>