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:
win 2026-05-09 00:41:19 +08:00
parent 3fe228d143
commit de048fad25
46 changed files with 4091 additions and 47 deletions

View File

@ -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)

View File

@ -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 (

View File

@ -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.

View 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
}

View File

@ -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{

View File

@ -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)
}

View File

@ -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"`

View 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
}

View File

@ -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

View File

@ -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)

View File

@ -140,13 +140,19 @@ func GetClientCredentials(isEnterprise bool) (ClientCredentials, error) {
}
// BaseURLsForAccount 根据 isGcpTos 返回有序 URL 列表。
// 企业账号isGcpTos=true优先走 prod个人账号优先走 daily与真实 IDE 一致)。
// sandbox 作为最后兜底,仅在 prod/daily 都不可用时使用。
//
// - 企业账号isGcpTos=trueprod → daily → sandbox
// 企业账号拥有 GCP Workspace 权限,可访问真实 dailydaily-cloudcode-pa.googleapis.com
//
// - 个人账号isGcpTos=falsesandbox → prod
// 个人免费账号无权访问真实 daily该端点对个人账号会直接返回 429 RESOURCE_EXHAUSTED。
// sandboxdaily-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) {

View File

@ -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}

View 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
}

View 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)
}
}
}

View File

@ -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()))}
}

View File

@ -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",

View 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 ""
}

View 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)
}
})
}
}

View File

@ -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 服务器

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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))

View File

@ -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" {
// 注意:真实 dailydaily-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"
}

View 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++
}
}

View 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):
}
}

View File

@ -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 {

View 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)
}
})
}
}

View 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)
}
})
}
}

View File

@ -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))

View 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++
}
}

View 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
}

View File

@ -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 {

View File

@ -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 }
}

View File

@ -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

View File

@ -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,

View File

@ -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')
})
})

View 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>

View File

@ -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 },
{

View File

@ -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',

View File

@ -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: '邮箱',

View File

@ -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',

View File

@ -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[]

View File

@ -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>

View 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>

View 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>