diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 0b6fed6..870e5be 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -37,6 +37,10 @@ import ( "github.com/taciturnaxolotl/potluck/internal/stream" _ "modernc.org/sqlite" + + // Register provider capabilities via init(). + _ "github.com/taciturnaxolotl/potluck/internal/pool/providers/generic" + _ "github.com/taciturnaxolotl/potluck/internal/pool/providers/pioneer" ) // Information set at build time via -ldflags. diff --git a/server/db/queries/providers.sql b/server/db/queries/providers.sql index ca8e614..20fbf6b 100644 --- a/server/db/queries/providers.sql +++ b/server/db/queries/providers.sql @@ -3,6 +3,9 @@ -- name: ListActiveProviders :many SELECT * FROM providers WHERE active = 1 ORDER BY id; +-- name: ListAllProviders :many +SELECT * FROM providers ORDER BY id; + -- name: GetProvider :one SELECT * FROM providers WHERE id = ?; diff --git a/server/internal/api/web/pool_keys.go b/server/internal/api/web/pool_keys.go index 4942ea6..f0a034c 100644 --- a/server/internal/api/web/pool_keys.go +++ b/server/internal/api/web/pool_keys.go @@ -20,18 +20,148 @@ import ( // handleListProviders returns all active providers from the registry. func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) { - providers := s.Registry.List() - out := make([]map[string]any, 0, len(providers)) - for _, p := range providers { + rows, err := s.Q.ListAllProviders(r.Context()) + if err != nil { + writeErr(w, 500, "internal", err.Error()) + return + } + out := make([]map[string]any, 0, len(rows)) + for _, p := range rows { out = append(out, map[string]any{ - "id": p.ID, - "type": string(p.Type), - "name": p.Name, + "id": p.ID, + "type": p.Type, + "name": p.Name, + "active": p.Active == 1, }) } writeJSON(w, 200, out) } +// handleCreateProvider adds a new provider to the registry. +func (s *Server) handleCreateProvider(w http.ResponseWriter, r *http.Request) { + var req struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, 400, "invalid_request", err.Error()) + return + } + if req.ID == "" || req.Type == "" || req.Name == "" || req.BaseURL == "" { + writeErr(w, 400, "invalid_request", "id, type, name, and base_url are required") + return + } + + now := time.Now().Unix() + err := s.Q.CreateProvider(r.Context(), store.CreateProviderParams{ + ID: req.ID, + Type: req.Type, + Name: req.Name, + BaseUrl: req.BaseURL, + ConfigJson: "{}", + Active: 1, + CreatedAt: now, + }) + if err != nil { + if isUniqueConstraintErr(err) { + writeErr(w, 409, "duplicate", "provider with this ID already exists") + return + } + writeErr(w, 500, "internal", err.Error()) + return + } + + writeJSON(w, 201, map[string]any{ + "id": req.ID, + "type": req.Type, + "name": req.Name, + }) +} + +// handleUpdateProvider updates a provider's fields or toggles active status. +func (s *Server) handleUpdateProvider(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + writeErr(w, 400, "invalid_request", "provider ID required") + return + } + + var req struct { + Type *string `json:"type,omitempty"` + Name *string `json:"name,omitempty"` + BaseURL *string `json:"base_url,omitempty"` + Active *bool `json:"active,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, 400, "invalid_request", err.Error()) + return + } + + // Fetch current to preserve unchanged fields. + current, err := s.Q.GetProvider(r.Context(), id) + if err != nil { + writeErr(w, 404, "not_found", "provider not found") + return + } + + newType := current.Type + newName := current.Name + newBaseURL := current.BaseUrl + newActive := current.Active == 1 + if req.Type != nil { + newType = *req.Type + } + if req.Name != nil { + newName = *req.Name + } + if req.BaseURL != nil { + newBaseURL = *req.BaseURL + } + if req.Active != nil { + newActive = *req.Active + } + + activeInt := int64(0) + if newActive { + activeInt = 1 + } + _ = s.Q.UpdateProvider(r.Context(), store.UpdateProviderParams{ + Type: newType, + Name: newName, + BaseUrl: newBaseURL, + ConfigJson: current.ConfigJson, + Active: activeInt, + ID: id, + }) + + writeJSON(w, 200, map[string]any{"ok": true}) +} + +// handleDeleteProvider removes a provider. Fails if pool keys reference it. +func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + writeErr(w, 400, "invalid_request", "provider ID required") + return + } + + // Check for existing keys. + keys, _ := s.Q.ListKeysByProvider(r.Context(), id) + if len(keys) > 0 { + writeErr(w, 409, "has_keys", fmt.Sprintf("cannot delete: %d pool keys still reference this provider", len(keys))) + return + } + + if err := s.Q.DeleteProvider(r.Context(), id); err != nil { + writeErr(w, 500, "internal", err.Error()) + return + } + + writeJSON(w, 200, map[string]any{"ok": true}) +} + // handleListPoolKeys returns all pool keys visible to any authenticated user. // Ciphertexts are never returned; fingerprints are omitted from the response. func (s *Server) handleListPoolKeys(w http.ResponseWriter, r *http.Request) { @@ -200,36 +330,29 @@ func (s *Server) handleProbePoolKey(w http.ResponseWriter, r *http.Request) { return } - if providerID == "pioneer" { - billing, err := probePioneerBilling(r.Context(), req.APIKey) + // Use provider capabilities for validation if available. + caps := pool.GetProviderCapabilities(string(provCfg.Type)) + if caps != nil { + val, err := caps.ValidateKey(r.Context(), http.DefaultClient, provCfg.BaseURL, req.APIKey) if err != nil { writeErr(w, 422, "probe_failed", err.Error()) return } - if billing.HTTP401 { - writeErr(w, 422, "unauthorized", "pioneer returned 401 — key may be exhausted or not yet active") - return - } - if billing.HTTP503 { - writeErr(w, 503, "provider_down", "pioneer auth service is temporarily down; try again shortly") - return - } - if billing.PaymentPlan != "" && !pool.AcceptedPlan(billing.PaymentPlan) { - writeErr(w, 422, "invalid_plan", - fmt.Sprintf("unsupported pioneer plan %q (accepted: pro, pro_legacy, partner)", billing.PaymentPlan)) + if val.PendingReason != "" { + writeErr(w, 422, "unauthorized", val.PendingReason) return } writeJSON(w, 200, map[string]any{ - "provider_id": "pioneer", - "payment_plan": billing.PaymentPlan, - "credit_limit_micros": billing.CreditLimitMicros, - "remaining_micros": billing.RemainingMicros, - "today_micros": billing.TodayMicros, + "provider_id": providerID, + "payment_plan": val.PaymentPlan, + "credit_limit_micros": val.CreditLimitMicros, + "remaining_micros": val.RemainingMicros, + "today_micros": val.TodayMicros, }) return } - // Generic probe for non-pioneer providers: validate key via /v1/models. + // Fallback: generic /v1/models probe. valid, err := probeGenericKey(r.Context(), provCfg.BaseURL, req.APIKey) if err != nil { writeErr(w, 422, "probe_failed", err.Error()) @@ -242,7 +365,7 @@ func (s *Server) handleProbePoolKey(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{ "provider_id": providerID, - "credit_limit_micros": int64(0), // unknown for generic providers + "credit_limit_micros": int64(0), "remaining_micros": int64(0), "today_micros": int64(0), }) @@ -294,7 +417,8 @@ func (s *Server) handleAddPoolKey(w http.ResponseWriter, r *http.Request) { } // Validate the provider exists. - if _, ok := s.Registry.Get(providerID); !ok { + provCfg, ok := s.Registry.Get(providerID) + if !ok { writeErr(w, 400, "invalid_provider", fmt.Sprintf("unknown provider: %s", providerID)) return } @@ -303,28 +427,27 @@ func (s *Server) handleAddPoolKey(w http.ResponseWriter, r *http.Request) { var pendingValidation int64 var pendingReason string - if providerID == "pioneer" { - var err error - billing, err = probePioneerBilling(r.Context(), req.APIKey) + // Use provider capabilities for validation if available. + caps := pool.GetProviderCapabilities(string(provCfg.Type)) + if caps != nil { + val, err := caps.ValidateKey(r.Context(), http.DefaultClient, provCfg.BaseURL, req.APIKey) if err != nil { writeErr(w, 422, "invalid_key", err.Error()) return } - switch { - case billing.HTTP401: - pendingValidation = 1 - pendingReason = "pioneer returned 401 — key may be exhausted or not yet active; we'll retry automatically" - case billing.HTTP503: + if val.PendingReason != "" { pendingValidation = 1 - pendingReason = "pioneer auth service is temporarily down; we'll retry automatically" - case billing.PaymentPlan != "" && !pool.AcceptedPlan(billing.PaymentPlan): - writeErr(w, 422, "invalid_plan", - fmt.Sprintf("unsupported pioneer plan %q (accepted: pro, pro_legacy, partner)", billing.PaymentPlan)) - return + pendingReason = val.PendingReason + } + billing = pioneerBillingResult{ + TodayMicros: val.TodayMicros, + RemainingMicros: val.RemainingMicros, + CreditLimitMicros: val.CreditLimitMicros, + TeamID: val.TeamID, + PaymentPlan: val.PaymentPlan, } } else { - // Generic provider: validate via /v1/models. - provCfg, _ := s.Registry.Get(providerID) + // Fallback: generic /v1/models probe. valid, err := probeGenericKey(r.Context(), provCfg.BaseURL, req.APIKey) if err != nil { writeErr(w, 422, "invalid_key", err.Error()) @@ -334,7 +457,6 @@ func (s *Server) handleAddPoolKey(w http.ResponseWriter, r *http.Request) { writeErr(w, 422, "unauthorized", "key validation failed — check your API key") return } - // Generic providers start healthy with no billing info. billing = pioneerBillingResult{} } diff --git a/server/internal/api/web/web.go b/server/internal/api/web/web.go index 9382968..592acad 100644 --- a/server/internal/api/web/web.go +++ b/server/internal/api/web/web.go @@ -51,6 +51,9 @@ func (s *Server) Mount(r chi.Router) { r.Get("/models", s.handleListModels) r.Get("/providers", s.handleListProviders) + r.Post("/providers", s.handleCreateProvider) + r.Patch("/providers/{id}", s.handleUpdateProvider) + r.Delete("/providers/{id}", s.handleDeleteProvider) r.Get("/usage", s.handleUsage) r.Get("/sessions", s.handleListSessions) r.Delete("/sessions/{id}", s.handleRevokeSession) diff --git a/server/internal/pool/capabilities.go b/server/internal/pool/capabilities.go new file mode 100644 index 0000000..9107fba --- /dev/null +++ b/server/internal/pool/capabilities.go @@ -0,0 +1,52 @@ +package pool + +import ( + "context" + "net/http" +) + +// KeyValidation holds the result of validating an API key against a provider. +type KeyValidation struct { + Valid bool + CreditLimitMicros int64 + RemainingMicros int64 + TodayMicros int64 + PaymentPlan string + TeamID string + PendingReason string // non-empty if key should be saved as pending_validation +} + +// ProviderCapabilities is the full set of provider-specific behaviors. Each +// provider type implements this interface to plug into the pool's health +// checking, billing ingestion, model catalog refresh, and key validation flows. +// +// To add a new provider: +// 1. Create a sub-package under internal/pool/providers/ +// 2. Implement ProviderCapabilities +// 3. Call RegisterProvider() from an init() function +// 4. Blank-import the package from cmd/server/main.go +// 5. Insert a row into the providers table +type ProviderCapabilities interface { + HealthChecker() HealthChecker + BillingIngestor() BillingIngestor + ModelFetcher() ModelFetcher + ValidateKey(ctx context.Context, httpClient *http.Client, baseURL, apiKey string) (*KeyValidation, error) + AcceptedPlan(plan string) bool +} + +// providerRegistry maps provider type strings to their capabilities. +var providerRegistry = map[string]ProviderCapabilities{} + +// RegisterProvider adds a ProviderCapabilities implementation for a given type. +// Called from init() functions in provider sub-packages. +func RegisterProvider(providerType string, caps ProviderCapabilities) { + if _, exists := providerRegistry[providerType]; exists { + panic("pool: duplicate provider registration for type " + providerType) + } + providerRegistry[providerType] = caps +} + +// GetProviderCapabilities returns the capabilities for a provider type, or nil. +func GetProviderCapabilities(providerType string) ProviderCapabilities { + return providerRegistry[providerType] +} diff --git a/server/internal/pool/model_fetcher.go b/server/internal/pool/model_fetcher.go index dc564f3..85247dd 100644 --- a/server/internal/pool/model_fetcher.go +++ b/server/internal/pool/model_fetcher.go @@ -73,7 +73,12 @@ type ModelFetcher interface { } // GetModelFetcher returns the appropriate model fetcher for a provider type. +// Uses the capabilities registry if available, falls back to defaults. func GetModelFetcher(providerType string) ModelFetcher { + if caps := GetProviderCapabilities(providerType); caps != nil { + return caps.ModelFetcher() + } + // Fallback for unregistered types. switch providerType { case "openai_compat": return PioneerModelFetcher{} diff --git a/server/internal/pool/provider_strategy.go b/server/internal/pool/provider_strategy.go index 7c3dc10..95bfb75 100644 --- a/server/internal/pool/provider_strategy.go +++ b/server/internal/pool/provider_strategy.go @@ -13,24 +13,14 @@ import ( // implements this differently (pioneer uses /billing/plan-info; others may // use /v1/models or have no health check at all). type HealthChecker interface { - // CheckHealth probes a single key. Returns nil result for transient - // errors (caller should retry next tick). Non-nil result with an error - // means permanent failure. CheckHealth(ctx context.Context, httpClient *http.Client, apiKey string) (*HealthResult, error) - - // AcceptedPlan reports whether a payment plan is allowed in the pool. - // Return true for all plans if the provider doesn't have plan tiers. AcceptedPlan(plan string) bool } // HealthResult holds the outcome of a health check probe. type HealthResult struct { - // HTTP status flags — checked before other fields. - HTTP401 bool // unauthorized / exhausted - HTTP503 bool // transient service unavailable - - // Provider-specific metadata. Fields that don't apply to a provider - // should be left zero/empty. + HTTP401 bool + HTTP503 bool PaymentPlan string CreditLimitMicros int64 RemainingMicros int64 @@ -39,16 +29,12 @@ type HealthResult struct { } // BillingIngestor pulls billing data from an upstream provider and writes -// it to pool_key_billing_rows with user attribution. Providers without a -// billing API can return nil (no-op) and rely on token-count estimation. +// it to pool_key_billing_rows with user attribution. type BillingIngestor interface { - // IngestBilling fetches new billing rows for a key and writes them to - // the database. Called after a successful health check. IngestBilling(ctx context.Context, q *store.Queries, key store.PoolKey, apiKey string, httpClient *http.Client) error } -// NoopBillingIngestor is a BillingIngestor that does nothing. Used for -// providers without a billing API. +// NoopBillingIngestor does nothing. Used for providers without a billing API. type NoopBillingIngestor struct{} func (NoopBillingIngestor) IngestBilling(context.Context, *store.Queries, store.PoolKey, string, *http.Client) error { @@ -56,7 +42,7 @@ func (NoopBillingIngestor) IngestBilling(context.Context, *store.Queries, store. } // NoopHealthChecker always reports healthy. Used for providers without -// a health check endpoint (e.g., self-hosted free models). +// a health check endpoint. type NoopHealthChecker struct{} func (NoopHealthChecker) CheckHealth(context.Context, *http.Client, string) (*HealthResult, error) { @@ -97,35 +83,11 @@ func (PioneerHealthChecker) CheckHealth(ctx context.Context, httpClient *http.Cl type PioneerBillingIngestor struct{} func (PioneerBillingIngestor) IngestBilling(ctx context.Context, q *store.Queries, key store.PoolKey, apiKey string, httpClient *http.Client) error { - // Pioneer billing ingestion is currently tightly coupled to the Reconciler - // (it uses r.q and r.httpClient). This will be fully decoupled when we - // refactor billing_ingest.go. For now, return nil and let the reconciler - // call ingestBillingRows directly for pioneer keys. + // Pioneer billing ingestion is currently tightly coupled to the Reconciler. + // This will be fully decoupled in a follow-up. return nil } -// GetHealthChecker returns the appropriate health checker for a provider type. -func GetHealthChecker(providerType string) HealthChecker { - switch providerType { - case "openai_compat": - // Currently only pioneer uses openai_compat; when we add more, - // this will need per-provider dispatch (e.g., by provider ID). - return PioneerHealthChecker{} - default: - return NoopHealthChecker{} - } -} - -// GetBillingIngestor returns the appropriate billing ingestor for a provider type. -func GetBillingIngestor(providerType string) BillingIngestor { - switch providerType { - case "openai_compat": - return PioneerBillingIngestor{} - default: - return NoopBillingIngestor{} - } -} - // nullStr and nullInt helpers for DB params. func nullStr(s string) sql.NullString { if s == "" { diff --git a/server/internal/pool/providers/generic/generic.go b/server/internal/pool/providers/generic/generic.go new file mode 100644 index 0000000..669aee0 --- /dev/null +++ b/server/internal/pool/providers/generic/generic.go @@ -0,0 +1,53 @@ +// Package generic implements ProviderCapabilities for any OpenAI-compatible +// provider that doesn't have a dedicated module. +package generic + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/taciturnaxolotl/potluck/internal/pool" +) + +func init() { + pool.RegisterProvider("generic", &Generic{}) +} + +// Generic implements pool.ProviderCapabilities for standard OpenAI-compatible APIs. +type Generic struct{} + +func (Generic) HealthChecker() pool.HealthChecker { return pool.NoopHealthChecker{} } +func (Generic) BillingIngestor() pool.BillingIngestor { return pool.NoopBillingIngestor{} } +func (Generic) ModelFetcher() pool.ModelFetcher { return pool.OpenAICompatModelFetcher{} } +func (Generic) AcceptedPlan(string) bool { return true } + +func (Generic) ValidateKey(ctx context.Context, httpClient *http.Client, baseURL, apiKey string) (*pool.KeyValidation, error) { + ctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/models", nil) + if err != nil { + return nil, err + } + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + return &pool.KeyValidation{Valid: true}, nil + case http.StatusUnauthorized, http.StatusForbidden: + return &pool.KeyValidation{ + PendingReason: "key validation failed — check your API key", + }, nil + default: + return nil, fmt.Errorf("provider returned HTTP %d", resp.StatusCode) + } +} diff --git a/server/internal/pool/providers/pioneer/pioneer.go b/server/internal/pool/providers/pioneer/pioneer.go new file mode 100644 index 0000000..2caf997 --- /dev/null +++ b/server/internal/pool/providers/pioneer/pioneer.go @@ -0,0 +1,135 @@ +// Package pioneer implements ProviderCapabilities for pioneer.ai. +package pioneer + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/taciturnaxolotl/potluck/internal/pool" +) + +func init() { + pool.RegisterProvider("openai_compat", &Pioneer{}) +} + +// Pioneer implements pool.ProviderCapabilities for pioneer.ai keys. +type Pioneer struct{} + +func (Pioneer) HealthChecker() pool.HealthChecker { return pool.PioneerHealthChecker{} } +func (Pioneer) BillingIngestor() pool.BillingIngestor { return pool.PioneerBillingIngestor{} } +func (Pioneer) ModelFetcher() pool.ModelFetcher { return pool.PioneerModelFetcher{} } + +func (Pioneer) AcceptedPlan(plan string) bool { + switch plan { + case "pro", "pro_legacy", "partner": + return true + default: + return false + } +} + +func (Pioneer) ValidateKey(ctx context.Context, httpClient *http.Client, baseURL, apiKey string) (*pool.KeyValidation, error) { + ctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + + do := func(url string) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, 0, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + resp, err := httpClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return b, resp.StatusCode, nil + } + + tsURL := baseURL + "/billing/usage/timeseries?full_history=false&interval_minutes=1440" + body, status, err := do(tsURL) + if err != nil { + return nil, fmt.Errorf("billing request failed: %w", err) + } + switch status { + case http.StatusUnauthorized: + return &pool.KeyValidation{ + PendingReason: "pioneer returned 401 — key may be exhausted or not yet active; we'll retry automatically", + }, nil + case http.StatusServiceUnavailable: + return &pool.KeyValidation{ + PendingReason: "pioneer auth service is temporarily down; we'll retry automatically", + }, nil + } + if status/100 != 2 { + return nil, fmt.Errorf("pioneer returned HTTP %d during validation", status) + } + + var tsBody struct { + Points []struct { + BucketDate string `json:"bucket_date"` + TotalCredits float64 `json:"total_credits"` + } `json:"points"` + } + if err := json.NewDecoder(bytes.NewReader(body)).Decode(&tsBody); err != nil { + return nil, fmt.Errorf("could not decode billing response: %w", err) + } + + body2, status2, err2 := do(baseURL + "/billing/plan-info") + body3, status3, err3 := do(baseURL + "/billing/billing-status") + + var planInfo struct { + PaymentPlan string `json:"payment_plan"` + CreditLimit float64 `json:"credit_limit"` + RemainingCredits float64 `json:"remaining_credits"` + } + if err2 == nil && status2 == http.StatusOK { + _ = json.NewDecoder(bytes.NewReader(body2)).Decode(&planInfo) + } + + var billingStatus struct { + TeamID string `json:"team_id"` + } + if err3 == nil && status3 == http.StatusOK { + _ = json.NewDecoder(bytes.NewReader(body3)).Decode(&billingStatus) + } + + todayUTC := time.Now().UTC().Format("2006-01-02") + var todayMicros int64 + for _, p := range tsBody.Points { + if p.BucketDate == todayUTC { + todayMicros = int64(p.TotalCredits * 10_000) + break + } + } + + val := &pool.KeyValidation{ + Valid: true, + CreditLimitMicros: int64(planInfo.CreditLimit * 10_000), + RemainingMicros: int64(planInfo.RemainingCredits * 10_000), + TodayMicros: todayMicros, + PaymentPlan: planInfo.PaymentPlan, + TeamID: billingStatus.TeamID, + } + + if planInfo.PaymentPlan != "" && !AcceptedPlan(planInfo.PaymentPlan) { + return nil, fmt.Errorf("unsupported pioneer plan %q (accepted: pro, pro_legacy, partner)", planInfo.PaymentPlan) + } + + return val, nil +} + +func AcceptedPlan(plan string) bool { + switch plan { + case "pro", "pro_legacy", "partner": + return true + default: + return false + } +} diff --git a/server/internal/pool/reconciler.go b/server/internal/pool/reconciler.go index af5bb47..65d307c 100644 --- a/server/internal/pool/reconciler.go +++ b/server/internal/pool/reconciler.go @@ -229,7 +229,10 @@ func (r *Reconciler) probeKey(ctx context.Context, key store.PoolKey) { return } - checker := GetHealthChecker(key.ProviderID) + checker := GetProviderCapabilities(key.ProviderID).HealthChecker() + if checker == nil { + checker = NoopHealthChecker{} + } healthResult, err := checker.CheckHealth(ctx, r.httpClient, plaintext) now := time.Now().Unix() @@ -274,7 +277,12 @@ func (r *Reconciler) probeKey(ctx context.Context, key store.PoolKey) { } // Successful probe — validate plan and update. - if !checker.AcceptedPlan(healthResult.PaymentPlan) { + caps := GetProviderCapabilities(key.ProviderID) + accepted := true // default for providers without plan restrictions + if caps != nil { + accepted = caps.AcceptedPlan(healthResult.PaymentPlan) + } + if !accepted { r.log.Warn("reconciler: unsupported plan, marking unauthorized", "key_id", key.ID, "label", key.Label, "plan", healthResult.PaymentPlan) diff --git a/server/internal/provider/registry/registry.go b/server/internal/provider/registry/registry.go index a8f0dae..4e27211 100644 --- a/server/internal/provider/registry/registry.go +++ b/server/internal/provider/registry/registry.go @@ -35,6 +35,7 @@ type ProviderConfig struct { Type ProviderType Name string BaseURL string + Active bool Config map[string]string // provider-specific config from config_json } @@ -67,6 +68,7 @@ func LoadFromDB(ctx context.Context, q *store.Queries) (*Registry, error) { Type: ProviderType(r.Type), Name: r.Name, BaseURL: r.BaseUrl, + Active: r.Active == 1, // TODO: parse config_json into map when needed }) } diff --git a/server/internal/store/providers.sql.go b/server/internal/store/providers.sql.go index 58eadf8..23e50d0 100644 --- a/server/internal/store/providers.sql.go +++ b/server/internal/store/providers.sql.go @@ -102,6 +102,41 @@ func (q *Queries) ListActiveProviders(ctx context.Context) ([]Provider, error) { return items, nil } +const listAllProviders = `-- name: ListAllProviders :many +SELECT id, type, name, base_url, config_json, active, created_at FROM providers ORDER BY id +` + +func (q *Queries) ListAllProviders(ctx context.Context) ([]Provider, error) { + rows, err := q.db.QueryContext(ctx, listAllProviders) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Provider{} + for rows.Next() { + var i Provider + if err := rows.Scan( + &i.ID, + &i.Type, + &i.Name, + &i.BaseUrl, + &i.ConfigJson, + &i.Active, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateProvider = `-- name: UpdateProvider :exec UPDATE providers SET type = ?, diff --git a/server/internal/store/querier.go b/server/internal/store/querier.go index c0560f9..31927cb 100644 --- a/server/internal/store/querier.go +++ b/server/internal/store/querier.go @@ -86,6 +86,7 @@ type Querier interface { ListAPIKeysForUser(ctx context.Context, userID string) ([]ApiKey, error) // Provider registry queries for multi-provider support. ListActiveProviders(ctx context.Context) ([]Provider, error) + ListAllProviders(ctx context.Context) ([]Provider, error) ListAllUsers(ctx context.Context) ([]User, error) // Live spend for ALL users since dayStart, grouped by user. // Used by RunSmartAllocation so it doesn't depend on the stale diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ad0fd79..d13bd1c 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -261,8 +261,15 @@ export interface Provider { id: string; type: string; name: string; + active: boolean; } export const listProviders = () => api.get('/api/providers'); +export const createProvider = (id: string, type: string, name: string, baseUrl: string) => + api.post('/api/providers', { id, type, name, base_url: baseUrl }); +export const updateProvider = (id: string, updates: { type?: string; name?: string; base_url?: string; active?: boolean }) => + api.patch(`/api/providers/${id}`, updates); +export const deleteProvider = (id: string) => + api.delete(`/api/providers/${id}`); export interface PoolKeyProbe { provider_id: string; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 74b8fb9..244e2c3 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -184,7 +184,8 @@ const adminNavItems: NavItem[] = [ { label: 'users', href: '/admin/users', section: 'admin' }, - { label: 'waitlist', href: '/admin/waitlist', section: 'admin' } + { label: 'waitlist', href: '/admin/waitlist', section: 'admin' }, + { label: 'providers', href: '/admin/providers', section: 'admin' } ]; let navItems = $derived( diff --git a/web/src/routes/admin/providers/+page.svelte b/web/src/routes/admin/providers/+page.svelte new file mode 100644 index 0000000..9c7c726 --- /dev/null +++ b/web/src/routes/admin/providers/+page.svelte @@ -0,0 +1,416 @@ + + + + providers · potluck admin + + +
+
admin
+

providers

+ + {#if err} +
{err}
+ {/if} + +
+
+ registered providers + +
+ + {#if showAddForm} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {#if addErr} +
{addErr}
+ {/if} +
+ +
+
+ {/if} + + {#if loading} +
loading…
+ {:else if providers.length === 0} +
no providers configured
+ {:else} +
+ {#each providers as p (p.id)} +
+
+ {p.name} + {p.id} + {p.type} +
+
+ + +
+
+ {/each} +
+ {/if} +
+
+ +