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

View File

@ -0,0 +1,458 @@
// Package provider binds a configured source + Lua adapter and performs the
// HTTP call / stream / image generation against the upstream LLM, with
// per-source concurrency limiting and availability backoff.
package provider
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"llmsproxy/internal/config"
"llmsproxy/internal/lua"
"llmsproxy/internal/types"
)
// health tracks availability with exponential backoff.
type health struct {
failCount int
unavailableUntil time.Time
permanent bool
}
func (h *health) reset() { h.failCount = 0; h.unavailableUntil = time.Time{}; h.permanent = false }
func (h *health) available() bool {
if h.permanent {
return false
}
return time.Now().After(h.unavailableUntil)
}
func (h *health) backoff() {
h.failCount++
cooldown := 5 * time.Second * time.Duration(1<<(h.failCount-1))
if cooldown > 30*time.Minute {
cooldown = 30 * time.Minute
}
h.unavailableUntil = time.Now().Add(cooldown)
}
func (h *health) markPermanent() {
h.permanent = true
h.unavailableUntil = time.Time{}
}
// Provider is a single configured upstream LLM source.
type Provider struct {
cfg config.Source
vm *lua.VM
adapter string
client *http.Client
mu sync.Mutex
sem chan struct{}
health health
}
func New(cfg config.Source, vm *lua.VM) *Provider {
p := &Provider{
cfg: cfg,
vm: vm,
adapter: cfg.Adapter,
client: &http.Client{Timeout: cfg.Timeout},
sem: make(chan struct{}, cfg.MaxConcurrent),
}
if cfg.MaxConcurrent <= 0 {
p.sem = nil
}
return p
}
func (p *Provider) Name() string { return p.cfg.Name }
func (p *Provider) Adapter() string { return p.cfg.Adapter }
func (p *Provider) MaxConcurrent() int { return p.cfg.MaxConcurrent }
func (p *Provider) Config() *config.Source { return &p.cfg }
// Models returns the model ids exposed by this source.
func (p *Provider) Models() []string {
out := make([]string, 0, len(p.cfg.Models))
for _, m := range p.cfg.Models {
out = append(out, m.ID)
}
return out
}
// ModelByID returns the model definition if owned by this source.
func (p *Provider) ModelByID(id string) *config.Model {
for i := range p.cfg.Models {
if p.cfg.Models[i].ID == id {
return &p.cfg.Models[i]
}
}
return nil
}
// Endpoint resolves the upstream chat path.
func (p *Provider) Endpoint() string {
if p.cfg.Endpoint != "" {
return p.cfg.Endpoint
}
if ep := p.vm.Endpoint(p.adapter); ep != "" {
return ep
}
return "/chat/completions"
}
// ImageEndpoint resolves the upstream image-generation path.
func (p *Provider) ImageEndpoint() string {
if p.cfg.ImageEndpoint != "" {
return p.cfg.ImageEndpoint
}
if ep := p.vm.Endpoint(p.adapter + "_image"); ep != "" {
return ep
}
return "/v1/images/generations"
}
func (p *Provider) URL() string {
return strings.TrimRight(p.cfg.BaseURL, "/") + p.Endpoint()
}
func (p *Provider) ImageURL() string {
return strings.TrimRight(p.cfg.BaseURL, "/") + p.ImageEndpoint()
}
// ---- availability ----
func (p *Provider) Available() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.health.available()
}
// ReportStatus records an upstream HTTP status for backoff decisions.
func (p *Provider) ReportStatus(code int) {
p.mu.Lock()
defer p.mu.Unlock()
if code == 401 || code == 403 {
p.health.markPermanent()
return
}
if code >= 500 || code == 429 {
p.health.backoff()
}
}
func (p *Provider) reportError() {
p.mu.Lock()
p.health.backoff()
p.mu.Unlock()
}
func (p *Provider) reportOK() {
p.mu.Lock()
p.health.reset()
p.mu.Unlock()
}
// ---- concurrency limiting ----
// Acquire waits for a free concurrency slot (bounded by cfg.QueueTimeout),
// or context cancel. The HTTP call itself is not truncated.
func (p *Provider) Acquire(ctx context.Context) error {
if p.sem == nil {
return nil
}
var qCtx context.Context
var cancel context.CancelFunc
if p.cfg.QueueTimeout > 0 {
qCtx, cancel = context.WithTimeout(ctx, p.cfg.QueueTimeout)
} else {
qCtx, cancel = context.WithCancel(ctx)
}
defer cancel()
select {
case p.sem <- struct{}{}:
return nil
case <-qCtx.Done():
return qCtx.Err()
}
}
func (p *Provider) Release() {
if p.sem == nil {
return
}
<-p.sem
}
// ---- request construction ----
func (p *Provider) buildHeaders(body, url string) (http.Header, error) {
meta := map[string]interface{}{
"url": url,
"method": http.MethodPost,
"body": body,
"api_key": p.cfg.APIKey,
"timestamp": types.Now(),
"source": map[string]interface{}{
"name": p.cfg.Name,
"meta": p.cfg.Meta,
},
}
hdrs, err := p.vm.BuildHeaders(p.adapter, meta)
if err != nil {
return nil, err
}
h := http.Header{}
h.Set("Content-Type", "application/json")
for k, v := range p.cfg.Headers {
h.Set(k, v)
}
for k, v := range hdrs {
if _, ok := p.cfg.Headers[k]; !ok {
h.Set(k, v)
}
}
if h.Get("Authorization") == "" && p.cfg.APIKey != "" {
h.Set("Authorization", "Bearer "+p.cfg.APIKey)
}
return h, nil
}
// ---- chat ----
// Chat performs a non-streaming round trip and returns the unified response.
func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.UnifiedResponse, error) {
if err := p.Acquire(ctx); err != nil {
return nil, err
}
defer p.Release()
body, err := marshalTransform(p.vm, p.adapter, "transform_request", req)
if err != nil {
return nil, err
}
hdrs, err := p.buildHeaders(body, p.URL())
if err != nil {
return nil, err
}
raw, status, err := p.do(ctx, p.URL(), body, hdrs)
if err != nil {
p.reportError()
return nil, err
}
if status != 200 {
p.ReportStatus(status)
return nil, fmt.Errorf("api error %d: %s", status, truncate(raw, 500))
}
unified, err := p.vm.Transform(p.adapter, "transform_response", raw)
if err != nil {
return nil, err
}
var out types.UnifiedResponse
if err := json.Unmarshal([]byte(unified), &out); err != nil {
return nil, fmt.Errorf("unmarshal unified response: %w (body: %s)", err, unified)
}
p.reportOK()
return &out, nil
}
// ChatStream performs a streaming round trip, emitting unified chunks.
func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-chan types.UnifiedChunk, error) {
if err := p.Acquire(ctx); err != nil {
return nil, err
}
req.Stream = true
body, err := marshalTransform(p.vm, p.adapter, "transform_request", req)
if err != nil {
p.Release()
return nil, err
}
hdrs, err := p.buildHeaders(body, p.URL())
if err != nil {
p.Release()
return nil, err
}
type respOrErr struct {
resp *http.Response
err error
}
rc := make(chan respOrErr, 1)
go func() {
resp, err := p.doRaw(ctx, p.URL(), body, hdrs)
rc <- respOrErr{resp, err}
}()
ch := make(chan types.UnifiedChunk, 64)
go func() {
defer p.Release()
defer close(ch)
sel := <-rc
if sel.err != nil {
p.reportError()
return
}
defer sel.resp.Body.Close()
if sel.resp.StatusCode != 200 {
raw, _ := io.ReadAll(sel.resp.Body)
p.ReportStatus(sel.resp.StatusCode)
_ = raw
return
}
scanner := bufio.NewScanner(sel.resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" {
continue
}
if data == "[DONE]" {
select {
case ch <- types.UnifiedChunk{Done: true}:
case <-ctx.Done():
}
continue
}
unified, err := p.vm.Transform(p.adapter, "transform_stream_chunk", data)
if err != nil || unified == "" {
continue
}
if unified == data {
unified = standardSSEChunk(data)
if unified == "" {
continue
}
}
var ck types.UnifiedChunk
if err := json.Unmarshal([]byte(unified), &ck); err != nil {
continue
}
select {
case ch <- ck:
case <-ctx.Done():
return
}
}
}()
return ch, nil
}
// Image generates images via /v1/images/generations.
func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*types.UnifiedResponse, error) {
if err := p.Acquire(ctx); err != nil {
return nil, err
}
defer p.Release()
b, _ := json.Marshal(req)
transformed, err := p.vm.Transform(p.adapter+"_image", "transform_request", string(b))
if err != nil {
// fall back to passthrough adapter (openai-style)
transformed = string(b)
}
hdrs, err := p.buildHeaders(transformed, p.ImageURL())
if err != nil {
return nil, err
}
raw, status, err := p.do(ctx, p.ImageURL(), transformed, hdrs)
if err != nil {
p.reportError()
return nil, err
}
if status != 200 {
p.ReportStatus(status)
return nil, fmt.Errorf("image api error %d: %s", status, truncate(raw, 500))
}
var out types.UnifiedResponse
// try adapter transform_response; if missing, parse standard openai image format
unified, terr := p.vm.Transform(p.adapter+"_image", "transform_response", raw)
if terr == nil && unified != raw {
if err := json.Unmarshal([]byte(unified), &out); err == nil {
p.reportOK()
return &out, nil
}
}
var img types.ImageGenResponse
if err := json.Unmarshal([]byte(raw), &img); err != nil {
return nil, fmt.Errorf("unmarshal image response: %w", err)
}
out.ImageData = img.Data
p.reportOK()
return &out, nil
}
// ---- http helpers ----
func (p *Provider) do(ctx context.Context, url, body string, hdr http.Header) (string, int, error) {
resp, err := p.doRaw(ctx, url, body, hdr)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return string(raw), resp.StatusCode, nil
}
func (p *Provider) doRaw(ctx context.Context, url, body string, hdr http.Header) (*http.Response, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader([]byte(body)))
if err != nil {
return nil, err
}
httpReq.Header = hdr
return p.client.Do(httpReq)
}
func marshalTransform(vm *lua.VM, adapter, fn string, v interface{}) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
out, err := vm.Transform(adapter, fn, string(b))
if err != nil {
return "", err
}
return out, nil
}
func standardSSEChunk(data string) string {
var raw struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &raw); err != nil {
return ""
}
if len(raw.Choices) == 0 {
return ""
}
out, _ := json.Marshal(types.UnifiedChunk{
Content: raw.Choices[0].Delta.Content,
Done: raw.Choices[0].FinishReason != nil,
})
return string(out)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

View File

@ -0,0 +1,172 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"llmsproxy/internal/config"
"llmsproxy/internal/lua"
"llmsproxy/internal/types"
)
func newTestProvider(t *testing.T, src config.Source) *Provider {
t.Helper()
vm := lua.NewVM(t.TempDir())
if err := vm.Start(); err != nil {
t.Fatalf("vm: %v", err)
}
t.Cleanup(vm.Stop)
return New(src, vm)
}
func src(name, url, adapter string, models ...string) config.Source {
s := config.Source{Name: name, BaseURL: url, Adapter: adapter, MaxConcurrent: 4}
for _, m := range models {
s.Models = append(s.Models, config.Model{ID: m, Priority: 0})
}
return s
}
func TestProviderChat(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&body)
if body["model"] != "mock-model" {
t.Errorf("model = %v", body["model"])
}
fmt.Fprint(w, `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]}`)
}))
defer up.Close()
p := newTestProvider(t, src("mock", up.URL, "openai", "mock-model"))
resp, err := p.Chat(context.Background(), &types.ChatRequest{
Model: "mock-model",
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hello")}},
})
if err != nil {
t.Fatalf("chat: %v", err)
}
if resp.Content != "hi" {
t.Fatalf("content = %q", resp.Content)
}
}
func TestProviderChatStream(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n")
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"b\"}}]}\n\n")
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer up.Close()
p := newTestProvider(t, src("mock", up.URL, "openai", "m"))
ch, err := p.ChatStream(context.Background(), &types.ChatRequest{
Model: "m",
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
})
if err != nil {
t.Fatalf("stream: %v", err)
}
var text string
var done bool
for ck := range ch {
text += ck.Content
if ck.Done {
done = true
}
}
if text != "ab" {
t.Fatalf("text = %q", text)
}
if !done {
t.Fatal("expected done")
}
}
func TestProviderImage(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"created":123,"data":[{"b64_json":"QUJD"}]}`)
}))
defer up.Close()
p := newTestProvider(t, src("img", up.URL, "openai", "flux-1"))
resp, err := p.Image(context.Background(), &types.ImageGenRequest{Model: "flux-1", Prompt: "cat"})
if err != nil {
t.Fatalf("image: %v", err)
}
if len(resp.ImageData) != 1 || resp.ImageData[0].B64JSON != "QUJD" {
t.Fatalf("image data = %+v", resp.ImageData)
}
}
func TestProviderBackoff(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
fmt.Fprint(w, "boom")
}))
defer up.Close()
p := newTestProvider(t, src("mock", up.URL, "openai", "m"))
_, err := p.Chat(context.Background(), &types.ChatRequest{
Model: "m",
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}},
})
if err == nil {
t.Fatal("expected error")
}
if p.Available() {
t.Fatal("expected provider to be in backoff")
}
// 401 -> permanent
up2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
}))
defer up2.Close()
p2 := newTestProvider(t, src("mock2", up2.URL, "openai", "m2"))
p2.Chat(context.Background(), &types.ChatRequest{Model: "m2", Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}}})
if p2.Available() {
t.Fatal("expected permanent unavailability on 401")
}
}
func TestProviderConcurrencyCap(t *testing.T) {
release := make(chan struct{})
started := make(chan struct{}, 100)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started <- struct{}{}
<-release
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"}}]}`)
}))
defer up.Close()
// cap 2
p := newTestProvider(t, src("mock", up.URL, "openai", "m"))
p.cfg.MaxConcurrent = 2
p.sem = make(chan struct{}, 2)
var wg sync.WaitGroup
for i := 0; i < 6; i++ {
wg.Add(1)
go func() {
defer wg.Done()
p.Chat(context.Background(), &types.ChatRequest{Model: "m", Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}}})
}()
}
// wait until 2 requests started
deadline := time.Now().Add(2 * time.Second)
for len(started) < 2 {
if time.Now().After(deadline) {
t.Fatal("timeout waiting for first two")
}
time.Sleep(5 * time.Millisecond)
}
time.Sleep(100 * time.Millisecond)
if len(started) > 2 {
t.Fatalf("more than 2 concurrent: %d", len(started))
}
close(release)
wg.Wait()
}

View File

@ -0,0 +1,170 @@
// Package provider also provides the top-level registry that owns all sources,
// routes model requests (explicit or AUTO), and supports hot reload.
package provider
import (
"sort"
"strings"
"sync"
)
// Registry holds all configured providers and routes model requests.
type Registry struct {
mu sync.RWMutex
providers []*Provider
byModel map[string]*Provider // modelID -> provider
defaultM string // default model id ("" means AUTO)
}
func NewRegistry(providers []*Provider, defaultModel string) *Registry {
r := &Registry{byModel: map[string]*Provider{}, defaultM: defaultModel}
r.set(providers)
return r
}
// Replace atomically swaps the provider set (hot reload).
func (r *Registry) Replace(providers []*Provider) {
r.mu.Lock()
defer r.mu.Unlock()
r.set(providers)
}
func (r *Registry) set(providers []*Provider) {
r.providers = providers
r.byModel = map[string]*Provider{}
for _, p := range providers {
for _, m := range p.Models() {
r.byModel[strings.ToLower(m)] = p
}
}
}
func (r *Registry) Providers() []*Provider {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*Provider, len(r.providers))
copy(out, r.providers)
return out
}
func (r *Registry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.providers)
}
// ModelList returns all exposed model ids (chat + image).
func (r *Registry) ModelList() []string {
r.mu.RLock()
defer r.mu.RUnlock()
seen := map[string]bool{}
for _, p := range r.providers {
for _, m := range p.Models() {
if !seen[m] {
seen[m] = true
}
}
}
out := make([]string, 0, len(seen))
for m := range seen {
out = append(out, m)
}
sort.Strings(out)
return out
}
// Resolve returns the ordered candidate providers to try for a request,
// honoring explicit model selection or AUTO (priority order, healthy first).
//
// model "" or "AUTO" -> all sources sorted by (priority desc, healthy first).
// Otherwise the owning provider, if healthy; else its source anyway.
func (r *Registry) Resolve(model string) []*Provider {
r.mu.RLock()
defer r.mu.RUnlock()
model = strings.TrimSpace(model)
if model == "" || strings.EqualFold(model, "AUTO") {
// priority chain across all models
type cand struct {
prov *Provider
priority int
}
var cands []cand
seen := map[string]bool{}
for _, p := range r.providers {
prio := -1
for _, m := range p.cfg.Models {
if m.Priority > prio {
prio = m.Priority
}
}
if prio < 0 {
prio = 0
}
cands = append(cands, cand{p, prio})
seen[p.Name()] = true
}
sort.SliceStable(cands, func(i, j int) bool {
if cands[i].priority != cands[j].priority {
return cands[i].priority > cands[j].priority
}
// healthy preferred at same priority
return cands[i].prov.Available() && !cands[j].prov.Available()
})
out := make([]*Provider, 0, len(cands))
for _, c := range cands {
out = append(out, c.prov)
}
return out
}
// explicit model
if p, ok := r.byModel[strings.ToLower(model)]; ok {
// switch to the owning source but pin the model via request
return []*Provider{p}
}
// unknown model -> fall back to default/AUTO chain
return r.AUTOChain()
}
// AUTOChain returns the priority-sorted providers for AUTO.
func (r *Registry) AUTOChain() []*Provider {
return r.Resolve("AUTO")
}
// Default returns the highest-priority available provider.
func (r *Registry) Default() *Provider {
chain := r.AUTOChain()
if len(chain) == 0 {
return nil
}
return chain[0]
}
// ModelStatus is a web-UI friendly snapshot per source.
type SourceStatus struct {
Name string `json:"name"`
Adapter string `json:"adapter"`
Models []string `json:"models"`
Available bool `json:"available"`
Healthy bool `json:"healthy"`
MaxConcurrent int `json:"max_concurrent"`
}
func (r *Registry) Status() []SourceStatus {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]SourceStatus, 0, len(r.providers))
for _, p := range r.providers {
s := SourceStatus{
Name: p.Name(),
Adapter: p.Adapter(),
Models: p.Models(),
Available: p.Available(),
Healthy: p.Available(),
MaxConcurrent: p.MaxConcurrent(),
}
out = append(out, s)
}
return out
}