mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 17:07:59 +00:00
feat: ModelRouter — unified OpenAI-compatible multi-source LLM gateway
- Lua adapters per upstream (transform_request/response/stream_chunk, build_headers signing hooks) - AUTO priority routing with per-model kind (chat/image), explicit source/model routing - Per-source concurrency caps with queueing, exponential backoff, AUTO failover - OpenAI-compatible API: chat completions, SSE streaming, image generations, models - Gateway key auth, web UI for adapter/source management, runtime persistence - e2e test running the real binary against mocked upstreams
This commit is contained in:
116
internal/gateway/api.go
Normal file
116
internal/gateway/api.go
Normal 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
283
internal/gateway/chat.go
Normal 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,
|
||||
})
|
||||
}
|
||||
306
internal/gateway/gateway_test.go
Normal file
306
internal/gateway/gateway_test.go
Normal 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
149
internal/gateway/server.go
Normal 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)
|
||||
}
|
||||
}
|
||||
241
internal/gateway/ui/index.html
Normal file
241
internal/gateway/ui/index.html
Normal 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,'"')})">编辑</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><name>.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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function escAttr(s) { return esc(s).replace(/"/g, '"'); }
|
||||
|
||||
refresh('status');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user