From 6f7ceee4043968b7a5dce5407cd55b2dd4aaf031 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 25 Mar 2026 17:33:51 +0000 Subject: [PATCH] fix(ai): point quickstart to owned license endpoint --- .../v6/internal/subsystems/ai-runtime.md | 8 +++ internal/ai/providers/quickstart.go | 21 +++++--- internal/ai/providers/quickstart_test.go | 52 +++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 internal/ai/providers/quickstart_test.go diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index c7c645199..2e8bf6a64 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -80,6 +80,14 @@ governed floor is ready. `internal/ai/` is the live backend AI engine. It owns chat execution, Patrol orchestration, findings generation, investigation support, quickstart and provider selection, remediation flow, and cost persistence. +That quickstart ownership includes the public proxy dependency under +`internal/ai/providers/quickstart.go`: the runtime must default to the owned +commercial API edge at `https://license.pulserelay.pro/v1/quickstart/patrol` +instead of depending on an ungoverned standalone hostname. Runtime overrides +may exist only as an explicit environment-controlled rollout escape hatch, and +the canonical quickstart proxy contract remains an OpenAI-compatible server-owned +surface that lives behind the public license API rather than a tenant-local or +mobile-local adapter. The same runtime ownership now includes the customer-facing AI usage and cost surface. `frontend-modern/src/components/AI/AICostDashboard.tsx` is the diff --git a/internal/ai/providers/quickstart.go b/internal/ai/providers/quickstart.go index cc7acacc3..5c74c822a 100644 --- a/internal/ai/providers/quickstart.go +++ b/internal/ai/providers/quickstart.go @@ -7,15 +7,17 @@ import ( "fmt" "io" "net/http" + "os" + "strings" "time" "github.com/rs/zerolog/log" ) const ( - // quickstartProxyURL is the Pulse-hosted proxy that forwards to MiniMax. + // defaultQuickstartProxyURL is the owned public quickstart proxy surface. // The API key lives server-side — self-hosted binaries never see it. - quickstartProxyURL = "https://api.pulserelay.pro/v1/quickstart/patrol" + defaultQuickstartProxyURL = "https://license.pulserelay.pro/v1/quickstart/patrol" quickstartModel = "minimax-2.5m" quickstartRequestTimeout = 300 * time.Second // 5 minutes @@ -24,14 +26,21 @@ const ( ) // QuickstartClient implements the Provider interface for the Pulse-hosted -// quickstart proxy. It forwards patrol chat requests to the MiniMax 2.5M -// model via api.pulserelay.pro so users don't need their own API key. +// quickstart proxy. It forwards patrol chat requests through the public +// commercial API edge so users don't need their own API key. type QuickstartClient struct { // licenseID identifies the workspace (for rate limiting / credit tracking server-side). licenseID string client *http.Client } +func quickstartProxyURL() string { + if override := strings.TrimSpace(os.Getenv("PULSE_AI_QUICKSTART_PROXY_URL")); override != "" { + return override + } + return defaultQuickstartProxyURL +} + // NewQuickstartClient creates a quickstart provider that uses the hosted proxy. func NewQuickstartClient(licenseID string) *QuickstartClient { return &QuickstartClient{ @@ -90,7 +99,7 @@ func (c *QuickstartClient) Chat(ctx context.Context, req ChatRequest) (*ChatResp } } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, quickstartProxyURL, bytes.NewReader(body)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, quickstartProxyURL(), bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("quickstart: create request: %w", err) } @@ -144,7 +153,7 @@ func (c *QuickstartClient) Chat(ctx context.Context, req ChatRequest) (*ChatResp // TestConnection validates connectivity to the quickstart proxy. func (c *QuickstartClient) TestConnection(ctx context.Context) error { // Simple connectivity check — send a minimal request. - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, quickstartProxyURL, bytes.NewReader([]byte(`{"messages":[],"license_id":"`+c.licenseID+`"}`))) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, quickstartProxyURL(), bytes.NewReader([]byte(`{"messages":[],"license_id":"`+c.licenseID+`"}`))) if err != nil { return fmt.Errorf("quickstart: create test request: %w", err) } diff --git a/internal/ai/providers/quickstart_test.go b/internal/ai/providers/quickstart_test.go new file mode 100644 index 000000000..2e01949f8 --- /dev/null +++ b/internal/ai/providers/quickstart_test.go @@ -0,0 +1,52 @@ +package providers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestQuickstartProxyURL_Default(t *testing.T) { + t.Setenv("PULSE_AI_QUICKSTART_PROXY_URL", "") + if got := quickstartProxyURL(); got != defaultQuickstartProxyURL { + t.Fatalf("quickstartProxyURL()=%q want %q", got, defaultQuickstartProxyURL) + } +} + +func TestQuickstartClientChat_UsesOverrideProxyURL(t *testing.T) { + var seenLicenseID string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req quickstartRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + seenLicenseID = req.LicenseID + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(quickstartResponse{ + Content: "hello", + Model: quickstartModel, + StopReason: "end_turn", + }); err != nil { + t.Fatalf("encode response: %v", err) + } + })) + defer server.Close() + + t.Setenv("PULSE_AI_QUICKSTART_PROXY_URL", server.URL) + + client := NewQuickstartClient("lic_test") + resp, err := client.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "Hi"}}, + }) + if err != nil { + t.Fatalf("Chat(): %v", err) + } + if seenLicenseID != "lic_test" { + t.Fatalf("license_id=%q want lic_test", seenLicenseID) + } + if resp.Content != "hello" { + t.Fatalf("content=%q want hello", resp.Content) + } +}