refactor(gateway): extract bootstrap and provider registry
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
- 鉴权运行时支持两种模式:
|
||||
- `inmemory`
|
||||
- `remote_introspection`
|
||||
- 当前 provider 注册仍是代码内装配,不是配置驱动。默认只在入口里注册 OpenAI adapter。
|
||||
- provider 注册已通过 [config.LoadConfig](/home/long/project/立交桥/gateway/internal/config/config.go) 提供的 `Providers` 配置装配;默认会基于环境变量生成一个 OpenAI provider。
|
||||
- 审计发射器支持 PostgreSQL 与内存实现;数据库未配置时会回退到内存实现。
|
||||
|
||||
## 目录结构
|
||||
@@ -38,6 +38,8 @@ gateway/
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="..."
|
||||
export OPENAI_BASE_URL="https://api.openai.com"
|
||||
export OPENAI_MODELS="gpt-4,gpt-3.5-turbo"
|
||||
export GATEWAY_ENV="dev"
|
||||
export GATEWAY_TOKEN_RUNTIME_MODE="inmemory"
|
||||
```
|
||||
@@ -76,5 +78,5 @@ bash scripts/ci/repo_integrity_check.sh
|
||||
|
||||
## 已知限制
|
||||
|
||||
- `internal/config.Config.Providers` 已存在,但当前入口尚未按该配置动态注册 provider。
|
||||
- 生产级 provider 装配和更细的 bootstrap 拆分,仍在后续重构计划中。
|
||||
- 当前默认 provider 只有 OpenAI;其他 provider 类型还未接入到 `internal/app/providers.go`。
|
||||
- 更细的生产级 bootstrap 和多 provider 装配,仍在后续重构计划中。
|
||||
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -10,12 +9,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/gateway/internal/adapter"
|
||||
"lijiaoqiao/gateway/internal/app"
|
||||
"lijiaoqiao/gateway/internal/config"
|
||||
"lijiaoqiao/gateway/internal/handler"
|
||||
"lijiaoqiao/gateway/internal/middleware"
|
||||
"lijiaoqiao/gateway/internal/ratelimit"
|
||||
"lijiaoqiao/gateway/internal/router"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -25,105 +20,14 @@ func main() {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// 初始化Router
|
||||
r := router.NewRouter(router.StrategyLatency)
|
||||
|
||||
// 注册Provider (示例: OpenAI)
|
||||
openaiAdapter := adapter.NewOpenAIAdapter(
|
||||
"https://api.openai.com",
|
||||
os.Getenv("OPENAI_API_KEY"),
|
||||
[]string{"gpt-4", "gpt-3.5-turbo"},
|
||||
)
|
||||
r.RegisterProvider("openai", openaiAdapter)
|
||||
|
||||
// 初始化限流中间件
|
||||
var limiterMiddleware *ratelimit.Middleware
|
||||
if cfg.RateLimit.Algorithm == "token_bucket" {
|
||||
limiter := ratelimit.NewTokenBucketLimiter(
|
||||
cfg.RateLimit.DefaultRPM,
|
||||
cfg.RateLimit.DefaultTPM,
|
||||
cfg.RateLimit.BurstMultiplier,
|
||||
)
|
||||
limiterMiddleware = ratelimit.NewMiddleware(limiter)
|
||||
} else {
|
||||
limiter := ratelimit.NewSlidingWindowLimiter(
|
||||
time.Minute,
|
||||
cfg.RateLimit.DefaultRPM,
|
||||
)
|
||||
limiterMiddleware = ratelimit.NewMiddleware(limiter)
|
||||
}
|
||||
|
||||
// 初始化审计发射器
|
||||
var auditor middleware.AuditEmitter
|
||||
if cfg.Database.Host != "" {
|
||||
// MED-10: 使用 GetPassword() 获取解密后的密码,避免在日志中暴露明文密码
|
||||
dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
|
||||
cfg.Database.User,
|
||||
cfg.Database.GetPassword(),
|
||||
cfg.Database.Host,
|
||||
cfg.Database.Port,
|
||||
cfg.Database.Database,
|
||||
)
|
||||
auditEmitter, err := middleware.NewDatabaseAuditEmitter(dsn, time.Now)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to create database audit emitter: %v, using memory emitter", err)
|
||||
auditor = middleware.NewMemoryAuditEmitter()
|
||||
} else {
|
||||
auditor = auditEmitter
|
||||
defer auditEmitter.Close()
|
||||
}
|
||||
} else {
|
||||
log.Printf("Warning: Database not configured, using memory audit emitter")
|
||||
auditor = middleware.NewMemoryAuditEmitter()
|
||||
}
|
||||
|
||||
// 初始化 token 运行时
|
||||
var tokenRuntime interface {
|
||||
middleware.TokenVerifier
|
||||
middleware.TokenStatusResolver
|
||||
}
|
||||
switch cfg.Auth.TokenRuntimeMode {
|
||||
case "inmemory":
|
||||
tokenRuntime = middleware.NewInMemoryTokenRuntime(time.Now)
|
||||
case "remote_introspection":
|
||||
tokenRuntime = middleware.NewRemoteTokenRuntime(cfg.Auth.TokenRuntimeURL, http.DefaultClient, time.Now)
|
||||
default:
|
||||
log.Fatalf("unsupported token runtime mode: %s", cfg.Auth.TokenRuntimeMode)
|
||||
}
|
||||
|
||||
// 构建认证中间件配置
|
||||
authMiddlewareConfig := middleware.AuthMiddlewareConfig{
|
||||
Verifier: tokenRuntime,
|
||||
StatusResolver: tokenRuntime,
|
||||
Authorizer: middleware.NewScopeRoleAuthorizer(),
|
||||
Auditor: auditor,
|
||||
ProtectedPrefixes: []string{
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/api/v1/chat/completions",
|
||||
"/api/v1/completions",
|
||||
"/api/v1/supply",
|
||||
"/api/v1/platform",
|
||||
},
|
||||
ExcludedPrefixes: []string{"/health", "/healthz", "/metrics", "/readyz"},
|
||||
Now: time.Now,
|
||||
}
|
||||
|
||||
// 初始化Handler
|
||||
h := handler.NewHandler(r)
|
||||
|
||||
// 创建Server
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
|
||||
Handler: createMux(h, limiterMiddleware, authMiddlewareConfig),
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
IdleTimeout: cfg.Server.IdleTimeout,
|
||||
server, err := app.BuildServer(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to build server: %v", err)
|
||||
}
|
||||
|
||||
// 启动Server
|
||||
go func() {
|
||||
log.Printf("Starting gateway server on %s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
log.Printf("Starting gateway server on %s", server.Addr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
@@ -146,42 +50,3 @@ func main() {
|
||||
|
||||
log.Println("Server exited")
|
||||
}
|
||||
|
||||
func createMux(h *handler.Handler, limiter *ratelimit.Middleware, authConfig middleware.AuthMiddlewareConfig) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
chatHandler := middleware.BuildTokenAuthChain(authConfig, http.HandlerFunc(h.ChatCompletionsHandle))
|
||||
completionsHandler := middleware.BuildTokenAuthChain(authConfig, http.HandlerFunc(h.CompletionsHandle))
|
||||
|
||||
mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
limitHandler(limiter, chatHandler).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/v1/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
limitHandler(limiter, completionsHandler).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/v1/models", h.ModelsHandle)
|
||||
|
||||
mux.HandleFunc("/api/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
limitHandler(limiter, chatHandler).ServeHTTP(w, r)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
limitHandler(limiter, completionsHandler).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/health", h.HealthHandle)
|
||||
mux.HandleFunc("/healthz", h.HealthHandle)
|
||||
mux.HandleFunc("/readyz", h.HealthHandle)
|
||||
|
||||
return middleware.CORSMiddleware(middleware.DefaultCORSConfig())(mux)
|
||||
}
|
||||
|
||||
func limitHandler(limiter *ratelimit.Middleware, next http.Handler) http.Handler {
|
||||
if limiter == nil {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
limiter.Limit(next.ServeHTTP)(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/gateway/internal/adapter"
|
||||
"lijiaoqiao/gateway/internal/app"
|
||||
"lijiaoqiao/gateway/internal/handler"
|
||||
"lijiaoqiao/gateway/internal/middleware"
|
||||
"lijiaoqiao/gateway/internal/ratelimit"
|
||||
@@ -85,7 +86,7 @@ func TestCreateMux_ProtectsCompletionRoutes(t *testing.T) {
|
||||
Now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
mux := createMux(h, limiter, authConfig)
|
||||
mux := app.BuildMux(h, limiter, authConfig)
|
||||
|
||||
for _, path := range []string{
|
||||
"/v1/chat/completions",
|
||||
@@ -118,7 +119,7 @@ func TestCreateMux_HealthRoutesRemainOpen(t *testing.T) {
|
||||
Now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
mux := createMux(h, limiter, authConfig)
|
||||
mux := app.BuildMux(h, limiter, authConfig)
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
@@ -153,7 +154,7 @@ func TestCreateMux_CompletionsRouteUsesCompletionsHandler(t *testing.T) {
|
||||
Now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
mux := createMux(h, limiter, authConfig)
|
||||
mux := app.BuildMux(h, limiter, authConfig)
|
||||
reqBody := `{"model":"gpt-4","prompt":"hello","max_tokens":16}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/completions", bytes.NewBufferString(reqBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
222
gateway/internal/app/bootstrap.go
Normal file
222
gateway/internal/app/bootstrap.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lijiaoqiao/gateway/internal/config"
|
||||
"lijiaoqiao/gateway/internal/handler"
|
||||
"lijiaoqiao/gateway/internal/middleware"
|
||||
"lijiaoqiao/gateway/internal/ratelimit"
|
||||
"lijiaoqiao/gateway/internal/router"
|
||||
)
|
||||
|
||||
func BuildServer(cfg *config.Config) (*http.Server, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("config is required")
|
||||
}
|
||||
|
||||
normalized := normalizeConfig(*cfg)
|
||||
|
||||
if err := config.ValidateAuthConfig(normalized.Auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r, err := buildRouter(&normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limiter := buildLimiter(normalized.RateLimit)
|
||||
auditor, err := buildAuditor(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tokenRuntime, err := buildTokenRuntime(normalized.Auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authConfig := middleware.AuthMiddlewareConfig{
|
||||
Verifier: tokenRuntime,
|
||||
StatusResolver: tokenRuntime,
|
||||
Authorizer: middleware.NewScopeRoleAuthorizer(),
|
||||
Auditor: auditor,
|
||||
ProtectedPrefixes: []string{
|
||||
"/v1/chat/completions",
|
||||
"/v1/completions",
|
||||
"/api/v1/chat/completions",
|
||||
"/api/v1/completions",
|
||||
"/api/v1/supply",
|
||||
"/api/v1/platform",
|
||||
},
|
||||
ExcludedPrefixes: []string{"/health", "/healthz", "/metrics", "/readyz"},
|
||||
Now: time.Now,
|
||||
}
|
||||
|
||||
h := handler.NewHandler(r)
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", normalized.Server.Host, normalized.Server.Port),
|
||||
Handler: BuildMux(h, limiter, authConfig),
|
||||
ReadTimeout: normalized.Server.ReadTimeout,
|
||||
WriteTimeout: normalized.Server.WriteTimeout,
|
||||
IdleTimeout: normalized.Server.IdleTimeout,
|
||||
}
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func BuildMux(h *handler.Handler, limiter *ratelimit.Middleware, authConfig middleware.AuthMiddlewareConfig) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
chatHandler := middleware.BuildTokenAuthChain(authConfig, http.HandlerFunc(h.ChatCompletionsHandle))
|
||||
completionsHandler := middleware.BuildTokenAuthChain(authConfig, http.HandlerFunc(h.CompletionsHandle))
|
||||
|
||||
mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
limitHandler(limiter, chatHandler).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/v1/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
limitHandler(limiter, completionsHandler).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/v1/models", h.ModelsHandle)
|
||||
|
||||
mux.HandleFunc("/api/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
limitHandler(limiter, chatHandler).ServeHTTP(w, r)
|
||||
})
|
||||
mux.HandleFunc("/api/v1/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
limitHandler(limiter, completionsHandler).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/health", h.HealthHandle)
|
||||
mux.HandleFunc("/healthz", h.HealthHandle)
|
||||
mux.HandleFunc("/readyz", h.HealthHandle)
|
||||
|
||||
return middleware.CORSMiddleware(middleware.DefaultCORSConfig())(mux)
|
||||
}
|
||||
|
||||
func buildRouter(cfg *config.Config) (*router.Router, error) {
|
||||
if len(cfg.Providers) == 0 {
|
||||
return nil, fmt.Errorf("at least one provider must be configured")
|
||||
}
|
||||
|
||||
r := router.NewRouter(resolveStrategy(cfg.Router.Strategy))
|
||||
for _, providerCfg := range cfg.Providers {
|
||||
provider, err := buildProvider(providerCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.RegisterProvider(providerCfg.Name, provider)
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func buildLimiter(cfg config.RateLimitConfig) *ratelimit.Middleware {
|
||||
if strings.EqualFold(cfg.Algorithm, "sliding_window") {
|
||||
limiter := ratelimit.NewSlidingWindowLimiter(time.Minute, cfg.DefaultRPM)
|
||||
return ratelimit.NewMiddleware(limiter)
|
||||
}
|
||||
|
||||
limiter := ratelimit.NewTokenBucketLimiter(cfg.DefaultRPM, cfg.DefaultTPM, cfg.BurstMultiplier)
|
||||
return ratelimit.NewMiddleware(limiter)
|
||||
}
|
||||
|
||||
func buildAuditor(cfg config.Config) (middleware.AuditEmitter, error) {
|
||||
if strings.TrimSpace(cfg.Database.Host) == "" {
|
||||
return middleware.NewMemoryAuditEmitter(), nil
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"postgres://%s:%s@%s:%d/%s?sslmode=disable",
|
||||
cfg.Database.User,
|
||||
cfg.Database.GetPassword(),
|
||||
cfg.Database.Host,
|
||||
cfg.Database.Port,
|
||||
cfg.Database.Database,
|
||||
)
|
||||
|
||||
auditor, err := middleware.NewDatabaseAuditEmitter(dsn, time.Now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create database audit emitter: %w", err)
|
||||
}
|
||||
|
||||
return auditor, nil
|
||||
}
|
||||
|
||||
func buildTokenRuntime(cfg config.AuthConfig) (interface {
|
||||
middleware.TokenVerifier
|
||||
middleware.TokenStatusResolver
|
||||
}, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(cfg.TokenRuntimeMode)) {
|
||||
case "", "inmemory":
|
||||
return middleware.NewInMemoryTokenRuntime(time.Now), nil
|
||||
case "remote_introspection":
|
||||
return middleware.NewRemoteTokenRuntime(cfg.TokenRuntimeURL, http.DefaultClient, time.Now), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported token runtime mode: %s", cfg.TokenRuntimeMode)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveStrategy(strategy string) router.LoadBalancerStrategy {
|
||||
switch strings.ToLower(strings.TrimSpace(strategy)) {
|
||||
case string(router.StrategyRoundRobin):
|
||||
return router.StrategyRoundRobin
|
||||
case string(router.StrategyWeighted):
|
||||
return router.StrategyWeighted
|
||||
case string(router.StrategyAvailability):
|
||||
return router.StrategyAvailability
|
||||
default:
|
||||
return router.StrategyLatency
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeConfig(cfg config.Config) config.Config {
|
||||
if strings.TrimSpace(cfg.Server.Host) == "" {
|
||||
cfg.Server.Host = "0.0.0.0"
|
||||
}
|
||||
if cfg.Server.Port == 0 {
|
||||
cfg.Server.Port = 8080
|
||||
}
|
||||
if cfg.Server.ReadTimeout == 0 {
|
||||
cfg.Server.ReadTimeout = 30 * time.Second
|
||||
}
|
||||
if cfg.Server.WriteTimeout == 0 {
|
||||
cfg.Server.WriteTimeout = 30 * time.Second
|
||||
}
|
||||
if cfg.Server.IdleTimeout == 0 {
|
||||
cfg.Server.IdleTimeout = 120 * time.Second
|
||||
}
|
||||
if cfg.Router.Strategy == "" {
|
||||
cfg.Router.Strategy = string(router.StrategyLatency)
|
||||
}
|
||||
if cfg.RateLimit.DefaultRPM == 0 {
|
||||
cfg.RateLimit.DefaultRPM = 60
|
||||
}
|
||||
if cfg.RateLimit.DefaultTPM == 0 {
|
||||
cfg.RateLimit.DefaultTPM = 60000
|
||||
}
|
||||
if cfg.RateLimit.BurstMultiplier == 0 {
|
||||
cfg.RateLimit.BurstMultiplier = 1.5
|
||||
}
|
||||
if cfg.RateLimit.Algorithm == "" {
|
||||
cfg.RateLimit.Algorithm = "token_bucket"
|
||||
}
|
||||
if cfg.Auth.TokenRuntimeMode == "" {
|
||||
cfg.Auth.TokenRuntimeMode = "inmemory"
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func limitHandler(limiter *ratelimit.Middleware, next http.Handler) http.Handler {
|
||||
if limiter == nil {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
limiter.Limit(next.ServeHTTP)(w, r)
|
||||
})
|
||||
}
|
||||
89
gateway/internal/app/bootstrap_test.go
Normal file
89
gateway/internal/app/bootstrap_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"lijiaoqiao/gateway/internal/config"
|
||||
)
|
||||
|
||||
func TestBuildServer_FromConfigProviders(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Providers: []config.ProviderConfig{{
|
||||
Name: "openai",
|
||||
Type: "openai",
|
||||
BaseURL: "https://api.openai.com",
|
||||
APIKey: "secret",
|
||||
Models: []string{"gpt-4o"},
|
||||
}},
|
||||
}
|
||||
|
||||
server, err := BuildServer(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildServer returned error: %v", err)
|
||||
}
|
||||
if server == nil {
|
||||
t.Fatal("expected server")
|
||||
}
|
||||
if server.Addr != "0.0.0.0:8080" {
|
||||
t.Fatalf("unexpected addr: %s", server.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServer_RejectsEmptyProviderList(t *testing.T) {
|
||||
_, err := BuildServer(&config.Config{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMux_HealthRouteRemainsOpen(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Providers: []config.ProviderConfig{{
|
||||
Name: "openai",
|
||||
Type: "openai",
|
||||
BaseURL: "https://api.openai.com",
|
||||
APIKey: "secret",
|
||||
Models: []string{"gpt-4o"},
|
||||
}},
|
||||
}
|
||||
|
||||
server, err := BuildServer(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildServer returned error: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/health", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
rr := newTestResponseRecorder()
|
||||
server.Handler.ServeHTTP(rr, req)
|
||||
if rr.code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.code)
|
||||
}
|
||||
}
|
||||
|
||||
type testResponseRecorder struct {
|
||||
header http.Header
|
||||
code int
|
||||
}
|
||||
|
||||
func newTestResponseRecorder() *testResponseRecorder {
|
||||
return &testResponseRecorder{header: make(http.Header)}
|
||||
}
|
||||
|
||||
func (r *testResponseRecorder) Header() http.Header {
|
||||
return r.header
|
||||
}
|
||||
|
||||
func (r *testResponseRecorder) WriteHeader(statusCode int) {
|
||||
r.code = statusCode
|
||||
}
|
||||
|
||||
func (r *testResponseRecorder) Write(b []byte) (int, error) {
|
||||
if r.code == 0 {
|
||||
r.code = http.StatusOK
|
||||
}
|
||||
return len(b), nil
|
||||
}
|
||||
28
gateway/internal/app/providers.go
Normal file
28
gateway/internal/app/providers.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"lijiaoqiao/gateway/internal/adapter"
|
||||
"lijiaoqiao/gateway/internal/config"
|
||||
)
|
||||
|
||||
func buildProvider(providerCfg config.ProviderConfig) (adapter.ProviderAdapter, error) {
|
||||
name := strings.TrimSpace(providerCfg.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("provider name is required")
|
||||
}
|
||||
|
||||
providerType := strings.ToLower(strings.TrimSpace(providerCfg.Type))
|
||||
switch providerType {
|
||||
case "", "openai":
|
||||
baseURL := strings.TrimSpace(providerCfg.BaseURL)
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com"
|
||||
}
|
||||
return adapter.NewOpenAIAdapter(baseURL, providerCfg.APIKey, providerCfg.Models), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported provider type %q", providerCfg.Type)
|
||||
}
|
||||
}
|
||||
33
gateway/internal/app/providers_test.go
Normal file
33
gateway/internal/app/providers_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"lijiaoqiao/gateway/internal/config"
|
||||
)
|
||||
|
||||
func TestBuildProvider_OpenAI(t *testing.T) {
|
||||
provider, err := buildProvider(config.ProviderConfig{
|
||||
Name: "openai",
|
||||
Type: "openai",
|
||||
BaseURL: "https://api.openai.com",
|
||||
APIKey: "secret",
|
||||
Models: []string{"gpt-4o"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildProvider returned error: %v", err)
|
||||
}
|
||||
if provider.ProviderName() != "openai" {
|
||||
t.Fatalf("unexpected provider name: %s", provider.ProviderName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProvider_RejectsUnsupportedType(t *testing.T) {
|
||||
_, err := buildProvider(config.ProviderConfig{
|
||||
Name: "anthropic",
|
||||
Type: "anthropic",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
@@ -202,16 +202,27 @@ func LoadConfig(path string) (*Config, error) {
|
||||
Secret: getEnv("FEISHU_SECRET", ""),
|
||||
},
|
||||
},
|
||||
Providers: []ProviderConfig{
|
||||
{
|
||||
Name: "openai",
|
||||
Type: "openai",
|
||||
BaseURL: strings.TrimSpace(getEnv("OPENAI_BASE_URL", "https://api.openai.com")),
|
||||
APIKey: getEnv("OPENAI_API_KEY", ""),
|
||||
Models: splitCSV(getEnv("OPENAI_MODELS", "gpt-4,gpt-3.5-turbo")),
|
||||
Priority: 1,
|
||||
Weight: 1.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := validateAuthConfig(cfg.Auth); err != nil {
|
||||
if err := ValidateAuthConfig(cfg.Auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func validateAuthConfig(cfg AuthConfig) error {
|
||||
func ValidateAuthConfig(cfg AuthConfig) error {
|
||||
mode := strings.ToLower(strings.TrimSpace(cfg.TokenRuntimeMode))
|
||||
env := strings.ToLower(strings.TrimSpace(cfg.Env))
|
||||
|
||||
@@ -231,6 +242,18 @@ func validateAuthConfig(cfg AuthConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
rawParts := strings.Split(value, ",")
|
||||
parts := make([]string, 0, len(rawParts))
|
||||
for _, part := range rawParts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
|
||||
@@ -433,3 +433,29 @@ func TestLoadConfig_ProdRequiresTokenRuntimeURL(t *testing.T) {
|
||||
t.Fatalf("expected error to mention TOKEN_RUNTIME_URL, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAuthConfig_ProdRequiresRemoteIntrospection(t *testing.T) {
|
||||
cfg := AuthConfig{Env: "prod", TokenRuntimeMode: "inmemory"}
|
||||
if err := ValidateAuthConfig(cfg); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_DefaultProvider(t *testing.T) {
|
||||
t.Setenv("OPENAI_BASE_URL", "https://api.openai.com")
|
||||
t.Setenv("OPENAI_MODELS", "gpt-4o-mini,gpt-4o")
|
||||
|
||||
cfg, err := LoadConfig("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(cfg.Providers) != 1 {
|
||||
t.Fatalf("expected 1 provider, got %d", len(cfg.Providers))
|
||||
}
|
||||
if cfg.Providers[0].Name != "openai" {
|
||||
t.Fatalf("unexpected provider name: %s", cfg.Providers[0].Name)
|
||||
}
|
||||
if len(cfg.Providers[0].Models) != 2 {
|
||||
t.Fatalf("unexpected model count: %d", len(cfg.Providers[0].Models))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user