chore(wip): save Windsurf/Antigravity/ops customizations before upstream merge
WIP commit保存以下定制工作以便后续合并 upstream v0.1.124-125: - Windsurf: tier access service, NLU extractor, cold threshold, Google login - Antigravity: client/oauth 调整 - Ops: log stream handler/broadcaster/middleware, OpsLogStreamView - Frontend: WindsurfLoginModal Google, GoogleIcon, AccountsView, sidebar/router/i18n
This commit is contained in:
parent
3fe228d143
commit
de048fad25
@ -205,7 +205,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService)
|
||||
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService)
|
||||
requestEventBus := service.NewRequestEventBus()
|
||||
opsHandler := admin.NewOpsHandler(opsService, requestEventBus)
|
||||
opsLogBroadcaster := service.ProvideOpsLogBroadcaster()
|
||||
opsHandler := admin.NewOpsHandler(opsService, requestEventBus, opsLogBroadcaster)
|
||||
updateCache := repository.NewUpdateCache(redisClient)
|
||||
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
|
||||
serviceBuildInfo := provideServiceBuildInfo(buildInfo)
|
||||
@ -240,7 +241,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
windsurfAuthService := service.ProvideWindsurfAuthService(configConfig, accountRepository, proxyRepository, adminService)
|
||||
windsurfRefreshService := service.ProvideWindsurfRefreshService(configConfig, accountRepository, proxyRepository)
|
||||
windsurfProbeService := service.ProvideWindsurfProbeService(configConfig, accountRepository, proxyRepository)
|
||||
windsurfHandler := handler.ProvideWindsurfHandler(windsurfAuthService, windsurfLSService, windsurfProbeService)
|
||||
windsurfTierAccessService := service.ProvideWindsurfTierAccessService(configConfig, accountRepository)
|
||||
windsurfHandler := handler.ProvideWindsurfHandler(windsurfAuthService, windsurfLSService, windsurfProbeService, windsurfTierAccessService)
|
||||
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler, windsurfHandler, affiliateHandler)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
@ -260,7 +262,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
|
||||
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
|
||||
healthService := service.NewHealthService(db, redisClient)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, healthService, redisClient)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, healthService, redisClient, opsLogBroadcaster)
|
||||
httpServer := server.ProvideHTTPServer(configConfig, engine)
|
||||
opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig)
|
||||
opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig)
|
||||
|
||||
@ -27,8 +27,15 @@ const (
|
||||
)
|
||||
|
||||
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
|
||||
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
|
||||
const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://*.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://*.stripe.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware.
|
||||
//
|
||||
// Firebase Auth popup flow (used by Windsurf Google login) requires:
|
||||
// - script-src https://apis.google.com (loads gapi for the OAuth iframe)
|
||||
// - frame-src https://*.firebaseapp.com https://accounts.google.com https://apis.google.com
|
||||
// - media-src 'self' data: (Firebase plays a tiny silent base64 WAV
|
||||
// to keep the popup channel alive across
|
||||
// browser autoplay restrictions)
|
||||
const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://*.stripe.com https://apis.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; media-src 'self' data:; frame-src https://challenges.cloudflare.com https://*.stripe.com https://*.firebaseapp.com https://accounts.google.com https://apis.google.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
|
||||
// UMQ(用户消息队列)模式常量
|
||||
const (
|
||||
|
||||
@ -18,6 +18,7 @@ import (
|
||||
type OpsHandler struct {
|
||||
opsService *service.OpsService
|
||||
requestEventBus *service.RequestEventBus
|
||||
logBroadcaster *service.OpsLogBroadcaster
|
||||
}
|
||||
|
||||
// GetErrorLogByID returns ops error log detail.
|
||||
@ -71,8 +72,8 @@ func parseOpsViewParam(c *gin.Context) string {
|
||||
}
|
||||
}
|
||||
|
||||
func NewOpsHandler(opsService *service.OpsService, requestEventBus *service.RequestEventBus) *OpsHandler {
|
||||
return &OpsHandler{opsService: opsService, requestEventBus: requestEventBus}
|
||||
func NewOpsHandler(opsService *service.OpsService, requestEventBus *service.RequestEventBus, logBroadcaster *service.OpsLogBroadcaster) *OpsHandler {
|
||||
return &OpsHandler{opsService: opsService, requestEventBus: requestEventBus, logBroadcaster: logBroadcaster}
|
||||
}
|
||||
|
||||
// GetErrorLogs lists ops error logs.
|
||||
|
||||
210
backend/internal/handler/admin/ops_log_stream_handler.go
Normal file
210
backend/internal/handler/admin/ops_log_stream_handler.go
Normal file
@ -0,0 +1,210 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
opsLogStreamHeartbeat = 25 * time.Second
|
||||
opsLogStreamRecentMax = 200
|
||||
opsLogStreamSubBufEntries = 1024
|
||||
opsLogStreamModelMaxLen = 256
|
||||
)
|
||||
|
||||
// LogStream serves a Server-Sent Events feed of every gateway request.
|
||||
//
|
||||
// GET /api/v1/admin/ops/logs/stream?min_status=400&model=glm-4.7&account_id=42&min_latency_ms=2000
|
||||
//
|
||||
// Filter query params (all optional, AND-combined):
|
||||
//
|
||||
// min_status — int only emit when entry.status >= this value
|
||||
// model — exact match on model key
|
||||
// account_id — int64
|
||||
// group_id — int64
|
||||
// min_latency_ms — int64 only emit when entry.latency_ms >= this value
|
||||
//
|
||||
// The handler keeps the connection open until the client disconnects, the
|
||||
// monitoring is disabled, or the broadcaster is torn down. A heartbeat
|
||||
// comment line is sent every 25s so reverse proxies don't time out idle
|
||||
// streams.
|
||||
func (h *OpsHandler) LogStream(c *gin.Context) {
|
||||
if h.logBroadcaster == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "log broadcaster not configured")
|
||||
return
|
||||
}
|
||||
// nil opsService is allowed for lightweight deployments / tests where
|
||||
// the OpsService dependency is intentionally absent. The admin auth
|
||||
// middleware on the route group still enforces JWT + admin role, so
|
||||
// the stream is never reachable anonymously.
|
||||
if h.opsService != nil {
|
||||
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
filter, err := parseOpsLogFilter(c)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
response.Error(c, http.StatusInternalServerError, "streaming unsupported")
|
||||
return
|
||||
}
|
||||
|
||||
ch, unsubscribe := h.logBroadcaster.Subscribe(filter, opsLogStreamSubBufEntries)
|
||||
defer unsubscribe()
|
||||
|
||||
// Prime client with recent buffered history (so a fresh dashboard tab
|
||||
// renders something immediately instead of staying blank).
|
||||
for _, e := range h.logBroadcaster.Snapshot(filter, opsLogStreamRecentMax) {
|
||||
if err := writeOpsLogSSE(c.Writer, &e); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
flusher.Flush()
|
||||
|
||||
heartbeat := time.NewTicker(opsLogStreamHeartbeat)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
ctxDone := c.Request.Context().Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctxDone:
|
||||
return
|
||||
case <-heartbeat.C:
|
||||
if _, err := io.WriteString(c.Writer, ": ping\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
case entry := <-ch:
|
||||
if err := writeOpsLogSSE(c.Writer, &entry); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LogStreamRecent returns the broadcaster history without subscribing.
|
||||
// Useful for one-shot polling when the admin panel cannot keep an open
|
||||
// SSE connection (e.g. behind a buffering proxy).
|
||||
//
|
||||
// GET /api/v1/admin/ops/logs/recent?min_status=400&max=500
|
||||
func (h *OpsHandler) LogStreamRecent(c *gin.Context) {
|
||||
if h.logBroadcaster == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "log broadcaster not configured")
|
||||
return
|
||||
}
|
||||
if h.opsService != nil {
|
||||
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
filter, err := parseOpsLogFilter(c)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
maxN := opsLogStreamRecentMax
|
||||
if v := strings.TrimSpace(c.Query("max")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 2000 {
|
||||
maxN = n
|
||||
}
|
||||
}
|
||||
|
||||
published, dropped, subs := h.logBroadcaster.Stats()
|
||||
response.Success(c, gin.H{
|
||||
"entries": h.logBroadcaster.Snapshot(filter, maxN),
|
||||
"published_total": published,
|
||||
"dropped_total": dropped,
|
||||
"subscribers": subs,
|
||||
})
|
||||
}
|
||||
|
||||
func parseOpsLogFilter(c *gin.Context) (service.OpsLogFilter, error) {
|
||||
f := service.OpsLogFilter{}
|
||||
if v := strings.TrimSpace(c.Query("min_status")); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
return f, fmt.Errorf("invalid min_status")
|
||||
}
|
||||
f.MinStatus = n
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("model")); v != "" {
|
||||
// Cap input to keep an authenticated admin from stuffing huge
|
||||
// strings into long-lived subscription state.
|
||||
if len(v) > opsLogStreamModelMaxLen {
|
||||
return f, fmt.Errorf("model too long (max %d)", opsLogStreamModelMaxLen)
|
||||
}
|
||||
f.Model = v
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("account_id")); v != "" {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
// Reject 0 — matches() treats AccountID==0 as "match all", so a
|
||||
// param of 0 would silently degrade to no-filter without telling
|
||||
// the user. Demand a positive id (callers wanting all should omit
|
||||
// the param).
|
||||
if err != nil || n <= 0 {
|
||||
return f, fmt.Errorf("invalid account_id")
|
||||
}
|
||||
f.AccountID = n
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("group_id")); v != "" {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return f, fmt.Errorf("invalid group_id")
|
||||
}
|
||||
f.GroupID = n
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("min_latency_ms")); v != "" {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return f, fmt.Errorf("invalid min_latency_ms")
|
||||
}
|
||||
f.MinLatencyMs = n
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// writeOpsLogSSE writes one SSE frame: an `event: log` line followed by a
|
||||
// single `data:` line containing the JSON-encoded entry, terminated by a
|
||||
// blank line per the SSE protocol.
|
||||
//
|
||||
// This assumes the JSON payload contains no bare LF — which holds because
|
||||
// every string field in OpsLogEntry passes through encoding/json escaping.
|
||||
// If a future field is added that emits raw bytes (e.g. a []byte body),
|
||||
// the marshalled output must be split across multiple `data:` lines.
|
||||
func writeOpsLogSSE(w io.Writer, e *service.OpsLogEntry) error {
|
||||
payload, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(w, "event: log\ndata: "); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.WriteString(w, "\n\n")
|
||||
return err
|
||||
}
|
||||
@ -116,7 +116,7 @@ func newRuntimeOpsService(t *testing.T) *service.OpsService {
|
||||
}
|
||||
|
||||
func TestOpsRuntimeLoggingHandler_GetConfig(t *testing.T) {
|
||||
h := NewOpsHandler(newRuntimeOpsService(t), nil)
|
||||
h := NewOpsHandler(newRuntimeOpsService(t), nil, nil)
|
||||
r := newOpsRuntimeRouter(h, false)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@ -128,7 +128,7 @@ func TestOpsRuntimeLoggingHandler_GetConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOpsRuntimeLoggingHandler_UpdateUnauthorized(t *testing.T) {
|
||||
h := NewOpsHandler(newRuntimeOpsService(t), nil)
|
||||
h := NewOpsHandler(newRuntimeOpsService(t), nil, nil)
|
||||
r := newOpsRuntimeRouter(h, false)
|
||||
|
||||
body := `{"level":"debug","enable_sampling":false,"sampling_initial":100,"sampling_thereafter":100,"caller":true,"stacktrace_level":"error","retention_days":30}`
|
||||
@ -142,7 +142,7 @@ func TestOpsRuntimeLoggingHandler_UpdateUnauthorized(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOpsRuntimeLoggingHandler_UpdateAndResetSuccess(t *testing.T) {
|
||||
h := NewOpsHandler(newRuntimeOpsService(t), nil)
|
||||
h := NewOpsHandler(newRuntimeOpsService(t), nil, nil)
|
||||
r := newOpsRuntimeRouter(h, true)
|
||||
|
||||
payload := map[string]any{
|
||||
|
||||
@ -13,20 +13,23 @@ import (
|
||||
)
|
||||
|
||||
type WindsurfHandler struct {
|
||||
authService *service.WindsurfAuthService
|
||||
lsService *service.WindsurfLSService
|
||||
probeService *service.WindsurfProbeService
|
||||
authService *service.WindsurfAuthService
|
||||
lsService *service.WindsurfLSService
|
||||
probeService *service.WindsurfProbeService
|
||||
tierAccessService *service.WindsurfTierAccessService
|
||||
}
|
||||
|
||||
func NewWindsurfHandler(
|
||||
authService *service.WindsurfAuthService,
|
||||
lsService *service.WindsurfLSService,
|
||||
probeService *service.WindsurfProbeService,
|
||||
tierAccessService *service.WindsurfTierAccessService,
|
||||
) *WindsurfHandler {
|
||||
return &WindsurfHandler{
|
||||
authService: authService,
|
||||
lsService: lsService,
|
||||
probeService: probeService,
|
||||
authService: authService,
|
||||
lsService: lsService,
|
||||
probeService: probeService,
|
||||
tierAccessService: tierAccessService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,6 +84,61 @@ func (h *WindsurfHandler) Login(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WindsurfHandler) TokenLogin(c *gin.Context) {
|
||||
var req dto.WindsurfTokenLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
concurrency := 1
|
||||
if req.Concurrency != nil && *req.Concurrency > 0 {
|
||||
concurrency = *req.Concurrency
|
||||
}
|
||||
priority := 0
|
||||
if req.Priority != nil {
|
||||
priority = *req.Priority
|
||||
}
|
||||
probeAfter := false
|
||||
if req.ProbeAfter != nil {
|
||||
probeAfter = *req.ProbeAfter
|
||||
}
|
||||
|
||||
input := &service.WindsurfTokenLoginInput{
|
||||
Token: req.Token,
|
||||
Email: req.Email,
|
||||
Name: req.Name,
|
||||
Notes: req.Notes,
|
||||
ProxyID: req.ProxyID,
|
||||
GroupIDs: req.GroupIDs,
|
||||
Concurrency: concurrency,
|
||||
Priority: priority,
|
||||
ProbeAfter: probeAfter,
|
||||
LSInstanceID: req.LSInstanceID,
|
||||
}
|
||||
|
||||
output, err := h.authService.TokenLogin(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
// ErrorFrom maps typed ApplicationError (BadRequest/Conflict/etc.)
|
||||
// to its real HTTP code; falls through to 500 for opaque errors.
|
||||
if !response.ErrorFrom(c, err) {
|
||||
response.Error(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.WindsurfLoginResponse{
|
||||
AccountID: output.AccountID,
|
||||
Platform: "windsurf",
|
||||
Type: "windsurf-session",
|
||||
Email: output.Email,
|
||||
Tier: output.Tier,
|
||||
AuthMethod: output.AuthMethod,
|
||||
APIKeyPresent: output.APIKeyPresent,
|
||||
RefreshTokenPresent: output.RefreshTokenPresent,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WindsurfHandler) BatchLogin(c *gin.Context) {
|
||||
var req dto.WindsurfBatchLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@ -309,3 +367,18 @@ func (h *WindsurfHandler) GetRuntime(c *gin.Context) {
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// GetTierAccess returns per-model account-pool availability for the
|
||||
// admin dashboard. Backed by a 60s in-memory snapshot cache.
|
||||
func (h *WindsurfHandler) GetTierAccess(c *gin.Context) {
|
||||
if h.tierAccessService == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "tier access service not configured")
|
||||
return
|
||||
}
|
||||
snap, err := h.tierAccessService.Snapshot(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, snap)
|
||||
}
|
||||
|
||||
@ -13,6 +13,26 @@ type WindsurfLoginRequest struct {
|
||||
LSInstanceID string `json:"ls_instance_id,omitempty"`
|
||||
}
|
||||
|
||||
// WindsurfTokenLoginRequest carries a pre-obtained Windsurf auth token
|
||||
// (copied by the user from https://windsurf.com/show-auth-token after
|
||||
// signing in to windsurf.com via Google / GitHub / email).
|
||||
//
|
||||
// Token field accepts whatever windsurf.com/show-auth-token displays —
|
||||
// the backend tries to exchange it directly with Codeium's register_user
|
||||
// endpoint, mirroring the dwgx/WindsurfAPI reference behaviour.
|
||||
type WindsurfTokenLoginRequest struct {
|
||||
Token string `json:"token" binding:"required,max=16384"`
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
ProxyID *int64 `json:"proxy_id,omitempty"`
|
||||
GroupIDs []int64 `json:"group_ids,omitempty"`
|
||||
Concurrency *int `json:"concurrency,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
ProbeAfter *bool `json:"probe_after,omitempty"`
|
||||
LSInstanceID string `json:"ls_instance_id,omitempty"`
|
||||
}
|
||||
|
||||
type WindsurfBatchLoginRequest struct {
|
||||
Items []string `json:"items" binding:"required,min=1"`
|
||||
ProxyID *int64 `json:"proxy_id,omitempty"`
|
||||
|
||||
100
backend/internal/handler/ops_log_stream_middleware.go
Normal file
100
backend/internal/handler/ops_log_stream_middleware.go
Normal file
@ -0,0 +1,100 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// OpsLogStreamMiddleware fans every gateway request out to the in-memory
|
||||
// OpsLogBroadcaster so admin tools can subscribe to a real-time SSE feed.
|
||||
//
|
||||
// This is intentionally separate from OpsErrorLoggerMiddleware:
|
||||
// - OpsErrorLoggerMiddleware persists 4xx/5xx into the database for audit.
|
||||
// - This middleware streams every request (success + failure) for live UX.
|
||||
//
|
||||
// The broadcaster.Publish call is non-blocking by design (see the
|
||||
// implementation): a slow/missing subscriber NEVER stalls the request path.
|
||||
// Empty broadcaster (nil receiver, or no subscribers) is a no-op.
|
||||
func OpsLogStreamMiddleware(b *service.OpsLogBroadcaster) gin.HandlerFunc {
|
||||
if b == nil {
|
||||
return func(c *gin.Context) { c.Next() }
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
entry := service.OpsLogEntry{
|
||||
Time: start,
|
||||
Method: c.Request.Method,
|
||||
Path: c.Request.URL.Path,
|
||||
Status: c.Writer.Status(),
|
||||
LatencyMs: time.Since(start).Milliseconds(),
|
||||
}
|
||||
|
||||
if v, ok := c.Get(opsModelKey); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
entry.Model = s
|
||||
}
|
||||
}
|
||||
if v, ok := c.Get(opsStreamKey); ok {
|
||||
if streamFlag, ok := v.(bool); ok {
|
||||
entry.Stream = streamFlag
|
||||
}
|
||||
}
|
||||
if v, ok := c.Get(opsAccountIDKey); ok {
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
entry.AccountID = t
|
||||
case int:
|
||||
entry.AccountID = int64(t)
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort api-key + group + user from middleware context.
|
||||
if apiKey, ok := servermiddleware.GetAPIKeyFromContext(c); ok && apiKey != nil {
|
||||
entry.APIKeyID = apiKey.ID
|
||||
if apiKey.GroupID != nil {
|
||||
entry.GroupID = *apiKey.GroupID
|
||||
}
|
||||
entry.UserID = apiKey.UserID
|
||||
}
|
||||
|
||||
// Pull upstream error context (set by gateway services on retries).
|
||||
if v, ok := c.Get(service.OpsUpstreamStatusCodeKey); ok {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
entry.UpstreamCode = t
|
||||
case int64:
|
||||
entry.UpstreamCode = int(t)
|
||||
}
|
||||
}
|
||||
if v, ok := c.Get(service.OpsUpstreamErrorMessageKey); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
entry.ErrorMessage = trimForStream(s)
|
||||
}
|
||||
}
|
||||
if v, ok := c.Get(service.OpsUpstreamErrorDetailKey); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
entry.ErrorDetail = trimForStream(s)
|
||||
}
|
||||
}
|
||||
|
||||
b.Publish(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// trimForStream caps long error strings so a single broken upstream cannot
|
||||
// flood the SSE channel with megabyte-sized error blobs.
|
||||
func trimForStream(s string) string {
|
||||
const max = 512
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > max {
|
||||
return s[:max] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -80,11 +80,11 @@ func ProvideSystemHandler(updateService *service.UpdateService, lockService *ser
|
||||
}
|
||||
|
||||
// ProvideWindsurfHandler returns nil when windsurf auth service is disabled.
|
||||
func ProvideWindsurfHandler(authService *service.WindsurfAuthService, lsService *service.WindsurfLSService, probeService *service.WindsurfProbeService) *admin.WindsurfHandler {
|
||||
func ProvideWindsurfHandler(authService *service.WindsurfAuthService, lsService *service.WindsurfLSService, probeService *service.WindsurfProbeService, tierAccessService *service.WindsurfTierAccessService) *admin.WindsurfHandler {
|
||||
if authService == nil {
|
||||
return nil
|
||||
}
|
||||
return admin.NewWindsurfHandler(authService, lsService, probeService)
|
||||
return admin.NewWindsurfHandler(authService, lsService, probeService, tierAccessService)
|
||||
}
|
||||
|
||||
// ProvideSettingHandler creates SettingHandler with version from BuildInfo
|
||||
|
||||
@ -486,7 +486,7 @@ func (c *Client) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo
|
||||
func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadCodeAssistResponse, map[string]any, error) {
|
||||
reqBody := LoadCodeAssistRequest{}
|
||||
reqBody.Metadata.IDEType = "ANTIGRAVITY"
|
||||
reqBody.Metadata.IDEVersion = "1.20.6"
|
||||
reqBody.Metadata.IDEVersion = currentUserAgentVersion()
|
||||
reqBody.Metadata.IDEName = "antigravity"
|
||||
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
|
||||
@ -140,13 +140,19 @@ func GetClientCredentials(isEnterprise bool) (ClientCredentials, error) {
|
||||
}
|
||||
|
||||
// BaseURLsForAccount 根据 isGcpTos 返回有序 URL 列表。
|
||||
// 企业账号(isGcpTos=true)优先走 prod;个人账号优先走 daily(与真实 IDE 一致)。
|
||||
// sandbox 作为最后兜底,仅在 prod/daily 都不可用时使用。
|
||||
//
|
||||
// - 企业账号(isGcpTos=true):prod → daily → sandbox
|
||||
// 企业账号拥有 GCP Workspace 权限,可访问真实 daily(daily-cloudcode-pa.googleapis.com)。
|
||||
//
|
||||
// - 个人账号(isGcpTos=false):sandbox → prod
|
||||
// 个人免费账号无权访问真实 daily,该端点对个人账号会直接返回 429 RESOURCE_EXHAUSTED。
|
||||
// sandbox(daily-cloudcode-pa.sandbox.googleapis.com)对个人账号可用,与上游行为一致。
|
||||
func BaseURLsForAccount(isGcpTos bool) []string {
|
||||
if isGcpTos {
|
||||
return []string{antigravityProdBaseURL, antigravityDailyBaseURL, antigravitySandboxBaseURL}
|
||||
}
|
||||
return []string{antigravityDailyBaseURL, antigravityProdBaseURL, antigravitySandboxBaseURL}
|
||||
// 个人账号跳过真实 daily,直接使用 sandbox → prod 顺序(与上游 ForwardBaseURLs 一致)
|
||||
return []string{antigravitySandboxBaseURL, antigravityProdBaseURL}
|
||||
}
|
||||
|
||||
func getClientSecret() (string, error) {
|
||||
|
||||
@ -276,6 +276,12 @@ func (a *AuthClient) loginViaFirebase(ctx context.Context, email, password strin
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RegisterWithCodeiumDefault wraps RegisterWithCodeium with a freshly generated
|
||||
// browser fingerprint, so callers (e.g. Google login) don't need to construct one.
|
||||
func (a *AuthClient) RegisterWithCodeiumDefault(ctx context.Context, token, proxyURL string) (*RegisterResult, error) {
|
||||
return a.RegisterWithCodeium(ctx, token, generateFingerprint(), proxyURL)
|
||||
}
|
||||
|
||||
func (a *AuthClient) RegisterWithCodeium(ctx context.Context, token string, fp http.Header, proxyURL string) (*RegisterResult, error) {
|
||||
c := newClient(a.RequestTimeout, proxyURL)
|
||||
body := map[string]string{"firebase_id_token": token}
|
||||
|
||||
108
backend/internal/pkg/windsurf/cold_threshold.go
Normal file
108
backend/internal/pkg/windsurf/cold_threshold.go
Normal file
@ -0,0 +1,108 @@
|
||||
package windsurf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ColdThresholdConfig parameterises the adaptive cold-stall timeout. Values
|
||||
// can be overridden via env vars (see DefaultColdThresholdConfig).
|
||||
type ColdThresholdConfig struct {
|
||||
// Base is the timeout for an empty / tiny prompt (e.g. "ping").
|
||||
Base time.Duration
|
||||
// PerKChar adds this much time for every 1000 characters of input.
|
||||
PerKChar time.Duration
|
||||
// Max caps the total threshold regardless of input size. Callers may
|
||||
// further clamp via the runtime maxWait argument.
|
||||
Max time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
defaultColdCfg ColdThresholdConfig
|
||||
defaultColdCfgOnce sync.Once
|
||||
)
|
||||
|
||||
// DefaultColdThresholdConfig returns the active config. The first call
|
||||
// resolves env-var overrides:
|
||||
//
|
||||
// WINDSURF_COLD_BASE_SECONDS (default 30)
|
||||
// WINDSURF_COLD_PER_KCHAR_SEC (default 5)
|
||||
// WINDSURF_COLD_MAX_SECONDS (default 90)
|
||||
//
|
||||
// Defaults match dwgx/WindsurfAPI's empirical "long inputs up to 90s" rule
|
||||
// while preserving the prior 30s base for backward compatibility.
|
||||
func DefaultColdThresholdConfig() ColdThresholdConfig {
|
||||
defaultColdCfgOnce.Do(func() {
|
||||
defaultColdCfg = ColdThresholdConfig{
|
||||
Base: envSeconds("WINDSURF_COLD_BASE_SECONDS", 30),
|
||||
PerKChar: envSeconds("WINDSURF_COLD_PER_KCHAR_SEC", 5),
|
||||
Max: envSeconds("WINDSURF_COLD_MAX_SECONDS", 90),
|
||||
}
|
||||
if defaultColdCfg.Base <= 0 {
|
||||
defaultColdCfg.Base = 30 * time.Second
|
||||
}
|
||||
if defaultColdCfg.Max <= 0 {
|
||||
defaultColdCfg.Max = 90 * time.Second
|
||||
}
|
||||
if defaultColdCfg.Max < defaultColdCfg.Base {
|
||||
defaultColdCfg.Max = defaultColdCfg.Base
|
||||
}
|
||||
})
|
||||
return defaultColdCfg
|
||||
}
|
||||
|
||||
// AdaptiveColdThreshold returns the cold-stall timeout for a given prompt
|
||||
// size, applying the active ColdThresholdConfig and an absolute upstream
|
||||
// cap (typically the StreamCascadeChat maxWait constant).
|
||||
//
|
||||
// The returned threshold is the minimum of:
|
||||
//
|
||||
// Base + PerKChar * (inputChars / 1000)
|
||||
// Config.Max
|
||||
// upstreamCap (when > 0)
|
||||
//
|
||||
// inputChars < 0 is treated as 0. The result is always >= Base unless
|
||||
// upstreamCap < Base, in which case upstreamCap wins.
|
||||
func AdaptiveColdThreshold(inputChars int, upstreamCap time.Duration) time.Duration {
|
||||
cfg := DefaultColdThresholdConfig()
|
||||
return ComputeColdThreshold(cfg, inputChars, upstreamCap)
|
||||
}
|
||||
|
||||
// maxInputCharsForOverflowGuard caps inputChars before multiplication to keep
|
||||
// the resulting time.Duration (int64 ns) from wrapping. 2^31 chars (~2GB)
|
||||
// is already absurd for an LLM prompt; anything beyond is a bug or DoS attempt.
|
||||
const maxInputCharsForOverflowGuard = 1<<31 - 1
|
||||
|
||||
// ComputeColdThreshold is the pure form used by tests and callers that want
|
||||
// to inject a custom config without touching the singleton.
|
||||
func ComputeColdThreshold(cfg ColdThresholdConfig, inputChars int, upstreamCap time.Duration) time.Duration {
|
||||
if inputChars < 0 {
|
||||
inputChars = 0
|
||||
}
|
||||
if inputChars > maxInputCharsForOverflowGuard {
|
||||
inputChars = maxInputCharsForOverflowGuard
|
||||
}
|
||||
scaled := cfg.Base + time.Duration(inputChars/1000)*cfg.PerKChar
|
||||
if cfg.Max > 0 && scaled > cfg.Max {
|
||||
scaled = cfg.Max
|
||||
}
|
||||
if upstreamCap > 0 && scaled > upstreamCap {
|
||||
scaled = upstreamCap
|
||||
}
|
||||
return scaled
|
||||
}
|
||||
|
||||
func envSeconds(key string, defaultSec int) time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return time.Duration(defaultSec) * time.Second
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return time.Duration(defaultSec) * time.Second
|
||||
}
|
||||
return time.Duration(v) * time.Second
|
||||
}
|
||||
87
backend/internal/pkg/windsurf/cold_threshold_test.go
Normal file
87
backend/internal/pkg/windsurf/cold_threshold_test.go
Normal file
@ -0,0 +1,87 @@
|
||||
package windsurf
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestComputeColdThreshold(t *testing.T) {
|
||||
cfg := ColdThresholdConfig{
|
||||
Base: 15 * time.Second,
|
||||
PerKChar: 6 * time.Second,
|
||||
Max: 90 * time.Second,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputChars int
|
||||
upstreamCap time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{"empty prompt → base", 0, 0, 15 * time.Second},
|
||||
{"under 1k → still base", 999, 0, 15 * time.Second},
|
||||
{"1k → base + per-k", 1000, 0, 21 * time.Second},
|
||||
{"5k → base + 5*per-k", 5000, 0, 45 * time.Second},
|
||||
{"50k → base + 50*per-k clamped to max", 50000, 0, 90 * time.Second},
|
||||
{"100k → max", 100000, 0, 90 * time.Second},
|
||||
{"upstreamCap below max wins", 50000, 60 * time.Second, 60 * time.Second},
|
||||
{"upstreamCap above max no-op", 50000, 200 * time.Second, 90 * time.Second},
|
||||
{"negative input treated as 0", -100, 0, 15 * time.Second},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ComputeColdThreshold(cfg, tc.inputChars, tc.upstreamCap)
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdaptiveColdThreshold_DefaultsHonorEnv(t *testing.T) {
|
||||
// Note: DefaultColdThresholdConfig caches via sync.Once, so this test
|
||||
// exercises the public path but cannot assert overridden values without
|
||||
// resetting the singleton. We verify the function returns a sensible
|
||||
// value for representative inputs.
|
||||
got := AdaptiveColdThreshold(5000, 200*time.Second)
|
||||
if got <= 0 {
|
||||
t.Fatalf("expected positive threshold, got %v", got)
|
||||
}
|
||||
if got > 200*time.Second {
|
||||
t.Fatalf("expected <= upstreamCap, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeColdThreshold_PreservesLegacyDefaults(t *testing.T) {
|
||||
// Regression: pre-PR-2 behaviour used base=30s, perKChar=5s/1500 chars,
|
||||
// cap=180s. New defaults (base=30s, perKChar=5s/1000 chars, cap=90s)
|
||||
// must NOT shorten timeouts for sub-12k inputs that already worked.
|
||||
cfg := ColdThresholdConfig{Base: 30 * time.Second, PerKChar: 5 * time.Second, Max: 90 * time.Second}
|
||||
for _, chars := range []int{0, 500, 1500, 6000, 12000} {
|
||||
got := ComputeColdThreshold(cfg, chars, 180*time.Second)
|
||||
legacy := 30*time.Second + time.Duration(chars/1500)*5*time.Second
|
||||
if legacy > 180*time.Second {
|
||||
legacy = 180 * time.Second
|
||||
}
|
||||
if got < legacy {
|
||||
t.Fatalf("chars=%d: new=%v shorter than legacy=%v", chars, got, legacy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeColdThreshold_OverflowGuard(t *testing.T) {
|
||||
// Without the overflow guard, inputChars near math.MaxInt would make
|
||||
// the duration multiplication wrap negative, returning a "stalled
|
||||
// immediately" timeout. Verify the result is always >= Base and clamped
|
||||
// to Max, never negative.
|
||||
cfg := ColdThresholdConfig{Base: 15 * time.Second, PerKChar: 6 * time.Second, Max: 90 * time.Second}
|
||||
for _, chars := range []int{1 << 31, 1 << 40, 1<<63 - 1} {
|
||||
got := ComputeColdThreshold(cfg, chars, 200*time.Second)
|
||||
if got <= 0 {
|
||||
t.Fatalf("chars=%d: expected positive duration, got %v", chars, got)
|
||||
}
|
||||
if got != cfg.Max {
|
||||
t.Fatalf("chars=%d: expected clamped to Max=%v, got %v", chars, cfg.Max, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -502,10 +502,7 @@ func (l *LocalLSClient) StreamCascadeChat(ctx context.Context, token, modelUID,
|
||||
|
||||
// Cold stall: active but no text/thinking after threshold
|
||||
elapsed := time.Since(startTime)
|
||||
coldThreshold := 30*time.Second + time.Duration(inputChars/1500)*5*time.Second
|
||||
if coldThreshold > maxWait {
|
||||
coldThreshold = maxWait
|
||||
}
|
||||
coldThreshold := AdaptiveColdThreshold(inputChars, maxWait)
|
||||
if elapsed > coldThreshold && sawActive && !sawText && totalThinking == 0 {
|
||||
return nil, &CascadeModelError{Msg: fmt.Sprintf("Cascade planner stalled — no output after %ds", int(coldThreshold.Seconds()))}
|
||||
}
|
||||
|
||||
@ -12,8 +12,23 @@ type ModelMeta struct {
|
||||
EnumValue int `json:"enum_value"`
|
||||
ModelUID string `json:"model_uid,omitempty"`
|
||||
Credit float64 `json:"credit"`
|
||||
// EmulationFlavor controls how the chat service interprets tool-call
|
||||
// output from this model. Values:
|
||||
// "tool_use" — model emits well-formed <tool_use> tags (Claude family).
|
||||
// "nlu" — model emits free-form text describing tool intent
|
||||
// (GLM-4.7, Kimi-K2.5). Engage NLU fallback when no
|
||||
// structured tags parse.
|
||||
// "" / "auto" — try tool_use first, fall back to NLU heuristic only
|
||||
// when the response carries clear NLU signals.
|
||||
EmulationFlavor string `json:"emulation_flavor,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
EmulationFlavorAuto = "auto"
|
||||
EmulationFlavorToolUse = "tool_use"
|
||||
EmulationFlavorNLU = "nlu"
|
||||
)
|
||||
|
||||
type ModelListEntry struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
@ -263,8 +278,15 @@ func buildLookup() {
|
||||
}
|
||||
}
|
||||
|
||||
// ensureLookup builds the lookupMap once. It serializes via cloudModelsMu so
|
||||
// concurrent first-touch readers don't race with MergeCloudModels (which
|
||||
// rebuilds lookupMap as part of its write critical section).
|
||||
func ensureLookup() {
|
||||
lookupOnce.Do(buildLookup)
|
||||
lookupOnce.Do(func() {
|
||||
cloudModelsMu.Lock()
|
||||
defer cloudModelsMu.Unlock()
|
||||
buildLookup()
|
||||
})
|
||||
}
|
||||
|
||||
func ResolveModel(name string) string {
|
||||
@ -272,6 +294,8 @@ func ResolveModel(name string) string {
|
||||
return ""
|
||||
}
|
||||
ensureLookup()
|
||||
cloudModelsMu.RLock()
|
||||
defer cloudModelsMu.RUnlock()
|
||||
if id, ok := lookupMap[name]; ok {
|
||||
return id
|
||||
}
|
||||
@ -282,6 +306,8 @@ func ResolveModel(name string) string {
|
||||
}
|
||||
|
||||
func GetModelInfo(id string) *ModelMeta {
|
||||
cloudModelsMu.RLock()
|
||||
defer cloudModelsMu.RUnlock()
|
||||
if m, ok := catalog[id]; ok {
|
||||
return &m
|
||||
}
|
||||
@ -304,11 +330,37 @@ func GetChatMode(m *ModelMeta, legacyEnumCutoff int) string {
|
||||
return "cascade"
|
||||
}
|
||||
|
||||
// ResolveEmulationFlavor returns the emulation flavor for a model, applying
|
||||
// per-provider defaults when the model entry doesn't override.
|
||||
//
|
||||
// Defaults follow dwgx/WindsurfAPI's empirical findings:
|
||||
// - Anthropic Claude family: tool_use is reliable, no NLU needed.
|
||||
// - Zhipu GLM, Moonshot Kimi: free-form intent text, NLU fallback required.
|
||||
// - Everything else: auto (try tool_use first, fall back to NLU on signal).
|
||||
func ResolveEmulationFlavor(m *ModelMeta) string {
|
||||
if m == nil {
|
||||
return EmulationFlavorAuto
|
||||
}
|
||||
if m.EmulationFlavor != "" {
|
||||
return m.EmulationFlavor
|
||||
}
|
||||
switch m.Provider {
|
||||
case "anthropic":
|
||||
return EmulationFlavorToolUse
|
||||
case "zhipu", "moonshot":
|
||||
return EmulationFlavorNLU
|
||||
default:
|
||||
return EmulationFlavorAuto
|
||||
}
|
||||
}
|
||||
|
||||
var freeTierModels = []string{"gpt-4o-mini", "gemini-2.5-flash"}
|
||||
|
||||
func GetTierModels(tier string) []string {
|
||||
switch tier {
|
||||
case "pro":
|
||||
cloudModelsMu.RLock()
|
||||
defer cloudModelsMu.RUnlock()
|
||||
keys := make([]string, 0, len(catalog))
|
||||
for k := range catalog {
|
||||
keys = append(keys, k)
|
||||
@ -325,6 +377,8 @@ func GetTierModels(tier string) []string {
|
||||
|
||||
func ListModelsOpenAI() []ModelListEntry {
|
||||
ts := time.Now().Unix()
|
||||
cloudModelsMu.RLock()
|
||||
defer cloudModelsMu.RUnlock()
|
||||
entries := make([]ModelListEntry, 0, len(catalog))
|
||||
for _, info := range catalog {
|
||||
entries = append(entries, ModelListEntry{
|
||||
@ -337,12 +391,19 @@ func ListModelsOpenAI() []ModelListEntry {
|
||||
return entries
|
||||
}
|
||||
|
||||
var cloudModelsMu sync.Mutex
|
||||
// cloudModelsMu protects concurrent reads/writes of the package-level
|
||||
// catalog/lookupMap state. MergeCloudModels takes the write lock; all
|
||||
// public readers (GetModelInfo, ListModelsOpenAI, GetTierModels, ResolveModel)
|
||||
// take the read lock. Without this, the new tier-access hot path would
|
||||
// race against cloud-model merge on `go test -race`.
|
||||
var cloudModelsMu sync.RWMutex
|
||||
|
||||
func MergeCloudModels(configs []ModelInfo) int {
|
||||
cloudModelsMu.Lock()
|
||||
defer cloudModelsMu.Unlock()
|
||||
ensureLookup()
|
||||
// Already inside the write lock — call buildLookup directly to avoid
|
||||
// re-entering cloudModelsMu via ensureLookup → would deadlock.
|
||||
lookupOnce.Do(buildLookup)
|
||||
|
||||
providerMap := map[string]string{
|
||||
"MODEL_PROVIDER_ANTHROPIC": "anthropic",
|
||||
|
||||
202
backend/internal/pkg/windsurf/nlu_extractor.go
Normal file
202
backend/internal/pkg/windsurf/nlu_extractor.go
Normal file
@ -0,0 +1,202 @@
|
||||
package windsurf
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExtractToolCallsNLU is a best-effort fallback parser used when a model
|
||||
// (typically GLM-4.7 / Kimi family) emits tool-call intent in free-form
|
||||
// text instead of well-formed <tool_use> tags.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Look for "function:NAME" / "tool_call:NAME" / "call NAME" markers.
|
||||
// 2. Look for the nearest JSON object after the marker as arguments.
|
||||
// 3. Validate the function name is in the available tool list.
|
||||
//
|
||||
// availableTools is the list of tool names the request advertised. If empty,
|
||||
// the extractor still tries name discovery but is best-effort. Returns nil
|
||||
// when no plausible tool call is found — callers should treat that as
|
||||
// "no tools" not "error".
|
||||
func ExtractToolCallsNLU(text string, availableTools []string) []ToolCall {
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
available := make(map[string]struct{}, len(availableTools))
|
||||
for _, name := range availableTools {
|
||||
if n := strings.TrimSpace(name); n != "" {
|
||||
available[n] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
calls := nluFindMarkedCalls(text, available)
|
||||
if len(calls) > 0 {
|
||||
return calls
|
||||
}
|
||||
if len(available) > 0 {
|
||||
// Last-resort: some models just say "I'll use edit_file with {...}"
|
||||
// — try to spot any known tool name followed by a JSON object.
|
||||
calls = nluFindBareNameCalls(text, available)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
// HasNLUSignal reports whether `text` looks like it intended to call a tool
|
||||
// but malformed the tags. Used to decide whether to spend CPU on the NLU
|
||||
// extractor when EmulationFlavor=auto. Conservative — false negatives are
|
||||
// fine, false positives waste a few microseconds.
|
||||
func HasNLUSignal(text string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
for _, kw := range nluSignalKeywords {
|
||||
if strings.Contains(lower, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var nluSignalKeywords = []string{
|
||||
"tool_call",
|
||||
"function_call",
|
||||
"function:",
|
||||
"tool:",
|
||||
"arguments:",
|
||||
"i'll call",
|
||||
"i will call",
|
||||
"calling tool",
|
||||
"调用工具",
|
||||
"使用工具",
|
||||
}
|
||||
|
||||
// nluMarkerRE matches "function: name", "tool_call: name", "call name"
|
||||
// followed (possibly with delimiters) by a JSON object. The name capture
|
||||
// stops at whitespace, comma, paren, or brace.
|
||||
var nluMarkerRE = regexp.MustCompile(`(?i)(?:function|tool_call|tool|call)[\s:=]+([a-zA-Z_][a-zA-Z0-9_]*)`)
|
||||
|
||||
func nluFindMarkedCalls(text string, available map[string]struct{}) []ToolCall {
|
||||
matches := nluMarkerRE.FindAllStringSubmatchIndex(text, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
var calls []ToolCall
|
||||
seen := make(map[string]struct{})
|
||||
for _, m := range matches {
|
||||
name := text[m[2]:m[3]]
|
||||
if _, ok := available[name]; len(available) > 0 && !ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
continue
|
||||
}
|
||||
args := nluFindNearestJSONAfter(text, m[1])
|
||||
if args == "" {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
calls = append(calls, ToolCall{
|
||||
ID: nluCallID(name, len(calls)),
|
||||
Name: name,
|
||||
ArgumentsJSON: args,
|
||||
})
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
func nluFindBareNameCalls(text string, available map[string]struct{}) []ToolCall {
|
||||
// Iterate available names in deterministic (alphabetical) order so the
|
||||
// returned slice is stable across runs and Go map randomization. Without
|
||||
// this, two identical inputs can yield differently ordered tool-call
|
||||
// slices, which makes upstream replay/retry behaviour inconsistent.
|
||||
names := make([]string, 0, len(available))
|
||||
for name := range available {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
var calls []ToolCall
|
||||
seen := make(map[string]struct{})
|
||||
for _, name := range names {
|
||||
idx := strings.Index(text, name)
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
args := nluFindNearestJSONAfter(text, idx+len(name))
|
||||
if args == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
calls = append(calls, ToolCall{
|
||||
ID: nluCallID(name, len(calls)),
|
||||
Name: name,
|
||||
ArgumentsJSON: args,
|
||||
})
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
// nluCallID generates a stable, namespaced ID for an NLU-extracted tool
|
||||
// call. The numeric suffix prevents collisions when the same tool name
|
||||
// appears in multiple turns within a session.
|
||||
func nluCallID(name string, idx int) string {
|
||||
return fmt.Sprintf("nlu_%s_%d", name, idx)
|
||||
}
|
||||
|
||||
// nluFindNearestJSONAfter scans forward from `start` and returns the first
|
||||
// JSON object literal it encounters. Empty string when none found within a
|
||||
// reasonable lookahead (4KB).
|
||||
func nluFindNearestJSONAfter(text string, start int) string {
|
||||
const lookahead = 4096
|
||||
end := start + lookahead
|
||||
if end > len(text) {
|
||||
end = len(text)
|
||||
}
|
||||
region := text[start:end]
|
||||
open := strings.Index(region, "{")
|
||||
if open < 0 {
|
||||
return ""
|
||||
}
|
||||
depth := 0
|
||||
inString := false
|
||||
escape := false
|
||||
for i := open; i < len(region); i++ {
|
||||
ch := region[i]
|
||||
if escape {
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' {
|
||||
escape = true
|
||||
continue
|
||||
}
|
||||
if ch == '"' {
|
||||
inString = !inString
|
||||
continue
|
||||
}
|
||||
if inString {
|
||||
continue
|
||||
}
|
||||
switch ch {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
candidate := region[open : i+1]
|
||||
if json.Valid([]byte(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
144
backend/internal/pkg/windsurf/nlu_extractor_test.go
Normal file
144
backend/internal/pkg/windsurf/nlu_extractor_test.go
Normal file
@ -0,0 +1,144 @@
|
||||
package windsurf
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractToolCallsNLU(t *testing.T) {
|
||||
tools := []string{"edit_file", "read_file", "run_command"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
available []string
|
||||
wantCount int
|
||||
wantName string
|
||||
}{
|
||||
{
|
||||
name: "marker form: function: edit_file with JSON",
|
||||
text: `I'll use function: edit_file with {"path": "/tmp/x", "content": "abc"}`,
|
||||
available: tools,
|
||||
wantCount: 1,
|
||||
wantName: "edit_file",
|
||||
},
|
||||
{
|
||||
name: "marker form: tool_call read_file",
|
||||
text: `tool_call: read_file arguments: {"path": "/etc/hosts"}`,
|
||||
available: tools,
|
||||
wantCount: 1,
|
||||
wantName: "read_file",
|
||||
},
|
||||
{
|
||||
name: "marker form: nested JSON object",
|
||||
text: `function: run_command with {"cmd": "ls", "opts": {"long": true}}`,
|
||||
available: tools,
|
||||
wantCount: 1,
|
||||
wantName: "run_command",
|
||||
},
|
||||
{
|
||||
name: "bare name fallback when no marker",
|
||||
text: `Sure, I'll edit_file {"path": "/tmp/y"} for you.`,
|
||||
available: tools,
|
||||
wantCount: 1,
|
||||
wantName: "edit_file",
|
||||
},
|
||||
{
|
||||
name: "unknown tool name rejected when available list is non-empty",
|
||||
text: `function: delete_universe {"target": "all"}`,
|
||||
available: tools,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "no JSON after marker yields no call",
|
||||
text: `function: edit_file but I'm not sure what arguments to use`,
|
||||
available: tools,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "empty text returns nil",
|
||||
text: "",
|
||||
available: tools,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "duplicate names deduplicated",
|
||||
text: `function: edit_file {"path": "/a"} then function: edit_file {"path": "/b"}`,
|
||||
available: tools,
|
||||
wantCount: 1,
|
||||
wantName: "edit_file",
|
||||
},
|
||||
{
|
||||
name: "name not in available list is rejected even when JSON valid",
|
||||
text: `Calling foo with {"x": 1}`,
|
||||
available: []string{"bar"},
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "marker with no available list still extracts",
|
||||
text: `function: my_tool {"x": 1}`,
|
||||
available: nil,
|
||||
wantCount: 1,
|
||||
wantName: "my_tool",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := ExtractToolCallsNLU(tc.text, tc.available)
|
||||
if len(got) != tc.wantCount {
|
||||
t.Fatalf("expected %d call(s), got %d: %+v", tc.wantCount, len(got), got)
|
||||
}
|
||||
if tc.wantCount > 0 && got[0].Name != tc.wantName {
|
||||
t.Fatalf("expected name %q, got %q", tc.wantName, got[0].Name)
|
||||
}
|
||||
if tc.wantCount > 0 && got[0].ArgumentsJSON == "" {
|
||||
t.Fatalf("expected non-empty ArgumentsJSON, got %q", got[0].ArgumentsJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasNLUSignal(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{"function: edit_file {}", true},
|
||||
{"I'll call edit_file", true},
|
||||
{"calling tool edit_file", true},
|
||||
{"调用工具 edit_file", true},
|
||||
{"Hello, just a chat reply.", false},
|
||||
{"", false},
|
||||
{"<tool_use><name>foo</name></tool_use>", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.text, func(t *testing.T) {
|
||||
if got := HasNLUSignal(tc.text); got != tc.want {
|
||||
t.Fatalf("HasNLUSignal(%q) = %v, want %v", tc.text, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEmulationFlavor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
meta *ModelMeta
|
||||
want string
|
||||
}{
|
||||
{"nil meta", nil, EmulationFlavorAuto},
|
||||
{"explicit override wins", &ModelMeta{Provider: "anthropic", EmulationFlavor: "nlu"}, "nlu"},
|
||||
{"anthropic default tool_use", &ModelMeta{Provider: "anthropic"}, EmulationFlavorToolUse},
|
||||
{"zhipu default nlu", &ModelMeta{Provider: "zhipu"}, EmulationFlavorNLU},
|
||||
{"moonshot default nlu", &ModelMeta{Provider: "moonshot"}, EmulationFlavorNLU},
|
||||
{"openai default auto", &ModelMeta{Provider: "openai"}, EmulationFlavorAuto},
|
||||
{"unknown provider auto", &ModelMeta{Provider: "xyz"}, EmulationFlavorAuto},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ResolveEmulationFlavor(tc.meta); got != tc.want {
|
||||
t.Fatalf("ResolveEmulationFlavor = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,7 @@ func ProvideRouter(
|
||||
settingService *service.SettingService,
|
||||
healthService *service.HealthService,
|
||||
redisClient *redis.Client,
|
||||
opsLogBroadcaster *service.OpsLogBroadcaster,
|
||||
) *gin.Engine {
|
||||
if cfg.Server.Mode == "release" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
@ -96,7 +97,7 @@ func ProvideRouter(
|
||||
service.SetWebSearchManager(websearch.NewManager(configs, redisClient))
|
||||
})
|
||||
|
||||
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, healthService, cfg, redisClient)
|
||||
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, healthService, cfg, redisClient, opsLogBroadcaster)
|
||||
}
|
||||
|
||||
// ProvideHTTPServer 提供 HTTP 服务器
|
||||
|
||||
@ -33,6 +33,7 @@ func SetupRouter(
|
||||
healthService *service.HealthService,
|
||||
cfg *config.Config,
|
||||
redisClient *redis.Client,
|
||||
opsLogBroadcaster *service.OpsLogBroadcaster,
|
||||
) *gin.Engine {
|
||||
// 缓存 iframe 页面的 origin 列表,用于动态注入 CSP frame-src
|
||||
var cachedFrameOrigins atomic.Pointer[[]string]
|
||||
@ -82,7 +83,7 @@ func SetupRouter(
|
||||
}
|
||||
|
||||
// 注册路由
|
||||
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, healthService, cfg, redisClient)
|
||||
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, healthService, cfg, redisClient, opsLogBroadcaster)
|
||||
|
||||
return r
|
||||
}
|
||||
@ -101,6 +102,7 @@ func registerRoutes(
|
||||
healthService *service.HealthService,
|
||||
cfg *config.Config,
|
||||
redisClient *redis.Client,
|
||||
opsLogBroadcaster *service.OpsLogBroadcaster,
|
||||
) {
|
||||
// 通用路由(健康检查、状态等)
|
||||
routes.RegisterCommonRoutes(r, healthService)
|
||||
@ -112,10 +114,10 @@ func registerRoutes(
|
||||
routes.RegisterAuthRoutes(v1, h, jwtAuth, redisClient, settingService)
|
||||
routes.RegisterUserRoutes(v1, h, jwtAuth, settingService)
|
||||
routes.RegisterAdminRoutes(v1, h, adminAuth)
|
||||
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg)
|
||||
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, opsLogBroadcaster)
|
||||
|
||||
// Windsurf gateway routes
|
||||
routes.RegisterWindsurfGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg)
|
||||
routes.RegisterWindsurfGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, opsLogBroadcaster)
|
||||
|
||||
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService)
|
||||
}
|
||||
|
||||
@ -115,6 +115,8 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
ops.GET("/user-concurrency", h.Admin.Ops.GetUserConcurrencyStats)
|
||||
ops.GET("/account-availability", h.Admin.Ops.GetAccountAvailability)
|
||||
ops.GET("/realtime-traffic", h.Admin.Ops.GetRealtimeTrafficSummary)
|
||||
ops.GET("/logs/stream", h.Admin.Ops.LogStream)
|
||||
ops.GET("/logs/recent", h.Admin.Ops.LogStreamRecent)
|
||||
|
||||
// Alerts (rules + events)
|
||||
ops.GET("/alert-rules", h.Admin.Ops.ListAlertRules)
|
||||
@ -588,6 +590,7 @@ func registerWindsurfRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
ws := admin.Group("/windsurf")
|
||||
{
|
||||
ws.POST("/accounts/login", h.Admin.Windsurf.Login)
|
||||
ws.POST("/accounts/token-login", h.Admin.Windsurf.TokenLogin)
|
||||
ws.POST("/accounts/batch-login", h.Admin.Windsurf.BatchLogin)
|
||||
ws.POST("/accounts/:id/probe", h.Admin.Windsurf.Probe)
|
||||
ws.POST("/accounts/batch-probe", h.Admin.Windsurf.BatchProbe)
|
||||
@ -596,6 +599,7 @@ func registerWindsurfRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
ws.GET("/accounts/:id/runtime", h.Admin.Windsurf.GetRuntime)
|
||||
ws.GET("/ls/status", h.Admin.Windsurf.GetLSStatus)
|
||||
ws.GET("/models", h.Admin.Windsurf.ListModels)
|
||||
ws.GET("/tier-access", h.Admin.Windsurf.GetTierAccess)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -21,10 +21,12 @@ func RegisterGatewayRoutes(
|
||||
opsService *service.OpsService,
|
||||
settingService *service.SettingService,
|
||||
cfg *config.Config,
|
||||
opsLogBroadcaster *service.OpsLogBroadcaster,
|
||||
) {
|
||||
bodyLimit := middleware.RequestBodyLimit(cfg.Gateway.MaxBodySize)
|
||||
clientRequestID := middleware.ClientRequestID()
|
||||
opsErrorLogger := handler.OpsErrorLoggerMiddleware(opsService)
|
||||
opsLogStream := handler.OpsLogStreamMiddleware(opsLogBroadcaster)
|
||||
endpointNorm := handler.InboundEndpointMiddleware()
|
||||
|
||||
// 未分组 Key 拦截中间件(按协议格式区分错误响应)
|
||||
@ -36,6 +38,7 @@ func RegisterGatewayRoutes(
|
||||
gateway.Use(bodyLimit)
|
||||
gateway.Use(clientRequestID)
|
||||
gateway.Use(opsErrorLogger)
|
||||
gateway.Use(opsLogStream)
|
||||
gateway.Use(endpointNorm)
|
||||
gateway.Use(gin.HandlerFunc(apiKeyAuth))
|
||||
gateway.Use(requireGroupAnthropic)
|
||||
|
||||
@ -18,6 +18,7 @@ func RegisterWindsurfGatewayRoutes(
|
||||
opsService *service.OpsService,
|
||||
settingService *service.SettingService,
|
||||
cfg *config.Config,
|
||||
opsLogBroadcaster *service.OpsLogBroadcaster,
|
||||
) {
|
||||
if h.Gateway == nil {
|
||||
return
|
||||
@ -26,6 +27,7 @@ func RegisterWindsurfGatewayRoutes(
|
||||
bodyLimit := middleware.RequestBodyLimit(cfg.Gateway.MaxBodySize)
|
||||
clientRequestID := middleware.ClientRequestID()
|
||||
opsErrorLogger := handler.OpsErrorLoggerMiddleware(opsService)
|
||||
opsLogStream := handler.OpsLogStreamMiddleware(opsLogBroadcaster)
|
||||
endpointNorm := handler.InboundEndpointMiddleware()
|
||||
requireGroupAnthropic := middleware.RequireGroupAssignment(settingService, middleware.AnthropicErrorWriter)
|
||||
|
||||
@ -33,6 +35,7 @@ func RegisterWindsurfGatewayRoutes(
|
||||
windsurfV1.Use(bodyLimit)
|
||||
windsurfV1.Use(clientRequestID)
|
||||
windsurfV1.Use(opsErrorLogger)
|
||||
windsurfV1.Use(opsLogStream)
|
||||
windsurfV1.Use(endpointNorm)
|
||||
windsurfV1.Use(middleware.ForcePlatform(service.PlatformWindsurf))
|
||||
windsurfV1.Use(gin.HandlerFunc(apiKeyAuth))
|
||||
|
||||
@ -215,13 +215,20 @@ type antigravityRetryLoopResult struct {
|
||||
}
|
||||
|
||||
// resolveAntigravityForwardBaseURL 解析转发用 base URL。
|
||||
// 根据账号类型选择优先 URL:企业账号(isGcpTos=true)→ prod;个人账号 → daily(与真实 IDE 一致)。
|
||||
// 可通过环境变量 GATEWAY_ANTIGRAVITY_FORWARD_BASE_URL=daily 或 =prod 强制覆盖。
|
||||
// 根据账号类型选择优先 URL:
|
||||
// - 企业账号(isGcpTos=true)→ prod 优先,可访问真实 daily
|
||||
// - 个人账号(isGcpTos=false)→ sandbox 优先(真实 daily 对个人账号返回 429)
|
||||
//
|
||||
// 可通过环境变量 GATEWAY_ANTIGRAVITY_FORWARD_BASE_URL=sandbox/daily/prod 强制覆盖。
|
||||
func resolveAntigravityForwardBaseURL(account *Account) string {
|
||||
mode := strings.ToLower(strings.TrimSpace(os.Getenv(antigravityForwardBaseURLEnv)))
|
||||
if mode == "daily" {
|
||||
// 注意:真实 daily(daily-cloudcode-pa.googleapis.com)仅对企业账号可用
|
||||
return "https://daily-cloudcode-pa.googleapis.com"
|
||||
}
|
||||
if mode == "sandbox" {
|
||||
return "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||
}
|
||||
if mode == "prod" {
|
||||
return "https://cloudcode-pa.googleapis.com"
|
||||
}
|
||||
|
||||
237
backend/internal/service/ops_log_broadcaster.go
Normal file
237
backend/internal/service/ops_log_broadcaster.go
Normal file
@ -0,0 +1,237 @@
|
||||
// Package service exposes domain services. opslog provides a lightweight
|
||||
// in-memory pub/sub for streaming admin log events without persisting them.
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OpsLogEntry is one streamed log event. All fields are optional except Time
|
||||
// — any missing data simply renders as empty in the admin UI.
|
||||
type OpsLogEntry struct {
|
||||
Time time.Time `json:"time"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Status int `json:"status"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
AccountID int64 `json:"account_id,omitempty"`
|
||||
GroupID int64 `json:"group_id,omitempty"`
|
||||
APIKeyID int64 `json:"api_key_id,omitempty"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
Turns int `json:"turns,omitempty"`
|
||||
PromptChars int `json:"prompt_chars,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
ErrorDetail string `json:"error_detail,omitempty"`
|
||||
UpstreamCode int `json:"upstream_status,omitempty"`
|
||||
}
|
||||
|
||||
// OpsLogFilter restricts which entries a subscriber receives. Empty fields
|
||||
// match everything; non-empty fields are AND-combined.
|
||||
type OpsLogFilter struct {
|
||||
MinStatus int
|
||||
Model string
|
||||
AccountID int64
|
||||
GroupID int64
|
||||
MinLatencyMs int64
|
||||
}
|
||||
|
||||
// matches reports whether the entry passes the filter.
|
||||
func (f OpsLogFilter) matches(e *OpsLogEntry) bool {
|
||||
if f.MinStatus > 0 && e.Status < f.MinStatus {
|
||||
return false
|
||||
}
|
||||
if f.Model != "" && e.Model != f.Model {
|
||||
return false
|
||||
}
|
||||
if f.AccountID > 0 && e.AccountID != f.AccountID {
|
||||
return false
|
||||
}
|
||||
if f.GroupID > 0 && e.GroupID != f.GroupID {
|
||||
return false
|
||||
}
|
||||
if f.MinLatencyMs > 0 && e.LatencyMs < f.MinLatencyMs {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// OpsLogBroadcaster is a lock-free-ish fan-out broadcaster with a bounded
|
||||
// ring buffer for history (so freshly connected clients can prime their UI)
|
||||
// and per-subscriber non-blocking sends (so a slow client never stalls a
|
||||
// publish on the hot request path).
|
||||
type OpsLogBroadcaster struct {
|
||||
subsMu sync.RWMutex
|
||||
subscribers map[int64]*opsLogSubscription
|
||||
nextID atomic.Int64
|
||||
|
||||
historyMu sync.Mutex
|
||||
history []OpsLogEntry
|
||||
histHead int
|
||||
histLen int
|
||||
histCap int
|
||||
|
||||
// publishedTotal / droppedTotal expose simple ops counters for an
|
||||
// admin dashboard cell. Atomic so callers don't need to lock.
|
||||
publishedTotal atomic.Int64
|
||||
droppedTotal atomic.Int64
|
||||
}
|
||||
|
||||
type opsLogSubscription struct {
|
||||
ch chan OpsLogEntry
|
||||
filter OpsLogFilter
|
||||
// closed is set atomically by unsubscribe() before any cleanup. Publish
|
||||
// reads this flag under subsMu.RLock and skips closed subscriptions
|
||||
// instead of attempting a send-on-closed-channel (which would panic).
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
// NewOpsLogBroadcaster constructs a broadcaster. historyCap controls how
|
||||
// many recent entries are kept for newly connected clients; 1000 is a sane
|
||||
// default. Pass historyCap<=0 to disable the buffer entirely.
|
||||
func NewOpsLogBroadcaster(historyCap int) *OpsLogBroadcaster {
|
||||
if historyCap < 0 {
|
||||
historyCap = 0
|
||||
}
|
||||
b := &OpsLogBroadcaster{
|
||||
subscribers: make(map[int64]*opsLogSubscription),
|
||||
histCap: historyCap,
|
||||
}
|
||||
if historyCap > 0 {
|
||||
b.history = make([]OpsLogEntry, historyCap)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Publish fans the entry out to every matching subscriber and appends it to
|
||||
// the history buffer. Never blocks: if a subscriber's channel is full, the
|
||||
// entry is dropped for that subscriber and the broadcaster's drop counter
|
||||
// is incremented. Hot-path safe.
|
||||
//
|
||||
// The same entry value is delivered (by value) to all subscribers and to
|
||||
// the ring buffer — no shared mutable pointer is leaked, so subscribers
|
||||
// holding references to past entries cannot observe later mutations.
|
||||
func (b *OpsLogBroadcaster) Publish(entry OpsLogEntry) {
|
||||
if entry.Time.IsZero() {
|
||||
entry.Time = time.Now()
|
||||
}
|
||||
b.publishedTotal.Add(1)
|
||||
|
||||
b.appendHistory(entry)
|
||||
|
||||
b.subsMu.RLock()
|
||||
subs := make([]*opsLogSubscription, 0, len(b.subscribers))
|
||||
for _, s := range b.subscribers {
|
||||
subs = append(subs, s)
|
||||
}
|
||||
b.subsMu.RUnlock()
|
||||
|
||||
for _, s := range subs {
|
||||
// Skip subscriptions that have been unsubscribed since we snapped
|
||||
// the list. Without this check, a concurrent unsubscribe → close(ch)
|
||||
// would race the send below and panic on send-to-closed-channel.
|
||||
if s.closed.Load() {
|
||||
continue
|
||||
}
|
||||
if !s.filter.matches(&entry) {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case s.ch <- entry:
|
||||
default:
|
||||
b.droppedTotal.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a new listener. The returned channel is buffered;
|
||||
// callers MUST drain it. Cancel by calling the returned unsubscribe func
|
||||
// (idempotent and safe from any goroutine).
|
||||
//
|
||||
// IMPORTANT: unsubscribe does NOT close the channel. Closing it would race
|
||||
// with concurrent Publish goroutines that may already be holding a snapshot
|
||||
// of the subscription pointer (causing a panic on send-to-closed-channel).
|
||||
// Instead, unsubscribe (a) sets the closed atomic flag — Publish skips sends
|
||||
// to flagged subs — and (b) removes from the subscriber map. Any in-flight
|
||||
// send that slips past the flag check still proceeds harmlessly into the
|
||||
// channel buffer and is garbage-collected with the channel once the caller
|
||||
// drops its reference. Subscribers that need to know the broadcaster is
|
||||
// done with them should rely on the parent ctx, not channel close.
|
||||
func (b *OpsLogBroadcaster) Subscribe(filter OpsLogFilter, bufSize int) (<-chan OpsLogEntry, func()) {
|
||||
if bufSize <= 0 {
|
||||
bufSize = 1024
|
||||
}
|
||||
id := b.nextID.Add(1)
|
||||
sub := &opsLogSubscription{
|
||||
ch: make(chan OpsLogEntry, bufSize),
|
||||
filter: filter,
|
||||
}
|
||||
b.subsMu.Lock()
|
||||
b.subscribers[id] = sub
|
||||
b.subsMu.Unlock()
|
||||
|
||||
var unsubOnce sync.Once
|
||||
unsubscribe := func() {
|
||||
unsubOnce.Do(func() {
|
||||
sub.closed.Store(true)
|
||||
b.subsMu.Lock()
|
||||
delete(b.subscribers, id)
|
||||
b.subsMu.Unlock()
|
||||
})
|
||||
}
|
||||
return sub.ch, unsubscribe
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the recent history (oldest → newest), filtered
|
||||
// by the given filter. Used by /admin/ops/logs/recent to prime newly opened
|
||||
// dashboards before live events arrive.
|
||||
func (b *OpsLogBroadcaster) Snapshot(filter OpsLogFilter, maxEntries int) []OpsLogEntry {
|
||||
b.historyMu.Lock()
|
||||
defer b.historyMu.Unlock()
|
||||
|
||||
if b.histLen == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]OpsLogEntry, 0, b.histLen)
|
||||
start := b.histHead - b.histLen
|
||||
if start < 0 {
|
||||
start += b.histCap
|
||||
}
|
||||
for i := 0; i < b.histLen; i++ {
|
||||
idx := (start + i) % b.histCap
|
||||
e := b.history[idx]
|
||||
if !filter.matches(&e) {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if maxEntries > 0 && len(out) > maxEntries {
|
||||
out = out[len(out)-maxEntries:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Stats reports cumulative publish/drop counts and the current subscriber
|
||||
// count for diagnostics.
|
||||
func (b *OpsLogBroadcaster) Stats() (published, dropped int64, subscribers int) {
|
||||
b.subsMu.RLock()
|
||||
subscribers = len(b.subscribers)
|
||||
b.subsMu.RUnlock()
|
||||
return b.publishedTotal.Load(), b.droppedTotal.Load(), subscribers
|
||||
}
|
||||
|
||||
func (b *OpsLogBroadcaster) appendHistory(e OpsLogEntry) {
|
||||
if b.histCap == 0 {
|
||||
return
|
||||
}
|
||||
b.historyMu.Lock()
|
||||
defer b.historyMu.Unlock()
|
||||
b.history[b.histHead] = e
|
||||
b.histHead = (b.histHead + 1) % b.histCap
|
||||
if b.histLen < b.histCap {
|
||||
b.histLen++
|
||||
}
|
||||
}
|
||||
267
backend/internal/service/ops_log_broadcaster_test.go
Normal file
267
backend/internal/service/ops_log_broadcaster_test.go
Normal file
@ -0,0 +1,267 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOpsLogBroadcaster_FanOutDeliversToMatchingSubscribers(t *testing.T) {
|
||||
b := NewOpsLogBroadcaster(16)
|
||||
|
||||
chHigh, unHigh := b.Subscribe(OpsLogFilter{MinStatus: 500}, 8)
|
||||
defer unHigh()
|
||||
chAll, unAll := b.Subscribe(OpsLogFilter{}, 8)
|
||||
defer unAll()
|
||||
|
||||
b.Publish(OpsLogEntry{Status: 200, Model: "claude-sonnet-4.6"})
|
||||
b.Publish(OpsLogEntry{Status: 503, Model: "kimi-k2.5"})
|
||||
|
||||
got200 := receiveOrTimeout(t, chAll, 200*time.Millisecond)
|
||||
got503 := receiveOrTimeout(t, chAll, 200*time.Millisecond)
|
||||
if got200.Status != 200 || got503.Status != 503 {
|
||||
t.Fatalf("unexpected fan-out: %d / %d", got200.Status, got503.Status)
|
||||
}
|
||||
|
||||
gotHigh := receiveOrTimeout(t, chHigh, 200*time.Millisecond)
|
||||
if gotHigh.Status != 503 {
|
||||
t.Fatalf("filter MinStatus=500 should drop 200, got %d", gotHigh.Status)
|
||||
}
|
||||
expectNoMessage(t, chHigh, 50*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestOpsLogBroadcaster_FilterByModelAndAccount(t *testing.T) {
|
||||
b := NewOpsLogBroadcaster(0)
|
||||
|
||||
chKimi, unKimi := b.Subscribe(OpsLogFilter{Model: "kimi-k2.5"}, 4)
|
||||
defer unKimi()
|
||||
chAcct, unAcct := b.Subscribe(OpsLogFilter{AccountID: 42}, 4)
|
||||
defer unAcct()
|
||||
|
||||
b.Publish(OpsLogEntry{Status: 200, Model: "claude-sonnet-4.6", AccountID: 1})
|
||||
b.Publish(OpsLogEntry{Status: 200, Model: "kimi-k2.5", AccountID: 42})
|
||||
|
||||
got := receiveOrTimeout(t, chKimi, 200*time.Millisecond)
|
||||
if got.Model != "kimi-k2.5" {
|
||||
t.Fatalf("expected kimi entry, got %+v", got)
|
||||
}
|
||||
expectNoMessage(t, chKimi, 50*time.Millisecond)
|
||||
|
||||
gotA := receiveOrTimeout(t, chAcct, 200*time.Millisecond)
|
||||
if gotA.AccountID != 42 {
|
||||
t.Fatalf("expected account 42, got %d", gotA.AccountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsLogBroadcaster_NeverBlocksOnSlowSubscriber(t *testing.T) {
|
||||
b := NewOpsLogBroadcaster(0)
|
||||
|
||||
// Subscriber with buffer=1, never reads. After the second Publish,
|
||||
// the entry must be dropped instead of blocking the publisher.
|
||||
_, unsub := b.Subscribe(OpsLogFilter{}, 1)
|
||||
defer unsub()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
b.Publish(OpsLogEntry{Status: 200})
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("publisher blocked on slow subscriber")
|
||||
}
|
||||
|
||||
_, dropped, _ := b.Stats()
|
||||
if dropped == 0 {
|
||||
t.Fatal("expected dropped count > 0 when subscriber buffer overflows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsLogBroadcaster_HistorySnapshot(t *testing.T) {
|
||||
b := NewOpsLogBroadcaster(3)
|
||||
|
||||
for i := 1; i <= 5; i++ {
|
||||
b.Publish(OpsLogEntry{Status: 200 + i})
|
||||
}
|
||||
|
||||
got := b.Snapshot(OpsLogFilter{}, 0)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected ring of 3, got %d", len(got))
|
||||
}
|
||||
// Oldest → newest
|
||||
if got[0].Status != 203 || got[1].Status != 204 || got[2].Status != 205 {
|
||||
t.Fatalf("expected 203/204/205, got %d/%d/%d", got[0].Status, got[1].Status, got[2].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsLogBroadcaster_HistoryAppliesFilter(t *testing.T) {
|
||||
b := NewOpsLogBroadcaster(8)
|
||||
|
||||
b.Publish(OpsLogEntry{Status: 200})
|
||||
b.Publish(OpsLogEntry{Status: 500})
|
||||
b.Publish(OpsLogEntry{Status: 503})
|
||||
|
||||
got := b.Snapshot(OpsLogFilter{MinStatus: 500}, 0)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 high-status entries, got %d: %+v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsLogBroadcaster_UnsubscribeIdempotent(t *testing.T) {
|
||||
b := NewOpsLogBroadcaster(0)
|
||||
_, unsub := b.Subscribe(OpsLogFilter{}, 1)
|
||||
unsub()
|
||||
unsub() // second call must not panic
|
||||
unsub() // and a third
|
||||
}
|
||||
|
||||
func TestOpsLogBroadcaster_ZeroTimeFilledIn(t *testing.T) {
|
||||
b := NewOpsLogBroadcaster(2)
|
||||
|
||||
b.Publish(OpsLogEntry{Status: 200}) // Time intentionally zero
|
||||
got := b.Snapshot(OpsLogFilter{}, 0)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(got))
|
||||
}
|
||||
if got[0].Time.IsZero() {
|
||||
t.Fatal("Publish should populate zero Time with time.Now()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsLogBroadcaster_ConcurrentSafe(t *testing.T) {
|
||||
b := NewOpsLogBroadcaster(64)
|
||||
|
||||
// Spin a few subscribers and producers; rely on -race to surface
|
||||
// any concurrency bugs in the fan-out path.
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
ch, unsub := b.Subscribe(OpsLogFilter{}, 32)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer unsub()
|
||||
deadline := time.Now().Add(200 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(5 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 200; j++ {
|
||||
b.Publish(OpsLogEntry{Status: 200, Model: "x"})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
pub, _, _ := b.Stats()
|
||||
if pub != 800 {
|
||||
t.Fatalf("expected 800 publishes, got %d", pub)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpsLogBroadcaster_ConcurrentUnsubscribeNoPanic exercises the exact
|
||||
// race the audit identified: a Publish goroutine has snapped a subscription
|
||||
// pointer while another goroutine unsubscribes (close(ch)) the moment before
|
||||
// the send. Without the closed-flag guard in Publish, this races into
|
||||
// "send on closed channel" and panics. With the guard, Publish observes
|
||||
// closed=true and skips the send. Run with -race.
|
||||
func TestOpsLogBroadcaster_ConcurrentUnsubscribeNoPanic(t *testing.T) {
|
||||
b := NewOpsLogBroadcaster(0)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 200; j++ {
|
||||
ch, unsub := b.Subscribe(OpsLogFilter{}, 1)
|
||||
// Drain non-blockingly until the next publish lands.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
timer := time.NewTimer(50 * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
case <-timer.C:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
b.Publish(OpsLogEntry{Status: 200})
|
||||
unsub()
|
||||
b.Publish(OpsLogEntry{Status: 200}) // post-unsub publish must not panic
|
||||
<-done
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestOpsLogBroadcaster_SnapshotConcurrentWithPublish ensures Snapshot is
|
||||
// safe under concurrent Publish (verifies subsMu vs historyMu coexistence).
|
||||
func TestOpsLogBroadcaster_SnapshotConcurrentWithPublish(t *testing.T) {
|
||||
b := NewOpsLogBroadcaster(32)
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
b.Publish(OpsLogEntry{Status: 200})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 200; i++ {
|
||||
_ = b.Snapshot(OpsLogFilter{}, 0)
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// helpers --------------------------------------------------------------
|
||||
|
||||
func receiveOrTimeout(t *testing.T, ch <-chan OpsLogEntry, d time.Duration) OpsLogEntry {
|
||||
t.Helper()
|
||||
select {
|
||||
case e := <-ch:
|
||||
return e
|
||||
case <-time.After(d):
|
||||
t.Fatalf("timeout waiting for entry after %s", d)
|
||||
}
|
||||
return OpsLogEntry{}
|
||||
}
|
||||
|
||||
func expectNoMessage(t *testing.T, ch <-chan OpsLogEntry, d time.Duration) {
|
||||
t.Helper()
|
||||
select {
|
||||
case e := <-ch:
|
||||
t.Fatalf("unexpected message: %+v", e)
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,9 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
@ -238,6 +240,22 @@ func (s *WindsurfChatService) chatCascade(
|
||||
}
|
||||
}
|
||||
|
||||
toolCalls := result.ToolCalls
|
||||
if len(toolCalls) == 0 && shouldRunNLUFallback(meta, result.Text) {
|
||||
if nluCalls := windsurf.ExtractToolCallsNLU(result.Text, availableToolNames(toolPreamble)); len(nluCalls) > 0 {
|
||||
slog.Info("windsurf_cascade_nlu_fallback",
|
||||
"model", modelKey, "calls", len(nluCalls), "text_chars", len(result.Text))
|
||||
toolCalls = make([]windsurf.NativeToolCall, 0, len(nluCalls))
|
||||
for _, c := range nluCalls {
|
||||
toolCalls = append(toolCalls, windsurf.NativeToolCall{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
ArgumentsJSON: c.ArgumentsJSON,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &WindsurfChatResponse{
|
||||
Text: result.Text,
|
||||
Thinking: result.Thinking,
|
||||
@ -245,10 +263,119 @@ func (s *WindsurfChatService) chatCascade(
|
||||
Mode: "cascade",
|
||||
Usage: result.Usage,
|
||||
FirstTextAt: result.FirstTextAt,
|
||||
ToolCalls: result.ToolCalls,
|
||||
ToolCalls: toolCalls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// shouldRunNLUFallback decides whether to spend CPU on the NLU extractor.
|
||||
// Two cases run the fallback: (a) model is explicitly NLU-flavored —
|
||||
// always extract, even when no obvious signal, since these models routinely
|
||||
// emit half-broken intent; (b) flavor is auto and the text shows NLU
|
||||
// signals. tool_use flavored models (Claude family) are NEVER run through
|
||||
// NLU because their tags are reliable and an erroneous fallback would
|
||||
// invent calls the user did not request. Setting WINDSURF_NLU_FALLBACK_DISABLED=1
|
||||
// short-circuits all of the above.
|
||||
func shouldRunNLUFallback(meta *windsurf.ModelMeta, text string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
if isNLUFallbackDisabled() {
|
||||
return false
|
||||
}
|
||||
flavor := windsurf.ResolveEmulationFlavor(meta)
|
||||
switch flavor {
|
||||
case windsurf.EmulationFlavorNLU:
|
||||
return true
|
||||
case windsurf.EmulationFlavorAuto:
|
||||
return windsurf.HasNLUSignal(text)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// availableToolNames extracts tool names from the cascade tool preamble for
|
||||
// validation in the NLU extractor. Format heuristic: lines like
|
||||
// "- TOOL_NAME(...)" or "name: TOOL_NAME". Returns nil when nothing parses
|
||||
// — extractor still works in best-effort mode.
|
||||
func availableToolNames(preamble string) []string {
|
||||
if preamble == "" {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, raw := range strings.Split(preamble, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if name := extractToolNameFromPreambleLine(line); name != "" {
|
||||
if _, dup := seen[name]; !dup {
|
||||
seen[name] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func extractToolNameFromPreambleLine(line string) string {
|
||||
trim := strings.TrimLeft(line, "-* \t")
|
||||
if trim == "" {
|
||||
return ""
|
||||
}
|
||||
// "name: foo" form
|
||||
if strings.HasPrefix(strings.ToLower(trim), "name:") {
|
||||
return strings.TrimSpace(trim[len("name:"):])
|
||||
}
|
||||
// "foo(args)" form — take identifier before "(".
|
||||
if idx := strings.IndexByte(trim, '('); idx > 0 {
|
||||
candidate := strings.TrimSpace(trim[:idx])
|
||||
if isIdentifier(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isIdentifier(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
switch {
|
||||
case r == '_':
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9' && i > 0:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// nluFallbackDisabledFn resolves the NLU-fallback kill switch. Tests may
|
||||
// replace it via withNLUFallbackDisabledFn to assert behaviour. Production
|
||||
// uses readNLUFallbackDisabledEnvOnce, which reads the env var exactly once.
|
||||
var nluFallbackDisabledFn = readNLUFallbackDisabledEnvOnce
|
||||
|
||||
func isNLUFallbackDisabled() bool {
|
||||
return nluFallbackDisabledFn()
|
||||
}
|
||||
|
||||
var (
|
||||
nluFallbackDisabledCachedOnce sync.Once
|
||||
nluFallbackDisabledCached bool
|
||||
)
|
||||
|
||||
func readNLUFallbackDisabledEnvOnce() bool {
|
||||
nluFallbackDisabledCachedOnce.Do(func() {
|
||||
v := strings.ToLower(strings.TrimSpace(os.Getenv("WINDSURF_NLU_FALLBACK_DISABLED")))
|
||||
nluFallbackDisabledCached = v == "1" || v == "true" || v == "yes" || v == "on"
|
||||
})
|
||||
return nluFallbackDisabledCached
|
||||
}
|
||||
|
||||
// buildCascadeCacheKey 构造 Cascade 复用 cache 的 key。
|
||||
// 任一组件变化(账号、模型、LS 实例、会话、system prompt)都会自动 cache miss。
|
||||
func buildCascadeCacheKey(groupID, accountID int64, modelUID, lsEndpoint, sessionHash, sysHash string) string {
|
||||
|
||||
237
backend/internal/service/windsurf_google_login_test.go
Normal file
237
backend/internal/service/windsurf_google_login_test.go
Normal file
@ -0,0 +1,237 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||
)
|
||||
|
||||
// tokenLoginRepoStub is a minimal AccountRepository stub used by
|
||||
// TestWindsurfAuthService_TokenLogin_*. It implements just FindByCredentialField
|
||||
// (the only repo method TokenLogin reaches before the validation short-circuits).
|
||||
// All other methods panic so accidental calls are loud.
|
||||
type tokenLoginRepoStub struct {
|
||||
existing []Account
|
||||
findErr error
|
||||
}
|
||||
|
||||
func (s *tokenLoginRepoStub) FindByCredentialField(_ context.Context, _, _, _ string) ([]Account, error) {
|
||||
return s.existing, s.findErr
|
||||
}
|
||||
|
||||
func (*tokenLoginRepoStub) Create(context.Context, *Account) error { panic("unexpected") }
|
||||
func (*tokenLoginRepoStub) GetByID(context.Context, int64) (*Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) GetByIDs(context.Context, []int64) ([]*Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ExistsByID(context.Context, int64) (bool, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) GetByCRSAccountID(context.Context, string) (*Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) FindByExtraField(context.Context, string, any) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ListCRSAccountIDs(context.Context) (map[string]int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) Update(context.Context, *Account) error { panic("unexpected") }
|
||||
func (*tokenLoginRepoStub) Delete(context.Context, int64) error { panic("unexpected") }
|
||||
func (*tokenLoginRepoStub) List(context.Context, pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, string, int64, string) ([]Account, *pagination.PaginationResult, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ListByGroup(context.Context, int64) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ListActive(context.Context) ([]Account, error) { panic("unexpected") }
|
||||
func (*tokenLoginRepoStub) ListByPlatform(context.Context, string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) UpdateLastUsed(context.Context, int64) error { panic("unexpected") }
|
||||
func (*tokenLoginRepoStub) BatchUpdateLastUsed(context.Context, map[int64]time.Time) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) SetError(context.Context, int64, string) error { panic("unexpected") }
|
||||
func (*tokenLoginRepoStub) ClearError(context.Context, int64) error { panic("unexpected") }
|
||||
func (*tokenLoginRepoStub) SetSchedulable(context.Context, int64, bool) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) AutoPauseExpiredAccounts(context.Context, time.Time) (int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) BindGroups(context.Context, int64, []int64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ListSchedulable(context.Context) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ListSchedulableByPlatform(context.Context, string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ListSchedulableByGroupIDAndPlatform(context.Context, int64, string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ListSchedulableByPlatforms(context.Context, []string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ListSchedulableByGroupIDAndPlatforms(context.Context, int64, []string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ListSchedulableUngroupedByPlatform(context.Context, string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ListSchedulableUngroupedByPlatforms(context.Context, []string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) SetRateLimited(context.Context, int64, time.Time) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) SetModelRateLimit(context.Context, int64, string, time.Time) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) SetOverloaded(context.Context, int64, time.Time) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) SetTempUnschedulable(context.Context, int64, time.Time, string) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ClearTempUnschedulable(context.Context, int64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ClearRateLimit(context.Context, int64) error { panic("unexpected") }
|
||||
func (*tokenLoginRepoStub) ClearAntigravityQuotaScopes(context.Context, int64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ClearModelRateLimits(context.Context, int64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) UpdateSessionWindow(context.Context, int64, *time.Time, *time.Time, string) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) UpdateExtra(context.Context, int64, map[string]any) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) BulkUpdate(context.Context, []int64, AccountBulkUpdate) (int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) IncrementQuotaUsed(context.Context, int64, float64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tokenLoginRepoStub) ResetQuotaUsed(context.Context, int64) error { panic("unexpected") }
|
||||
|
||||
// TestWindsurfAuthService_TokenLogin_Validation exercises input validation and
|
||||
// dedup short-circuits in TokenLogin (these run before any external dependency
|
||||
// is touched).
|
||||
func TestWindsurfAuthService_TokenLogin_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *WindsurfTokenLoginInput
|
||||
repo *tokenLoginRepoStub
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty token rejected",
|
||||
input: &WindsurfTokenLoginInput{Email: "user@example.com"},
|
||||
repo: &tokenLoginRepoStub{},
|
||||
wantErr: "token required",
|
||||
},
|
||||
{
|
||||
name: "duplicate email rejected with conflict error",
|
||||
input: &WindsurfTokenLoginInput{Token: "fake-token", Email: "dup@example.com"},
|
||||
repo: &tokenLoginRepoStub{existing: []Account{{ID: 42}}},
|
||||
wantErr: "already exists",
|
||||
},
|
||||
{
|
||||
name: "find error propagated",
|
||||
input: &WindsurfTokenLoginInput{Token: "fake-token", Email: "boom@example.com"},
|
||||
repo: &tokenLoginRepoStub{findErr: errors.New("db down")},
|
||||
wantErr: "check existing account",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := &WindsurfAuthService{
|
||||
accountRepo: tc.repo,
|
||||
authClient: &windsurf.AuthClient{},
|
||||
}
|
||||
|
||||
_, err := svc.TokenLogin(context.Background(), tc.input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tc.wantErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindsurfAuthService_TokenLogin_PlatformConst guards against accidental
|
||||
// drift in the platform/type constants used to persist the account.
|
||||
func TestWindsurfAuthService_TokenLogin_PlatformConst(t *testing.T) {
|
||||
if domain.PlatformWindsurf == "" {
|
||||
t.Fatal("PlatformWindsurf constant is empty")
|
||||
}
|
||||
if domain.AccountTypeWindsurfSession == "" {
|
||||
t.Fatal("AccountTypeWindsurfSession constant is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindsurfAuthService_TokenLogin_TypedErrors verifies that validation
|
||||
// failures surface as ApplicationError with the right HTTP code, so the
|
||||
// handler maps them to 4xx instead of 500.
|
||||
func TestWindsurfAuthService_TokenLogin_TypedErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input *WindsurfTokenLoginInput
|
||||
repo *tokenLoginRepoStub
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "missing token returns 400",
|
||||
input: &WindsurfTokenLoginInput{Email: "x@y.z"},
|
||||
repo: &tokenLoginRepoStub{},
|
||||
wantCode: 400,
|
||||
},
|
||||
{
|
||||
name: "duplicate email returns 409",
|
||||
input: &WindsurfTokenLoginInput{Token: "tok", Email: "dup@example.com"},
|
||||
repo: &tokenLoginRepoStub{existing: []Account{{ID: 1}}},
|
||||
wantCode: 409,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := &WindsurfAuthService{
|
||||
accountRepo: tc.repo,
|
||||
authClient: &windsurf.AuthClient{},
|
||||
}
|
||||
|
||||
_, err := svc.TokenLogin(context.Background(), tc.input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if got := infraerrors.Code(err); got != tc.wantCode {
|
||||
t.Fatalf("expected HTTP code %d, got %d (err=%v)", tc.wantCode, got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
123
backend/internal/service/windsurf_nlu_fallback_test.go
Normal file
123
backend/internal/service/windsurf_nlu_fallback_test.go
Normal file
@ -0,0 +1,123 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||
)
|
||||
|
||||
func TestShouldRunNLUFallback(t *testing.T) {
|
||||
// Force the fallback-enabled path; tests below cover the disabled path
|
||||
// via withNLUFallbackDisabledFn directly. The production env-Once design
|
||||
// makes t.Setenv ineffective once init has fired in a prior test.
|
||||
prev := nluFallbackDisabledFn
|
||||
nluFallbackDisabledFn = func() bool { return false }
|
||||
t.Cleanup(func() { nluFallbackDisabledFn = prev })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
meta *windsurf.ModelMeta
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{"empty text never runs", &windsurf.ModelMeta{Provider: "zhipu"}, "", false},
|
||||
{"explicit nlu always runs", &windsurf.ModelMeta{Provider: "zhipu"}, "Sure, I helped.", true},
|
||||
{"explicit override nlu wins over provider", &windsurf.ModelMeta{Provider: "anthropic", EmulationFlavor: "nlu"}, "any text", true},
|
||||
{"explicit tool_use never runs", &windsurf.ModelMeta{Provider: "anthropic"}, "function: edit_file {}", false},
|
||||
{"auto with no signal skips", &windsurf.ModelMeta{Provider: "openai"}, "Just a chat reply.", false},
|
||||
{"auto with signal runs", &windsurf.ModelMeta{Provider: "openai"}, `function: edit_file {"x":1}`, true},
|
||||
{"nil meta auto, signal", nil, "function: edit_file {}", true},
|
||||
{"nil meta auto, no signal", nil, "Hello world", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := shouldRunNLUFallback(tc.meta, tc.text); got != tc.want {
|
||||
t.Fatalf("shouldRunNLUFallback = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRunNLUFallback_DisabledByOverride(t *testing.T) {
|
||||
prev := nluFallbackDisabledFn
|
||||
nluFallbackDisabledFn = func() bool { return true }
|
||||
t.Cleanup(func() { nluFallbackDisabledFn = prev })
|
||||
|
||||
got := shouldRunNLUFallback(&windsurf.ModelMeta{Provider: "zhipu"}, "function: x {}")
|
||||
if got {
|
||||
t.Fatal("expected disabled by override, got enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableToolNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
preamble string
|
||||
want []string
|
||||
}{
|
||||
{"empty preamble", "", nil},
|
||||
{
|
||||
name: "function-call format with parens",
|
||||
preamble: `Tools:
|
||||
- edit_file(path, content)
|
||||
- read_file(path)
|
||||
- run_command(cmd)`,
|
||||
want: []string{"edit_file", "read_file", "run_command"},
|
||||
},
|
||||
{
|
||||
name: "name: form",
|
||||
preamble: `tools:
|
||||
- name: foo
|
||||
- name: bar`,
|
||||
want: []string{"foo", "bar"},
|
||||
},
|
||||
{
|
||||
name: "deduplicates",
|
||||
preamble: `- edit_file(p)
|
||||
- edit_file(p)`,
|
||||
want: []string{"edit_file"},
|
||||
},
|
||||
{
|
||||
name: "ignores non-identifier lines",
|
||||
preamble: `Use the following tools:`,
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := availableToolNames(tc.preamble)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("expected %v, got %v", tc.want, got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("expected %v, got %v", tc.want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIdentifier(t *testing.T) {
|
||||
cases := []struct {
|
||||
s string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"foo", true},
|
||||
{"foo_bar", true},
|
||||
{"FooBar", true},
|
||||
{"foo123", true},
|
||||
{"123foo", false},
|
||||
{"foo bar", false},
|
||||
{"foo-bar", false},
|
||||
{"foo.bar", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.s, func(t *testing.T) {
|
||||
if got := isIdentifier(tc.s); got != tc.want {
|
||||
t.Fatalf("isIdentifier(%q) = %v, want %v", tc.s, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||
)
|
||||
|
||||
@ -244,6 +245,124 @@ func (s *WindsurfAuthService) Login(ctx context.Context, input *WindsurfLoginInp
|
||||
}, nil
|
||||
}
|
||||
|
||||
type WindsurfTokenLoginInput struct {
|
||||
Token string
|
||||
Email string
|
||||
Name string
|
||||
Notes *string
|
||||
ProxyID *int64
|
||||
GroupIDs []int64
|
||||
Concurrency int
|
||||
Priority int
|
||||
ProbeAfter bool
|
||||
LSInstanceID string
|
||||
}
|
||||
|
||||
// TokenLogin registers a Windsurf account by exchanging a token obtained from
|
||||
// https://windsurf.com/show-auth-token (after the user signed in on
|
||||
// windsurf.com via Google / GitHub / email) with Codeium's register_user
|
||||
// endpoint. Because the OAuth round-trip happens entirely on windsurf.com,
|
||||
// no Firebase Referer-restricted requests originate from our own domain —
|
||||
// this is the only flow that works for self-hosted deployments.
|
||||
func (s *WindsurfAuthService) TokenLogin(ctx context.Context, input *WindsurfTokenLoginInput) (*WindsurfLoginOutput, error) {
|
||||
if input.Token == "" {
|
||||
return nil, infraerrors.BadRequest("WINDSURF_TOKEN_REQUIRED", "token required")
|
||||
}
|
||||
|
||||
if input.Email != "" {
|
||||
existing, err := s.accountRepo.FindByCredentialField(ctx, domain.PlatformWindsurf, "email", input.Email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check existing account: %w", err)
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
return nil, infraerrors.Conflict(
|
||||
"WINDSURF_ACCOUNT_EMAIL_EXISTS",
|
||||
"windsurf account with this email already exists",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
proxyURL := ""
|
||||
if input.ProxyID != nil {
|
||||
proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get proxy: %w", err)
|
||||
}
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
|
||||
reg, err := s.authClient.RegisterWithCodeiumDefault(ctx, input.Token, proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("codeium register (token): %w", err)
|
||||
}
|
||||
|
||||
emailForRecord := input.Email
|
||||
if emailForRecord == "" {
|
||||
emailForRecord = reg.Name // best-effort label when caller didn't supply one
|
||||
}
|
||||
|
||||
creds := WindsurfCredentials{
|
||||
Email: emailForRecord,
|
||||
APIKey: reg.APIKey,
|
||||
AuthMethod: "token",
|
||||
APIServerURL: reg.APIServerURL,
|
||||
RegisteredAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
credMap := StoreWindsurfCredentials(creds)
|
||||
|
||||
extra := WindsurfExtra{
|
||||
Profile: WindsurfProfileSnapshot{TierSource: "login"},
|
||||
Refresh: WindsurfRefreshState{},
|
||||
}
|
||||
if input.LSInstanceID != "" {
|
||||
extra.LSBinding = WindsurfLSBinding{ContainerID: input.LSInstanceID}
|
||||
}
|
||||
extraMap := StoreWindsurfExtra(extra)
|
||||
|
||||
name := input.Name
|
||||
if name == "" {
|
||||
name = reg.Name
|
||||
}
|
||||
if name == "" {
|
||||
name = emailForRecord
|
||||
}
|
||||
if name == "" {
|
||||
name = "Windsurf Account"
|
||||
}
|
||||
|
||||
concurrency := input.Concurrency
|
||||
if concurrency <= 0 {
|
||||
concurrency = 1
|
||||
}
|
||||
|
||||
createInput := &CreateAccountInput{
|
||||
Name: name,
|
||||
Notes: input.Notes,
|
||||
Platform: domain.PlatformWindsurf,
|
||||
Type: domain.AccountTypeWindsurfSession,
|
||||
Credentials: credMap,
|
||||
Extra: extraMap,
|
||||
ProxyID: input.ProxyID,
|
||||
Concurrency: concurrency,
|
||||
Priority: input.Priority,
|
||||
GroupIDs: input.GroupIDs,
|
||||
}
|
||||
|
||||
account, err := s.adminSvc.CreateAccount(ctx, createInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create account: %w", err)
|
||||
}
|
||||
|
||||
return &WindsurfLoginOutput{
|
||||
AccountID: account.ID,
|
||||
Email: emailForRecord,
|
||||
Tier: "unknown",
|
||||
AuthMethod: "token",
|
||||
APIKeyPresent: reg.APIKey != "",
|
||||
RefreshTokenPresent: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *WindsurfAuthService) BatchLogin(ctx context.Context, items []string, proxyID *int64, groupIDs []int64, concurrency, priority int, probeAfter bool) ([]WindsurfBatchResult, error) {
|
||||
results := make([]WindsurfBatchResult, 0, len(items))
|
||||
|
||||
|
||||
216
backend/internal/service/windsurf_tier_access_service.go
Normal file
216
backend/internal/service/windsurf_tier_access_service.go
Normal file
@ -0,0 +1,216 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||
)
|
||||
|
||||
// WindsurfTierAccessRow describes account-pool availability for one model.
|
||||
//
|
||||
// Counts are exclusive: free + pro + trial sums to total schedulable accounts
|
||||
// that can serve the model; blocked excludes accounts whose schedulable=false
|
||||
// or capability check failed. Total = free + pro + trial.
|
||||
type WindsurfTierAccessRow struct {
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
EmulationFlavor string `json:"emulation_flavor"`
|
||||
Free int `json:"free"`
|
||||
Pro int `json:"pro"`
|
||||
Trial int `json:"trial"`
|
||||
Blocked int `json:"blocked"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// WindsurfTierAccessSnapshot is the cacheable result of a tier-access scan.
|
||||
type WindsurfTierAccessSnapshot struct {
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Accounts int `json:"accounts_considered"`
|
||||
Rows []WindsurfTierAccessRow `json:"rows"`
|
||||
}
|
||||
|
||||
// WindsurfTierAccessService aggregates per-model availability from the
|
||||
// account pool. The Snapshot result is cached for cacheTTL to keep this
|
||||
// cheap when called from a busy admin dashboard.
|
||||
type WindsurfTierAccessService struct {
|
||||
accountRepo AccountRepository
|
||||
cacheTTL time.Duration
|
||||
|
||||
cache atomic.Pointer[WindsurfTierAccessSnapshot]
|
||||
mu sync.Mutex // guards rebuild
|
||||
}
|
||||
|
||||
// NewWindsurfTierAccessService creates a service with a default 60s cache.
|
||||
func NewWindsurfTierAccessService(accountRepo AccountRepository) *WindsurfTierAccessService {
|
||||
return &WindsurfTierAccessService{
|
||||
accountRepo: accountRepo,
|
||||
cacheTTL: 60 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot returns the latest tier-access snapshot, rebuilding from the
|
||||
// repository when the cache is stale or absent. Concurrent callers during
|
||||
// a rebuild get the freshly generated snapshot; only one rebuild runs at a
|
||||
// time.
|
||||
func (s *WindsurfTierAccessService) Snapshot(ctx context.Context) (*WindsurfTierAccessSnapshot, error) {
|
||||
if cached := s.cache.Load(); cached != nil && time.Since(cached.GeneratedAt) < s.cacheTTL {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// Re-check after acquiring lock — another goroutine may have rebuilt.
|
||||
if cached := s.cache.Load(); cached != nil && time.Since(cached.GeneratedAt) < s.cacheTTL {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
snap, err := s.build(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.cache.Store(snap)
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
func (s *WindsurfTierAccessService) build(ctx context.Context) (*WindsurfTierAccessSnapshot, error) {
|
||||
accounts, err := s.accountRepo.ListByPlatform(ctx, domain.PlatformWindsurf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list windsurf accounts: %w", err)
|
||||
}
|
||||
|
||||
byModel := make(map[string]*tierCounter)
|
||||
getCounter := func(model string) *tierCounter {
|
||||
c, ok := byModel[model]
|
||||
if !ok {
|
||||
c = &tierCounter{}
|
||||
byModel[model] = c
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
considered := 0
|
||||
for i := range accounts {
|
||||
acct := &accounts[i]
|
||||
creds := LoadWindsurfCredentials(acct.Credentials)
|
||||
extra := LoadWindsurfExtra(acct.Extra)
|
||||
if creds.APIKey == "" {
|
||||
continue // un-registered account; cannot serve traffic
|
||||
}
|
||||
considered++
|
||||
|
||||
tierBucket := tierBucketFor(creds.Tier)
|
||||
schedulable := acct.IsSchedulable()
|
||||
|
||||
// 1) Walk the account's allowedModels (authoritative when present).
|
||||
seen := make(map[string]struct{})
|
||||
for _, am := range extra.UserStatus.AllowedModels {
|
||||
model := am.ModelKey
|
||||
if model == "" {
|
||||
model = am.Alias
|
||||
}
|
||||
if model == "" {
|
||||
continue
|
||||
}
|
||||
seen[model] = struct{}{}
|
||||
c := getCounter(model)
|
||||
if !schedulable {
|
||||
c.blocked++
|
||||
continue
|
||||
}
|
||||
capCheck := extra.Capabilities[model]
|
||||
if !capabilityOK(capCheck) {
|
||||
c.blocked++
|
||||
continue
|
||||
}
|
||||
incTier(c, tierBucket)
|
||||
}
|
||||
|
||||
// 2) Fall back to capability map for any model not already counted
|
||||
// (older accounts may have probe data without allowedModels).
|
||||
for model, capCheck := range extra.Capabilities {
|
||||
if _, ok := seen[model]; ok {
|
||||
continue
|
||||
}
|
||||
c := getCounter(model)
|
||||
if !schedulable || !capabilityOK(capCheck) {
|
||||
c.blocked++
|
||||
continue
|
||||
}
|
||||
incTier(c, tierBucket)
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]WindsurfTierAccessRow, 0, len(byModel))
|
||||
for model, c := range byModel {
|
||||
meta := windsurf.GetModelInfo(model)
|
||||
row := WindsurfTierAccessRow{
|
||||
Model: model,
|
||||
Free: c.free,
|
||||
Pro: c.pro,
|
||||
Trial: c.trial,
|
||||
Blocked: c.blocked,
|
||||
Total: c.free + c.pro + c.trial,
|
||||
}
|
||||
if meta != nil {
|
||||
row.Provider = meta.Provider
|
||||
row.EmulationFlavor = windsurf.ResolveEmulationFlavor(meta)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].Total != rows[j].Total {
|
||||
return rows[i].Total > rows[j].Total
|
||||
}
|
||||
return rows[i].Model < rows[j].Model
|
||||
})
|
||||
|
||||
return &WindsurfTierAccessSnapshot{
|
||||
GeneratedAt: time.Now(),
|
||||
Accounts: considered,
|
||||
Rows: rows,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tierCounter is the per-model tally used during a snapshot build.
|
||||
type tierCounter struct {
|
||||
free, pro, trial, blocked int
|
||||
}
|
||||
|
||||
func capabilityOK(c WindsurfModelCapability) bool {
|
||||
if c.Reason == "not_entitled" {
|
||||
return false
|
||||
}
|
||||
return c.Available
|
||||
}
|
||||
|
||||
func tierBucketFor(tier string) string {
|
||||
switch tier {
|
||||
case "pro":
|
||||
return "pro"
|
||||
case "trial":
|
||||
return "trial"
|
||||
case "free":
|
||||
return "free"
|
||||
default:
|
||||
// Unknown tiers (legacy / pre-probe accounts) bucket as free for
|
||||
// display purposes — they're typically free until probed.
|
||||
return "free"
|
||||
}
|
||||
}
|
||||
|
||||
func incTier(c *tierCounter, bucket string) {
|
||||
switch bucket {
|
||||
case "pro":
|
||||
c.pro++
|
||||
case "trial":
|
||||
c.trial++
|
||||
default:
|
||||
c.free++
|
||||
}
|
||||
}
|
||||
266
backend/internal/service/windsurf_tier_access_service_test.go
Normal file
266
backend/internal/service/windsurf_tier_access_service_test.go
Normal file
@ -0,0 +1,266 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
)
|
||||
|
||||
// tierAccessRepoStub satisfies AccountRepository with a hand-rolled
|
||||
// ListByPlatform; every other method panics so accidental calls are loud.
|
||||
type tierAccessRepoStub struct {
|
||||
accounts []Account
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *tierAccessRepoStub) ListByPlatform(_ context.Context, _ string) ([]Account, error) {
|
||||
return s.accounts, s.err
|
||||
}
|
||||
|
||||
func (*tierAccessRepoStub) Create(context.Context, *Account) error { panic("unexpected") }
|
||||
func (*tierAccessRepoStub) GetByID(context.Context, int64) (*Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) GetByIDs(context.Context, []int64) ([]*Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ExistsByID(context.Context, int64) (bool, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) GetByCRSAccountID(context.Context, string) (*Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) FindByExtraField(context.Context, string, any) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) FindByCredentialField(context.Context, string, string, string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ListCRSAccountIDs(context.Context) (map[string]int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) Update(context.Context, *Account) error { panic("unexpected") }
|
||||
func (*tierAccessRepoStub) Delete(context.Context, int64) error { panic("unexpected") }
|
||||
func (*tierAccessRepoStub) List(context.Context, pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, string, int64, string) ([]Account, *pagination.PaginationResult, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ListByGroup(context.Context, int64) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ListActive(context.Context) ([]Account, error) { panic("unexpected") }
|
||||
func (*tierAccessRepoStub) UpdateLastUsed(context.Context, int64) error { panic("unexpected") }
|
||||
func (*tierAccessRepoStub) BatchUpdateLastUsed(context.Context, map[int64]time.Time) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) SetError(context.Context, int64, string) error { panic("unexpected") }
|
||||
func (*tierAccessRepoStub) ClearError(context.Context, int64) error { panic("unexpected") }
|
||||
func (*tierAccessRepoStub) SetSchedulable(context.Context, int64, bool) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) AutoPauseExpiredAccounts(context.Context, time.Time) (int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) BindGroups(context.Context, int64, []int64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ListSchedulable(context.Context) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ListSchedulableByPlatform(context.Context, string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ListSchedulableByGroupIDAndPlatform(context.Context, int64, string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ListSchedulableByPlatforms(context.Context, []string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ListSchedulableByGroupIDAndPlatforms(context.Context, int64, []string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ListSchedulableUngroupedByPlatform(context.Context, string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ListSchedulableUngroupedByPlatforms(context.Context, []string) ([]Account, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) SetRateLimited(context.Context, int64, time.Time) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) SetModelRateLimit(context.Context, int64, string, time.Time) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) SetOverloaded(context.Context, int64, time.Time) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) SetTempUnschedulable(context.Context, int64, time.Time, string) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ClearTempUnschedulable(context.Context, int64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ClearRateLimit(context.Context, int64) error { panic("unexpected") }
|
||||
func (*tierAccessRepoStub) ClearAntigravityQuotaScopes(context.Context, int64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ClearModelRateLimits(context.Context, int64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) UpdateSessionWindow(context.Context, int64, *time.Time, *time.Time, string) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) UpdateExtra(context.Context, int64, map[string]any) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) BulkUpdate(context.Context, []int64, AccountBulkUpdate) (int64, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) IncrementQuotaUsed(context.Context, int64, float64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (*tierAccessRepoStub) ResetQuotaUsed(context.Context, int64) error { panic("unexpected") }
|
||||
|
||||
func mkAccount(id int64, tier string, status string, allowed []WindsurfAllowedModel, caps map[string]WindsurfModelCapability) Account {
|
||||
creds := WindsurfCredentials{
|
||||
APIKey: "key-" + tier,
|
||||
Tier: tier,
|
||||
}
|
||||
extra := WindsurfExtra{
|
||||
UserStatus: WindsurfUserStatusSnapshot{AllowedModels: allowed},
|
||||
Capabilities: caps,
|
||||
}
|
||||
return Account{
|
||||
ID: id,
|
||||
Platform: domain.PlatformWindsurf,
|
||||
Status: status,
|
||||
Schedulable: status == StatusActive,
|
||||
Credentials: StoreWindsurfCredentials(creds),
|
||||
Extra: StoreWindsurfExtra(extra),
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindsurfTierAccessService_Snapshot_HappyPath(t *testing.T) {
|
||||
repo := &tierAccessRepoStub{
|
||||
accounts: []Account{
|
||||
mkAccount(1, "free", StatusActive,
|
||||
[]WindsurfAllowedModel{{ModelKey: "gemini-2.5-flash"}, {ModelKey: "kimi-k2"}},
|
||||
nil),
|
||||
mkAccount(2, "pro", StatusActive,
|
||||
[]WindsurfAllowedModel{{ModelKey: "gemini-2.5-flash"}, {ModelKey: "claude-sonnet-4.6"}},
|
||||
nil),
|
||||
mkAccount(3, "trial", StatusActive,
|
||||
[]WindsurfAllowedModel{{ModelKey: "claude-sonnet-4.6"}},
|
||||
nil),
|
||||
},
|
||||
}
|
||||
svc := NewWindsurfTierAccessService(repo)
|
||||
snap, err := svc.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if snap.Accounts != 3 {
|
||||
t.Fatalf("expected 3 accounts considered, got %d", snap.Accounts)
|
||||
}
|
||||
rowsByModel := make(map[string]WindsurfTierAccessRow)
|
||||
for _, r := range snap.Rows {
|
||||
rowsByModel[r.Model] = r
|
||||
}
|
||||
if got := rowsByModel["gemini-2.5-flash"]; got.Free != 1 || got.Pro != 1 || got.Trial != 0 || got.Total != 2 {
|
||||
t.Fatalf("gemini-2.5-flash unexpected counts: %+v", got)
|
||||
}
|
||||
if got := rowsByModel["claude-sonnet-4.6"]; got.Free != 0 || got.Pro != 1 || got.Trial != 1 || got.Total != 2 {
|
||||
t.Fatalf("claude-sonnet-4.6 unexpected counts: %+v", got)
|
||||
}
|
||||
if got := rowsByModel["kimi-k2"]; got.Free != 1 {
|
||||
t.Fatalf("kimi-k2 unexpected counts: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindsurfTierAccessService_Snapshot_BlockedAccountsCounted(t *testing.T) {
|
||||
caps := map[string]WindsurfModelCapability{
|
||||
"gemini-2.5-flash": {Available: false, Reason: "not_entitled"},
|
||||
}
|
||||
repo := &tierAccessRepoStub{
|
||||
accounts: []Account{
|
||||
mkAccount(1, "free", StatusActive, nil, caps),
|
||||
mkAccount(2, "free", "paused", []WindsurfAllowedModel{{ModelKey: "gemini-2.5-flash"}}, nil),
|
||||
},
|
||||
}
|
||||
svc := NewWindsurfTierAccessService(repo)
|
||||
snap, _ := svc.Snapshot(context.Background())
|
||||
row := findTierRow(snap, "gemini-2.5-flash")
|
||||
if row == nil {
|
||||
t.Fatal("expected gemini-2.5-flash row")
|
||||
}
|
||||
if row.Blocked != 2 || row.Total != 0 {
|
||||
t.Fatalf("expected blocked=2 total=0, got %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindsurfTierAccessService_Snapshot_SkipsUnregisteredAccounts(t *testing.T) {
|
||||
acct := Account{
|
||||
ID: 1,
|
||||
Platform: domain.PlatformWindsurf,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: StoreWindsurfCredentials(WindsurfCredentials{Email: "a@b.c"}), // no APIKey
|
||||
}
|
||||
svc := NewWindsurfTierAccessService(&tierAccessRepoStub{accounts: []Account{acct}})
|
||||
snap, _ := svc.Snapshot(context.Background())
|
||||
if snap.Accounts != 0 {
|
||||
t.Fatalf("expected accounts considered=0, got %d", snap.Accounts)
|
||||
}
|
||||
if len(snap.Rows) != 0 {
|
||||
t.Fatalf("expected no rows, got %+v", snap.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindsurfTierAccessService_Snapshot_PropagatesRepoError(t *testing.T) {
|
||||
svc := NewWindsurfTierAccessService(&tierAccessRepoStub{err: errors.New("db down")})
|
||||
if _, err := svc.Snapshot(context.Background()); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindsurfTierAccessService_Snapshot_CachesWithinTTL(t *testing.T) {
|
||||
repo := &tierAccessRepoStub{
|
||||
accounts: []Account{
|
||||
mkAccount(1, "free", StatusActive, []WindsurfAllowedModel{{ModelKey: "x"}}, nil),
|
||||
},
|
||||
}
|
||||
svc := NewWindsurfTierAccessService(repo)
|
||||
first, _ := svc.Snapshot(context.Background())
|
||||
|
||||
// Pointer equality is the cache-hit signal: atomic.Pointer.Store fires
|
||||
// only on rebuild, so a returned pointer identical to the prior call
|
||||
// proves the build() path was skipped. We mutate the underlying repo
|
||||
// to a state that would yield a different snapshot if the rebuild
|
||||
// actually ran — the assertion below catches a regression in either
|
||||
// direction (TTL gate broken, or sync.Once style misuse). The 60s
|
||||
// default TTL is large enough that this test never sees an expiry
|
||||
// during normal CI runs.
|
||||
repo.accounts = nil
|
||||
second, _ := svc.Snapshot(context.Background())
|
||||
if first != second {
|
||||
t.Fatal("expected cached pointer reuse")
|
||||
}
|
||||
}
|
||||
|
||||
func findTierRow(snap *WindsurfTierAccessSnapshot, model string) *WindsurfTierAccessRow {
|
||||
for i := range snap.Rows {
|
||||
if snap.Rows[i].Model == model {
|
||||
return &snap.Rows[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -528,6 +528,8 @@ var ProviderSet = wire.NewSet(
|
||||
ProvideWindsurfTokenProvider,
|
||||
ProvideWindsurfRefreshService,
|
||||
ProvideWindsurfProbeService,
|
||||
ProvideWindsurfTierAccessService,
|
||||
ProvideOpsLogBroadcaster,
|
||||
ProvideChannelMonitorService,
|
||||
ProvideChannelMonitorRunner,
|
||||
NewChannelMonitorRequestTemplateService,
|
||||
@ -546,6 +548,26 @@ func ProvideWindsurfAuthService(cfg *config.Config, accountRepo AccountRepositor
|
||||
return NewWindsurfAuthService(cfg.Windsurf, accountRepo, proxyRepo, adminSvc)
|
||||
}
|
||||
|
||||
// ProvideWindsurfTierAccessService creates the tier-access aggregator
|
||||
// (nil when windsurf is disabled).
|
||||
func ProvideWindsurfTierAccessService(cfg *config.Config, accountRepo AccountRepository) *WindsurfTierAccessService {
|
||||
if !cfg.Windsurf.Enabled {
|
||||
return nil
|
||||
}
|
||||
return NewWindsurfTierAccessService(accountRepo)
|
||||
}
|
||||
|
||||
// ProvideOpsLogBroadcaster builds the in-memory ops log fan-out.
|
||||
//
|
||||
// Always returns a non-nil broadcaster — middleware/handler call sites
|
||||
// rely on a stable receiver and gracefully no-op when the feature is
|
||||
// effectively disabled (e.g. monitoring globally turned off via OpsService).
|
||||
// History capacity is fixed at 1000 entries to bound memory.
|
||||
func ProvideOpsLogBroadcaster() *OpsLogBroadcaster {
|
||||
const historyCap = 1000
|
||||
return NewOpsLogBroadcaster(historyCap)
|
||||
}
|
||||
|
||||
// ProvideWindsurfLSService creates WindsurfLSService (nil when windsurf is disabled).
|
||||
func ProvideWindsurfLSService(cfg *config.Config) *WindsurfLSService {
|
||||
if !cfg.Windsurf.Enabled {
|
||||
|
||||
@ -1418,7 +1418,190 @@ export const opsAPI = {
|
||||
updateMetricThresholds,
|
||||
listSystemLogs,
|
||||
cleanupSystemLogs,
|
||||
getSystemLogSinkHealth
|
||||
getSystemLogSinkHealth,
|
||||
getRecentOpsLogs,
|
||||
subscribeOpsLogStream
|
||||
}
|
||||
|
||||
export default opsAPI
|
||||
|
||||
// ===== Real-time ops log stream =====================================
|
||||
|
||||
export interface OpsLogEntry {
|
||||
time: string
|
||||
method?: string
|
||||
path?: string
|
||||
status: number
|
||||
latency_ms: number
|
||||
model?: string
|
||||
stream?: boolean
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
api_key_id?: number
|
||||
user_id?: number
|
||||
turns?: number
|
||||
prompt_chars?: number
|
||||
error_message?: string
|
||||
error_detail?: string
|
||||
upstream_status?: number
|
||||
}
|
||||
|
||||
export interface OpsLogFilter {
|
||||
min_status?: number
|
||||
model?: string
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
min_latency_ms?: number
|
||||
}
|
||||
|
||||
export interface OpsLogRecentResponse {
|
||||
entries: OpsLogEntry[]
|
||||
published_total: number
|
||||
dropped_total: number
|
||||
subscribers: number
|
||||
}
|
||||
|
||||
function buildLogQuery(filter: OpsLogFilter): string {
|
||||
const params = new URLSearchParams()
|
||||
if (filter.min_status && filter.min_status > 0) params.set('min_status', String(filter.min_status))
|
||||
if (filter.model) params.set('model', filter.model)
|
||||
if (filter.account_id && filter.account_id > 0) params.set('account_id', String(filter.account_id))
|
||||
if (filter.group_id && filter.group_id > 0) params.set('group_id', String(filter.group_id))
|
||||
if (filter.min_latency_ms && filter.min_latency_ms > 0) params.set('min_latency_ms', String(filter.min_latency_ms))
|
||||
const qs = params.toString()
|
||||
return qs ? `?${qs}` : ''
|
||||
}
|
||||
|
||||
export async function getRecentOpsLogs(
|
||||
filter: OpsLogFilter = {},
|
||||
max?: number
|
||||
): Promise<OpsLogRecentResponse> {
|
||||
const params: Record<string, string | number> = {}
|
||||
if (filter.min_status) params.min_status = filter.min_status
|
||||
if (filter.model) params.model = filter.model
|
||||
if (filter.account_id) params.account_id = filter.account_id
|
||||
if (filter.group_id) params.group_id = filter.group_id
|
||||
if (filter.min_latency_ms) params.min_latency_ms = filter.min_latency_ms
|
||||
if (max) params.max = max
|
||||
|
||||
const { data } = await apiClient.get<OpsLogRecentResponse>('/admin/ops/logs/recent', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export interface SubscribeLogsOptions {
|
||||
onEntry: (entry: OpsLogEntry) => void
|
||||
onStatus?: (status: 'connecting' | 'live' | 'closed' | 'error') => void
|
||||
onError?: (err: Error) => void
|
||||
}
|
||||
|
||||
export type LogStreamHandle = {
|
||||
close: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to /admin/ops/logs/stream via fetch + ReadableStream.
|
||||
*
|
||||
* EventSource doesn't allow custom headers, so we cannot use it for our
|
||||
* Bearer-token authenticated SSE endpoint. fetch with `accept: text/event-stream`
|
||||
* gets us the same wire protocol with Authorization support.
|
||||
*
|
||||
* The returned handle is idempotent — calling close() multiple times is safe.
|
||||
*/
|
||||
export function subscribeOpsLogStream(
|
||||
filter: OpsLogFilter,
|
||||
opts: SubscribeLogsOptions
|
||||
): LogStreamHandle {
|
||||
const ctrl = new AbortController()
|
||||
let closed = false
|
||||
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
ctrl.abort()
|
||||
opts.onStatus?.('closed')
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
opts.onStatus?.('connecting')
|
||||
const baseURL = (apiClient.defaults.baseURL ?? '/api/v1').replace(/\/+$/, '')
|
||||
const url = `${baseURL}/admin/ops/logs/stream${buildLogQuery(filter)}`
|
||||
const token = localStorage.getItem('auth_token') ?? ''
|
||||
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: ctrl.signal,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
accept: 'text/event-stream',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
if (closed || ctrl.signal.aborted) return
|
||||
opts.onStatus?.('error')
|
||||
opts.onError?.(e instanceof Error ? e : new Error(String(e)))
|
||||
return
|
||||
}
|
||||
|
||||
if (!resp.ok || !resp.body) {
|
||||
opts.onStatus?.('error')
|
||||
opts.onError?.(new Error(`SSE ${resp.status} ${resp.statusText}`))
|
||||
return
|
||||
}
|
||||
|
||||
opts.onStatus?.('live')
|
||||
const reader = resp.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (!closed) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
// Normalize CRLF and bare CR to LF before scanning. The SSE wire
|
||||
// format permits \r\n line endings; without this, a trailing \r
|
||||
// would leak into the JSON payload and silently break JSON.parse.
|
||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, '\n')
|
||||
|
||||
// SSE events are separated by a blank line.
|
||||
let sep: number
|
||||
while ((sep = buffer.indexOf('\n\n')) !== -1) {
|
||||
const rawEvent = buffer.slice(0, sep)
|
||||
buffer = buffer.slice(sep + 2)
|
||||
|
||||
let dataLine = ''
|
||||
for (const line of rawEvent.split('\n')) {
|
||||
if (line.startsWith(':')) continue // comment / heartbeat
|
||||
if (line.startsWith('data:')) {
|
||||
// Per SSE spec, multiple `data:` lines in one event are
|
||||
// joined by '\n', not concatenated. Our JSON-encoded entries
|
||||
// never contain unescaped LF, but we follow the spec to
|
||||
// future-proof against a field that emits raw bytes.
|
||||
const piece = line.slice(5).replace(/^ /, '')
|
||||
dataLine += dataLine ? '\n' + piece : piece
|
||||
}
|
||||
}
|
||||
if (!dataLine) continue
|
||||
try {
|
||||
const parsed = JSON.parse(dataLine) as OpsLogEntry
|
||||
opts.onEntry(parsed)
|
||||
} catch {
|
||||
// skip malformed payload — server uses well-formed JSON, this is defensive
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!closed) {
|
||||
opts.onStatus?.('error')
|
||||
opts.onError?.(e instanceof Error ? e : new Error(String(e)))
|
||||
}
|
||||
} finally {
|
||||
if (!closed) opts.onStatus?.('closed')
|
||||
}
|
||||
}
|
||||
|
||||
void run()
|
||||
return { close }
|
||||
}
|
||||
|
||||
@ -4,9 +4,11 @@ import type {
|
||||
WindsurfLoginResponse,
|
||||
WindsurfBatchLoginRequest,
|
||||
WindsurfBatchLoginResponse,
|
||||
WindsurfTokenLoginRequest,
|
||||
WindsurfRefreshTokenResponse,
|
||||
WindsurfLSStatusResponse,
|
||||
WindsurfRuntimeResponse
|
||||
WindsurfRuntimeResponse,
|
||||
WindsurfTierAccessSnapshot
|
||||
} from '@/types'
|
||||
|
||||
export async function login(req: WindsurfLoginRequest): Promise<WindsurfLoginResponse> {
|
||||
@ -14,6 +16,14 @@ export async function login(req: WindsurfLoginRequest): Promise<WindsurfLoginRes
|
||||
return data
|
||||
}
|
||||
|
||||
export async function tokenLogin(req: WindsurfTokenLoginRequest): Promise<WindsurfLoginResponse> {
|
||||
const { data } = await apiClient.post<WindsurfLoginResponse>(
|
||||
'/admin/windsurf/accounts/token-login',
|
||||
req
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function batchLogin(req: WindsurfBatchLoginRequest): Promise<WindsurfBatchLoginResponse> {
|
||||
const { data } = await apiClient.post<WindsurfBatchLoginResponse>(
|
||||
'/admin/windsurf/accounts/batch-login',
|
||||
@ -62,14 +72,21 @@ export async function getRuntime(accountId: number): Promise<WindsurfRuntimeResp
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getTierAccess(): Promise<WindsurfTierAccessSnapshot> {
|
||||
const { data } = await apiClient.get<WindsurfTierAccessSnapshot>('/admin/windsurf/tier-access')
|
||||
return data
|
||||
}
|
||||
|
||||
export const windsurfAPI = {
|
||||
login,
|
||||
tokenLogin,
|
||||
batchLogin,
|
||||
refreshToken,
|
||||
batchRefreshTokens,
|
||||
getLSStatus,
|
||||
listModels,
|
||||
getRuntime
|
||||
getRuntime,
|
||||
getTierAccess
|
||||
}
|
||||
|
||||
export default windsurfAPI
|
||||
|
||||
@ -9,8 +9,20 @@
|
||||
{{ t('admin.windsurf.loginDesc') }}
|
||||
</p>
|
||||
|
||||
<!-- Tab: Single / Batch -->
|
||||
<!-- Tab: Google / Single / Batch -->
|
||||
<div class="mb-5 flex rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="mode = 'google'"
|
||||
:class="[
|
||||
'flex flex-1 items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-all',
|
||||
mode === 'google'
|
||||
? 'bg-white text-primary-600 shadow-sm dark:bg-dark-600 dark:text-primary-400'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
]"
|
||||
>
|
||||
{{ t('admin.windsurf.googleLogin') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="mode = 'single'"
|
||||
@ -38,8 +50,61 @@
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Google / Token Login -->
|
||||
<template v-if="mode === 'google'">
|
||||
<ol class="space-y-4 text-sm">
|
||||
<li class="flex flex-col gap-2">
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">
|
||||
{{ t('admin.windsurf.googleStep1Title') }}
|
||||
</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.windsurf.googleStep1Hint') }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="openWindsurfAuthPage"
|
||||
class="flex items-center justify-center gap-2 self-start rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-dark-500 dark:bg-dark-600 dark:text-gray-200 dark:hover:bg-dark-500"
|
||||
>
|
||||
<GoogleIcon class="h-4 w-4" />
|
||||
<span>{{ t('admin.windsurf.googleStep1Button') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="flex flex-col gap-2">
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">
|
||||
{{ t('admin.windsurf.googleStep2Title') }}
|
||||
</span>
|
||||
<textarea
|
||||
v-model.trim="tokenForm.token"
|
||||
rows="4"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="t('admin.windsurf.googleTokenPlaceholder')"
|
||||
></textarea>
|
||||
<p class="input-hint">{{ t('admin.windsurf.googleStep2Hint') }}</p>
|
||||
</li>
|
||||
<li class="flex flex-col gap-2">
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">
|
||||
{{ t('admin.windsurf.googleStep3Title') }}
|
||||
</span>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
v-model.trim="tokenForm.email"
|
||||
type="email"
|
||||
class="input"
|
||||
:placeholder="t('admin.windsurf.email') + ' (' + t('admin.windsurf.googleEmailOptional') + ')'"
|
||||
/>
|
||||
<input
|
||||
v-model.trim="tokenForm.name"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.accountName') + ' (' + t('admin.windsurf.googleEmailOptional') + ')'"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
|
||||
<!-- Single Login -->
|
||||
<template v-if="mode === 'single'">
|
||||
<template v-else-if="mode === 'single'">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.windsurf.email') }}</label>
|
||||
<input
|
||||
@ -71,7 +136,7 @@
|
||||
</template>
|
||||
|
||||
<!-- Batch Login -->
|
||||
<template v-else>
|
||||
<template v-else-if="mode === 'batch'">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.windsurf.batchItems') }}</label>
|
||||
<textarea
|
||||
@ -176,8 +241,16 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import GoogleIcon from '@/components/icons/GoogleIcon.vue'
|
||||
import type { Proxy, AdminGroup, WindsurfBatchLoginResult } from '@/types'
|
||||
|
||||
// windsurf.com/show-auth-token displays the token after user authenticates
|
||||
// (Google / GitHub / email) on Windsurf's own domain. We open it in a new tab
|
||||
// rather than calling Firebase from our domain — Windsurf's Firebase apiKey is
|
||||
// HTTP-Referer-restricted to *.windsurf.com so direct browser SDK usage from
|
||||
// any other host returns 403 API_KEY_HTTP_REFERRER_BLOCKED.
|
||||
const WINDSURF_AUTH_TOKEN_URL = 'https://windsurf.com/show-auth-token'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
proxies: Proxy[]
|
||||
@ -193,9 +266,17 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const mode = ref<'single' | 'batch'>('single')
|
||||
type LoginMode = 'google' | 'single' | 'batch'
|
||||
|
||||
const mode = ref<LoginMode>('google')
|
||||
const submitting = ref(false)
|
||||
|
||||
const tokenForm = reactive({
|
||||
token: '',
|
||||
email: '',
|
||||
name: ''
|
||||
})
|
||||
|
||||
const singleForm = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
@ -230,16 +311,29 @@ function handleClose() {
|
||||
singleForm.email = ''
|
||||
singleForm.password = ''
|
||||
singleForm.name = ''
|
||||
tokenForm.token = ''
|
||||
tokenForm.email = ''
|
||||
tokenForm.name = ''
|
||||
batchText.value = ''
|
||||
batchResults.value = []
|
||||
// Reset transient login state so a re-opened modal starts clean even if
|
||||
// the parent dismissed us mid-flight.
|
||||
submitting.value = false
|
||||
mode.value = 'google'
|
||||
}
|
||||
|
||||
function openWindsurfAuthPage() {
|
||||
window.open(WINDSURF_AUTH_TOKEN_URL, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (mode.value === 'single') {
|
||||
if (mode.value === 'google') {
|
||||
await handleTokenLogin()
|
||||
} else if (mode.value === 'single') {
|
||||
await handleSingleLogin()
|
||||
} else {
|
||||
} else if (mode.value === 'batch') {
|
||||
await handleBatchLogin()
|
||||
}
|
||||
} catch (e: any) {
|
||||
@ -249,6 +343,29 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTokenLogin() {
|
||||
if (!tokenForm.token) {
|
||||
appStore.showError(t('admin.windsurf.googleTokenRequired'))
|
||||
throw new Error('token required')
|
||||
}
|
||||
|
||||
const resp = await adminAPI.windsurf.tokenLogin({
|
||||
token: tokenForm.token,
|
||||
email: tokenForm.email || undefined,
|
||||
name: tokenForm.name || tokenForm.email || undefined,
|
||||
proxy_id: commonOpts.proxy_id,
|
||||
group_ids: selectedGroupIds(),
|
||||
concurrency: commonOpts.concurrency,
|
||||
probe_after: commonOpts.probe_after
|
||||
})
|
||||
|
||||
appStore.showSuccess(
|
||||
`${t('admin.windsurf.googleLoginSuccess')} — ${resp.email || resp.account_id} (${resp.tier})`
|
||||
)
|
||||
emit('created')
|
||||
handleClose()
|
||||
}
|
||||
|
||||
async function handleSingleLogin() {
|
||||
const resp = await adminAPI.windsurf.login({
|
||||
email: singleForm.email,
|
||||
|
||||
@ -0,0 +1,144 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import WindsurfLoginModal from '../WindsurfLoginModal.vue'
|
||||
|
||||
const { tokenLoginMock, showSuccessMock, showErrorMock } = vi.hoisted(() => ({
|
||||
tokenLoginMock: vi.fn(),
|
||||
showSuccessMock: vi.fn(),
|
||||
showErrorMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
windsurf: {
|
||||
tokenLogin: tokenLoginMock,
|
||||
login: vi.fn(),
|
||||
batchLogin: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess: showSuccessMock,
|
||||
showError: showErrorMock,
|
||||
showInfo: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
if (params) return `${key}:${JSON.stringify(params)}`
|
||||
return key
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/common/BaseDialog.vue', () => ({
|
||||
default: {
|
||||
props: ['show', 'title', 'width'],
|
||||
emits: ['close'],
|
||||
template: '<div v-if="show" data-test="base-dialog"><slot /></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/icons/GoogleIcon.vue', () => ({
|
||||
default: { template: '<svg data-test="google-icon" />' }
|
||||
}))
|
||||
|
||||
function mountModal() {
|
||||
return mount(WindsurfLoginModal, {
|
||||
props: { show: true, proxies: [], groups: [] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('WindsurfLoginModal token-paste flow', () => {
|
||||
beforeEach(() => {
|
||||
tokenLoginMock.mockReset()
|
||||
showSuccessMock.mockReset()
|
||||
showErrorMock.mockReset()
|
||||
})
|
||||
|
||||
it('defaults to Google tab and renders the three-step token paste UI', () => {
|
||||
const wrapper = mountModal()
|
||||
expect(wrapper.text()).toContain('admin.windsurf.googleLogin')
|
||||
expect(wrapper.text()).toContain('admin.windsurf.googleStep1Title')
|
||||
expect(wrapper.text()).toContain('admin.windsurf.googleStep1Button')
|
||||
expect(wrapper.text()).toContain('admin.windsurf.googleStep2Title')
|
||||
expect(wrapper.text()).toContain('admin.windsurf.googleStep3Title')
|
||||
})
|
||||
|
||||
it('opens the windsurf.com auth-token page in a new tab when the helper button is clicked', async () => {
|
||||
const openSpy = vi.fn()
|
||||
vi.stubGlobal('open', openSpy)
|
||||
|
||||
const wrapper = mountModal()
|
||||
const helperBtn = wrapper.findAll('button').find(b => b.text().includes('admin.windsurf.googleStep1Button'))
|
||||
await helperBtn!.trigger('click')
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://windsurf.com/show-auth-token',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('rejects submit when the token textarea is empty', async () => {
|
||||
const wrapper = mountModal()
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(showErrorMock).toHaveBeenCalledWith('admin.windsurf.googleTokenRequired')
|
||||
expect(tokenLoginMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exchanges the pasted token via tokenLogin and emits created on success', async () => {
|
||||
tokenLoginMock.mockResolvedValue({
|
||||
account_id: 99,
|
||||
platform: 'windsurf',
|
||||
type: 'windsurf-session',
|
||||
email: 'user@example.com',
|
||||
tier: 'pro',
|
||||
auth_method: 'token',
|
||||
api_key_present: true,
|
||||
refresh_token_present: false
|
||||
})
|
||||
|
||||
const wrapper = mountModal()
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue(' windsurf-token-value ')
|
||||
const inputs = wrapper.findAll('input[type="email"], input[type="text"]')
|
||||
await inputs[0].setValue('user@example.com')
|
||||
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(tokenLoginMock).toHaveBeenCalledTimes(1)
|
||||
expect(tokenLoginMock.mock.calls[0][0]).toMatchObject({
|
||||
token: 'windsurf-token-value',
|
||||
email: 'user@example.com'
|
||||
})
|
||||
expect(showSuccessMock).toHaveBeenCalledOnce()
|
||||
expect(wrapper.emitted('created')).toHaveLength(1)
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces backend errors via the toast', async () => {
|
||||
tokenLoginMock.mockRejectedValue({
|
||||
response: { data: { message: 'codeium register (token): upstream 401' } }
|
||||
})
|
||||
|
||||
const wrapper = mountModal()
|
||||
await wrapper.find('textarea').setValue('bad-token')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(showErrorMock).toHaveBeenCalledWith('codeium register (token): upstream 401')
|
||||
})
|
||||
})
|
||||
25
frontend/src/components/icons/GoogleIcon.vue
Normal file
25
frontend/src/components/icons/GoogleIcon.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 18 18"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
d="M17.64 9.20455C17.64 8.56636 17.5827 7.95273 17.4764 7.36364H9V10.845H13.8436C13.635 11.97 13.0009 12.9232 12.0477 13.5614V15.8195H14.9564C16.6582 14.2527 17.64 11.9455 17.64 9.20455Z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M9 18C11.43 18 13.4673 17.1941 14.9564 15.8195L12.0477 13.5614C11.2418 14.1014 10.2109 14.4205 9 14.4205C6.65591 14.4205 4.67182 12.8373 3.96409 10.71H1.03091V12.9845C2.51591 15.9314 5.53909 18 9 18Z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M3.96409 10.71C3.78409 10.17 3.68182 9.59318 3.68182 9C3.68182 8.40682 3.78409 7.83 3.96409 7.29V5.01545H1.03091C0.421364 6.21409 0.0681818 7.56818 0.0681818 9C0.0681818 10.4318 0.421364 11.7859 1.03091 12.9845L3.96409 10.71Z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M9 3.57955C10.3214 3.57955 11.5077 4.03364 12.4405 4.92545L15.0218 2.34409C13.4632 0.891818 11.4259 0 9 0C5.53909 0 2.51591 2.06864 1.03091 5.01545L3.96409 7.29C4.67182 5.16273 6.65591 3.57955 9 3.57955Z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@ -702,6 +702,7 @@ const adminNavItems = computed((): NavItem[] => {
|
||||
const baseItems: NavItem[] = [
|
||||
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||
{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon, featureFlag: flagOpsMonitoring },
|
||||
{ path: '/admin/ops/logs', label: t('nav.opsLogStream'), icon: ChartIcon, featureFlag: flagOpsMonitoring },
|
||||
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
|
||||
{
|
||||
|
||||
@ -362,6 +362,7 @@ export default {
|
||||
proxies: 'Proxies',
|
||||
redeemCodes: 'Redeem Codes',
|
||||
ops: 'Ops',
|
||||
opsLogStream: 'Live Logs',
|
||||
promoCodes: 'Promo Codes',
|
||||
settings: 'Settings',
|
||||
myAccount: 'My Account',
|
||||
@ -4085,6 +4086,39 @@ export default {
|
||||
ops: {
|
||||
title: 'Ops Monitoring',
|
||||
description: 'Operational monitoring and troubleshooting',
|
||||
logStream: {
|
||||
title: 'Live Request Logs',
|
||||
description: 'Subscribe to a real-time SSE stream of every gateway request',
|
||||
empty: 'No logs yet — waiting for new requests or adjust filters',
|
||||
loadError: 'Failed to load live log stream',
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
clearLogs: 'Clear',
|
||||
scrollToBottom: 'Jump to latest',
|
||||
statusBar: 'Showing {shown} · {pending} queued while paused · {dropped} dropped',
|
||||
status: {
|
||||
live: 'Live',
|
||||
connecting: 'Connecting',
|
||||
closed: 'Disconnected',
|
||||
error: 'Error'
|
||||
},
|
||||
filter: {
|
||||
allStatuses: 'All statuses',
|
||||
modelPlaceholder: 'Model (exact)',
|
||||
accountIdPlaceholder: 'Account ID',
|
||||
minLatencyMs: 'Min latency ms'
|
||||
},
|
||||
col: {
|
||||
time: 'Time',
|
||||
method: 'Method',
|
||||
path: 'Path',
|
||||
status: 'Status',
|
||||
latency: 'Latency',
|
||||
model: 'Model',
|
||||
accountId: 'Account',
|
||||
error: 'Error'
|
||||
}
|
||||
},
|
||||
// Dashboard
|
||||
systemHealth: 'System Health',
|
||||
overview: 'Overview',
|
||||
@ -5899,7 +5933,39 @@ export default {
|
||||
},
|
||||
windsurf: {
|
||||
loginTitle: 'Windsurf Account Login',
|
||||
loginDesc: 'Login with email and password for Windsurf',
|
||||
loginDesc: 'Login to Windsurf via Google or email/password',
|
||||
tierAccess: {
|
||||
title: 'Windsurf Model Availability',
|
||||
description: 'Per-model schedulable capacity in the current Windsurf account pool',
|
||||
summary: '{count} accounts considered · {models} models',
|
||||
generatedAt: 'Snapshot at',
|
||||
empty: 'No Windsurf accounts yet. Add accounts and probe them, then come back.',
|
||||
loadError: 'Failed to load tier-access',
|
||||
col: {
|
||||
model: 'Model',
|
||||
provider: 'Provider',
|
||||
flavor: 'Tool-call flavor',
|
||||
distribution: 'Available / Total',
|
||||
free: 'Free',
|
||||
pro: 'Pro',
|
||||
trial: 'Trial',
|
||||
blocked: 'Blocked'
|
||||
},
|
||||
aria: {
|
||||
distribution: 'Distribution: {free} free, {pro} pro, {trial} trial, {blocked} blocked'
|
||||
}
|
||||
},
|
||||
googleLogin: 'Google Login',
|
||||
googleStep1Title: 'Step 1: Sign in to windsurf.com with Google',
|
||||
googleStep1Hint: "Opens the official Windsurf login page in a new tab. The OAuth round-trip happens entirely on windsurf.com (their Firebase apiKey is HTTP-Referer-restricted to *.windsurf.com — direct browser auth from any other host returns 403).",
|
||||
googleStep1Button: 'Open windsurf.com login',
|
||||
googleStep2Title: 'Step 2: Paste the Auth Token shown on windsurf.com',
|
||||
googleStep2Hint: 'The token is only used here to exchange for an api_key. Nothing is forwarded to third parties.',
|
||||
googleStep3Title: 'Step 3 (optional): Email / account name for labelling',
|
||||
googleEmailOptional: 'optional',
|
||||
googleTokenPlaceholder: 'Paste the token shown at windsurf.com/show-auth-token',
|
||||
googleTokenRequired: 'Paste the token from windsurf.com first',
|
||||
googleLoginSuccess: 'Windsurf login successful',
|
||||
singleLogin: 'Single Login',
|
||||
batchLogin: 'Batch Login',
|
||||
email: 'Email',
|
||||
|
||||
@ -362,6 +362,7 @@ export default {
|
||||
proxies: 'IP管理',
|
||||
redeemCodes: '兑换码',
|
||||
ops: '运维监控',
|
||||
opsLogStream: '实时日志',
|
||||
promoCodes: '优惠码',
|
||||
settings: '系统设置',
|
||||
myAccount: '我的账户',
|
||||
@ -4241,6 +4242,39 @@ export default {
|
||||
ops: {
|
||||
title: '运维监控',
|
||||
description: '运维监控与排障',
|
||||
logStream: {
|
||||
title: '实时请求日志',
|
||||
description: '订阅每条网关请求的 SSE 实时流',
|
||||
empty: '暂无日志,请等待新请求或调整过滤器',
|
||||
loadError: '加载实时日志失败',
|
||||
pause: '暂停',
|
||||
resume: '继续',
|
||||
clearLogs: '清空',
|
||||
scrollToBottom: '回到最新',
|
||||
statusBar: '当前显示 {shown} 条,暂停队列 {pending} 条,已丢弃 {dropped} 条',
|
||||
status: {
|
||||
live: '实时',
|
||||
connecting: '连接中',
|
||||
closed: '已断开',
|
||||
error: '错误'
|
||||
},
|
||||
filter: {
|
||||
allStatuses: '全部状态',
|
||||
modelPlaceholder: '模型 (精确匹配)',
|
||||
accountIdPlaceholder: 'Account ID',
|
||||
minLatencyMs: '最小延迟 ms'
|
||||
},
|
||||
col: {
|
||||
time: '时间',
|
||||
method: '方法',
|
||||
path: '路径',
|
||||
status: '状态',
|
||||
latency: '延迟',
|
||||
model: '模型',
|
||||
accountId: '账号',
|
||||
error: '错误'
|
||||
}
|
||||
},
|
||||
// Dashboard
|
||||
systemHealth: '系统健康',
|
||||
overview: '概览',
|
||||
@ -6059,7 +6093,39 @@ export default {
|
||||
},
|
||||
windsurf: {
|
||||
loginTitle: 'Windsurf 账号登录',
|
||||
loginDesc: '使用邮箱和密码登录 Windsurf 账号',
|
||||
loginDesc: '通过 Google 或邮箱密码登录 Windsurf 账号',
|
||||
tierAccess: {
|
||||
title: 'Windsurf 模型可用度',
|
||||
description: '查看每个 Windsurf 模型当前在账号池中的可调度容量',
|
||||
summary: '已统计 {count} 个账号 · 共 {models} 个模型',
|
||||
generatedAt: '快照时间',
|
||||
empty: '暂无 Windsurf 账号或未完成探测,先去添加账号再返回',
|
||||
loadError: '加载 tier-access 失败',
|
||||
col: {
|
||||
model: '模型',
|
||||
provider: '厂商',
|
||||
flavor: '工具调用模式',
|
||||
distribution: '可用 / 总数',
|
||||
free: 'Free',
|
||||
pro: 'Pro',
|
||||
trial: 'Trial',
|
||||
blocked: '受限'
|
||||
},
|
||||
aria: {
|
||||
distribution: '可用分布:Free {free}、Pro {pro}、Trial {trial}、Blocked {blocked}'
|
||||
}
|
||||
},
|
||||
googleLogin: 'Google 登录',
|
||||
googleStep1Title: '第一步:在 windsurf.com 用 Google 账号登录',
|
||||
googleStep1Hint: '点击下方按钮在新标签页打开 Windsurf 官网登录页(OAuth 在 windsurf.com 完成,避开 Referer 限制)',
|
||||
googleStep1Button: '打开 windsurf.com 登录页',
|
||||
googleStep2Title: '第二步:复制官网显示的 Auth Token 粘贴到下方',
|
||||
googleStep2Hint: 'token 仅传给本服务用于兑换 api_key,不会上传给第三方',
|
||||
googleStep3Title: '第三步(可选):填写邮箱和账号名作为标识',
|
||||
googleEmailOptional: '可选',
|
||||
googleTokenPlaceholder: '在此粘贴 windsurf.com/show-auth-token 显示的 token',
|
||||
googleTokenRequired: '请先粘贴从 windsurf.com 获取的 token',
|
||||
googleLoginSuccess: 'Windsurf 登录成功',
|
||||
singleLogin: '单个登录',
|
||||
batchLogin: '批量登录',
|
||||
email: '邮箱',
|
||||
|
||||
@ -358,6 +358,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'admin.ops.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/ops/logs',
|
||||
name: 'AdminOpsLogStream',
|
||||
component: () => import('@/views/admin/ops/OpsLogStreamView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Live Log Stream',
|
||||
titleKey: 'admin.ops.logStream.title',
|
||||
descriptionKey: 'admin.ops.logStream.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'AdminUsers',
|
||||
@ -445,6 +457,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'admin.accounts.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/accounts/tier-access',
|
||||
name: 'AdminWindsurfTierAccess',
|
||||
component: () => import('@/views/admin/WindsurfTierAccessView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Windsurf Tier Access',
|
||||
titleKey: 'admin.windsurf.tierAccess.title',
|
||||
descriptionKey: 'admin.windsurf.tierAccess.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/announcements',
|
||||
name: 'AdminAnnouncements',
|
||||
|
||||
@ -849,6 +849,19 @@ export interface WindsurfBatchLoginRequest {
|
||||
probe_after?: boolean
|
||||
}
|
||||
|
||||
export interface WindsurfTokenLoginRequest {
|
||||
token: string
|
||||
email?: string
|
||||
name?: string
|
||||
notes?: string | null
|
||||
proxy_id?: number | null
|
||||
group_ids?: number[]
|
||||
concurrency?: number
|
||||
priority?: number
|
||||
probe_after?: boolean
|
||||
ls_instance_id?: string
|
||||
}
|
||||
|
||||
export interface WindsurfLoginResponse {
|
||||
account_id: number
|
||||
platform: string
|
||||
@ -911,6 +924,23 @@ export interface WindsurfRuntimeResponse {
|
||||
last_status_refresh_at?: string
|
||||
}
|
||||
|
||||
export interface WindsurfTierAccessRow {
|
||||
model: string
|
||||
provider: string
|
||||
emulation_flavor: string
|
||||
free: number
|
||||
pro: number
|
||||
trial: number
|
||||
blocked: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface WindsurfTierAccessSnapshot {
|
||||
generated_at: string
|
||||
accounts_considered: number
|
||||
rows: WindsurfTierAccessRow[]
|
||||
}
|
||||
|
||||
export interface TempUnschedulableRule {
|
||||
error_code: number
|
||||
keywords: string[]
|
||||
|
||||
@ -22,6 +22,14 @@
|
||||
<PlatformIcon platform="windsurf" size="xs" class="mr-1" />
|
||||
Windsurf
|
||||
</button>
|
||||
<router-link
|
||||
to="/admin/accounts/tier-access"
|
||||
class="btn btn-secondary"
|
||||
:title="t('admin.windsurf.tierAccess.title')"
|
||||
>
|
||||
<PlatformIcon platform="windsurf" size="xs" class="mr-1" />
|
||||
{{ t('admin.windsurf.tierAccess.title') }}
|
||||
</router-link>
|
||||
<button @click="showImportData = true" class="btn btn-secondary">
|
||||
{{ t('admin.accounts.dataImport') }}
|
||||
</button>
|
||||
|
||||
315
frontend/src/views/admin/WindsurfTierAccessView.vue
Normal file
315
frontend/src/views/admin/WindsurfTierAccessView.vue
Normal file
@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #filters>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ t('admin.windsurf.tierAccess.title') }}
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.windsurf.tierAccess.summary', { count: accountsConsidered, models: rows.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="generatedAt" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.windsurf.tierAccess.generatedAt') }}: {{ formattedGeneratedAt }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="loading"
|
||||
@click="reload()"
|
||||
>
|
||||
<span v-if="loading" class="mr-2 inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="loading && rows.length === 0" class="py-12 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="rows.length === 0" class="py-12 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.windsurf.tierAccess.empty') }}
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto" aria-live="polite">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-600">
|
||||
<thead class="bg-gray-50 dark:bg-dark-700">
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
scope="col"
|
||||
:tabindex="isSortable(col.key) ? 0 : -1"
|
||||
:aria-sort="ariaSortFor(col.key)"
|
||||
:class="[
|
||||
'px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 outline-none',
|
||||
isSortable(col.key) ? 'cursor-pointer hover:text-primary-600 focus-visible:ring-2 focus-visible:ring-primary-500' : ''
|
||||
]"
|
||||
@click="setSort(col.key)"
|
||||
@keydown.enter.prevent="setSort(col.key)"
|
||||
@keydown.space.prevent="setSort(col.key)"
|
||||
>
|
||||
{{ t(col.labelKey) }}
|
||||
<span v-if="sortKey === col.key" class="ml-1 text-primary-600 dark:text-primary-400">
|
||||
{{ sortDir === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-600 dark:bg-dark-800">
|
||||
<tr
|
||||
v-for="row in sortedRows"
|
||||
:key="row.model"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-700"
|
||||
>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ row.model }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ row.provider || '—' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
:class="flavorBadgeClass(row.emulation_flavor)"
|
||||
>
|
||||
{{ row.emulation_flavor || 'auto' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-2 w-40 overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600"
|
||||
role="img"
|
||||
:aria-label="ariaForRow(row)"
|
||||
>
|
||||
<template v-for="(seg, idx) in barSegments(row)" :key="idx">
|
||||
<div
|
||||
v-if="seg.width > 0"
|
||||
:class="seg.cls"
|
||||
:style="{ width: seg.width + '%' }"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ row.total }}/{{ row.total + row.blocked }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ row.free }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-primary-600 dark:text-primary-400">
|
||||
{{ row.pro }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
{{ row.trial }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-red-500 dark:text-red-400">
|
||||
{{ row.blocked }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { WindsurfTierAccessRow } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const rows = ref<WindsurfTierAccessRow[]>([])
|
||||
const accountsConsidered = ref(0)
|
||||
const generatedAt = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
type SortKey = 'model' | 'total' | 'free' | 'pro' | 'trial' | 'blocked'
|
||||
type ColumnKey = SortKey | 'provider' | 'flavor' | 'distribution'
|
||||
|
||||
const sortKey = ref<SortKey>('total')
|
||||
const sortDir = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
const columns: { key: ColumnKey; labelKey: string }[] = [
|
||||
{ key: 'model', labelKey: 'admin.windsurf.tierAccess.col.model' },
|
||||
{ key: 'provider', labelKey: 'admin.windsurf.tierAccess.col.provider' },
|
||||
{ key: 'flavor', labelKey: 'admin.windsurf.tierAccess.col.flavor' },
|
||||
{ key: 'distribution', labelKey: 'admin.windsurf.tierAccess.col.distribution' },
|
||||
{ key: 'free', labelKey: 'admin.windsurf.tierAccess.col.free' },
|
||||
{ key: 'pro', labelKey: 'admin.windsurf.tierAccess.col.pro' },
|
||||
{ key: 'trial', labelKey: 'admin.windsurf.tierAccess.col.trial' },
|
||||
{ key: 'blocked', labelKey: 'admin.windsurf.tierAccess.col.blocked' }
|
||||
]
|
||||
|
||||
const sortableKeys: ReadonlySet<ColumnKey> = new Set<ColumnKey>([
|
||||
'model',
|
||||
'total',
|
||||
'free',
|
||||
'pro',
|
||||
'trial',
|
||||
'blocked'
|
||||
])
|
||||
|
||||
function isSortable(key: ColumnKey): boolean {
|
||||
return sortableKeys.has(key)
|
||||
}
|
||||
|
||||
function ariaSortFor(key: ColumnKey): 'ascending' | 'descending' | 'none' {
|
||||
if (!isSortable(key) || sortKey.value !== key) return 'none'
|
||||
return sortDir.value === 'asc' ? 'ascending' : 'descending'
|
||||
}
|
||||
|
||||
const sortedRows = computed(() => {
|
||||
const out = [...rows.value]
|
||||
const k = sortKey.value
|
||||
out.sort((a, b) => {
|
||||
const av = a[k as keyof WindsurfTierAccessRow]
|
||||
const bv = b[k as keyof WindsurfTierAccessRow]
|
||||
let cmp: number
|
||||
if (typeof av === 'number' && typeof bv === 'number') {
|
||||
cmp = av - bv
|
||||
} else {
|
||||
cmp = String(av ?? '').localeCompare(String(bv ?? ''))
|
||||
}
|
||||
return sortDir.value === 'asc' ? cmp : -cmp
|
||||
})
|
||||
return out
|
||||
})
|
||||
|
||||
const formattedGeneratedAt = computed(() => {
|
||||
if (!generatedAt.value) return '—'
|
||||
try {
|
||||
return new Date(generatedAt.value).toLocaleString()
|
||||
} catch {
|
||||
return generatedAt.value
|
||||
}
|
||||
})
|
||||
|
||||
function setSort(key: ColumnKey) {
|
||||
if (!isSortable(key)) return
|
||||
if (sortKey.value === key) {
|
||||
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortKey.value = key as SortKey
|
||||
sortDir.value = 'desc'
|
||||
}
|
||||
}
|
||||
|
||||
interface BarSegment {
|
||||
width: number
|
||||
cls: string
|
||||
}
|
||||
|
||||
// barSegments returns the four-color distribution bar with widths that sum
|
||||
// to exactly 100% (when total > 0). The rounding remainder is absorbed by
|
||||
// the last visible segment so the bar never under- or over-fills due to
|
||||
// Math.round drift across four small percentages.
|
||||
function barSegments(row: WindsurfTierAccessRow): BarSegment[] {
|
||||
const denom = row.total + row.blocked
|
||||
const raw: BarSegment[] = [
|
||||
{ width: pctOf(row.free, denom), cls: 'bg-gray-400 dark:bg-gray-500' },
|
||||
{ width: pctOf(row.pro, denom), cls: 'bg-primary-500' },
|
||||
{ width: pctOf(row.trial, denom), cls: 'bg-emerald-500' },
|
||||
{ width: pctOf(row.blocked, denom), cls: 'bg-red-400 dark:bg-red-500' }
|
||||
]
|
||||
if (denom <= 0) return raw
|
||||
const visible = raw.filter(seg => seg.width > 0)
|
||||
if (visible.length === 0) return raw
|
||||
const summed = visible.reduce((s, seg) => s + seg.width, 0)
|
||||
const drift = 100 - summed
|
||||
if (drift !== 0) {
|
||||
visible[visible.length - 1].width += drift
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function pctOf(part: number, total: number): number {
|
||||
if (total <= 0) return 0
|
||||
return Math.max(0, Math.min(100, Math.round((part / total) * 100)))
|
||||
}
|
||||
|
||||
function flavorBadgeClass(flavor: string): string {
|
||||
switch (flavor) {
|
||||
case 'tool_use':
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300'
|
||||
case 'nlu':
|
||||
return 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-dark-600 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
function ariaForRow(row: WindsurfTierAccessRow): string {
|
||||
return t('admin.windsurf.tierAccess.aria.distribution', {
|
||||
free: row.free,
|
||||
pro: row.pro,
|
||||
trial: row.trial,
|
||||
blocked: row.blocked
|
||||
})
|
||||
}
|
||||
|
||||
let mounted = true
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function reload(showSpinner = true) {
|
||||
if (showSpinner) loading.value = true
|
||||
try {
|
||||
const snap = await adminAPI.windsurf.getTierAccess()
|
||||
if (!mounted) return
|
||||
rows.value = snap.rows ?? []
|
||||
accountsConsidered.value = snap.accounts_considered
|
||||
generatedAt.value = snap.generated_at
|
||||
} catch (e: any) {
|
||||
if (!mounted) return
|
||||
appStore.showError(
|
||||
e?.response?.data?.message || e?.message || t('admin.windsurf.tierAccess.loadError')
|
||||
)
|
||||
} finally {
|
||||
if (mounted) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (!mounted) return
|
||||
if (document.visibilityState === 'visible') {
|
||||
// Tab regained focus — pull a fresh snapshot immediately so admins
|
||||
// returning after an hour don't see stale numbers.
|
||||
reload(false)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reload()
|
||||
// 90s polling — backend cache TTL is 60s, this gives a guaranteed
|
||||
// refresh between polls without thundering the snapshot rebuild.
|
||||
pollTimer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
reload(false)
|
||||
}
|
||||
}, 90_000)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mounted = false
|
||||
if (pollTimer !== null) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
})
|
||||
</script>
|
||||
390
frontend/src/views/admin/ops/OpsLogStreamView.vue
Normal file
390
frontend/src/views/admin/ops/OpsLogStreamView.vue
Normal file
@ -0,0 +1,390 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #filters>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ t('admin.ops.logStream.title') }}
|
||||
</h2>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
statusBadge.cls
|
||||
]"
|
||||
:aria-live="'polite'"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="statusBadge.dot" />
|
||||
{{ t(statusBadge.labelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select v-model.number="filter.min_status" class="input h-8 w-auto text-sm">
|
||||
<option :value="0">{{ t('admin.ops.logStream.filter.allStatuses') }}</option>
|
||||
<option :value="200">≥ 200</option>
|
||||
<option :value="400">≥ 400</option>
|
||||
<option :value="500">≥ 500</option>
|
||||
</select>
|
||||
<input
|
||||
v-model.trim="filter.model"
|
||||
type="text"
|
||||
:placeholder="t('admin.ops.logStream.filter.modelPlaceholder')"
|
||||
class="input h-8 w-40 text-sm"
|
||||
/>
|
||||
<input
|
||||
v-model.number="filter.account_id"
|
||||
type="number"
|
||||
min="0"
|
||||
:placeholder="t('admin.ops.logStream.filter.accountIdPlaceholder')"
|
||||
class="input h-8 w-32 text-sm"
|
||||
/>
|
||||
<input
|
||||
v-model.number="filter.min_latency_ms"
|
||||
type="number"
|
||||
min="0"
|
||||
:placeholder="t('admin.ops.logStream.filter.minLatencyMs')"
|
||||
class="input h-8 w-32 text-sm"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary h-8 px-3 text-sm" @click="applyFilter()">
|
||||
{{ t('common.apply') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary h-8 px-3 text-sm"
|
||||
:class="{ 'text-amber-600': paused }"
|
||||
@click="togglePause"
|
||||
>
|
||||
{{ paused ? t('admin.ops.logStream.resume') : t('admin.ops.logStream.pause') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary h-8 px-3 text-sm" @click="entries = []">
|
||||
{{ t('admin.ops.logStream.clearLogs') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div
|
||||
ref="logBox"
|
||||
class="h-[calc(100vh-280px)] overflow-y-auto rounded border border-gray-200 bg-white text-xs dark:border-dark-600 dark:bg-dark-800"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-600">
|
||||
<thead class="sticky top-0 z-10 bg-gray-50 dark:bg-dark-700">
|
||||
<tr>
|
||||
<th class="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.logStream.col.time') }}
|
||||
</th>
|
||||
<th class="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.logStream.col.method') }}
|
||||
</th>
|
||||
<th class="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.logStream.col.path') }}
|
||||
</th>
|
||||
<th class="px-2 py-1 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.logStream.col.status') }}
|
||||
</th>
|
||||
<th class="px-2 py-1 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.logStream.col.latency') }}
|
||||
</th>
|
||||
<th class="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.logStream.col.model') }}
|
||||
</th>
|
||||
<th class="px-2 py-1 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.logStream.col.accountId') }}
|
||||
</th>
|
||||
<th class="px-2 py-1 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.logStream.col.error') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<tr v-if="entries.length === 0">
|
||||
<td colspan="8" class="px-2 py-6 text-center text-gray-400">
|
||||
{{ t('admin.ops.logStream.empty') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="row in entries"
|
||||
:key="row.key"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-700"
|
||||
>
|
||||
<td class="whitespace-nowrap px-2 py-1 font-mono text-gray-500 dark:text-gray-400">
|
||||
{{ formatTime(row.entry.time) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-2 py-1 font-mono">{{ row.entry.method || '—' }}</td>
|
||||
<td class="px-2 py-1 font-mono text-gray-600 dark:text-gray-300">{{ row.entry.path || '—' }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-1 text-right font-mono" :class="statusColor(row.entry.status)">
|
||||
{{ row.entry.status || '—' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-2 py-1 text-right font-mono" :class="latencyColor(row.entry.latency_ms)">
|
||||
{{ row.entry.latency_ms ?? 0 }}ms
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-2 py-1">{{ row.entry.model || '—' }}</td>
|
||||
<td class="whitespace-nowrap px-2 py-1 text-right font-mono">{{ row.entry.account_id || '—' }}</td>
|
||||
<td class="px-2 py-1 text-red-500 dark:text-red-400">{{ row.entry.error_message || '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>
|
||||
{{ t('admin.ops.logStream.statusBar', {
|
||||
shown: entries.length,
|
||||
pending: pending.length,
|
||||
dropped: stats.droppedTotal
|
||||
}) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="!autoScroll"
|
||||
type="button"
|
||||
class="rounded px-2 py-0.5 text-primary-600 hover:bg-primary-50 dark:hover:bg-dark-700"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
{{ t('admin.ops.logStream.scrollToBottom') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { opsAPI, type OpsLogEntry, type OpsLogFilter, type LogStreamHandle } from '@/api/admin/ops'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const MAX_BUFFERED = 2000
|
||||
const RECONNECT_BASE_MS = 1000
|
||||
const RECONNECT_MAX_MS = 30_000
|
||||
|
||||
interface KeyedEntry {
|
||||
key: number
|
||||
entry: OpsLogEntry
|
||||
}
|
||||
|
||||
const filter = reactive<OpsLogFilter>({
|
||||
min_status: 0,
|
||||
model: '',
|
||||
account_id: 0,
|
||||
min_latency_ms: 0
|
||||
})
|
||||
|
||||
let nextEntryKey = 0
|
||||
function wrap(entry: OpsLogEntry): KeyedEntry {
|
||||
return { key: ++nextEntryKey, entry }
|
||||
}
|
||||
|
||||
const entries = ref<KeyedEntry[]>([])
|
||||
const paused = ref(false)
|
||||
const pending = ref<KeyedEntry[]>([])
|
||||
const stats = reactive({ droppedTotal: 0 })
|
||||
const status = ref<'connecting' | 'live' | 'closed' | 'error'>('connecting')
|
||||
const autoScroll = ref(true)
|
||||
const logBox = ref<HTMLElement | null>(null)
|
||||
|
||||
const statusBadge = computed(() => {
|
||||
switch (status.value) {
|
||||
case 'live':
|
||||
return {
|
||||
cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
|
||||
dot: 'bg-emerald-500',
|
||||
labelKey: 'admin.ops.logStream.status.live'
|
||||
}
|
||||
case 'connecting':
|
||||
return {
|
||||
cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
||||
dot: 'bg-amber-500 animate-pulse',
|
||||
labelKey: 'admin.ops.logStream.status.connecting'
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
cls: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||
dot: 'bg-red-500',
|
||||
labelKey: 'admin.ops.logStream.status.error'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
cls: 'bg-gray-100 text-gray-600 dark:bg-dark-600 dark:text-gray-300',
|
||||
dot: 'bg-gray-400',
|
||||
labelKey: 'admin.ops.logStream.status.closed'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let handle: LogStreamHandle | null = null
|
||||
let primeAbort: AbortController | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectAttempt = 0
|
||||
let mounted = true
|
||||
|
||||
function formatTime(timeStr: string | undefined): string {
|
||||
if (!timeStr) return '—'
|
||||
try {
|
||||
const d = new Date(timeStr)
|
||||
return d.toLocaleTimeString()
|
||||
} catch {
|
||||
return timeStr
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(s: number): string {
|
||||
if (!s) return ''
|
||||
if (s >= 500) return 'text-red-600 dark:text-red-400'
|
||||
if (s >= 400) return 'text-amber-600 dark:text-amber-400'
|
||||
if (s >= 300) return 'text-blue-600 dark:text-blue-400'
|
||||
return 'text-emerald-600 dark:text-emerald-400'
|
||||
}
|
||||
|
||||
function latencyColor(ms: number): string {
|
||||
if (ms >= 5000) return 'text-red-600 dark:text-red-400'
|
||||
if (ms >= 2000) return 'text-amber-600 dark:text-amber-400'
|
||||
return 'text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
|
||||
function clampBuffer() {
|
||||
if (entries.value.length > MAX_BUFFERED) {
|
||||
entries.value = entries.value.slice(entries.value.length - MAX_BUFFERED)
|
||||
}
|
||||
}
|
||||
|
||||
function pushEntry(e: OpsLogEntry) {
|
||||
const wrapped = wrap(e)
|
||||
if (paused.value) {
|
||||
pending.value.push(wrapped)
|
||||
if (pending.value.length > MAX_BUFFERED) pending.value.shift()
|
||||
return
|
||||
}
|
||||
entries.value.push(wrapped)
|
||||
clampBuffer()
|
||||
if (autoScroll.value) {
|
||||
nextTick(() => {
|
||||
if (!mounted || !logBox.value) return
|
||||
logBox.value.scrollTop = logBox.value.scrollHeight
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
paused.value = !paused.value
|
||||
if (!paused.value && pending.value.length > 0) {
|
||||
entries.value.push(...pending.value)
|
||||
pending.value = []
|
||||
clampBuffer()
|
||||
if (autoScroll.value) {
|
||||
nextTick(() => {
|
||||
if (!mounted || !logBox.value) return
|
||||
logBox.value.scrollTop = logBox.value.scrollHeight
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll(e: Event) {
|
||||
const el = e.target as HTMLElement
|
||||
// Treat being within 80px of the bottom as "at bottom" so auto-scroll
|
||||
// resumes naturally when the user catches up.
|
||||
autoScroll.value = el.scrollHeight - el.scrollTop - el.clientHeight < 80
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!logBox.value) return
|
||||
logBox.value.scrollTop = logBox.value.scrollHeight
|
||||
autoScroll.value = true
|
||||
}
|
||||
|
||||
async function primeFromRecent() {
|
||||
// Abort any in-flight priming fetch — without this a rapid filter reapply
|
||||
// can race two fetches and let the older one overwrite newer state.
|
||||
if (primeAbort) primeAbort.abort()
|
||||
primeAbort = new AbortController()
|
||||
const ctrl = primeAbort
|
||||
try {
|
||||
const resp = await opsAPI.getRecentOpsLogs(buildFilter(), 200)
|
||||
if (!mounted || ctrl !== primeAbort) return
|
||||
entries.value = resp.entries.map(wrap)
|
||||
stats.droppedTotal = resp.dropped_total
|
||||
} catch (e: any) {
|
||||
if (!mounted || ctrl !== primeAbort) return
|
||||
appStore.showError(e?.response?.data?.message || e?.message || t('admin.ops.logStream.loadError'))
|
||||
}
|
||||
}
|
||||
|
||||
function buildFilter(): OpsLogFilter {
|
||||
const out: OpsLogFilter = {}
|
||||
if (filter.min_status && filter.min_status > 0) out.min_status = filter.min_status
|
||||
if (filter.model && filter.model.trim()) out.model = filter.model.trim()
|
||||
if (filter.account_id && filter.account_id > 0) out.account_id = filter.account_id
|
||||
if (filter.min_latency_ms && filter.min_latency_ms > 0) out.min_latency_ms = filter.min_latency_ms
|
||||
return out
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
reconnectAttempt = 0
|
||||
reconnect()
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!mounted) return
|
||||
if (reconnectTimer) return
|
||||
const delay = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_MAX_MS)
|
||||
reconnectAttempt = Math.min(reconnectAttempt + 1, 10)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
if (!mounted) return
|
||||
reconnect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
closeStream()
|
||||
void primeFromRecent()
|
||||
handle = opsAPI.subscribeOpsLogStream(buildFilter(), {
|
||||
onEntry: pushEntry,
|
||||
onStatus: s => {
|
||||
status.value = s
|
||||
if (s === 'live') {
|
||||
reconnectAttempt = 0
|
||||
}
|
||||
if (s === 'closed' || s === 'error') {
|
||||
scheduleReconnect()
|
||||
}
|
||||
},
|
||||
onError: e => {
|
||||
if (!mounted) return
|
||||
appStore.showError(e.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function closeStream() {
|
||||
if (handle) {
|
||||
handle.close()
|
||||
handle = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
reconnect()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mounted = false
|
||||
if (primeAbort) primeAbort.abort()
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
closeStream()
|
||||
})
|
||||
</script>
|
||||
Loading…
x
Reference in New Issue
Block a user