diff --git a/server/db/queries/pool_keys.sql b/server/db/queries/pool_keys.sql index 45754fa..1cef028 100644 --- a/server/db/queries/pool_keys.sql +++ b/server/db/queries/pool_keys.sql @@ -9,8 +9,8 @@ INSERT INTO pool_keys ( id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, - total_micros, request_count, created_at -) VALUES (?, ?, ?, ?, ?, 1, ?, 0, 0, 0, 0, ?) + total_micros, request_count, created_at, provider_id +) VALUES (?, ?, ?, ?, ?, 1, ?, 0, 0, 0, 0, ?, ?) RETURNING *; -- name: GetPoolKey :one diff --git a/server/internal/api/web/pool_keys.go b/server/internal/api/web/pool_keys.go index 6f6e41c..4942ea6 100644 --- a/server/internal/api/web/pool_keys.go +++ b/server/internal/api/web/pool_keys.go @@ -18,6 +18,20 @@ import ( "github.com/taciturnaxolotl/potluck/internal/store" ) +// 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 { + out = append(out, map[string]any{ + "id": p.ID, + "type": string(p.Type), + "name": p.Name, + }) + } + writeJSON(w, 200, out) +} + // 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) { @@ -34,6 +48,7 @@ func (s *Server) handleListPoolKeys(w http.ResponseWriter, r *http.Request) { "user_id": k.UserID, "label": k.Label, "active": k.Active == 1, + "provider_id": k.ProviderID, "max_micros": k.MaxMicros, "shared_micros": k.SharedMicros, "private_micros": k.MaxMicros - k.SharedMicros, @@ -46,6 +61,7 @@ func (s *Server) handleListPoolKeys(w http.ResponseWriter, r *http.Request) { "pioneer_credit_limit_micros": k.PioneerCreditLimitMicros.Int64, "pioneer_remaining_micros": k.PioneerRemainingMicros.Int64, "pending_validation": k.PendingValidation == 1, + "revoked": k.RevokedAt.Valid, "created_at": k.CreatedAt, "last_used_at": k.LastUsedAt.Int64, "last_billing_sync_at": k.LastBillingSyncAt.Int64, @@ -60,6 +76,7 @@ func (s *Server) handleListPoolKeys(w http.ResponseWriter, r *http.Request) { type addPoolKeyReq struct { Label string `json:"label"` APIKey string `json:"api_key"` + ProviderID string `json:"provider_id,omitempty"` // defaults to "pioneer" SharedMicros *int64 `json:"shared_micros,omitempty"` // how much to donate to pool; defaults to full credit limit } @@ -159,46 +176,106 @@ func probePioneerBilling(ctx context.Context, apiKey string) (pioneerBillingResu }, nil } -// handleProbePoolKey probes a pioneer key's billing info without storing it. +// handleProbePoolKey probes a key's billing info without storing it. // Used by the two-stage add flow: probe first, show plan/credit/spend to the // user, then let them confirm with shared_micros before calling handleAddPoolKey. func (s *Server) handleProbePoolKey(w http.ResponseWriter, r *http.Request) { var req struct { - APIKey string `json:"api_key"` + APIKey string `json:"api_key"` + ProviderID string `json:"provider_id,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.APIKey == "" { writeErr(w, 400, "invalid_request", "api_key is required") return } + providerID := req.ProviderID + if providerID == "" { + providerID = "pioneer" + } - billing, err := probePioneerBilling(r.Context(), req.APIKey) - if err != nil { - writeErr(w, 422, "probe_failed", err.Error()) + // Validate the provider exists. + provCfg, ok := s.Registry.Get(providerID) + if !ok { + writeErr(w, 400, "invalid_provider", fmt.Sprintf("unknown provider: %s", providerID)) return } - if billing.HTTP401 { - writeErr(w, 422, "unauthorized", "pioneer returned 401 — key may be exhausted or not yet active") + + if providerID == "pioneer" { + billing, err := probePioneerBilling(r.Context(), 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)) + 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, + }) return } - if billing.HTTP503 { - writeErr(w, 503, "provider_down", "pioneer auth service is temporarily down; try again shortly") + + // Generic probe for non-pioneer providers: validate key via /v1/models. + valid, err := probeGenericKey(r.Context(), provCfg.BaseURL, req.APIKey) + if err != nil { + writeErr(w, 422, "probe_failed", err.Error()) 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 !valid { + writeErr(w, 422, "unauthorized", "key validation failed — check your API key") return } writeJSON(w, 200, map[string]any{ - "payment_plan": billing.PaymentPlan, - "credit_limit_micros": billing.CreditLimitMicros, - "remaining_micros": billing.RemainingMicros, - "today_micros": billing.TodayMicros, + "provider_id": providerID, + "credit_limit_micros": int64(0), // unknown for generic providers + "remaining_micros": int64(0), + "today_micros": int64(0), }) } -// handleAddPoolKey validates a key against pioneer, then encrypts and stores it. +// probeGenericKey validates an API key by calling /v1/models on the provider. +// Returns true if the key is valid (200 response), false for 401/403. +func probeGenericKey(ctx context.Context, baseURL, apiKey string) (bool, 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 false, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusUnauthorized, http.StatusForbidden: + return false, nil + default: + return false, fmt.Errorf("provider returned HTTP %d", resp.StatusCode) + } +} + +// handleAddPoolKey validates a key against its provider, then encrypts and stores it. // If pioneer returns 401 (key exhausted or not yet active), we save it as // pending_validation and let the reconciler activate it when it comes back. // If pioneer returns 503 (auth service down), we also save as pending. @@ -211,26 +288,54 @@ func (s *Server) handleAddPoolKey(w http.ResponseWriter, r *http.Request) { return } - billing, err := probePioneerBilling(r.Context(), req.APIKey) - if err != nil { - writeErr(w, 422, "invalid_key", err.Error()) + providerID := req.ProviderID + if providerID == "" { + providerID = "pioneer" + } + + // Validate the provider exists. + if _, ok := s.Registry.Get(providerID); !ok { + writeErr(w, 400, "invalid_provider", fmt.Sprintf("unknown provider: %s", providerID)) return } - // Determine whether we can activate immediately. - pendingValidation := int64(0) + var billing pioneerBillingResult + var pendingValidation int64 var pendingReason string - 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: - 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 + + if providerID == "pioneer" { + var err error + billing, err = probePioneerBilling(r.Context(), 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: + 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 + } + } else { + // Generic provider: validate via /v1/models. + provCfg, _ := s.Registry.Get(providerID) + valid, err := probeGenericKey(r.Context(), provCfg.BaseURL, req.APIKey) + if err != nil { + writeErr(w, 422, "invalid_key", err.Error()) + return + } + if !valid { + writeErr(w, 422, "unauthorized", "key validation failed — check your API key") + return + } + // Generic providers start healthy with no billing info. + billing = pioneerBillingResult{} } fingerprint := pool.Fingerprint(req.APIKey) @@ -262,6 +367,7 @@ func (s *Server) handleAddPoolKey(w http.ResponseWriter, r *http.Request) { KeyFingerprint: fingerprint, DailyLimitMicros: maxMicros, CreatedAt: now, + ProviderID: providerID, }) if err != nil { if isUniqueConstraintErr(err) { diff --git a/server/internal/api/web/web.go b/server/internal/api/web/web.go index 7c9b943..9382968 100644 --- a/server/internal/api/web/web.go +++ b/server/internal/api/web/web.go @@ -50,6 +50,7 @@ func (s *Server) Mount(r chi.Router) { r.Post("/allocations/recompute", s.handleRecomputeAllocations) r.Get("/models", s.handleListModels) + r.Get("/providers", s.handleListProviders) r.Get("/usage", s.handleUsage) r.Get("/sessions", s.handleListSessions) r.Delete("/sessions/{id}", s.handleRevokeSession) diff --git a/server/internal/store/pool_keys.sql.go b/server/internal/store/pool_keys.sql.go index 6dd98cf..73f1132 100644 --- a/server/internal/store/pool_keys.sql.go +++ b/server/internal/store/pool_keys.sql.go @@ -15,8 +15,8 @@ const createPoolKey = `-- name: CreatePoolKey :one INSERT INTO pool_keys ( id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, - total_micros, request_count, created_at -) VALUES (?, ?, ?, ?, ?, 1, ?, 0, 0, 0, 0, ?) + total_micros, request_count, created_at, provider_id +) VALUES (?, ?, ?, ?, ?, 1, ?, 0, 0, 0, 0, ?, ?) RETURNING id, user_id, label, key_ciphertext, key_fingerprint, active, daily_limit_micros, today_date, today_micros, total_micros, request_count, created_at, last_used_at, max_micros, shared_micros, pioneer_team_id, pioneer_payment_plan, pioneer_credit_limit_micros, pioneer_remaining_micros, pioneer_health, pioneer_unhealthy_since, pending_validation, last_billing_sync_at, revoked_at, provider_id ` @@ -28,6 +28,7 @@ type CreatePoolKeyParams struct { KeyFingerprint string `json:"key_fingerprint"` DailyLimitMicros int64 `json:"daily_limit_micros"` CreatedAt int64 `json:"created_at"` + ProviderID string `json:"provider_id"` } // Pool key queries. @@ -45,6 +46,7 @@ func (q *Queries) CreatePoolKey(ctx context.Context, arg CreatePoolKeyParams) (P arg.KeyFingerprint, arg.DailyLimitMicros, arg.CreatedAt, + arg.ProviderID, ) var i PoolKey err := row.Scan( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c13ce37..ad0fd79 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -256,20 +256,33 @@ export type PoolKey = { }; export const listPoolKeys = () => api.get('/api/pool-keys'); + +export interface Provider { + id: string; + type: string; + name: string; +} +export const listProviders = () => api.get('/api/providers'); + export interface PoolKeyProbe { + provider_id: string; payment_plan: string; credit_limit_micros: number; remaining_micros: number; today_micros: number; } -export const probePoolKey = (apiKey: string) => - api.post('/api/pool-keys/probe', { api_key: apiKey }); +export const probePoolKey = (apiKey: string, providerId?: string) => + api.post('/api/pool-keys/probe', { + api_key: apiKey, + ...(providerId ? { provider_id: providerId } : {}) + }); -export const addPoolKey = (label: string, apiKey: string, sharedMicros?: number) => +export const addPoolKey = (label: string, apiKey: string, sharedMicros?: number, providerId?: string) => api.post('/api/pool-keys', { label, api_key: apiKey, - ...(sharedMicros != null ? { shared_micros: sharedMicros } : {}) + ...(sharedMicros != null ? { shared_micros: sharedMicros } : {}), + ...(providerId ? { provider_id: providerId } : {}) }); export const setPoolKeyActive = (id: string, active: boolean) => api.patch(`/api/pool-keys/${id}/active`, { active }); diff --git a/web/src/routes/pool/+page.svelte b/web/src/routes/pool/+page.svelte index 1427793..bae6d30 100644 --- a/web/src/routes/pool/+page.svelte +++ b/web/src/routes/pool/+page.svelte @@ -3,7 +3,9 @@ listPoolKeys, addPoolKey, probePoolKey, + listProviders, type PoolKeyProbe, + type Provider, setPoolKeyActive, updatePoolKeyLabel, updatePoolKeyLimits, @@ -14,6 +16,7 @@ import { onMount } from 'svelte'; let keys = $state([]); + let providers = $state([]); let loading = $state(true); let err = $state(null); @@ -23,6 +26,7 @@ let addStage = $state('input'); let newLabel = $state(''); let newAPIKey = $state(''); + let selectedProvider = $state('pioneer'); let probeResult = $state(null); let probeSharedDollars = $state(0); // shared split chosen by user in confirm stage let probing = $state(false); @@ -60,7 +64,9 @@ async function reload() { try { - keys = await listPoolKeys(); + const [k, p] = await Promise.all([listPoolKeys(), listProviders()]); + keys = k; + providers = p; } catch (e: unknown) { err = e instanceof Error ? e.message : 'failed to load'; } finally { @@ -72,6 +78,7 @@ addStage = 'input'; newLabel = ''; newAPIKey = ''; + selectedProvider = providers.length > 0 ? providers[0].id : 'pioneer'; probeResult = null; probeSharedDollars = 0; addErr = null; @@ -85,7 +92,7 @@ probing = true; addErr = null; try { - const result = await probePoolKey(newAPIKey.trim()); + const result = await probePoolKey(newAPIKey.trim(), selectedProvider); probeResult = result; probeSharedDollars = Math.round(result.credit_limit_micros / 1_000_000); // default 100% shared addStage = 'confirm'; @@ -108,7 +115,8 @@ const result = await addPoolKey( newLabel.trim() || 'unnamed key', newAPIKey.trim(), - sharedMicros < creditLimitMicros ? sharedMicros : undefined + sharedMicros < creditLimitMicros ? sharedMicros : undefined, + selectedProvider ); if (result.pending_validation) { addPending = true; @@ -304,14 +312,24 @@ {#if showAddForm} {#if addStage === 'input'}
+ {#if providers.length > 1} +
+ + +
+ {/if}
-
-
{#if addErr} @@ -321,18 +339,24 @@ -
pro/partner plan keys · encrypted at rest
+
encrypted at rest · validated against provider
{:else if addStage === 'confirm' && probeResult}
-
- {probeResult.payment_plan} - · - ${Math.round(probeResult.credit_limit_micros / 1_000_000)} credit limit - · - ${(probeResult.today_micros / 1_000_000).toFixed(2)} spent today -
+ {#if probeResult.credit_limit_micros > 0} +
+ {probeResult.payment_plan} + · + ${Math.round(probeResult.credit_limit_micros / 1_000_000)} credit limit + · + ${(probeResult.today_micros / 1_000_000).toFixed(2)} spent today +
+ {:else} +
+ key validated ✓ +
+ {/if}
@@ -397,6 +421,9 @@ {key.label || 'unnamed'} {/if} + {#if key.provider_id && key.provider_id !== 'pioneer'} + {key.provider_id} + {/if}
@@ -910,6 +937,18 @@ align-items: center; } + .provider-badge { + margin-left: 0.4rem; + font-size: 0.65rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.1em 0.4em; + border-radius: 3px; + background: var(--bg-sidebar); + color: var(--text-muted); + } + /* pending row dim */ .keys-table tr.pending td { opacity: 0.65;