chore: initial commit of Go API gateway
Baseline for the Kong replacement on api.riotpiao.com. Brings the working tree under version control for the first time: gateway source, the task board that drives the agent runs, test fixtures, and K8s manifests. Anchor the gateway ignore rule to the repo root. Unanchored, "gateway" also matched the cmd/gateway/ source directory, so the program entrypoint was excluded from every commit. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestLoadRoutesMissingAuthRequired tests that the auth-required flag is not a silent default:
|
||||
// if it is absent from YAML, startup must fail with a message naming the offending route and field.
|
||||
func TestLoadRoutesMissingAuthRequired(t *testing.T) {
|
||||
routes, err := config.LoadRoutesFromFile("../../testdata/config/missing-authRequired.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing authRequired, got nil")
|
||||
}
|
||||
if routes != nil {
|
||||
t.Error("expected nil routes on error")
|
||||
}
|
||||
want := "route \"test-route\": field 'authRequired' is required"
|
||||
got := err.Error()
|
||||
if got != want {
|
||||
t.Fatalf("error did not name the offending route and field; want %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds the gateway configuration.
|
||||
type Config struct {
|
||||
// ListenAddr is the address to listen on for HTTP traffic.
|
||||
ListenAddr string
|
||||
// ShutdownTimeout is the maximum time to wait for in-flight requests
|
||||
// to complete before forcing shutdown.
|
||||
ShutdownTimeout time.Duration
|
||||
// Routes maps route names to their upstream configuration.
|
||||
Routes map[string]*Route
|
||||
// Models maps model names to their upstream configuration.
|
||||
// Multiple models can point to the same upstream address.
|
||||
Models map[string]*ModelUpstream
|
||||
}
|
||||
|
||||
// ModelUpstream holds upstream configuration for a specific model.
|
||||
type ModelUpstream struct {
|
||||
// Name is the model name clients send (e.g., "reasoning", "ornith:35b").
|
||||
Name string
|
||||
// Address is the upstream server address (host:port).
|
||||
Address string
|
||||
// Path is the upstream path for this model (e.g., "/v1/chat/completions").
|
||||
Path string
|
||||
}
|
||||
|
||||
// Route represents a single route and its upstream configuration.
|
||||
type Route struct {
|
||||
// Name is the route identifier.
|
||||
Name string
|
||||
// Upstream holds the upstream server configuration.
|
||||
Upstream Upstream
|
||||
}
|
||||
|
||||
// Upstream holds upstream server configuration for a route.
|
||||
type Upstream struct {
|
||||
// Address is the upstream server address (host:port).
|
||||
Address string
|
||||
// PathRewrite is an optional path prefix rewrite. Empty string means no rewrite.
|
||||
PathRewrite string
|
||||
// ConnectTimeout is the maximum time to establish a connection to the upstream.
|
||||
ConnectTimeout time.Duration
|
||||
// ReadTimeout is the maximum time to read a response from the upstream.
|
||||
ReadTimeout time.Duration
|
||||
// WriteTimeout is the maximum time to write a request to the upstream.
|
||||
WriteTimeout time.Duration
|
||||
// MaxBodySize is the maximum request body size in bytes.
|
||||
MaxBodySize int64
|
||||
// AuthRequired indicates whether this route requires authentication.
|
||||
AuthRequired bool
|
||||
}
|
||||
|
||||
// LookupModel finds a model by name (case-sensitive, exact match).
|
||||
func (c *Config) LookupModel(name string) *ModelUpstream {
|
||||
if c == nil || c.Models == nil {
|
||||
return nil
|
||||
}
|
||||
return c.Models[name]
|
||||
}
|
||||
|
||||
// ModelNames returns a sorted list of all known model names.
|
||||
func (c *Config) ModelNames() []string {
|
||||
if c == nil || c.Models == nil {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(c.Models))
|
||||
for name := range c.Models {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables with defaults.
|
||||
func Load() (*Config, error) {
|
||||
listenAddr := "127.0.0.1:8080"
|
||||
// Allow override via environment variable
|
||||
if addr, ok := os.LookupEnv("LISTEN_ADDR"); ok {
|
||||
listenAddr = addr
|
||||
}
|
||||
|
||||
shutdownTimeout := 30 * time.Second
|
||||
// Allow override via environment variable
|
||||
if timeout, ok := os.LookupEnv("SHUTDOWN_TIMEOUT"); ok {
|
||||
d, err := time.ParseDuration(timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid SHUTDOWN_TIMEOUT: %w", err)
|
||||
}
|
||||
shutdownTimeout = d
|
||||
}
|
||||
|
||||
// Load routes and models from config file
|
||||
routes := make(map[string]*Route)
|
||||
models := make(map[string]*ModelUpstream)
|
||||
if configPath, ok := os.LookupEnv("CONFIG_PATH"); ok {
|
||||
loadedRoutes, loadedModels, err := LoadRoutesAndModelsFromFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routes = loadedRoutes
|
||||
models = loadedModels
|
||||
}
|
||||
|
||||
return &Config{
|
||||
ListenAddr: listenAddr,
|
||||
ShutdownTimeout: shutdownTimeout,
|
||||
Routes: routes,
|
||||
Models: models,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestLoadRoutesValidConfig tests that a valid configuration loads correctly.
|
||||
func TestLoadRoutesValidConfig(t *testing.T) {
|
||||
routes, err := config.LoadRoutesFromFile("../../testdata/config/valid.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error loading valid config: %v", err)
|
||||
}
|
||||
|
||||
// Verify all three routes are present
|
||||
if len(routes) != 3 {
|
||||
t.Errorf("expected 3 routes, got %d", len(routes))
|
||||
}
|
||||
|
||||
// Verify reasoning-chat route
|
||||
reasoningRoute, ok := routes["reasoning-chat"]
|
||||
if !ok {
|
||||
t.Fatal("reasoning-chat route not found")
|
||||
}
|
||||
if reasoningRoute.Name != "reasoning-chat" {
|
||||
t.Errorf("route name mismatch: expected 'reasoning-chat', got %q", reasoningRoute.Name)
|
||||
}
|
||||
if reasoningRoute.Upstream.Address != "reasoning-predictor.llm-serving:80" {
|
||||
t.Errorf("upstream address mismatch: expected 'reasoning-predictor.llm-serving:80', got %q", reasoningRoute.Upstream.Address)
|
||||
}
|
||||
if reasoningRoute.Upstream.ConnectTimeout != 10*time.Second {
|
||||
t.Errorf("connect timeout mismatch: expected 10s, got %v", reasoningRoute.Upstream.ConnectTimeout)
|
||||
}
|
||||
if reasoningRoute.Upstream.ReadTimeout != time.Hour {
|
||||
t.Errorf("read timeout mismatch: expected 1h, got %v", reasoningRoute.Upstream.ReadTimeout)
|
||||
}
|
||||
if reasoningRoute.Upstream.WriteTimeout != time.Hour {
|
||||
t.Errorf("write timeout mismatch: expected 1h, got %v", reasoningRoute.Upstream.WriteTimeout)
|
||||
}
|
||||
if reasoningRoute.Upstream.MaxBodySize != 10485760 {
|
||||
t.Errorf("max body size mismatch: expected 10485760, got %d", reasoningRoute.Upstream.MaxBodySize)
|
||||
}
|
||||
if !reasoningRoute.Upstream.AuthRequired {
|
||||
t.Error("auth required should be true")
|
||||
}
|
||||
|
||||
// Verify ornith-chat route
|
||||
ornithRoute, ok := routes["ornith-chat"]
|
||||
if !ok {
|
||||
t.Fatal("ornith-chat route not found")
|
||||
}
|
||||
if ornithRoute.Upstream.ReadTimeout != 10*time.Minute {
|
||||
t.Errorf("ornith read timeout mismatch: expected 10m, got %v", ornithRoute.Upstream.ReadTimeout)
|
||||
}
|
||||
if ornithRoute.Upstream.AuthRequired {
|
||||
t.Error("ornith auth required should be false")
|
||||
}
|
||||
|
||||
// Verify embeddings route
|
||||
embeddingsRoute, ok := routes["embeddings"]
|
||||
if !ok {
|
||||
t.Fatal("embeddings route not found")
|
||||
}
|
||||
if embeddingsRoute.Upstream.MaxBodySize != 5242880 {
|
||||
t.Errorf("embeddings max body size mismatch: expected 5242880, got %d", embeddingsRoute.Upstream.MaxBodySize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRoutesMissingAddress tests that missing address field is caught.
|
||||
func TestLoadRoutesMissingAddress(t *testing.T) {
|
||||
routes, err := config.LoadRoutesFromFile("../../testdata/config/missing-address.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing address, got nil")
|
||||
}
|
||||
if routes != nil {
|
||||
t.Error("expected nil routes on error")
|
||||
}
|
||||
if errMsg := err.Error(); errMsg != "route \"test-route\": field 'address' is required" {
|
||||
t.Errorf("expected error about missing address, got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRoutesMissingConnectTimeout tests that missing connectTimeout field is caught.
|
||||
func TestLoadRoutesMissingConnectTimeout(t *testing.T) {
|
||||
routes, err := config.LoadRoutesFromFile("../../testdata/config/missing-connectTimeout.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing connectTimeout, got nil")
|
||||
}
|
||||
if routes != nil {
|
||||
t.Error("expected nil routes on error")
|
||||
}
|
||||
if errMsg := err.Error(); errMsg != "route \"test-route\": field 'connectTimeout' is required" {
|
||||
t.Errorf("expected error about missing connectTimeout, got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRoutesMissingReadTimeout tests that missing readTimeout field is caught.
|
||||
func TestLoadRoutesMissingReadTimeout(t *testing.T) {
|
||||
routes, err := config.LoadRoutesFromFile("../../testdata/config/missing-readTimeout.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing readTimeout, got nil")
|
||||
}
|
||||
if routes != nil {
|
||||
t.Error("expected nil routes on error")
|
||||
}
|
||||
if errMsg := err.Error(); errMsg != "route \"test-route\": field 'readTimeout' is required" {
|
||||
t.Errorf("expected error about missing readTimeout, got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRoutesMissingWriteTimeout tests that missing writeTimeout field is caught.
|
||||
func TestLoadRoutesMissingWriteTimeout(t *testing.T) {
|
||||
routes, err := config.LoadRoutesFromFile("../../testdata/config/missing-writeTimeout.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing writeTimeout, got nil")
|
||||
}
|
||||
if routes != nil {
|
||||
t.Error("expected nil routes on error")
|
||||
}
|
||||
if errMsg := err.Error(); errMsg != "route \"test-route\": field 'writeTimeout' is required" {
|
||||
t.Errorf("expected error about missing writeTimeout, got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRoutesMissingMaxBodySize tests that missing maxBodySize field is caught.
|
||||
func TestLoadRoutesMissingMaxBodySize(t *testing.T) {
|
||||
routes, err := config.LoadRoutesFromFile("../../testdata/config/missing-maxBodySize.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing maxBodySize, got nil")
|
||||
}
|
||||
if routes != nil {
|
||||
t.Error("expected nil routes on error")
|
||||
}
|
||||
if errMsg := err.Error(); errMsg != "route \"test-route\": field 'maxBodySize' is required and must be > 0" {
|
||||
t.Errorf("expected error about missing maxBodySize, got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRoutesMalformedConnectTimeout tests that malformed connectTimeout is caught.
|
||||
func TestLoadRoutesMalformedConnectTimeout(t *testing.T) {
|
||||
routes, err := config.LoadRoutesFromFile("../../testdata/config/malformed-connectTimeout.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed connectTimeout, got nil")
|
||||
}
|
||||
if routes != nil {
|
||||
t.Error("expected nil routes on error")
|
||||
}
|
||||
if errMsg := err.Error(); errMsg != "route \"test-route\": invalid connectTimeout \"not-a-duration\": time: invalid duration \"not-a-duration\"" {
|
||||
t.Errorf("expected error about malformed connectTimeout, got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRoutesMalformedReadTimeout tests that malformed readTimeout is caught.
|
||||
func TestLoadRoutesMalformedReadTimeout(t *testing.T) {
|
||||
routes, err := config.LoadRoutesFromFile("../../testdata/config/malformed-readTimeout.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed readTimeout, got nil")
|
||||
}
|
||||
if routes != nil {
|
||||
t.Error("expected nil routes on error")
|
||||
}
|
||||
if errMsg := err.Error(); errMsg != "route \"test-route\": invalid readTimeout \"invalid\": time: invalid duration \"invalid\"" {
|
||||
t.Errorf("expected error about malformed readTimeout, got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRoutesMalformedWriteTimeout tests that malformed writeTimeout is caught.
|
||||
func TestLoadRoutesMalformedWriteTimeout(t *testing.T) {
|
||||
routes, err := config.LoadRoutesFromFile("../../testdata/config/malformed-writeTimeout.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed writeTimeout, got nil")
|
||||
}
|
||||
if routes != nil {
|
||||
t.Error("expected nil routes on error")
|
||||
}
|
||||
if errMsg := err.Error(); errMsg != "route \"test-route\": invalid writeTimeout \"bad\": time: invalid duration \"bad\"" {
|
||||
t.Errorf("expected error about malformed writeTimeout, got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRoutesInvalidAddressNoPort tests that address without port is caught.
|
||||
func TestLoadRoutesInvalidAddressNoPort(t *testing.T) {
|
||||
routes, err := config.LoadRoutesFromFile("../../testdata/config/invalid-address-no-port.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid address (no port), got nil")
|
||||
}
|
||||
if routes != nil {
|
||||
t.Error("expected nil routes on error")
|
||||
}
|
||||
if errMsg := err.Error(); errMsg != "route \"test-route\": invalid address \"localhost\": address localhost: missing port in address" {
|
||||
t.Errorf("expected error about invalid address, got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRoutesDuplicateRouteNames tests that duplicate route names are caught.
|
||||
func TestLoadRoutesDuplicateRouteNames(t *testing.T) {
|
||||
routes, err := config.LoadRoutesFromFile("../../testdata/config/duplicate-routes.yaml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate routes, got nil")
|
||||
}
|
||||
if routes != nil {
|
||||
t.Error("expected nil routes on error")
|
||||
}
|
||||
if errMsg := err.Error(); errMsg != "duplicate route: \"test-route\"" {
|
||||
t.Errorf("expected error about duplicate routes, got: %s", errMsg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestLoadIntegration tests the full Load function with CONFIG_PATH env var
|
||||
func TestLoadIntegration(t *testing.T) {
|
||||
// Set the environment variable
|
||||
originalConfigPath := os.Getenv("CONFIG_PATH")
|
||||
os.Setenv("CONFIG_PATH", "../../testdata/config/valid.yaml")
|
||||
defer func() {
|
||||
if originalConfigPath != "" {
|
||||
os.Setenv("CONFIG_PATH", originalConfigPath)
|
||||
} else {
|
||||
os.Unsetenv("CONFIG_PATH")
|
||||
}
|
||||
}()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if cfg == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
|
||||
if len(cfg.Routes) != 3 {
|
||||
t.Errorf("expected 3 routes, got %d", len(cfg.Routes))
|
||||
}
|
||||
|
||||
// Verify reasoning-chat is present
|
||||
reasoningRoute, ok := cfg.Routes["reasoning-chat"]
|
||||
if !ok {
|
||||
t.Fatal("reasoning-chat route not found")
|
||||
}
|
||||
if reasoningRoute.Upstream.Address != "reasoning-predictor.llm-serving:80" {
|
||||
t.Errorf("unexpected address: %s", reasoningRoute.Upstream.Address)
|
||||
}
|
||||
if reasoningRoute.Upstream.ConnectTimeout != 10*time.Second {
|
||||
t.Errorf("unexpected connect timeout: %v", reasoningRoute.Upstream.ConnectTimeout)
|
||||
}
|
||||
if reasoningRoute.Upstream.ReadTimeout != time.Hour {
|
||||
t.Errorf("unexpected read timeout: %v", reasoningRoute.Upstream.ReadTimeout)
|
||||
}
|
||||
if reasoningRoute.Upstream.WriteTimeout != time.Hour {
|
||||
t.Errorf("unexpected write timeout: %v", reasoningRoute.Upstream.WriteTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadIntegrationWithInvalidConfig tests that Load fails with invalid config
|
||||
func TestLoadIntegrationWithInvalidConfig(t *testing.T) {
|
||||
originalConfigPath := os.Getenv("CONFIG_PATH")
|
||||
os.Setenv("CONFIG_PATH", "../../testdata/config/missing-address.yaml")
|
||||
defer func() {
|
||||
if originalConfigPath != "" {
|
||||
os.Setenv("CONFIG_PATH", originalConfigPath)
|
||||
} else {
|
||||
os.Unsetenv("CONFIG_PATH")
|
||||
}
|
||||
}()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error but got nil")
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Error("expected nil config on error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// rawConfig represents the structure of the YAML configuration file.
|
||||
type rawConfig struct {
|
||||
Routes []rawRoute `yaml:"routes"`
|
||||
Models []rawModel `yaml:"models"`
|
||||
}
|
||||
|
||||
// rawRoute represents a single route in the YAML configuration.
|
||||
type rawRoute struct {
|
||||
Name string `yaml:"name"`
|
||||
Upstream rawUpstream `yaml:"upstream"`
|
||||
}
|
||||
|
||||
// rawModel represents a single model entry in the YAML configuration.
|
||||
type rawModel struct {
|
||||
Name string `yaml:"name"`
|
||||
Address string `yaml:"address"`
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
// rawUpstream represents upstream configuration in YAML.
|
||||
type rawUpstream struct {
|
||||
Address string `yaml:"address"`
|
||||
PathRewrite string `yaml:"pathRewrite"`
|
||||
ConnectTimeout string `yaml:"connectTimeout"`
|
||||
ReadTimeout string `yaml:"readTimeout"`
|
||||
WriteTimeout string `yaml:"writeTimeout"`
|
||||
MaxBodySize int64 `yaml:"maxBodySize"`
|
||||
AuthRequired *bool `yaml:"authRequired"`
|
||||
}
|
||||
|
||||
// LoadRoutesAndModelsFromFile loads both route and model configuration from a YAML file.
|
||||
func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
var raw rawConfig
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
// Load routes
|
||||
routes := make(map[string]*Route)
|
||||
for _, rawRoute := range raw.Routes {
|
||||
if rawRoute.Name == "" {
|
||||
return nil, nil, fmt.Errorf("route has empty name")
|
||||
}
|
||||
|
||||
if _, exists := routes[rawRoute.Name]; exists {
|
||||
return nil, nil, fmt.Errorf("duplicate route: \"%s\"", rawRoute.Name)
|
||||
}
|
||||
|
||||
upstream, err := parseUpstream(rawRoute.Name, rawRoute.Upstream)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
routes[rawRoute.Name] = &Route{
|
||||
Name: rawRoute.Name,
|
||||
Upstream: upstream,
|
||||
}
|
||||
}
|
||||
|
||||
// Load models
|
||||
models := make(map[string]*ModelUpstream)
|
||||
for _, rawModel := range raw.Models {
|
||||
// Validate model name is not empty
|
||||
if rawModel.Name == "" {
|
||||
return nil, nil, fmt.Errorf("model has empty name")
|
||||
}
|
||||
|
||||
// Check for duplicate model names
|
||||
if _, exists := models[rawModel.Name]; exists {
|
||||
return nil, nil, fmt.Errorf("duplicate model: \"%s\"", rawModel.Name)
|
||||
}
|
||||
|
||||
// Validate address is not empty
|
||||
if rawModel.Address == "" {
|
||||
return nil, nil, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name)
|
||||
}
|
||||
|
||||
// Validate address format (host:port)
|
||||
if _, _, err := net.SplitHostPort(rawModel.Address); err != nil {
|
||||
return nil, nil, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, rawModel.Address, err)
|
||||
}
|
||||
|
||||
models[rawModel.Name] = &ModelUpstream{
|
||||
Name: rawModel.Name,
|
||||
Address: rawModel.Address,
|
||||
Path: rawModel.Path,
|
||||
}
|
||||
}
|
||||
|
||||
return routes, models, nil
|
||||
}
|
||||
|
||||
// LoadRoutesFromFile loads route configuration from a YAML file.
|
||||
// It validates that all required fields are present and have valid values.
|
||||
// Returns an error if the configuration is invalid.
|
||||
// Deprecated: Use LoadRoutesAndModelsFromFile instead.
|
||||
func LoadRoutesFromFile(path string) (map[string]*Route, error) {
|
||||
routes, _, err := LoadRoutesAndModelsFromFile(path)
|
||||
return routes, err
|
||||
}
|
||||
|
||||
// parseUpstream validates and parses upstream configuration from raw YAML.
|
||||
func parseUpstream(routeName string, raw rawUpstream) (Upstream, error) {
|
||||
// Validate address is not empty
|
||||
if raw.Address == "" {
|
||||
return Upstream{}, fmt.Errorf("route \"%s\": field 'address' is required", routeName)
|
||||
}
|
||||
|
||||
// Validate address format (host:port)
|
||||
if _, _, err := net.SplitHostPort(raw.Address); err != nil {
|
||||
return Upstream{}, fmt.Errorf("route \"%s\": invalid address \"%s\": %w", routeName, raw.Address, err)
|
||||
}
|
||||
|
||||
// Validate connectTimeout
|
||||
if raw.ConnectTimeout == "" {
|
||||
return Upstream{}, fmt.Errorf("route \"%s\": field 'connectTimeout' is required", routeName)
|
||||
}
|
||||
connectTimeout, err := time.ParseDuration(raw.ConnectTimeout)
|
||||
if err != nil {
|
||||
return Upstream{}, fmt.Errorf("route \"%s\": invalid connectTimeout \"%s\": %w", routeName, raw.ConnectTimeout, err)
|
||||
}
|
||||
|
||||
// Validate readTimeout
|
||||
if raw.ReadTimeout == "" {
|
||||
return Upstream{}, fmt.Errorf("route \"%s\": field 'readTimeout' is required", routeName)
|
||||
}
|
||||
readTimeout, err := time.ParseDuration(raw.ReadTimeout)
|
||||
if err != nil {
|
||||
return Upstream{}, fmt.Errorf("route \"%s\": invalid readTimeout \"%s\": %w", routeName, raw.ReadTimeout, err)
|
||||
}
|
||||
|
||||
// Validate writeTimeout
|
||||
if raw.WriteTimeout == "" {
|
||||
return Upstream{}, fmt.Errorf("route \"%s\": field 'writeTimeout' is required", routeName)
|
||||
}
|
||||
writeTimeout, err := time.ParseDuration(raw.WriteTimeout)
|
||||
if err != nil {
|
||||
return Upstream{}, fmt.Errorf("route \"%s\": invalid writeTimeout \"%s\": %w", routeName, raw.WriteTimeout, err)
|
||||
}
|
||||
|
||||
// Validate maxBodySize is not zero (it must be explicitly set)
|
||||
if raw.MaxBodySize == 0 {
|
||||
return Upstream{}, fmt.Errorf("route \"%s\": field 'maxBodySize' is required and must be > 0", routeName)
|
||||
}
|
||||
|
||||
// Validate authRequired is not missing
|
||||
if raw.AuthRequired == nil {
|
||||
return Upstream{}, fmt.Errorf("route \"%s\": field 'authRequired' is required", routeName)
|
||||
}
|
||||
|
||||
return Upstream{
|
||||
Address: raw.Address,
|
||||
PathRewrite: raw.PathRewrite,
|
||||
ConnectTimeout: connectTimeout,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
MaxBodySize: raw.MaxBodySize,
|
||||
AuthRequired: *raw.AuthRequired,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadModelsValidConfig(t *testing.T) {
|
||||
data := `
|
||||
routes:
|
||||
- name: test-route
|
||||
upstream:
|
||||
address: "localhost:8000"
|
||||
pathRewrite: ""
|
||||
connectTimeout: "10s"
|
||||
readTimeout: "30s"
|
||||
writeTimeout: "30s"
|
||||
maxBodySize: 1048576
|
||||
authRequired: false
|
||||
|
||||
models:
|
||||
- name: "reasoning"
|
||||
address: "reasoning-predictor.llm-serving:80"
|
||||
path: "/v1/chat/completions"
|
||||
- name: "ornith:35b"
|
||||
address: "ornith-predictor.llm-serving:80"
|
||||
path: "/v1/chat/completions"
|
||||
- name: "qwen2.5:3b-instruct"
|
||||
address: "ornith-predictor.llm-serving:80"
|
||||
path: "/v1/chat/completions"
|
||||
`
|
||||
|
||||
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
|
||||
defer os.Remove(tmpFile.Name())
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if len(models) != 3 {
|
||||
t.Errorf("expected 3 models, got %d", len(models))
|
||||
}
|
||||
|
||||
// Test LookupModel
|
||||
cfg := &Config{Models: models}
|
||||
reasoning := cfg.LookupModel("reasoning")
|
||||
if reasoning == nil {
|
||||
t.Errorf("expected to find model 'reasoning'")
|
||||
}
|
||||
if reasoning.Address != "reasoning-predictor.llm-serving:80" {
|
||||
t.Errorf("expected address reasoning-predictor.llm-serving:80, got %s", reasoning.Address)
|
||||
}
|
||||
|
||||
// Test case sensitivity
|
||||
notFound := cfg.LookupModel("Reasoning")
|
||||
if notFound != nil {
|
||||
t.Errorf("model lookup should be case-sensitive")
|
||||
}
|
||||
|
||||
// Test ModelNames
|
||||
names := cfg.ModelNames()
|
||||
if len(names) != 3 {
|
||||
t.Errorf("expected 3 model names, got %d", len(names))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModelsDuplicateName(t *testing.T) {
|
||||
data := `
|
||||
routes:
|
||||
- name: test-route
|
||||
upstream:
|
||||
address: "localhost:8000"
|
||||
pathRewrite: ""
|
||||
connectTimeout: "10s"
|
||||
readTimeout: "30s"
|
||||
writeTimeout: "30s"
|
||||
maxBodySize: 1048576
|
||||
authRequired: false
|
||||
|
||||
models:
|
||||
- name: "reasoning"
|
||||
address: "upstream1:80"
|
||||
path: "/v1/chat"
|
||||
- name: "reasoning"
|
||||
address: "upstream2:80"
|
||||
path: "/v1/chat"
|
||||
`
|
||||
|
||||
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
|
||||
defer os.Remove(tmpFile.Name())
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err == nil {
|
||||
t.Errorf("expected error for duplicate model name, got nil")
|
||||
}
|
||||
if err.Error() != "duplicate model: \"reasoning\"" {
|
||||
t.Errorf("expected duplicate model error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModelsEmptyName(t *testing.T) {
|
||||
data := `
|
||||
routes:
|
||||
- name: test-route
|
||||
upstream:
|
||||
address: "localhost:8000"
|
||||
pathRewrite: ""
|
||||
connectTimeout: "10s"
|
||||
readTimeout: "30s"
|
||||
writeTimeout: "30s"
|
||||
maxBodySize: 1048576
|
||||
authRequired: false
|
||||
|
||||
models:
|
||||
- name: ""
|
||||
address: "upstream:80"
|
||||
path: "/v1/chat"
|
||||
`
|
||||
|
||||
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
|
||||
defer os.Remove(tmpFile.Name())
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err == nil {
|
||||
t.Errorf("expected error for empty model name, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModelsMissingAddress(t *testing.T) {
|
||||
data := `
|
||||
routes:
|
||||
- name: test-route
|
||||
upstream:
|
||||
address: "localhost:8000"
|
||||
pathRewrite: ""
|
||||
connectTimeout: "10s"
|
||||
readTimeout: "30s"
|
||||
writeTimeout: "30s"
|
||||
maxBodySize: 1048576
|
||||
authRequired: false
|
||||
|
||||
models:
|
||||
- name: "reasoning"
|
||||
address: ""
|
||||
path: "/v1/chat"
|
||||
`
|
||||
|
||||
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
|
||||
defer os.Remove(tmpFile.Name())
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err == nil {
|
||||
t.Errorf("expected error for missing address, got nil")
|
||||
}
|
||||
if err.Error() != "model \"reasoning\": field 'address' is required" {
|
||||
t.Errorf("expected missing address error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModelsInvalidAddress(t *testing.T) {
|
||||
data := `
|
||||
routes:
|
||||
- name: test-route
|
||||
upstream:
|
||||
address: "localhost:8000"
|
||||
pathRewrite: ""
|
||||
connectTimeout: "10s"
|
||||
readTimeout: "30s"
|
||||
writeTimeout: "30s"
|
||||
maxBodySize: 1048576
|
||||
authRequired: false
|
||||
|
||||
models:
|
||||
- name: "reasoning"
|
||||
address: "invalid-address-no-port"
|
||||
path: "/v1/chat"
|
||||
`
|
||||
|
||||
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
|
||||
defer os.Remove(tmpFile.Name())
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err == nil {
|
||||
t.Errorf("expected error for invalid address, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModelsOptionalPath(t *testing.T) {
|
||||
data := `
|
||||
routes:
|
||||
- name: test-route
|
||||
upstream:
|
||||
address: "localhost:8000"
|
||||
pathRewrite: ""
|
||||
connectTimeout: "10s"
|
||||
readTimeout: "30s"
|
||||
writeTimeout: "30s"
|
||||
maxBodySize: 1048576
|
||||
authRequired: false
|
||||
|
||||
models:
|
||||
- name: "reasoning"
|
||||
address: "upstream:80"
|
||||
`
|
||||
|
||||
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
|
||||
defer os.Remove(tmpFile.Name())
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if len(models) != 1 {
|
||||
t.Errorf("expected 1 model, got %d", len(models))
|
||||
}
|
||||
|
||||
model := models["reasoning"]
|
||||
if model.Path != "" {
|
||||
t.Errorf("expected empty path when not specified, got %s", model.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelSameUpstreamMultipleNames(t *testing.T) {
|
||||
data := `
|
||||
routes:
|
||||
- name: test-route
|
||||
upstream:
|
||||
address: "localhost:8000"
|
||||
pathRewrite: ""
|
||||
connectTimeout: "10s"
|
||||
readTimeout: "30s"
|
||||
writeTimeout: "30s"
|
||||
maxBodySize: 1048576
|
||||
authRequired: false
|
||||
|
||||
models:
|
||||
- name: "ornith:35b"
|
||||
address: "ollama-pod:80"
|
||||
path: "/v1/chat"
|
||||
- name: "qwen2.5:3b"
|
||||
address: "ollama-pod:80"
|
||||
path: "/v1/chat"
|
||||
`
|
||||
|
||||
tmpFile, _ := os.CreateTemp("", "config-*.yaml")
|
||||
defer os.Remove(tmpFile.Name())
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
routes, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
if len(models) != 2 {
|
||||
t.Errorf("expected 2 models, got %d", len(models))
|
||||
}
|
||||
|
||||
// Both should resolve independently
|
||||
cfg := &Config{Routes: routes, Models: models}
|
||||
m1 := cfg.LookupModel("ornith:35b")
|
||||
m2 := cfg.LookupModel("qwen2.5:3b")
|
||||
|
||||
if m1.Address != m2.Address {
|
||||
t.Errorf("expected both models to point to same upstream")
|
||||
}
|
||||
|
||||
if m1 == m2 {
|
||||
t.Errorf("expected different ModelUpstream objects even for same address")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user