fix(hosted): auto-bootstrap AI quickstart for hosted tenants

This commit is contained in:
rcourtman 2026-03-25 16:04:49 +00:00
parent 00a3817d9e
commit 2ac8a1fef9
14 changed files with 514 additions and 47 deletions

View file

@ -223,6 +223,13 @@ before any API-only token fallback or optional-auth anonymous fallback so
operators can mint relay-mobile credentials and continue onboarding from the
hosted runtime itself even after that tenant has already minted managed API
tokens.
That same lifecycle-adjacent hosted setup path also depends on AI bootstrap
staying canonical before the first settings write. Hosted operators may land
in Chat, Patrol-backed setup hints, or AI-dependent remediation surfaces
before anyone has visited AI Settings, so the shared `internal/api/`
hosted-AI bootstrap helper must persist the machine-owned quickstart-backed
`ai.enc` for entitled tenants instead of leaving lifecycle-adjacent hosted
flows in a fake `AI disabled` state that disappears only after a manual save.
That same lifecycle-adjacent hosted entitlement path must also preserve the
trial quickstart grant fields already seeded into billing state. Hosted setup
and pairing may depend on the shared `internal/api/` entitlement refresh, but

View file

@ -9,7 +9,7 @@
"contract_file": "docs/release-control/v6/internal/subsystems/ai-runtime.md",
"status_file": "docs/release-control/v6/internal/status.json",
"registry_file": "docs/release-control/v6/internal/subsystems/registry.json",
"dependency_subsystem_ids": ["frontend-primitives"]
"dependency_subsystem_ids": ["api-contracts", "frontend-primitives"]
}
```
@ -23,19 +23,20 @@ runtime cost control, and shared AI transport surfaces.
1. `internal/ai/`
2. `internal/api/ai_handler.go`
3. `internal/api/ai_handlers.go`
4. `internal/api/ai_intelligence_handlers.go`
5. `frontend-modern/src/api/ai.ts`
6. `frontend-modern/src/api/patrol.ts`
7. `frontend-modern/src/components/AI/AICostDashboard.tsx`
8. `frontend-modern/src/components/AI/Chat/`
9. `frontend-modern/src/utils/aiChatPresentation.ts`
10. `frontend-modern/src/utils/aiControlLevelPresentation.ts`
11. `frontend-modern/src/utils/aiCostPresentation.ts`
12. `frontend-modern/src/utils/aiExplorePresentation.ts`
13. `frontend-modern/src/utils/aiProviderHealthPresentation.ts`
14. `frontend-modern/src/utils/aiProviderPresentation.ts`
15. `frontend-modern/src/utils/aiSessionDiffPresentation.ts`
16. `frontend-modern/src/utils/textPresentation.ts`
4. `internal/api/ai_hosted_runtime.go`
5. `internal/api/ai_intelligence_handlers.go`
6. `frontend-modern/src/api/ai.ts`
7. `frontend-modern/src/api/patrol.ts`
8. `frontend-modern/src/components/AI/AICostDashboard.tsx`
9. `frontend-modern/src/components/AI/Chat/`
10. `frontend-modern/src/utils/aiChatPresentation.ts`
11. `frontend-modern/src/utils/aiControlLevelPresentation.ts`
12. `frontend-modern/src/utils/aiCostPresentation.ts`
13. `frontend-modern/src/utils/aiExplorePresentation.ts`
14. `frontend-modern/src/utils/aiProviderHealthPresentation.ts`
15. `frontend-modern/src/utils/aiProviderPresentation.ts`
16. `frontend-modern/src/utils/aiSessionDiffPresentation.ts`
17. `frontend-modern/src/utils/textPresentation.ts`
## Shared Boundaries
@ -105,6 +106,7 @@ it. `frontend-modern/src/api/ai.ts`,
`frontend-modern/src/api/patrol.ts`,
`internal/api/ai_handler.go`,
`internal/api/ai_handlers.go`, and
`internal/api/ai_hosted_runtime.go`, and
`internal/api/ai_intelligence_handlers.go` are runtime control surfaces for
the AI product while also remaining canonical payload contract boundaries.
That same AI transport boundary now also defines the narrow Pulse Mobile
@ -114,6 +116,16 @@ mobile runtime routes may accept that scope as a compatibility alias alongside
legacy `ai:chat` or `ai:execute` mobile tokens. Broader AI runtime surfaces
must stay on their canonical AI scopes instead of treating the mobile relay
capability as a general-purpose AI permission.
That same shared AI transport boundary now also owns hosted AI bootstrap.
When Pulse Cloud runs in hosted mode and no explicit `ai.enc` exists yet,
`internal/api/ai_hosted_runtime.go`, `internal/api/ai_handler.go`, and
`internal/api/ai_handlers.go` must derive the initial runtime config from the
canonical hosted billing state instead of falling back to a synthetic
`enabled=false` default. Entitled hosted tenants that carry AI capabilities
must persist one machine-owned quickstart-backed AI config with the canonical
`quickstart:minimax-2.5m` runtime models so Chat and Patrol can start before
the operator visits AI Settings, while any explicitly written AI config
remains authoritative and must not be overwritten by hosted bootstrap.
The same ownership includes the Pulse query tool schema under
`internal/ai/tools/`: topology-query input names must stay canonical inside
the AI runtime itself, so new tool arguments such as `max_proxmox_nodes`

View file

@ -56,6 +56,7 @@ Own canonical runtime payload shapes between backend and frontend.
33. `frontend-modern/src/utils/infrastructureSettingsPresentation.ts`
34. `internal/api/router_routes_auth_security.go`
35. `internal/api/relay_hosted_runtime.go`
36. `internal/api/ai_hosted_runtime.go`
## Shared Boundaries
@ -143,6 +144,7 @@ Own canonical runtime payload shapes between backend and frontend.
18. Keep tenant settings-scope authorization aligned with org management: `internal/api/security_setup_fix.go`, `internal/api/contract_test.go`, and settings-bound hosted callers must allow the current non-default org owner/admin membership to exercise privileged tenant routes, rather than requiring a separate configured local admin identity after hosted handoff.
19. Keep mobile onboarding payload reads aligned with the server-owned relay-mobile credential: `internal/api/router_routes_ai_relay.go`, `internal/api/onboarding_handlers.go`, and `internal/api/contract_test.go` must allow the dedicated `relay:mobile:access` scope to reach the governed QR, deep-link, and connection-validation payloads without reintroducing a broader `settings:read` requirement for token-authenticated pairing clients.
20. Keep hosted billing-state quickstart payload fields on the shared API contract: `internal/api/hosted_entitlement_refresh.go`, `internal/api/subscription_state_handlers.go`, and `internal/api/contract_test.go` must preserve `quickstart_credits_granted`, `quickstart_credits_used`, and `quickstart_credits_granted_at` through hosted signup, hosted lease refresh, and billing-state reads instead of letting lease rewrites silently erase seeded quickstart inventory.
21. Keep hosted AI settings bootstrap on the shared API contract: `internal/api/ai_hosted_runtime.go`, `internal/api/ai_handlers.go`, `internal/api/ai_handler.go`, and `internal/api/contract_test.go` must treat a missing `ai.enc` in hosted mode as a canonical bootstrap condition, persist one machine-owned quickstart-backed AI config when hosted entitlements grant AI capability, and preserve that configured settings payload as the same public contract that Chat, Patrol, and AI Settings consume.
## Forbidden Paths
@ -1630,3 +1632,14 @@ generated relay identity metadata instead of requiring a prior manual
`PUT /api/settings/relay`. The API response contract must continue to expose
only public relay fields while omitting the hosted instance secret and
private key.
That same shared backend API contract now also owns hosted AI bootstrap
reads. `internal/api/ai_hosted_runtime.go`, `internal/api/ai_handler.go`,
`internal/api/ai_handlers.go`, and `internal/api/contract_test.go` must derive
`/api/settings/ai` and the initial hosted AI runtime from the same runtime
helper. In hosted mode, when no explicit `ai.enc` exists but the default
hosted billing lease grants AI capability and carries hosted entitlement
proof, those read surfaces must persist a canonical quickstart-backed AI
config with the governed hosted default model instead of returning a synthetic
`enabled=false` payload that leaves Chat and Patrol unavailable until the
operator manually saves settings. Once a real AI config exists, that explicit
operator-owned state must remain authoritative over hosted bootstrap.

View file

@ -877,6 +877,14 @@ surfaces may run without local auth configured, but a valid tenant
the anonymous optional-auth fallback so hosted recovery, onboarding, and
support flows do not silently degrade into unauthenticated state or bearer-
token-only mode after cloud handoff.
That same shared `internal/api/` boundary also owns hosted AI bootstrap
continuity. Storage- and recovery-adjacent hosted flows may surface Patrol-
backed investigation or AI-assisted recovery guidance before an operator has
ever opened AI Settings, so the shared hosted runtime helper must persist the
canonical quickstart-backed `ai.enc` from entitled billing state when no
explicit AI config exists. Adjacent recovery surfaces must not invent their
own "AI disabled until configured" fallback when the hosted runtime already
has enough entitlement proof to bootstrap the machine-owned default.
That same shared settings helper layer must then preserve canonical
org-management privilege for non-default tenant requests. Storage- and
recovery-adjacent hosted flows that reuse settings-bound helpers must allow

View file

@ -77,6 +77,7 @@ type AIHandler struct {
mtMonitor *monitoring.MultiTenantMonitor
defaultConfig *config.Config
defaultPersistence AIPersistence
hostedMode bool
defaultService AIService
agentServer *agentexec.Server
services map[string]AIService
@ -118,6 +119,7 @@ func NewAIHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.MultiTenan
mtMonitor: mtm,
defaultConfig: defaultConfig,
defaultPersistence: defaultPersistence,
hostedMode: hostedModeEnabledFromEnv(),
agentServer: agentServer,
services: make(map[string]AIService),
unifiedStores: make(map[string]*unified.UnifiedStore),
@ -461,6 +463,21 @@ func (h *AIHandler) loadAIConfig(ctx context.Context) *config.AIConfig {
if p == nil {
return nil
}
if persistence, ok := p.(*config.ConfigPersistence); ok {
billingBaseDir := persistence.DataDir()
orgID := strings.TrimSpace(GetOrgID(ctx))
if orgID == "" {
orgID = "default"
}
if h.mtPersistence != nil {
billingBaseDir = h.mtPersistence.BaseDataDir()
}
cfg, err := loadHostedAwareAIConfig(h.hostedMode, billingBaseDir, orgID, persistence)
if err == nil {
return cfg
}
log.Warn().Err(err).Str("org_id", orgID).Msg("Failed to load hosted-aware Pulse Assistant config")
}
cfg, err := p.LoadAIConfig()
if err != nil {
return nil

View file

@ -48,6 +48,7 @@ type AISettingsHandler struct {
mtMonitor *monitoring.MultiTenantMonitor
defaultConfig *config.Config
defaultPersistence *config.ConfigPersistence
hostedMode bool
defaultAIService *ai.Service
aiServices map[string]*ai.Service
aiServicesMu sync.RWMutex
@ -186,6 +187,7 @@ func NewAISettingsHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.Mu
var defaultConfig *config.Config
var defaultPersistence *config.ConfigPersistence
var defaultAIService *ai.Service
hostedMode := hostedModeEnabledFromEnv()
if mtm != nil {
if m, err := mtm.GetMonitor("default"); err == nil && m != nil {
@ -198,36 +200,12 @@ func NewAISettingsHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.Mu
}
}
defaultAIService = ai.NewService(defaultPersistence, agentServer)
defaultAIService.SetOrgID("default")
defaultAIService.SetAlertAnalyzerFactory(getCreateAlertAnalyzer())
// Wire quickstart credit manager before LoadConfig so the quickstart
// provider path is available during initial configuration.
if defaultPersistence != nil {
billingStore := config.NewFileBillingStore(defaultPersistence.DataDir())
qsMgr := ai.NewFileQuickstartCreditManager(
billingStore,
"default",
func() *config.AIConfig {
cfg, _ := defaultPersistence.LoadAIConfig()
return cfg
},
"default",
)
defaultAIService.SetQuickstartCredits(qsMgr)
}
if defaultPersistence != nil {
if err := defaultAIService.LoadConfig(); err != nil {
log.Warn().Err(err).Msg("Failed to load AI config on startup")
}
}
return &AISettingsHandler{
handler := &AISettingsHandler{
mtPersistence: mtp,
mtMonitor: mtm,
defaultConfig: defaultConfig,
defaultPersistence: defaultPersistence,
defaultAIService: defaultAIService,
hostedMode: hostedMode,
aiServices: make(map[string]*ai.Service),
agentServer: agentServer,
proxmoxCorrelators: make(map[string]*proxmox.EventCorrelator),
@ -243,6 +221,57 @@ func NewAISettingsHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.Mu
incidentCoordinators: make(map[string]*ai.IncidentCoordinator),
incidentRecorders: make(map[string]*metrics.IncidentRecorder),
}
defaultAIService = ai.NewService(defaultPersistence, agentServer)
defaultAIService.SetOrgID("default")
defaultAIService.SetAlertAnalyzerFactory(getCreateAlertAnalyzer())
// Wire quickstart credit manager before LoadConfig so the quickstart
// provider path is available during initial configuration.
if defaultPersistence != nil {
billingStore := config.NewFileBillingStore(defaultPersistence.DataDir())
qsMgr := ai.NewFileQuickstartCreditManager(
billingStore,
"default",
func() *config.AIConfig {
cfg, _ := handler.loadAIConfigForPersistence(context.Background(), "default", defaultPersistence)
return cfg
},
"default",
)
defaultAIService.SetQuickstartCredits(qsMgr)
if _, err := handler.loadAIConfigForPersistence(context.Background(), "default", defaultPersistence); err != nil {
log.Warn().Err(err).Msg("Failed to bootstrap Pulse Assistant config on startup")
}
if err := defaultAIService.LoadConfig(); err != nil {
log.Warn().Err(err).Msg("Failed to load AI config on startup")
}
}
handler.defaultAIService = defaultAIService
return handler
}
func (h *AISettingsHandler) loadAIConfigForPersistence(_ context.Context, orgID string, persistence *config.ConfigPersistence) (*config.AIConfig, error) {
if persistence == nil {
return nil, fmt.Errorf("Pulse Assistant config persistence unavailable")
}
billingBaseDir := persistence.DataDir()
if h != nil && h.mtPersistence != nil {
billingBaseDir = h.mtPersistence.BaseDataDir()
}
return loadHostedAwareAIConfig(h != nil && h.hostedMode, billingBaseDir, orgID, persistence)
}
func (h *AISettingsHandler) loadAIConfig(ctx context.Context) (*config.AIConfig, error) {
persistence := h.getPersistence(ctx)
if persistence == nil {
return nil, fmt.Errorf("Pulse Assistant config persistence unavailable")
}
orgID := strings.TrimSpace(GetOrgID(ctx))
if orgID == "" {
orgID = "default"
}
return h.loadAIConfigForPersistence(ctx, orgID, persistence)
}
// GetAIService returns the underlying AI service
@ -303,12 +332,15 @@ func (h *AISettingsHandler) GetAIService(ctx context.Context) *ai.Service {
billingStore,
orgID,
func() *config.AIConfig {
cfg, _ := persistence.LoadAIConfig()
cfg, _ := h.loadAIConfigForPersistence(context.Background(), orgID, persistence)
return cfg
},
orgID,
)
svc.SetQuickstartCredits(qsMgr)
if _, err := h.loadAIConfigForPersistence(context.Background(), orgID, persistence); err != nil {
log.Warn().Str("orgID", orgID).Err(err).Msg("Failed to bootstrap Pulse Assistant config for tenant")
}
if err := svc.LoadConfig(); err != nil {
log.Warn().Str("orgID", orgID).Err(err).Msg("Failed to load AI config for tenant")
}
@ -2242,8 +2274,7 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R
}
ctx := r.Context()
persistence := h.getPersistence(ctx)
settings, err := persistence.LoadAIConfig()
settings, err := h.loadAIConfig(ctx)
if err != nil {
log.Error().Err(err).Msg("Failed to load Pulse Assistant settings")
http.Error(w, "Failed to load Pulse Assistant settings", http.StatusInternalServerError)
@ -2344,7 +2375,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
}
// Load existing settings
settings, err := h.getPersistence(r.Context()).LoadAIConfig()
settings, err := h.loadAIConfig(r.Context())
if err != nil {
log.Error().Err(err).Msg("Failed to load existing AI settings")
settings = config.NewDefaultAIConfig()
@ -4457,7 +4488,7 @@ func (h *AISettingsHandler) HandleOAuthExchange(w http.ResponseWriter, r *http.R
http.Error(w, "Tenant persistence unavailable", http.StatusInternalServerError)
return
}
settings, err := persistence.LoadAIConfig()
settings, err := h.loadAIConfig(oauthCtx)
if err != nil {
log.Error().Err(err).Msg("Failed to load Pulse Assistant settings for OAuth")
settings = config.NewDefaultAIConfig()
@ -4566,7 +4597,7 @@ func (h *AISettingsHandler) HandleOAuthCallback(w http.ResponseWriter, r *http.R
http.Redirect(w, r, "/settings?ai_oauth_error=save_failed", http.StatusTemporaryRedirect)
return
}
settings, err := persistence.LoadAIConfig()
settings, err := h.loadAIConfig(oauthCtx)
if err != nil {
log.Error().Err(err).Msg("Failed to load Pulse Assistant settings for OAuth")
settings = config.NewDefaultAIConfig()
@ -4623,7 +4654,7 @@ func (h *AISettingsHandler) HandleOAuthDisconnect(w http.ResponseWriter, r *http
http.Error(w, "Failed to load settings", http.StatusInternalServerError)
return
}
settings, err := persistence.LoadAIConfig()
settings, err := h.loadAIConfig(r.Context())
if err != nil {
log.Error().Err(err).Msg("Failed to load Pulse Assistant settings for OAuth disconnect")
http.Error(w, "Failed to load settings", http.StatusInternalServerError)

View file

@ -191,6 +191,36 @@ func TestAISettingsResponse_UsesCanonicalEmptyCollections(t *testing.T) {
}
}
func TestAISettingsHandler_GetHostedSettings_AutoBootstrapsQuickstart(t *testing.T) {
t.Setenv("PULSE_HOSTED_MODE", "true")
tmp := t.TempDir()
mtp := config.NewMultiTenantPersistence(tmp)
persistence, err := mtp.GetPersistence("default")
require.NoError(t, err)
seedHostedAIBillingState(t, mtp, "default")
handler := NewAISettingsHandler(mtp, nil, nil)
handler.defaultConfig = &config.Config{DataPath: tmp}
req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil)
rec := httptest.NewRecorder()
handler.HandleGetAISettings(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String())
var resp AISettingsResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.True(t, resp.Enabled)
assert.True(t, resp.Configured)
assert.Equal(t, config.DefaultModelForProvider(config.AIProviderQuickstart), resp.Model)
assert.Equal(t, 25, resp.QuickstartCreditsTotal)
assert.Equal(t, 25, resp.QuickstartCreditsRemaining)
assert.True(t, resp.QuickstartCreditsAvailable)
assert.True(t, persistence.HasAIConfig())
}
func TestAISettingsHandler_UpdateSettings_OpenRouterKeySetAndClear(t *testing.T) {
t.Parallel()

View file

@ -0,0 +1,102 @@
package api
import (
"fmt"
"os"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
func loadHostedAwareAIConfig(hostedMode bool, billingBaseDir, orgID string, persistence *config.ConfigPersistence) (*config.AIConfig, error) {
if persistence == nil {
return nil, fmt.Errorf("Pulse Assistant config persistence unavailable")
}
cfg, err := persistence.LoadAIConfig()
if err != nil {
return nil, err
}
if !shouldAutoBootstrapHostedAIConfig(hostedMode, persistence) {
return cfg, nil
}
state, err := ensureHostedAIQuickstartBillingState(billingBaseDir, orgID)
if err != nil {
return nil, err
}
if !hostedAIAutoBootstrapEligible(state) {
return cfg, nil
}
bootstrapped := config.NewDefaultAIConfig()
bootstrapped.Enabled = true
quickstartModel := config.DefaultModelForProvider(config.AIProviderQuickstart)
bootstrapped.Model = quickstartModel
bootstrapped.ChatModel = quickstartModel
bootstrapped.PatrolModel = quickstartModel
if err := persistence.SaveAIConfig(*bootstrapped); err != nil {
return nil, fmt.Errorf("persist hosted Pulse Assistant bootstrap config: %w", err)
}
return bootstrapped, nil
}
func shouldAutoBootstrapHostedAIConfig(hostedMode bool, persistence *config.ConfigPersistence) bool {
return hostedMode && persistence != nil && !persistence.HasAIConfig()
}
func ensureHostedAIQuickstartBillingState(billingBaseDir, orgID string) (*billingState, error) {
billingBaseDir = strings.TrimSpace(billingBaseDir)
if billingBaseDir == "" {
return nil, nil
}
orgID = normalizeHostedEntitlementOrgID(orgID)
billingStore := config.NewFileBillingStore(billingBaseDir)
state, err := billingStore.GetBillingState(orgID)
if err != nil || state == nil {
return state, err
}
if !hostedAIAutoBootstrapEligible(state) || state.QuickstartCreditsGranted {
return state, nil
}
updated := normalizeBillingStateFromLicensing(state)
updated.GrantQuickstartCredits()
if err := billingStore.SaveBillingState(orgID, updated); err != nil {
return nil, fmt.Errorf("save hosted Pulse Assistant quickstart billing state: %w", err)
}
return updated, nil
}
func hostedAIAutoBootstrapEligible(state *billingState) bool {
if state == nil {
return false
}
if strings.TrimSpace(state.EntitlementJWT) == "" && strings.TrimSpace(state.EntitlementRefreshToken) == "" {
return false
}
switch state.SubscriptionState {
case subscriptionStateActiveValue, subscriptionStateGraceValue, pkglicensing.SubStateTrial:
default:
return false
}
return hasBillingCapability(state, featureAIPatrolValue) || hasBillingCapability(state, featureAIAutoFixValue)
}
func hasBillingCapability(state *billingState, feature string) bool {
if state == nil || strings.TrimSpace(feature) == "" {
return false
}
for _, capability := range state.Capabilities {
if capability == feature {
return true
}
}
return false
}
func hostedModeEnabledFromEnv() bool {
return os.Getenv("PULSE_HOSTED_MODE") == "true"
}

View file

@ -0,0 +1,143 @@
package api
import (
"context"
"crypto/ed25519"
"encoding/base64"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
"github.com/stretchr/testify/mock"
)
func TestLoadHostedAwareAIConfig_AutoBootstrapsHostedQuickstart(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
persistence, err := mtp.GetPersistence("default")
if err != nil {
t.Fatalf("GetPersistence(default): %v", err)
}
seedHostedAIBillingState(t, mtp, "default")
loaded, err := loadHostedAwareAIConfig(true, mtp.BaseDataDir(), "default", persistence)
if err != nil {
t.Fatalf("loadHostedAwareAIConfig(): %v", err)
}
if loaded == nil {
t.Fatal("expected hosted Pulse Assistant config")
}
if !loaded.Enabled {
t.Fatal("expected hosted Pulse Assistant config to be enabled")
}
quickstartModel := config.DefaultModelForProvider(config.AIProviderQuickstart)
if loaded.Model != quickstartModel || loaded.ChatModel != quickstartModel || loaded.PatrolModel != quickstartModel {
t.Fatalf("expected quickstart models, got model=%q chat=%q patrol=%q", loaded.Model, loaded.ChatModel, loaded.PatrolModel)
}
if !persistence.HasAIConfig() {
t.Fatal("expected hosted AI bootstrap to persist ai config")
}
billingStore := config.NewFileBillingStore(mtp.BaseDataDir())
state, err := billingStore.GetBillingState("default")
if err != nil {
t.Fatalf("GetBillingState(default): %v", err)
}
if state == nil || !state.QuickstartCreditsGranted {
t.Fatal("expected hosted AI bootstrap to backfill quickstart credits")
}
}
func TestLoadHostedAwareAIConfig_DoesNotOverrideExplicitAIConfig(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
persistence, err := mtp.GetPersistence("default")
if err != nil {
t.Fatalf("GetPersistence(default): %v", err)
}
seedHostedAIBillingState(t, mtp, "default")
explicit := config.NewDefaultAIConfig()
explicit.Enabled = false
explicit.Model = config.DefaultModelForProvider(config.AIProviderAnthropic)
if err := persistence.SaveAIConfig(*explicit); err != nil {
t.Fatalf("SaveAIConfig(): %v", err)
}
loaded, err := loadHostedAwareAIConfig(true, mtp.BaseDataDir(), "default", persistence)
if err != nil {
t.Fatalf("loadHostedAwareAIConfig(): %v", err)
}
if loaded == nil {
t.Fatal("expected explicit Pulse Assistant config")
}
if loaded.Enabled {
t.Fatal("expected explicit disabled Pulse Assistant config to remain disabled")
}
if loaded.Model != explicit.Model {
t.Fatalf("model=%q, want %q", loaded.Model, explicit.Model)
}
}
func TestAIHandlerStart_HostedAutoBootstrapStartsService(t *testing.T) {
oldNewService := newChatService
defer func() { newChatService = oldNewService }()
mockSvc := new(MockAIService)
newChatService = func(cfg chat.Config) AIService {
return mockSvc
}
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
persistence, err := mtp.GetPersistence("default")
if err != nil {
t.Fatalf("GetPersistence(default): %v", err)
}
seedHostedAIBillingState(t, mtp, "default")
handler := NewAIHandler(mtp, nil, nil)
handler.defaultPersistence = persistence
handler.hostedMode = true
mockSvc.On("Start", mock.Anything).Return(nil).Once()
if err := handler.Start(context.Background(), nil); err != nil {
t.Fatalf("Start(): %v", err)
}
if handler.defaultService != mockSvc {
t.Fatal("expected hosted auto-bootstrap to start AI service")
}
}
func seedHostedAIBillingState(t *testing.T, mtp *config.MultiTenantPersistence, orgID string) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("generate entitlement keypair: %v", err)
}
t.Setenv(pkglicensing.TrialActivationPublicKeyEnvVar, base64.StdEncoding.EncodeToString(pub))
entitlementJWT, err := pkglicensing.SignEntitlementLeaseToken(priv, pkglicensing.EntitlementLeaseClaims{
OrgID: orgID,
InstanceHost: "t-hostedai.cloud.pulserelay.pro",
PlanVersion: "msp_starter",
SubscriptionState: pkglicensing.SubStateActive,
Capabilities: []string{pkglicensing.FeatureAIPatrol, pkglicensing.FeatureAIAutoFix},
})
if err != nil {
t.Fatalf("SignEntitlementLeaseToken(): %v", err)
}
billingStore := config.NewFileBillingStore(mtp.BaseDataDir())
if err := billingStore.SaveBillingState(orgID, &billingState{
EntitlementJWT: entitlementJWT,
EntitlementRefreshToken: "etr_hosted_test_bootstrap",
}); err != nil {
t.Fatalf("SaveBillingState(): %v", err)
}
}

View file

@ -243,6 +243,69 @@ func TestContract_BillingStateQuickstartJSONSnapshot(t *testing.T) {
assertJSONSnapshot(t, got, want)
}
func TestContract_HostedAISettingsAutoBootstrapJSONSnapshot(t *testing.T) {
t.Setenv("PULSE_HOSTED_MODE", "true")
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
persistence, err := mtp.GetPersistence("default")
if err != nil {
t.Fatalf("GetPersistence(default): %v", err)
}
seedHostedAIBillingState(t, mtp, "default")
handler := NewAISettingsHandler(mtp, nil, nil)
handler.defaultConfig = &config.Config{DataPath: baseDir}
req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil)
rec := httptest.NewRecorder()
handler.HandleGetAISettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
if !persistence.HasAIConfig() {
t.Fatal("expected hosted AI settings contract to persist canonical ai.enc bootstrap")
}
const want = `{
"enabled":true,
"model":"quickstart:minimax-2.5m",
"chat_model":"quickstart:minimax-2.5m",
"patrol_model":"quickstart:minimax-2.5m",
"configured":true,
"custom_context":"",
"auth_method":"api_key",
"oauth_connected":false,
"patrol_interval_minutes":360,
"patrol_enabled":true,
"patrol_auto_fix":false,
"alert_triggered_analysis":true,
"patrol_event_triggers_enabled":true,
"use_proactive_thresholds":false,
"available_models":[],
"anthropic_configured":false,
"openai_configured":false,
"openrouter_configured":false,
"deepseek_configured":false,
"gemini_configured":false,
"ollama_configured":false,
"ollama_base_url":"http://localhost:11434",
"configured_providers":[],
"control_level":"read_only",
"protected_guests":[],
"discovery_enabled":false,
"quickstart_credits_total":25,
"quickstart_credits_used":0,
"quickstart_credits_remaining":25,
"quickstart_credits_available":true,
"using_quickstart":true
}`
assertJSONSnapshot(t, rec.Body.Bytes(), want)
}
func TestContract_StripeWebhookHandlersUseCanonicalRuntimeDataDir(t *testing.T) {
envDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", envDir)

View file

@ -131,6 +131,7 @@ const (
DefaultAIModelOllama = "llama3"
DefaultAIModelDeepSeek = "deepseek-chat" // V3.2 with tool-use support
DefaultAIModelGemini = "gemini-2.5-flash" // Latest stable Gemini model
DefaultAIModelQuickstart = "minimax-2.5m"
DefaultOllamaBaseURL = "http://localhost:11434"
DefaultOpenRouterBaseURL = "https://openrouter.ai/api/v1/chat/completions"
DefaultDeepSeekBaseURL = "https://api.deepseek.com/chat/completions"
@ -331,6 +332,8 @@ func DefaultModelForProvider(provider string) string {
return FormatModelString(AIProviderGemini, DefaultAIModelGemini)
case AIProviderOllama:
return FormatModelString(AIProviderOllama, DefaultAIModelOllama)
case AIProviderQuickstart:
return FormatModelString(AIProviderQuickstart, DefaultAIModelQuickstart)
default:
return ""
}

View file

@ -223,6 +223,14 @@ func TestAIConfig_GetConfiguredProviders(t *testing.T) {
}
}
func TestDefaultModelForProvider_Quickstart(t *testing.T) {
got := DefaultModelForProvider(AIProviderQuickstart)
want := FormatModelString(AIProviderQuickstart, DefaultAIModelQuickstart)
if got != want {
t.Fatalf("DefaultModelForProvider(%q) = %q, want %q", AIProviderQuickstart, got, want)
}
}
func TestAIConfig_GetAPIKeyForProvider(t *testing.T) {
config := AIConfig{
AnthropicAPIKey: "anthropic-key",

View file

@ -1854,6 +1854,17 @@ func (c *ConfigPersistence) LoadAIConfig() (*AIConfig, error) {
return settings, nil
}
// HasAIConfig reports whether ai.enc exists on disk, which distinguishes
// explicit operator configuration from synthesized defaults returned by
// LoadAIConfig when the file is absent.
func (c *ConfigPersistence) HasAIConfig() bool {
if c == nil || c.fs == nil {
return false
}
_, err := c.fs.Stat(c.aiFile)
return err == nil
}
func applyLegacyAIConfigFields(settings *AIConfig, raw map[string]json.RawMessage) bool {
if settings == nil || len(raw) == 0 {
return false

View file

@ -145,6 +145,25 @@ func TestPersistence_AIConfig(t *testing.T) {
assert.Error(t, err)
}
func TestPersistence_HasAIConfig(t *testing.T) {
tempDir := t.TempDir()
p := NewConfigPersistence(tempDir)
if p.HasAIConfig() {
t.Fatal("expected HasAIConfig() to be false before ai config is saved")
}
cfg := NewDefaultAIConfig()
cfg.Enabled = true
if err := p.SaveAIConfig(*cfg); err != nil {
t.Fatalf("SaveAIConfig(): %v", err)
}
if !p.HasAIConfig() {
t.Fatal("expected HasAIConfig() to be true after ai config is saved")
}
}
func TestPersistence_AIConfig_MigratesSuggestControlLevel(t *testing.T) {
tempDir := t.TempDir()
p := NewConfigPersistence(tempDir)