From cd26802d7d6f9b784c39d5559b7fb09bd67f6169 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 14 Apr 2026 10:45:30 +0800 Subject: [PATCH] refactor(gateway): extract bootstrap and provider registry --- gateway/README.md | 8 +- gateway/cmd/gateway/main.go | 145 +--------------- gateway/cmd/gateway/main_test.go | 7 +- gateway/internal/app/bootstrap.go | 222 +++++++++++++++++++++++++ gateway/internal/app/bootstrap_test.go | 89 ++++++++++ gateway/internal/app/providers.go | 28 ++++ gateway/internal/app/providers_test.go | 33 ++++ gateway/internal/config/config.go | 27 ++- gateway/internal/config/config_test.go | 26 +++ 9 files changed, 437 insertions(+), 148 deletions(-) create mode 100644 gateway/internal/app/bootstrap.go create mode 100644 gateway/internal/app/bootstrap_test.go create mode 100644 gateway/internal/app/providers.go create mode 100644 gateway/internal/app/providers_test.go diff --git a/gateway/README.md b/gateway/README.md index 6e1a2c5f..75b714fb 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -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 装配,仍在后续重构计划中。 diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index adb383b8..07a49178 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -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) - }) -} diff --git a/gateway/cmd/gateway/main_test.go b/gateway/cmd/gateway/main_test.go index 3f1a16ba..a2cb83cc 100644 --- a/gateway/cmd/gateway/main_test.go +++ b/gateway/cmd/gateway/main_test.go @@ -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) diff --git a/gateway/internal/app/bootstrap.go b/gateway/internal/app/bootstrap.go new file mode 100644 index 00000000..6959fdec --- /dev/null +++ b/gateway/internal/app/bootstrap.go @@ -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) + }) +} diff --git a/gateway/internal/app/bootstrap_test.go b/gateway/internal/app/bootstrap_test.go new file mode 100644 index 00000000..d3cae08e --- /dev/null +++ b/gateway/internal/app/bootstrap_test.go @@ -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 +} diff --git a/gateway/internal/app/providers.go b/gateway/internal/app/providers.go new file mode 100644 index 00000000..bb094c8e --- /dev/null +++ b/gateway/internal/app/providers.go @@ -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) + } +} diff --git a/gateway/internal/app/providers_test.go b/gateway/internal/app/providers_test.go new file mode 100644 index 00000000..7789ad11 --- /dev/null +++ b/gateway/internal/app/providers_test.go @@ -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") + } +} diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index e5a2306e..a9097384 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -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 diff --git a/gateway/internal/config/config_test.go b/gateway/internal/config/config_test.go index 83d1f5de..aafef12c 100644 --- a/gateway/internal/config/config_test.go +++ b/gateway/internal/config/config_test.go @@ -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)) + } +}