diff options
Diffstat (limited to 'internal/config')
| -rw-r--r-- | internal/config/config.go | 89 | ||||
| -rw-r--r-- | internal/config/config_test.go | 25 |
2 files changed, 111 insertions, 3 deletions
diff --git a/internal/config/config.go b/internal/config/config.go index 5e1e518..8c21c6c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ type Config struct { UpstreamHTTP UpstreamHTTPConfig `json:"upstream_http"` Providers []ProviderConfig `json:"providers"` Models []ModelConfig `json:"models"` + Billing BillingConfig `json:"billing"` Observability ObservabilityConfig `json:"observability"` } @@ -74,9 +75,13 @@ type ProviderConfig struct { } type ModelConfig struct { - ID string `json:"id"` - OwnedBy string `json:"owned_by"` - Routes []RouteConfig `json:"routes"` + ID string `json:"id"` + OwnedBy string `json:"owned_by"` + InputPriceMicrosPerMillion int64 `json:"input_price_micros_per_million"` + OutputPriceMicrosPerMillion int64 `json:"output_price_micros_per_million"` + CacheReadPriceMicrosPerMillion int64 `json:"cache_read_price_micros_per_million"` + CacheWritePriceMicrosPerMillion int64 `json:"cache_write_price_micros_per_million"` + Routes []RouteConfig `json:"routes"` } type RouteConfig struct { @@ -91,6 +96,25 @@ type ObservabilityConfig struct { ExposeMetrics bool `json:"expose_metrics"` } +type BillingConfig struct { + Enabled bool `json:"enabled"` + Currency string `json:"currency"` + DefaultMaxOutputTokens int64 `json:"default_max_output_tokens"` + MinTopUpMinor int64 `json:"min_top_up_minor"` + MaxTopUpMinor int64 `json:"max_top_up_minor"` + Stripe StripeConfig `json:"stripe"` +} + +type StripeConfig struct { + Enabled bool `json:"enabled"` + APIKeyEnv string `json:"api_key_env"` + WebhookSecretEnv string `json:"webhook_secret_env"` + SuccessURL string `json:"success_url"` + CancelURL string `json:"cancel_url"` + APIKey string `json:"-"` + WebhookSecret string `json:"-"` +} + func Load(path string) (Config, error) { f, err := os.Open(path) if err != nil { @@ -179,6 +203,24 @@ func applyDefaults(cfg *Config) { if cfg.Observability.UsageBuffer == 0 { cfg.Observability.UsageBuffer = 8192 } + if cfg.Billing.Currency == "" { + cfg.Billing.Currency = "usd" + } + if cfg.Billing.DefaultMaxOutputTokens == 0 { + cfg.Billing.DefaultMaxOutputTokens = 4096 + } + if cfg.Billing.MaxTopUpMinor == 0 { + cfg.Billing.MaxTopUpMinor = 1000000 + } + if cfg.Billing.MinTopUpMinor == 0 { + cfg.Billing.MinTopUpMinor = 500 + } + if cfg.Billing.Stripe.APIKeyEnv == "" { + cfg.Billing.Stripe.APIKeyEnv = "AIGW_STRIPE_API_KEY" + } + if cfg.Billing.Stripe.WebhookSecretEnv == "" { + cfg.Billing.Stripe.WebhookSecretEnv = "AIGW_STRIPE_WEBHOOK_SECRET" + } for i := range cfg.Models { for j := range cfg.Models[i].Routes { if cfg.Models[i].Routes[j].Weight == 0 { @@ -197,6 +239,10 @@ func resolveSecrets(cfg *Config) error { if cfg.Admin.Enabled { cfg.Admin.Token = os.Getenv(cfg.Admin.TokenEnv) } + if cfg.Billing.Enabled && cfg.Billing.Stripe.Enabled { + cfg.Billing.Stripe.APIKey = os.Getenv(cfg.Billing.Stripe.APIKeyEnv) + cfg.Billing.Stripe.WebhookSecret = os.Getenv(cfg.Billing.Stripe.WebhookSecretEnv) + } for i := range cfg.Providers { provider := &cfg.Providers[i] if provider.APIKeyEnv == "" { @@ -240,6 +286,40 @@ func Validate(cfg Config) error { return errors.New("admin.base_path must start with / and cannot be /") } } + if cfg.Billing.Enabled { + if !cfg.ControlPlane.Enabled { + return errors.New("billing requires control_plane.enabled") + } + if cfg.Auth.AllowAnonymous { + return errors.New("billing cannot be enabled with auth.allow_anonymous") + } + if len(cfg.Billing.Currency) != 3 || strings.ToLower(cfg.Billing.Currency) != cfg.Billing.Currency { + return errors.New("billing.currency must be a lowercase ISO 4217 code") + } + if cfg.Billing.DefaultMaxOutputTokens < 1 { + return errors.New("billing.default_max_output_tokens must be positive") + } + if cfg.Billing.MinTopUpMinor < 1 || cfg.Billing.MaxTopUpMinor < cfg.Billing.MinTopUpMinor { + return errors.New("billing top-up bounds are invalid") + } + if cfg.Billing.Stripe.Enabled { + if cfg.Billing.Stripe.APIKey == "" { + return fmt.Errorf("billing.stripe: environment variable %s is empty", cfg.Billing.Stripe.APIKeyEnv) + } + if cfg.Billing.Stripe.WebhookSecret == "" { + return fmt.Errorf("billing.stripe: environment variable %s is empty", cfg.Billing.Stripe.WebhookSecretEnv) + } + if strings.TrimSpace(cfg.Billing.Stripe.SuccessURL) == "" || strings.TrimSpace(cfg.Billing.Stripe.CancelURL) == "" { + return errors.New("billing.stripe.success_url and cancel_url are required") + } + for name, value := range map[string]string{"success_url": cfg.Billing.Stripe.SuccessURL, "cancel_url": cfg.Billing.Stripe.CancelURL} { + parsed, err := url.Parse(value) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return fmt.Errorf("billing.stripe.%s must be an absolute http(s) URL", name) + } + } + } + } providers := make(map[string]ProviderConfig, len(cfg.Providers)) for _, provider := range cfg.Providers { @@ -274,6 +354,9 @@ func Validate(cfg Config) error { return fmt.Errorf("duplicate model id %q", model.ID) } models[model.ID] = struct{}{} + if model.InputPriceMicrosPerMillion < 0 || model.OutputPriceMicrosPerMillion < 0 || model.CacheReadPriceMicrosPerMillion < 0 || model.CacheWritePriceMicrosPerMillion < 0 { + return fmt.Errorf("model %q: prices cannot be negative", model.ID) + } if len(model.Routes) == 0 { return fmt.Errorf("model %q: at least one route is required", model.ID) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e681508..2331b0f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -85,6 +85,31 @@ func TestLoadRejectsAdminWithoutControlPlane(t *testing.T) { } } +func TestLoadResolvesStripeSecrets(t *testing.T) { + t.Setenv("AIGW_DATABASE_URL", "postgres://aigw:aigw@postgres/aigw") + t.Setenv("AIGW_CREDENTIAL_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + t.Setenv("TEST_STRIPE_KEY", "rk_test_example") + t.Setenv("TEST_STRIPE_WEBHOOK", "whsec_example") + path := writeConfig(t, `{ + "control_plane":{"enabled":true}, + "billing":{"enabled":true,"stripe":{"enabled":true,"api_key_env":"TEST_STRIPE_KEY","webhook_secret_env":"TEST_STRIPE_WEBHOOK","success_url":"http://localhost/admin/?topup=success","cancel_url":"http://localhost/admin/?topup=cancel"}} +}`) + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Billing.Stripe.APIKey != "rk_test_example" || cfg.Billing.Stripe.WebhookSecret != "whsec_example" { + t.Fatal("Stripe secrets were not resolved") + } +} + +func TestLoadRejectsBillingWithoutControlPlane(t *testing.T) { + path := writeConfig(t, `{"billing":{"enabled":true}}`) + if _, err := Load(path); err == nil { + t.Fatal("expected billing without control plane to be rejected") + } +} + func writeConfig(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.json") |
