mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +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:
458
internal/provider/provider.go
Normal file
458
internal/provider/provider.go
Normal 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] + "..."
|
||||
}
|
||||
172
internal/provider/provider_test.go
Normal file
172
internal/provider/provider_test.go
Normal 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()
|
||||
}
|
||||
170
internal/provider/registry.go
Normal file
170
internal/provider/registry.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user