feat(windsurf): 补全ops日志记录与endpoint派生,对齐其他平台
- windsurf_gateway_service: 添加上游延迟/TTFT/错误上下文记录 - endpoint: DeriveUpstreamEndpoint 添加 PlatformWindsurf 分支 - ops_error_logger: guessPlatformFromPath 添加 /windsurf/ 识别
This commit is contained in:
parent
ff7eab0392
commit
21325afb33
@ -7,7 +7,7 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
ARG NODE_IMAGE=node:24-alpine
|
ARG NODE_IMAGE=node:24-alpine
|
||||||
ARG GOLANG_IMAGE=golang:1.25-alpine
|
ARG GOLANG_IMAGE=golang:1.26-alpine
|
||||||
ARG ALPINE_IMAGE=alpine:3.21
|
ARG ALPINE_IMAGE=alpine:3.21
|
||||||
ARG POSTGRES_IMAGE=postgres:18-alpine
|
ARG POSTGRES_IMAGE=postgres:18-alpine
|
||||||
ARG GOPROXY=https://goproxy.cn,direct
|
ARG GOPROXY=https://goproxy.cn,direct
|
||||||
|
|||||||
@ -97,6 +97,7 @@ func provideCleanup(
|
|||||||
scheduledTestRunner *service.ScheduledTestRunnerService,
|
scheduledTestRunner *service.ScheduledTestRunnerService,
|
||||||
backupSvc *service.BackupService,
|
backupSvc *service.BackupService,
|
||||||
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
||||||
|
windsurfRefresh *service.WindsurfRefreshService,
|
||||||
) func() {
|
) func() {
|
||||||
return func() {
|
return func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@ -239,6 +240,12 @@ func provideCleanup(
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}},
|
}},
|
||||||
|
{"WindsurfRefreshService", func() error {
|
||||||
|
if windsurfRefresh != nil {
|
||||||
|
windsurfRefresh.Stop()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
infraSteps := []cleanupStep{
|
infraSteps := []cleanupStep{
|
||||||
|
|||||||
@ -144,7 +144,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oAuthRefreshAPI, tempUnschedCache)
|
antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oAuthRefreshAPI, tempUnschedCache)
|
||||||
internal500CounterCache := repository.NewInternal500CounterCache(redisClient)
|
internal500CounterCache := repository.NewInternal500CounterCache(redisClient)
|
||||||
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache)
|
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache)
|
||||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
windsurfLSService := service.ProvideWindsurfLSService(configConfig)
|
||||||
|
windsurfTokenProvider := service.ProvideWindsurfTokenProvider(configConfig, accountRepository, proxyRepository)
|
||||||
|
windsurfChatService := service.ProvideWindsurfChatService(configConfig, windsurfLSService, windsurfTokenProvider)
|
||||||
|
windsurfGatewayService := service.ProvideWindsurfGatewayService(configConfig, windsurfChatService, accountRepository)
|
||||||
|
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, windsurfChatService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
||||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||||
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
|
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
|
||||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||||
@ -221,11 +225,15 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService)
|
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService)
|
||||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
|
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
|
||||||
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
|
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
|
||||||
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, paymentHandler)
|
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)
|
||||||
|
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, paymentHandler, windsurfHandler)
|
||||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, userMessageQueueService, configConfig, settingService)
|
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, windsurfGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, userMessageQueueService, configConfig, settingService)
|
||||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, configConfig)
|
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, configConfig)
|
||||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
||||||
totpHandler := handler.NewTotpHandler(totpService)
|
totpHandler := handler.NewTotpHandler(totpService)
|
||||||
@ -250,7 +258,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
|
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
|
||||||
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
|
||||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService)
|
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, windsurfRefreshService)
|
||||||
application := &Application{
|
application := &Application{
|
||||||
Server: httpServer,
|
Server: httpServer,
|
||||||
Cleanup: v,
|
Cleanup: v,
|
||||||
@ -304,6 +312,7 @@ func provideCleanup(
|
|||||||
scheduledTestRunner *service.ScheduledTestRunnerService,
|
scheduledTestRunner *service.ScheduledTestRunnerService,
|
||||||
backupSvc *service.BackupService,
|
backupSvc *service.BackupService,
|
||||||
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
paymentOrderExpiry *service.PaymentOrderExpiryService,
|
||||||
|
windsurfRefresh *service.WindsurfRefreshService,
|
||||||
) func() {
|
) func() {
|
||||||
return func() {
|
return func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@ -445,6 +454,12 @@ func provideCleanup(
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}},
|
}},
|
||||||
|
{"WindsurfRefreshService", func() error {
|
||||||
|
if windsurfRefresh != nil {
|
||||||
|
windsurfRefresh.Stop()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
infraSteps := []cleanupStep{
|
infraSteps := []cleanupStep{
|
||||||
|
|||||||
@ -76,6 +76,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
|||||||
nil, // scheduledTestRunner
|
nil, // scheduledTestRunner
|
||||||
nil, // backupSvc
|
nil, // backupSvc
|
||||||
nil, // paymentOrderExpiry
|
nil, // paymentOrderExpiry
|
||||||
|
nil, // windsurfRefresh
|
||||||
)
|
)
|
||||||
|
|
||||||
require.NotPanics(t, func() {
|
require.NotPanics(t, func() {
|
||||||
|
|||||||
@ -206,7 +206,7 @@ func testOAuthTokenRefresh(ctx context.Context, refreshToken string) (bool, stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
tokenInfo, err := client.RefreshToken(ctx, refreshToken)
|
tokenInfo, err := client.RefreshToken(ctx, refreshToken, false)
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
501
backend/cmd/test_windsurf_minimal/main.go
Normal file
501
backend/cmd/test_windsurf_minimal/main.go
Normal file
@ -0,0 +1,501 @@
|
|||||||
|
// test_windsurf_minimal validates the Windsurf Cascade chat flow end-to-end:
|
||||||
|
//
|
||||||
|
// 1. JWT decode (local)
|
||||||
|
// 2. GetUserStatus (resolve user_id/team_id)
|
||||||
|
// 3. CheckChatCapacity
|
||||||
|
// 4. GetCascadeModelConfigs (pick cheapest non-BYOK model)
|
||||||
|
// 5. CascadeChat via local LS:
|
||||||
|
// a. WarmupCascade (InitializeCascadePanelState + AddTrackedWorkspace + UpdateWorkspaceTrust)
|
||||||
|
// b. StartCascade → cascade_id
|
||||||
|
// c. SendUserCascadeMessage
|
||||||
|
// d. Poll GetCascadeTrajectorySteps until IDLE
|
||||||
|
// 6. Completeness check (non-empty text)
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// WINDSURF_JWT="devin-session-token$xxx.yyy.zzz" \
|
||||||
|
// WINDSURF_CSRF_TOKEN="..." \
|
||||||
|
// go run ./cmd/test_windsurf_minimal -verbose
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cliFlags struct {
|
||||||
|
jwt string
|
||||||
|
baseURL string
|
||||||
|
model string
|
||||||
|
prompt string
|
||||||
|
proxy string
|
||||||
|
verbose bool
|
||||||
|
timeout time.Duration
|
||||||
|
userID string
|
||||||
|
teamID string
|
||||||
|
csrfToken string
|
||||||
|
lsPort int
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlags() cliFlags {
|
||||||
|
var f cliFlags
|
||||||
|
flag.StringVar(&f.jwt, "jwt", os.Getenv("WINDSURF_JWT"),
|
||||||
|
"full session token (e.g. devin-session-token$eyJ...). Defaults to $WINDSURF_JWT")
|
||||||
|
flag.StringVar(&f.baseURL, "base-url", envOr("WINDSURF_BASE_URL", windsurf.DefaultBaseURL),
|
||||||
|
"upstream base URL")
|
||||||
|
flag.StringVar(&f.model, "model", "",
|
||||||
|
"modelUid to use (e.g. claude-opus-4-7-medium); empty = pick cheapest from ListModels")
|
||||||
|
flag.StringVar(&f.prompt, "prompt", "Say hello in 3 words.",
|
||||||
|
"user prompt")
|
||||||
|
flag.StringVar(&f.proxy, "proxy", os.Getenv("HTTPS_PROXY"),
|
||||||
|
"optional HTTP proxy URL (mitm capture)")
|
||||||
|
flag.BoolVar(&f.verbose, "verbose", false, "print extra dump info")
|
||||||
|
flag.DurationVar(&f.timeout, "timeout", 90*time.Second, "per-step timeout")
|
||||||
|
flag.StringVar(&f.userID, "user-id", os.Getenv("WINDSURF_USER_ID"),
|
||||||
|
"metadata F20 user-XXX (from userStatus proto)")
|
||||||
|
flag.StringVar(&f.teamID, "team-id", os.Getenv("WINDSURF_TEAM_ID"),
|
||||||
|
"metadata F32 devin-team$account-XXX (from userStatus proto)")
|
||||||
|
flag.StringVar(&f.csrfToken, "csrf-token", os.Getenv("WINDSURF_CSRF_TOKEN"),
|
||||||
|
"x-codeium-csrf-token header value (WINDSURF_CSRF_TOKEN env or from LS process args)")
|
||||||
|
flag.IntVar(&f.lsPort, "ls-port", envInt("WINDSURF_LS_PORT", 0),
|
||||||
|
"local LanguageServerService gRPC port (0 = auto-detect)")
|
||||||
|
flag.Parse()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(k, def string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(k string, def int) int {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
var n int
|
||||||
|
if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
type stepResult struct {
|
||||||
|
name string
|
||||||
|
ok bool
|
||||||
|
detail string
|
||||||
|
elapsed time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
f := parseFlags()
|
||||||
|
if strings.TrimSpace(f.jwt) == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "ERROR: -jwt or WINDSURF_JWT required (full token incl. devin-session-token$ prefix)")
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := windsurf.NewClient(f.baseURL, f.proxy, f.csrfToken)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "ERROR build client:", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect CSRF token if not provided
|
||||||
|
if f.csrfToken == "" {
|
||||||
|
f.csrfToken = detectLSCSRF()
|
||||||
|
if f.verbose && f.csrfToken != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, " auto-detected CSRF token: %s\n", f.csrfToken[:8]+"...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]stepResult, 0, 8)
|
||||||
|
pickedModel := f.model
|
||||||
|
userID := f.userID
|
||||||
|
teamID := f.teamID
|
||||||
|
|
||||||
|
// ── Step 1: JWT decode ────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
t0 := time.Now()
|
||||||
|
claims, err := windsurf.DecodeJWTClaims(f.jwt)
|
||||||
|
el := time.Since(t0)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, stepResult{"JWT 解码", false, err.Error(), el})
|
||||||
|
printResults(results)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
expStr := "(no exp)"
|
||||||
|
expired := false
|
||||||
|
if claims.Exp > 0 {
|
||||||
|
expStr = time.Unix(claims.Exp, 0).Format(time.RFC3339)
|
||||||
|
if claims.Exp <= now {
|
||||||
|
expired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if userID == "" {
|
||||||
|
userID = claims.UserID
|
||||||
|
}
|
||||||
|
if teamID == "" {
|
||||||
|
teamID = claims.TeamID
|
||||||
|
}
|
||||||
|
detail := fmt.Sprintf("session_id=%s user_id=%s team_id=%s exp=%s",
|
||||||
|
elide(claims.SessionID, 20), claims.UserID, claims.TeamID, expStr)
|
||||||
|
if expired {
|
||||||
|
results = append(results, stepResult{"JWT 解码", false, detail + " (EXPIRED)", el})
|
||||||
|
printResults(results)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
results = append(results, stepResult{"JWT 解码", true, detail, el})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: GetUserStatus ─────────────────────────────────────────────
|
||||||
|
if userID == "" || teamID == "" {
|
||||||
|
t0 := time.Now()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
|
||||||
|
us, err := client.GetUserStatus(ctx, f.jwt)
|
||||||
|
cancel()
|
||||||
|
el := time.Since(t0)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, stepResult{"GetUserStatus", false, err.Error(), el})
|
||||||
|
printResults(results)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if userID == "" {
|
||||||
|
userID = us.UserID
|
||||||
|
}
|
||||||
|
if teamID == "" {
|
||||||
|
teamID = us.TeamID
|
||||||
|
}
|
||||||
|
detail := fmt.Sprintf("user_id=%s team_id=%s", elide(userID, 30), elide(teamID, 40))
|
||||||
|
results = append(results, stepResult{"GetUserStatus", true, detail, el})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: CheckChatCapacity ─────────────────────────────────────────
|
||||||
|
{
|
||||||
|
t0 := time.Now()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
|
||||||
|
hasCap, raw, err := client.CheckChatCapacity(ctx, f.jwt)
|
||||||
|
cancel()
|
||||||
|
el := time.Since(t0)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, stepResult{"CheckChatCapacity", false, err.Error(), el})
|
||||||
|
printResults(results)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
detail := fmt.Sprintf("hasCapacity=%v raw=%s", hasCap, raw)
|
||||||
|
results = append(results, stepResult{"CheckChatCapacity", hasCap, detail, el})
|
||||||
|
if !hasCap {
|
||||||
|
printResults(results)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4: List models ───────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
t0 := time.Now()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
|
||||||
|
models, err := client.ListModels(ctx, f.jwt)
|
||||||
|
cancel()
|
||||||
|
el := time.Since(t0)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, stepResult{"GetCascadeModelConfigs", false, err.Error(), el})
|
||||||
|
printResults(results)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if len(models) == 0 {
|
||||||
|
results = append(results, stepResult{"GetCascadeModelConfigs", false, "no models returned", el})
|
||||||
|
printResults(results)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if pickedModel == "" {
|
||||||
|
pickedModel = pickCheapest(models)
|
||||||
|
} else if !windsurf.HasModel(models, pickedModel) {
|
||||||
|
results = append(results, stepResult{"GetCascadeModelConfigs", false,
|
||||||
|
fmt.Sprintf("requested model %q not in catalog", pickedModel), el})
|
||||||
|
printResults(results)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
detail := fmt.Sprintf("got %d models, picked: %s", len(models), pickedModel)
|
||||||
|
if f.verbose {
|
||||||
|
detail += "\n Top 5 by multiplier:"
|
||||||
|
for i, m := range topNCheapest(models, 5) {
|
||||||
|
detail += fmt.Sprintf("\n [%d] %-40s ×%-5g %s", i+1, m.ModelUID, m.CreditMultiplier, m.Label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, stepResult{"GetCascadeModelConfigs", true, detail, el})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 5: Cascade chat via local LS ────────────────────────────────
|
||||||
|
finalText := ""
|
||||||
|
{
|
||||||
|
t0 := time.Now()
|
||||||
|
|
||||||
|
lsPort := f.lsPort
|
||||||
|
if lsPort == 0 {
|
||||||
|
lsPort = detectLSPort()
|
||||||
|
}
|
||||||
|
if lsPort == 0 {
|
||||||
|
results = append(results, stepResult{"CascadeChat", false,
|
||||||
|
"no local LS port found; set WINDSURF_LS_PORT or -ls-port", time.Since(t0)})
|
||||||
|
printResults(results)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
lsClient := windsurf.NewLocalLSClient(lsPort, f.csrfToken)
|
||||||
|
|
||||||
|
// Warmup
|
||||||
|
{
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
|
||||||
|
_ = lsClient.WarmupCascade(ctx, f.jwt)
|
||||||
|
cancel()
|
||||||
|
results = append(results, stepResult{"WarmupCascade", true,
|
||||||
|
fmt.Sprintf("ls_port=%d session=%s", lsPort, lsClient.SessionID[:8]), time.Since(t0)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartCascade
|
||||||
|
var cascadeID string
|
||||||
|
{
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
|
||||||
|
cid, err := lsClient.StartCascade(ctx, f.jwt)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, stepResult{"StartCascade", false, err.Error(), time.Since(t0)})
|
||||||
|
printResults(results)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
cascadeID = cid
|
||||||
|
results = append(results, stepResult{"StartCascade", true,
|
||||||
|
fmt.Sprintf("cascade_id=%s", cid), time.Since(t0)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendUserCascadeMessage
|
||||||
|
{
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
|
||||||
|
newCID, err := lsClient.SendUserCascadeMessage(ctx, f.jwt, cascadeID, f.prompt, pickedModel, "")
|
||||||
|
if err == nil && newCID != "" {
|
||||||
|
cascadeID = newCID
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, stepResult{"SendCascadeMsg", false, err.Error(), time.Since(t0)})
|
||||||
|
printResults(results)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
results = append(results, stepResult{"SendCascadeMsg", true,
|
||||||
|
fmt.Sprintf("model=%s prompt_len=%d", pickedModel, len(f.prompt)), time.Since(t0)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll trajectory steps until IDLE
|
||||||
|
t0Chat := time.Now()
|
||||||
|
ttft := time.Duration(0)
|
||||||
|
firstText := true
|
||||||
|
seenSteps := 0
|
||||||
|
deadline := time.Now().Add(f.timeout)
|
||||||
|
sawActive := false
|
||||||
|
graceEnd := time.Now().Add(8 * time.Second)
|
||||||
|
idleCount := 0
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
steps, err := lsClient.GetTrajectorySteps(ctx, cascadeID, 0)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
if f.verbose {
|
||||||
|
fmt.Fprintf(os.Stderr, " GetTrajectorySteps err: %v\n", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx, s := range steps {
|
||||||
|
if s.Text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if idx >= seenSteps {
|
||||||
|
if firstText {
|
||||||
|
ttft = time.Since(t0Chat)
|
||||||
|
firstText = false
|
||||||
|
}
|
||||||
|
if s.Type == 17 { // error step
|
||||||
|
if f.verbose {
|
||||||
|
fmt.Fprintf(os.Stderr, " error step[%d]: %s\n", idx, elide(s.Text, 100))
|
||||||
|
}
|
||||||
|
if strings.Contains(s.Text, "rate limit") {
|
||||||
|
finalText = "(rate-limited: " + elide(s.Text, 80) + ")"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalText += s.Text
|
||||||
|
if f.verbose {
|
||||||
|
fmt.Fprintf(os.Stderr, " step[%d] type=%d status=%d text=%q\n",
|
||||||
|
idx, s.Type, s.Status, elide(s.Text, 60))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seenSteps = idx + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
status, err := lsClient.GetTrajectoryStatus(ctx2, cascadeID)
|
||||||
|
cancel2()
|
||||||
|
if f.verbose {
|
||||||
|
fmt.Fprintf(os.Stderr, " trajectory status=%d err=%v steps_so_far=%d\n", status, err, seenSteps)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if status != 0 && status != 1 && status != 2 {
|
||||||
|
sawActive = true
|
||||||
|
}
|
||||||
|
if status == 1 || status == 2 { // IDLE
|
||||||
|
if !sawActive && time.Now().Before(graceEnd) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idleCount++
|
||||||
|
if (finalText != "" && idleCount >= 2) || (finalText == "" && idleCount >= 4) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sawActive = true
|
||||||
|
idleCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el := time.Since(t0)
|
||||||
|
detail := fmt.Sprintf("steps=%d TTFT=%v text_len=%d", seenSteps, ttft.Round(time.Millisecond), len(finalText))
|
||||||
|
results = append(results, stepResult{"CascadeChat 轨迹", finalText != "", detail, el})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 6: Completeness ──────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
t0 := time.Now()
|
||||||
|
var problems []string
|
||||||
|
if strings.TrimSpace(finalText) == "" {
|
||||||
|
problems = append(problems, "empty text")
|
||||||
|
}
|
||||||
|
ok := len(problems) == 0
|
||||||
|
detail := "all checks passed"
|
||||||
|
if !ok {
|
||||||
|
detail = strings.Join(problems, ", ")
|
||||||
|
}
|
||||||
|
results = append(results, stepResult{"完整性校验", ok, detail, time.Since(t0)})
|
||||||
|
}
|
||||||
|
|
||||||
|
printResults(results)
|
||||||
|
if finalText != "" {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("─── 模型回复 ───")
|
||||||
|
fmt.Println(finalText)
|
||||||
|
}
|
||||||
|
if !allPassed(results) {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printResults(rs []stepResult) {
|
||||||
|
fmt.Println()
|
||||||
|
for i, r := range rs {
|
||||||
|
mark := "✅"
|
||||||
|
if !r.ok {
|
||||||
|
mark = "❌"
|
||||||
|
}
|
||||||
|
fmt.Printf("[%d/%d] %-26s %s %-7s %s\n", i+1, len(rs), r.name, mark, r.elapsed.Round(time.Millisecond), r.detail)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
if allPassed(rs) {
|
||||||
|
fmt.Println("✅ 全部通过")
|
||||||
|
} else {
|
||||||
|
fmt.Println("❌ 有步骤失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allPassed(rs []stepResult) bool {
|
||||||
|
if len(rs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range rs {
|
||||||
|
if !r.ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func elide(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickCheapest(models []windsurf.ModelInfo) string {
|
||||||
|
if len(models) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
best := models[0]
|
||||||
|
for _, m := range models[1:] {
|
||||||
|
if strings.Contains(strings.ToLower(m.ModelUID), "byok") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.CreditMultiplier > 0 && m.CreditMultiplier < best.CreditMultiplier {
|
||||||
|
best = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best.ModelUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func topNCheapest(models []windsurf.ModelInfo, n int) []windsurf.ModelInfo {
|
||||||
|
cp := make([]windsurf.ModelInfo, 0, len(models))
|
||||||
|
for _, m := range models {
|
||||||
|
if strings.Contains(strings.ToLower(m.ModelUID), "byok") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cp = append(cp, m)
|
||||||
|
}
|
||||||
|
for i := 0; i < len(cp) && i < n; i++ {
|
||||||
|
minIdx := i
|
||||||
|
for j := i + 1; j < len(cp); j++ {
|
||||||
|
if cp[j].CreditMultiplier > 0 && cp[j].CreditMultiplier < cp[minIdx].CreditMultiplier {
|
||||||
|
minIdx = j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cp[i], cp[minIdx] = cp[minIdx], cp[i]
|
||||||
|
}
|
||||||
|
if len(cp) < n {
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
return cp[:n]
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectLSPort finds the local Windsurf LS gRPC port using lsof.
|
||||||
|
func detectLSPort() int {
|
||||||
|
cmd := exec.Command("sh", "-c",
|
||||||
|
`pgrep -f 'Windsurf.app.*language_server' 2>/dev/null | xargs -I{} lsof -p {} 2>/dev/null | awk '/LISTEN/{print $9}' | grep -oE '[0-9]+$' | head -1`)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil || len(out) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var port int
|
||||||
|
if _, err := fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &port); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectLSCSRF finds the CSRF token for the Windsurf LS serving the current workspace.
|
||||||
|
func detectLSCSRF() string {
|
||||||
|
cmd := exec.Command("sh", "-c",
|
||||||
|
`pgrep -f 'Windsurf.app.*language_server' 2>/dev/null | while read pid; do grep -z WINDSURF_CSRF_TOKEN /proc/$pid/environ 2>/dev/null || ps eww -p $pid 2>/dev/null | tr ' ' '\n' | grep WINDSURF_CSRF_TOKEN; done | grep -oE '[0-9a-f-]{36}' | head -1`)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil || len(out) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
@ -86,7 +86,7 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/docker v28.5.1+incompatible // indirect
|
github.com/docker/docker v28.5.2+incompatible // indirect
|
||||||
github.com/docker/go-connections v0.6.0 // indirect
|
github.com/docker/go-connections v0.6.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
|||||||
@ -110,6 +110,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
|||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
|
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
|
||||||
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||||
|
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
|||||||
@ -87,6 +87,7 @@ type Config struct {
|
|||||||
RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
|
RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
|
||||||
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
|
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
|
||||||
Gemini GeminiConfig `mapstructure:"gemini"`
|
Gemini GeminiConfig `mapstructure:"gemini"`
|
||||||
|
Windsurf WindsurfConfig `mapstructure:"windsurf"`
|
||||||
Update UpdateConfig `mapstructure:"update"`
|
Update UpdateConfig `mapstructure:"update"`
|
||||||
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
|
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
|
||||||
}
|
}
|
||||||
@ -1835,6 +1836,45 @@ func setDefaults() {
|
|||||||
viper.SetDefault("gemini.oauth.scopes", "")
|
viper.SetDefault("gemini.oauth.scopes", "")
|
||||||
viper.SetDefault("gemini.quota.policy", "")
|
viper.SetDefault("gemini.quota.policy", "")
|
||||||
|
|
||||||
|
// Windsurf - configure via environment variables or config file
|
||||||
|
viper.SetDefault("windsurf.enabled", false)
|
||||||
|
viper.SetDefault("windsurf.firebase_api_key", "")
|
||||||
|
viper.SetDefault("windsurf.auth1_base_url", "https://windsurf.com")
|
||||||
|
viper.SetDefault("windsurf.seat_service_base_url", "https://server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService")
|
||||||
|
viper.SetDefault("windsurf.codeium_register_url", "https://api.codeium.com/register_user/")
|
||||||
|
viper.SetDefault("windsurf.user_status_base_url", "https://server.codeium.com")
|
||||||
|
viper.SetDefault("windsurf.ls_mode", "docker")
|
||||||
|
viper.SetDefault("windsurf.request_timeout", "60s")
|
||||||
|
viper.SetDefault("windsurf.startup_timeout", "45s")
|
||||||
|
viper.SetDefault("windsurf.docker.host", "windsurf-ls")
|
||||||
|
viper.SetDefault("windsurf.docker.port", 42099)
|
||||||
|
viper.SetDefault("windsurf.docker.csrf_token", "")
|
||||||
|
viper.SetDefault("windsurf.docker.discover_interval", "60s")
|
||||||
|
viper.SetDefault("windsurf.docker.probe_interval", "30s")
|
||||||
|
viper.SetDefault("windsurf.docker.probe_timeout", "3s")
|
||||||
|
viper.SetDefault("windsurf.embedded.binary", "/opt/windsurf/language_server_linux_x64")
|
||||||
|
viper.SetDefault("windsurf.embedded.base_port", 42100)
|
||||||
|
viper.SetDefault("windsurf.embedded.data_dir", "/opt/windsurf/data")
|
||||||
|
viper.SetDefault("windsurf.embedded.api_server_url", "https://server.self-serve.windsurf.com")
|
||||||
|
viper.SetDefault("windsurf.refresh.enabled", true)
|
||||||
|
viper.SetDefault("windsurf.refresh.token_scan_interval", "5m")
|
||||||
|
viper.SetDefault("windsurf.refresh.refresh_before_expiry", "10m")
|
||||||
|
viper.SetDefault("windsurf.refresh.status_refresh_interval", "15m")
|
||||||
|
viper.SetDefault("windsurf.refresh.status_lock_ttl", "2m")
|
||||||
|
viper.SetDefault("windsurf.refresh.worker_concurrency", 4)
|
||||||
|
viper.SetDefault("windsurf.refresh.temp_unschedulable_on_network_error", "10m")
|
||||||
|
viper.SetDefault("windsurf.chat.default_mode", "auto")
|
||||||
|
viper.SetDefault("windsurf.chat.legacy_enum_cutoff", 280)
|
||||||
|
viper.SetDefault("windsurf.chat.cascade_poll_interval", "250ms")
|
||||||
|
viper.SetDefault("windsurf.chat.cascade_idle_grace", "8s")
|
||||||
|
viper.SetDefault("windsurf.chat.cascade_timeout", "180s")
|
||||||
|
viper.SetDefault("windsurf.chat.preflight_capacity_check", true)
|
||||||
|
viper.SetDefault("windsurf.chat.allow_mode_fallback", true)
|
||||||
|
viper.SetDefault("windsurf.scheduling.rpm_pro", 60)
|
||||||
|
viper.SetDefault("windsurf.scheduling.rpm_free", 10)
|
||||||
|
viper.SetDefault("windsurf.scheduling.rpm_unknown", 20)
|
||||||
|
viper.SetDefault("windsurf.scheduling.rpm_expired", 0)
|
||||||
|
|
||||||
// Subscription Maintenance (bounded queue + worker pool)
|
// Subscription Maintenance (bounded queue + worker pool)
|
||||||
viper.SetDefault("subscription_maintenance.worker_count", 2)
|
viper.SetDefault("subscription_maintenance.worker_count", 2)
|
||||||
viper.SetDefault("subscription_maintenance.queue_size", 1024)
|
viper.SetDefault("subscription_maintenance.queue_size", 1024)
|
||||||
|
|||||||
136
backend/internal/config/windsurf.go
Normal file
136
backend/internal/config/windsurf.go
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type WindsurfConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
FirebaseAPIKey string `mapstructure:"firebase_api_key"`
|
||||||
|
Auth1BaseURL string `mapstructure:"auth1_base_url"`
|
||||||
|
SeatServiceBaseURL string `mapstructure:"seat_service_base_url"`
|
||||||
|
CodeiumRegisterURL string `mapstructure:"codeium_register_url"`
|
||||||
|
UserStatusBaseURL string `mapstructure:"user_status_base_url"`
|
||||||
|
LSMode string `mapstructure:"ls_mode"`
|
||||||
|
RequestTimeout time.Duration `mapstructure:"request_timeout"`
|
||||||
|
StartupTimeout time.Duration `mapstructure:"startup_timeout"`
|
||||||
|
Docker WindsurfDockerConfig `mapstructure:"docker"`
|
||||||
|
Embedded WindsurfEmbeddedConfig `mapstructure:"embedded"`
|
||||||
|
External WindsurfExternalConfig `mapstructure:"external"`
|
||||||
|
Refresh WindsurfRefreshConfig `mapstructure:"refresh"`
|
||||||
|
Probe WindsurfProbeConfig `mapstructure:"probe"`
|
||||||
|
Chat WindsurfChatConfig `mapstructure:"chat"`
|
||||||
|
Scheduling WindsurfScheduleConfig `mapstructure:"scheduling"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfDockerConfig struct {
|
||||||
|
Host string `mapstructure:"host"`
|
||||||
|
Port int `mapstructure:"port"`
|
||||||
|
CSRFToken string `mapstructure:"csrf_token"`
|
||||||
|
DiscoverInterval time.Duration `mapstructure:"discover_interval"`
|
||||||
|
ProbeInterval time.Duration `mapstructure:"probe_interval"`
|
||||||
|
ProbeTimeout time.Duration `mapstructure:"probe_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfEmbeddedConfig struct {
|
||||||
|
Binary string `mapstructure:"binary"`
|
||||||
|
BasePort int `mapstructure:"base_port"`
|
||||||
|
DataDir string `mapstructure:"data_dir"`
|
||||||
|
APIServerURL string `mapstructure:"api_server_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfExternalConfig struct {
|
||||||
|
BaseURL string `mapstructure:"base_url"`
|
||||||
|
CSRFToken string `mapstructure:"csrf_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfRefreshConfig struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
TokenScanInterval time.Duration `mapstructure:"token_scan_interval"`
|
||||||
|
RefreshBeforeExpiry time.Duration `mapstructure:"refresh_before_expiry"`
|
||||||
|
StatusRefreshInterval time.Duration `mapstructure:"status_refresh_interval"`
|
||||||
|
StatusLockTTL time.Duration `mapstructure:"status_lock_ttl"`
|
||||||
|
WorkerConcurrency int `mapstructure:"worker_concurrency"`
|
||||||
|
TempUnschedulableOnNetworkErr time.Duration `mapstructure:"temp_unschedulable_on_network_error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfProbeConfig struct {
|
||||||
|
CanaryModels []string `mapstructure:"canary_models"`
|
||||||
|
ModelCatalogRefreshInterval time.Duration `mapstructure:"model_catalog_refresh_interval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfChatConfig struct {
|
||||||
|
DefaultMode string `mapstructure:"default_mode"`
|
||||||
|
LegacyEnumCutoff int32 `mapstructure:"legacy_enum_cutoff"`
|
||||||
|
CascadePollInterval time.Duration `mapstructure:"cascade_poll_interval"`
|
||||||
|
CascadeIdleGrace time.Duration `mapstructure:"cascade_idle_grace"`
|
||||||
|
CascadeTimeout time.Duration `mapstructure:"cascade_timeout"`
|
||||||
|
PreflightCapCheck bool `mapstructure:"preflight_capacity_check"`
|
||||||
|
AllowModeFallback bool `mapstructure:"allow_mode_fallback"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfScheduleConfig struct {
|
||||||
|
RPMPro int `mapstructure:"rpm_pro"`
|
||||||
|
RPMFree int `mapstructure:"rpm_free"`
|
||||||
|
RPMUnknown int `mapstructure:"rpm_unknown"`
|
||||||
|
RPMExpired int `mapstructure:"rpm_expired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultWindsurfConfig() WindsurfConfig {
|
||||||
|
return WindsurfConfig{
|
||||||
|
Enabled: false,
|
||||||
|
FirebaseAPIKey: "",
|
||||||
|
Auth1BaseURL: "https://windsurf.com",
|
||||||
|
SeatServiceBaseURL: "https://server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService",
|
||||||
|
CodeiumRegisterURL: "https://api.codeium.com/register_user/",
|
||||||
|
UserStatusBaseURL: "https://server.codeium.com",
|
||||||
|
LSMode: "docker",
|
||||||
|
RequestTimeout: 60 * time.Second,
|
||||||
|
StartupTimeout: 45 * time.Second,
|
||||||
|
Docker: WindsurfDockerConfig{
|
||||||
|
Host: "windsurf-ls",
|
||||||
|
Port: 42099,
|
||||||
|
CSRFToken: "",
|
||||||
|
DiscoverInterval: 60 * time.Second,
|
||||||
|
ProbeInterval: 30 * time.Second,
|
||||||
|
ProbeTimeout: 3 * time.Second,
|
||||||
|
},
|
||||||
|
Embedded: WindsurfEmbeddedConfig{
|
||||||
|
Binary: "/opt/windsurf/language_server_linux_x64",
|
||||||
|
BasePort: 42100,
|
||||||
|
DataDir: "/opt/windsurf/data",
|
||||||
|
APIServerURL: "https://server.self-serve.windsurf.com",
|
||||||
|
},
|
||||||
|
External: WindsurfExternalConfig{},
|
||||||
|
Refresh: WindsurfRefreshConfig{
|
||||||
|
Enabled: true,
|
||||||
|
TokenScanInterval: 5 * time.Minute,
|
||||||
|
RefreshBeforeExpiry: 10 * time.Minute,
|
||||||
|
StatusRefreshInterval: 15 * time.Minute,
|
||||||
|
StatusLockTTL: 2 * time.Minute,
|
||||||
|
WorkerConcurrency: 4,
|
||||||
|
TempUnschedulableOnNetworkErr: 10 * time.Minute,
|
||||||
|
},
|
||||||
|
Probe: WindsurfProbeConfig{
|
||||||
|
CanaryModels: []string{
|
||||||
|
"gpt-4o-mini",
|
||||||
|
"gemini-2.5-flash",
|
||||||
|
"claude-sonnet-4-6",
|
||||||
|
},
|
||||||
|
ModelCatalogRefreshInterval: 6 * time.Hour,
|
||||||
|
},
|
||||||
|
Chat: WindsurfChatConfig{
|
||||||
|
DefaultMode: "auto",
|
||||||
|
LegacyEnumCutoff: 280,
|
||||||
|
CascadePollInterval: 250 * time.Millisecond,
|
||||||
|
CascadeIdleGrace: 8 * time.Second,
|
||||||
|
CascadeTimeout: 180 * time.Second,
|
||||||
|
PreflightCapCheck: true,
|
||||||
|
AllowModeFallback: true,
|
||||||
|
},
|
||||||
|
Scheduling: WindsurfScheduleConfig{
|
||||||
|
RPMPro: 60,
|
||||||
|
RPMFree: 10,
|
||||||
|
RPMUnknown: 20,
|
||||||
|
RPMExpired: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ const (
|
|||||||
PlatformOpenAI = "openai"
|
PlatformOpenAI = "openai"
|
||||||
PlatformGemini = "gemini"
|
PlatformGemini = "gemini"
|
||||||
PlatformAntigravity = "antigravity"
|
PlatformAntigravity = "antigravity"
|
||||||
|
PlatformWindsurf = "windsurf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Account type constants
|
// Account type constants
|
||||||
@ -30,7 +31,8 @@ const (
|
|||||||
AccountTypeSetupToken = "setup-token" // Setup Token类型账号(inference only scope)
|
AccountTypeSetupToken = "setup-token" // Setup Token类型账号(inference only scope)
|
||||||
AccountTypeAPIKey = "apikey" // API Key类型账号
|
AccountTypeAPIKey = "apikey" // API Key类型账号
|
||||||
AccountTypeUpstream = "upstream" // 上游透传类型账号(通过 Base URL + API Key 连接上游)
|
AccountTypeUpstream = "upstream" // 上游透传类型账号(通过 Base URL + API Key 连接上游)
|
||||||
AccountTypeBedrock = "bedrock" // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
|
AccountTypeBedrock = "bedrock" // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
|
||||||
|
AccountTypeWindsurfSession = "windsurf-session" // Windsurf Session 类型账号(邮箱密码登录获取的 session token + api_key)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Redeem type constants
|
// Redeem type constants
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
@ -1888,6 +1889,21 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Windsurf accounts
|
||||||
|
if account.Platform == domain.PlatformWindsurf {
|
||||||
|
wsModels := windsurf.ListModelsOpenAI()
|
||||||
|
models := make([]claude.Model, 0, len(wsModels))
|
||||||
|
for _, m := range wsModels {
|
||||||
|
models = append(models, claude.Model{
|
||||||
|
ID: m.ID,
|
||||||
|
Type: "model",
|
||||||
|
DisplayName: m.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
response.Success(c, models)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Handle Antigravity accounts: return Claude + Gemini models
|
// Handle Antigravity accounts: return Claude + Gemini models
|
||||||
if account.Platform == service.PlatformAntigravity {
|
if account.Platform == service.PlatformAntigravity {
|
||||||
// 直接复用 antigravity.DefaultModels(),与 /v1/models 端点保持同步
|
// 直接复用 antigravity.DefaultModels(),与 /v1/models 端点保持同步
|
||||||
|
|||||||
@ -15,7 +15,8 @@ func NewAntigravityOAuthHandler(antigravityOAuthService *service.AntigravityOAut
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AntigravityGenerateAuthURLRequest struct {
|
type AntigravityGenerateAuthURLRequest struct {
|
||||||
ProxyID *int64 `json:"proxy_id"`
|
ProxyID *int64 `json:"proxy_id"`
|
||||||
|
IsEnterprise bool `json:"is_enterprise"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateAuthURL generates Google OAuth authorization URL
|
// GenerateAuthURL generates Google OAuth authorization URL
|
||||||
@ -27,7 +28,7 @@ func (h *AntigravityOAuthHandler) GenerateAuthURL(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.antigravityOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID)
|
result, err := h.antigravityOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID, req.IsEnterprise)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, "生成授权链接失败: "+err.Error())
|
response.InternalError(c, "生成授权链接失败: "+err.Error())
|
||||||
return
|
return
|
||||||
@ -70,6 +71,7 @@ func (h *AntigravityOAuthHandler) ExchangeCode(c *gin.Context) {
|
|||||||
type AntigravityRefreshTokenRequest struct {
|
type AntigravityRefreshTokenRequest struct {
|
||||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
ProxyID *int64 `json:"proxy_id"`
|
ProxyID *int64 `json:"proxy_id"`
|
||||||
|
IsEnterprise bool `json:"is_enterprise"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken validates an Antigravity refresh token and returns full token info
|
// RefreshToken validates an Antigravity refresh token and returns full token info
|
||||||
@ -81,7 +83,7 @@ func (h *AntigravityOAuthHandler) RefreshToken(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenInfo, err := h.antigravityOAuthService.ValidateRefreshToken(c.Request.Context(), req.RefreshToken, req.ProxyID)
|
tokenInfo, err := h.antigravityOAuthService.ValidateRefreshToken(c.Request.Context(), req.RefreshToken, req.ProxyID, req.IsEnterprise)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -84,7 +84,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
|
|||||||
type CreateGroupRequest struct {
|
type CreateGroupRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
|
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity windsurf"`
|
||||||
RateMultiplier float64 `json:"rate_multiplier"`
|
RateMultiplier float64 `json:"rate_multiplier"`
|
||||||
IsExclusive bool `json:"is_exclusive"`
|
IsExclusive bool `json:"is_exclusive"`
|
||||||
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
||||||
@ -118,7 +118,7 @@ type CreateGroupRequest struct {
|
|||||||
type UpdateGroupRequest struct {
|
type UpdateGroupRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
|
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity windsurf"`
|
||||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||||
IsExclusive *bool `json:"is_exclusive"`
|
IsExclusive *bool `json:"is_exclusive"`
|
||||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||||
|
|||||||
311
backend/internal/handler/admin/windsurf_handler.go
Normal file
311
backend/internal/handler/admin/windsurf_handler.go
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WindsurfHandler struct {
|
||||||
|
authService *service.WindsurfAuthService
|
||||||
|
lsService *service.WindsurfLSService
|
||||||
|
probeService *service.WindsurfProbeService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWindsurfHandler(
|
||||||
|
authService *service.WindsurfAuthService,
|
||||||
|
lsService *service.WindsurfLSService,
|
||||||
|
probeService *service.WindsurfProbeService,
|
||||||
|
) *WindsurfHandler {
|
||||||
|
return &WindsurfHandler{
|
||||||
|
authService: authService,
|
||||||
|
lsService: lsService,
|
||||||
|
probeService: probeService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WindsurfHandler) Login(c *gin.Context) {
|
||||||
|
var req dto.WindsurfLoginRequest
|
||||||
|
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.WindsurfLoginInput{
|
||||||
|
Email: req.Email,
|
||||||
|
Password: req.Password,
|
||||||
|
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.Login(c.Request.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := h.authService.BatchLogin(
|
||||||
|
c.Request.Context(),
|
||||||
|
req.Items,
|
||||||
|
req.ProxyID,
|
||||||
|
req.GroupIDs,
|
||||||
|
concurrency,
|
||||||
|
priority,
|
||||||
|
probeAfter,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
failCount := 0
|
||||||
|
batchResults := make([]dto.WindsurfBatchLoginResult, 0, len(results))
|
||||||
|
|
||||||
|
for _, r := range results {
|
||||||
|
br := dto.WindsurfBatchLoginResult{
|
||||||
|
Email: r.Email,
|
||||||
|
Success: r.Success,
|
||||||
|
Error: r.Error,
|
||||||
|
}
|
||||||
|
if r.Success && r.Output != nil {
|
||||||
|
successCount++
|
||||||
|
br.Account = &dto.WindsurfLoginResponse{
|
||||||
|
AccountID: r.Output.AccountID,
|
||||||
|
Platform: "windsurf",
|
||||||
|
Type: "windsurf-session",
|
||||||
|
Email: r.Output.Email,
|
||||||
|
Tier: r.Output.Tier,
|
||||||
|
AuthMethod: r.Output.AuthMethod,
|
||||||
|
APIKeyPresent: r.Output.APIKeyPresent,
|
||||||
|
RefreshTokenPresent: r.Output.RefreshTokenPresent,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failCount++
|
||||||
|
}
|
||||||
|
batchResults = append(batchResults, br)
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, dto.WindsurfBatchLoginResponse{
|
||||||
|
Results: batchResults,
|
||||||
|
Total: len(results),
|
||||||
|
SuccessCount: successCount,
|
||||||
|
FailCount: failCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WindsurfHandler) RefreshToken(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "invalid account id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.authService.RefreshToken(c.Request.Context(), id); err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, dto.WindsurfRefreshTokenResponse{
|
||||||
|
Refreshed: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WindsurfHandler) BatchRefreshTokens(c *gin.Context) {
|
||||||
|
var req dto.WindsurfBatchIDsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
failCount := 0
|
||||||
|
|
||||||
|
for _, id := range req.AccountIDs {
|
||||||
|
if err := h.authService.RefreshToken(c.Request.Context(), id); err != nil {
|
||||||
|
failCount++
|
||||||
|
} else {
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"total": len(req.AccountIDs),
|
||||||
|
"success_count": successCount,
|
||||||
|
"fail_count": failCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WindsurfHandler) GetLSStatus(c *gin.Context) {
|
||||||
|
if h.lsService == nil {
|
||||||
|
response.Success(c, dto.WindsurfLSStatusResponse{
|
||||||
|
Mode: "disabled",
|
||||||
|
Healthy: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := h.lsService.Status()
|
||||||
|
resp := dto.WindsurfLSStatusResponse{
|
||||||
|
Mode: status.Mode,
|
||||||
|
Healthy: status.Healthy,
|
||||||
|
Instances: status.Instances,
|
||||||
|
Endpoint: status.Endpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
if dc, ok := h.lsService.Connector().(*windsurf.DockerDiscoveryConnector); ok {
|
||||||
|
for _, inst := range dc.InstanceStatuses() {
|
||||||
|
resp.Details = append(resp.Details, dto.WindsurfLSInstanceDetail{
|
||||||
|
ContainerID: inst.ContainerID,
|
||||||
|
ContainerName: inst.ContainerName,
|
||||||
|
Host: inst.Host,
|
||||||
|
Port: inst.Port,
|
||||||
|
Healthy: inst.Healthy,
|
||||||
|
DiscoveredAt: inst.DiscoveredAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
LastProbeAt: inst.LastProbeAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
LastProbeErr: inst.LastProbeErr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WindsurfHandler) ListModels(c *gin.Context) {
|
||||||
|
models := windsurf.ListModelsOpenAI()
|
||||||
|
response.Success(c, models)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WindsurfHandler) Probe(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "invalid account id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.probeService.ProbeAccount(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WindsurfHandler) BatchProbe(c *gin.Context) {
|
||||||
|
var req dto.WindsurfBatchIDsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type probeResult struct {
|
||||||
|
AccountID int64 `json:"account_id"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Tier string `json:"tier,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]probeResult, 0, len(req.AccountIDs))
|
||||||
|
successCount := 0
|
||||||
|
failCount := 0
|
||||||
|
|
||||||
|
for _, id := range req.AccountIDs {
|
||||||
|
r, err := h.probeService.ProbeAccount(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
failCount++
|
||||||
|
results = append(results, probeResult{AccountID: id, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Error != "" {
|
||||||
|
failCount++
|
||||||
|
results = append(results, probeResult{AccountID: id, Error: r.Error})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
successCount++
|
||||||
|
results = append(results, probeResult{AccountID: id, Success: true, Tier: r.Tier})
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"results": results,
|
||||||
|
"total": len(req.AccountIDs),
|
||||||
|
"success_count": successCount,
|
||||||
|
"fail_count": failCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WindsurfHandler) GetRuntime(c *gin.Context) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "invalid account id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.probeService.GetRuntime(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
104
backend/internal/handler/dto/windsurf.go
Normal file
104
backend/internal/handler/dto/windsurf.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
type WindsurfLoginRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
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"`
|
||||||
|
GroupIDs []int64 `json:"group_ids,omitempty"`
|
||||||
|
Concurrency *int `json:"concurrency,omitempty"`
|
||||||
|
Priority *int `json:"priority,omitempty"`
|
||||||
|
ProbeAfter *bool `json:"probe_after,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfBatchIDsRequest struct {
|
||||||
|
AccountIDs []int64 `json:"account_ids" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfLoginResponse struct {
|
||||||
|
AccountID int64 `json:"account_id"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
AuthMethod string `json:"auth_method"`
|
||||||
|
APIKeyPresent bool `json:"api_key_present"`
|
||||||
|
RefreshTokenPresent bool `json:"refresh_token_present"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfBatchLoginResponse struct {
|
||||||
|
Results []WindsurfBatchLoginResult `json:"results"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
SuccessCount int `json:"success_count"`
|
||||||
|
FailCount int `json:"fail_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfBatchLoginResult struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Account *WindsurfLoginResponse `json:"account,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfRuntimeResponse struct {
|
||||||
|
AccountID int64 `json:"account_id"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
RPMLimit int `json:"rpm_limit"`
|
||||||
|
CurrentRPM int `json:"current_rpm"`
|
||||||
|
RPMUsagePercent float64 `json:"rpm_usage_percent"`
|
||||||
|
CurrentConcurrency int `json:"current_concurrency"`
|
||||||
|
MaxConcurrency int `json:"max_concurrency"`
|
||||||
|
Capabilities map[string]WindsurfModelCapability `json:"capabilities,omitempty"`
|
||||||
|
ModelMatrix map[string]WindsurfModelAvailability `json:"model_matrix,omitempty"`
|
||||||
|
LastProbeAt *string `json:"last_probe_at,omitempty"`
|
||||||
|
LastStatusRefreshAt *string `json:"last_status_refresh_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfModelCapability struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
CheckedAt string `json:"checked_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfModelAvailability struct {
|
||||||
|
Visible bool `json:"visible"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Blocked bool `json:"blocked"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfRefreshTokenResponse struct {
|
||||||
|
Refreshed bool `json:"refreshed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfLSStatusResponse struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
Instances int `json:"instances"`
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
Details []WindsurfLSInstanceDetail `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfLSInstanceDetail struct {
|
||||||
|
ContainerID string `json:"container_id"`
|
||||||
|
ContainerName string `json:"container_name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
DiscoveredAt string `json:"discovered_at"`
|
||||||
|
LastProbeAt string `json:"last_probe_at,omitempty"`
|
||||||
|
LastProbeErr string `json:"last_probe_err,omitempty"`
|
||||||
|
}
|
||||||
@ -97,6 +97,9 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
|
|||||||
return EndpointGeminiModels
|
return EndpointGeminiModels
|
||||||
}
|
}
|
||||||
return EndpointMessages
|
return EndpointMessages
|
||||||
|
|
||||||
|
case service.PlatformWindsurf:
|
||||||
|
return EndpointMessages
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown platform — fall back to inbound.
|
// Unknown platform — fall back to inbound.
|
||||||
|
|||||||
@ -39,6 +39,7 @@ type GatewayHandler struct {
|
|||||||
gatewayService *service.GatewayService
|
gatewayService *service.GatewayService
|
||||||
geminiCompatService *service.GeminiMessagesCompatService
|
geminiCompatService *service.GeminiMessagesCompatService
|
||||||
antigravityGatewayService *service.AntigravityGatewayService
|
antigravityGatewayService *service.AntigravityGatewayService
|
||||||
|
windsurfGatewayService *service.WindsurfGatewayService
|
||||||
userService *service.UserService
|
userService *service.UserService
|
||||||
billingCacheService *service.BillingCacheService
|
billingCacheService *service.BillingCacheService
|
||||||
usageService *service.UsageService
|
usageService *service.UsageService
|
||||||
@ -58,6 +59,7 @@ func NewGatewayHandler(
|
|||||||
gatewayService *service.GatewayService,
|
gatewayService *service.GatewayService,
|
||||||
geminiCompatService *service.GeminiMessagesCompatService,
|
geminiCompatService *service.GeminiMessagesCompatService,
|
||||||
antigravityGatewayService *service.AntigravityGatewayService,
|
antigravityGatewayService *service.AntigravityGatewayService,
|
||||||
|
windsurfGatewayService *service.WindsurfGatewayService,
|
||||||
userService *service.UserService,
|
userService *service.UserService,
|
||||||
concurrencyService *service.ConcurrencyService,
|
concurrencyService *service.ConcurrencyService,
|
||||||
billingCacheService *service.BillingCacheService,
|
billingCacheService *service.BillingCacheService,
|
||||||
@ -92,6 +94,7 @@ func NewGatewayHandler(
|
|||||||
gatewayService: gatewayService,
|
gatewayService: gatewayService,
|
||||||
geminiCompatService: geminiCompatService,
|
geminiCompatService: geminiCompatService,
|
||||||
antigravityGatewayService: antigravityGatewayService,
|
antigravityGatewayService: antigravityGatewayService,
|
||||||
|
windsurfGatewayService: windsurfGatewayService,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
billingCacheService: billingCacheService,
|
billingCacheService: billingCacheService,
|
||||||
usageService: usageService,
|
usageService: usageService,
|
||||||
@ -511,7 +514,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
|
|
||||||
// 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。
|
// 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。
|
||||||
// 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。
|
// 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。
|
||||||
if h.gatewayService.IsSingleAntigravityAccountGroup(c.Request.Context(), currentAPIKey.GroupID) {
|
if h.gatewayService.IsSingleAntigravityAccountGroup(c.Request.Context(), currentAPIKey.GroupID) ||
|
||||||
|
h.gatewayService.IsSingleWindsurfAccountGroup(c.Request.Context(), currentAPIKey.GroupID) {
|
||||||
ctx := service.WithSingleAccountRetry(c.Request.Context(), true, h.metadataBridgeEnabled())
|
ctx := service.WithSingleAccountRetry(c.Request.Context(), true, h.metadataBridgeEnabled())
|
||||||
c.Request = c.Request.WithContext(ctx)
|
c.Request = c.Request.WithContext(ctx)
|
||||||
}
|
}
|
||||||
@ -684,7 +688,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
// 记录 Forward 前已写入字节数,Forward 后若增加则说明 SSE 内容已发,禁止 failover
|
// 记录 Forward 前已写入字节数,Forward 后若增加则说明 SSE 内容已发,禁止 failover
|
||||||
writerSizeBeforeForward := c.Writer.Size()
|
writerSizeBeforeForward := c.Writer.Size()
|
||||||
if account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey {
|
if account.Platform == service.PlatformWindsurf {
|
||||||
|
result, err = h.windsurfGatewayService.Forward(requestCtx, c, account, body, hasBoundSession)
|
||||||
|
} else if account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey {
|
||||||
result, err = h.antigravityGatewayService.Forward(requestCtx, c, account, body, hasBoundSession)
|
result, err = h.antigravityGatewayService.Forward(requestCtx, c, account, body, hasBoundSession)
|
||||||
} else {
|
} else {
|
||||||
result, err = h.gatewayService.Forward(requestCtx, c, account, parsedReq)
|
result, err = h.gatewayService.Forward(requestCtx, c, account, parsedReq)
|
||||||
|
|||||||
@ -32,18 +32,19 @@ type AdminHandlers struct {
|
|||||||
ScheduledTest *admin.ScheduledTestHandler
|
ScheduledTest *admin.ScheduledTestHandler
|
||||||
Channel *admin.ChannelHandler
|
Channel *admin.ChannelHandler
|
||||||
Payment *admin.PaymentHandler
|
Payment *admin.PaymentHandler
|
||||||
|
Windsurf *admin.WindsurfHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers contains all HTTP handlers
|
// Handlers contains all HTTP handlers
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
Auth *AuthHandler
|
Auth *AuthHandler
|
||||||
User *UserHandler
|
User *UserHandler
|
||||||
APIKey *APIKeyHandler
|
APIKey *APIKeyHandler
|
||||||
Usage *UsageHandler
|
Usage *UsageHandler
|
||||||
Redeem *RedeemHandler
|
Redeem *RedeemHandler
|
||||||
Subscription *SubscriptionHandler
|
Subscription *SubscriptionHandler
|
||||||
Announcement *AnnouncementHandler
|
Announcement *AnnouncementHandler
|
||||||
Admin *AdminHandlers
|
Admin *AdminHandlers
|
||||||
Gateway *GatewayHandler
|
Gateway *GatewayHandler
|
||||||
OpenAIGateway *OpenAIGatewayHandler
|
OpenAIGateway *OpenAIGatewayHandler
|
||||||
Setting *SettingHandler
|
Setting *SettingHandler
|
||||||
|
|||||||
@ -1066,6 +1066,8 @@ func guessPlatformFromPath(path string) string {
|
|||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(p, "/antigravity/"):
|
case strings.HasPrefix(p, "/antigravity/"):
|
||||||
return service.PlatformAntigravity
|
return service.PlatformAntigravity
|
||||||
|
case strings.HasPrefix(p, "/windsurf/"):
|
||||||
|
return service.PlatformWindsurf
|
||||||
case strings.HasPrefix(p, "/v1beta/"):
|
case strings.HasPrefix(p, "/v1beta/"):
|
||||||
return service.PlatformGemini
|
return service.PlatformGemini
|
||||||
case strings.Contains(p, "/responses"), strings.Contains(p, "/images/"):
|
case strings.Contains(p, "/responses"), strings.Contains(p, "/images/"):
|
||||||
|
|||||||
@ -35,6 +35,7 @@ func ProvideAdminHandlers(
|
|||||||
scheduledTestHandler *admin.ScheduledTestHandler,
|
scheduledTestHandler *admin.ScheduledTestHandler,
|
||||||
channelHandler *admin.ChannelHandler,
|
channelHandler *admin.ChannelHandler,
|
||||||
paymentHandler *admin.PaymentHandler,
|
paymentHandler *admin.PaymentHandler,
|
||||||
|
windsurfHandler *admin.WindsurfHandler,
|
||||||
) *AdminHandlers {
|
) *AdminHandlers {
|
||||||
return &AdminHandlers{
|
return &AdminHandlers{
|
||||||
Dashboard: dashboardHandler,
|
Dashboard: dashboardHandler,
|
||||||
@ -63,6 +64,7 @@ func ProvideAdminHandlers(
|
|||||||
ScheduledTest: scheduledTestHandler,
|
ScheduledTest: scheduledTestHandler,
|
||||||
Channel: channelHandler,
|
Channel: channelHandler,
|
||||||
Payment: paymentHandler,
|
Payment: paymentHandler,
|
||||||
|
Windsurf: windsurfHandler,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,6 +73,14 @@ func ProvideSystemHandler(updateService *service.UpdateService, lockService *ser
|
|||||||
return admin.NewSystemHandler(updateService, lockService)
|
return admin.NewSystemHandler(updateService, lockService)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProvideWindsurfHandler returns nil when windsurf auth service is disabled.
|
||||||
|
func ProvideWindsurfHandler(authService *service.WindsurfAuthService, lsService *service.WindsurfLSService, probeService *service.WindsurfProbeService) *admin.WindsurfHandler {
|
||||||
|
if authService == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return admin.NewWindsurfHandler(authService, lsService, probeService)
|
||||||
|
}
|
||||||
|
|
||||||
// ProvideSettingHandler creates SettingHandler with version from BuildInfo
|
// ProvideSettingHandler creates SettingHandler with version from BuildInfo
|
||||||
func ProvideSettingHandler(settingService *service.SettingService, buildInfo BuildInfo) *SettingHandler {
|
func ProvideSettingHandler(settingService *service.SettingService, buildInfo BuildInfo) *SettingHandler {
|
||||||
return NewSettingHandler(settingService, buildInfo.Version)
|
return NewSettingHandler(settingService, buildInfo.Version)
|
||||||
@ -96,20 +106,20 @@ func ProvideHandlers(
|
|||||||
_ *service.IdempotencyCleanupService,
|
_ *service.IdempotencyCleanupService,
|
||||||
) *Handlers {
|
) *Handlers {
|
||||||
return &Handlers{
|
return &Handlers{
|
||||||
Auth: authHandler,
|
Auth: authHandler,
|
||||||
User: userHandler,
|
User: userHandler,
|
||||||
APIKey: apiKeyHandler,
|
APIKey: apiKeyHandler,
|
||||||
Usage: usageHandler,
|
Usage: usageHandler,
|
||||||
Redeem: redeemHandler,
|
Redeem: redeemHandler,
|
||||||
Subscription: subscriptionHandler,
|
Subscription: subscriptionHandler,
|
||||||
Announcement: announcementHandler,
|
Announcement: announcementHandler,
|
||||||
Admin: adminHandlers,
|
Admin: adminHandlers,
|
||||||
Gateway: gatewayHandler,
|
Gateway: gatewayHandler,
|
||||||
OpenAIGateway: openaiGatewayHandler,
|
OpenAIGateway: openaiGatewayHandler,
|
||||||
Setting: settingHandler,
|
Setting: settingHandler,
|
||||||
Totp: totpHandler,
|
Totp: totpHandler,
|
||||||
Payment: paymentHandler,
|
Payment: paymentHandler,
|
||||||
PaymentWebhook: paymentWebhookHandler,
|
PaymentWebhook: paymentWebhookHandler,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,6 +168,9 @@ var ProviderSet = wire.NewSet(
|
|||||||
admin.NewChannelHandler,
|
admin.NewChannelHandler,
|
||||||
admin.NewPaymentHandler,
|
admin.NewPaymentHandler,
|
||||||
|
|
||||||
|
// Windsurf handler
|
||||||
|
ProvideWindsurfHandler,
|
||||||
|
|
||||||
// AdminHandlers and Handlers constructors
|
// AdminHandlers and Handlers constructors
|
||||||
ProvideAdminHandlers,
|
ProvideAdminHandlers,
|
||||||
ProvideHandlers,
|
ProvideHandlers,
|
||||||
|
|||||||
@ -318,16 +318,17 @@ func shouldFallbackToNextURL(err error, statusCode int) bool {
|
|||||||
statusCode >= 500
|
statusCode >= 500
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExchangeCode 用 authorization code 交换 token
|
// ExchangeCode 用 authorization code 交换 token。
|
||||||
func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string) (*TokenResponse, error) {
|
// isEnterprise=true 时使用企业 OAuth client_id/secret。
|
||||||
clientSecret, err := getClientSecret()
|
func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string, isEnterprise bool) (*TokenResponse, error) {
|
||||||
|
creds, err := GetClientCredentials(isEnterprise)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("client_id", ClientID)
|
params.Set("client_id", creds.ClientID)
|
||||||
params.Set("client_secret", clientSecret)
|
params.Set("client_secret", creds.ClientSecret)
|
||||||
params.Set("code", code)
|
params.Set("code", code)
|
||||||
params.Set("redirect_uri", RedirectURI)
|
params.Set("redirect_uri", RedirectURI)
|
||||||
params.Set("grant_type", "authorization_code")
|
params.Set("grant_type", "authorization_code")
|
||||||
@ -362,16 +363,17 @@ func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string) (*
|
|||||||
return &tokenResp, nil
|
return &tokenResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken 刷新 access_token
|
// RefreshToken 刷新 access_token。
|
||||||
func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) {
|
// isEnterprise=true 时使用企业 OAuth client_id/secret。
|
||||||
clientSecret, err := getClientSecret()
|
func (c *Client) RefreshToken(ctx context.Context, refreshToken string, isEnterprise bool) (*TokenResponse, error) {
|
||||||
|
creds, err := GetClientCredentials(isEnterprise)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("client_id", ClientID)
|
params.Set("client_id", creds.ClientID)
|
||||||
params.Set("client_secret", clientSecret)
|
params.Set("client_secret", creds.ClientSecret)
|
||||||
params.Set("refresh_token", refreshToken)
|
params.Set("refresh_token", refreshToken)
|
||||||
params.Set("grant_type", "refresh_token")
|
params.Set("grant_type", "refresh_token")
|
||||||
|
|
||||||
@ -404,6 +406,39 @@ func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*TokenR
|
|||||||
return &tokenResp, nil
|
return &tokenResp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshTokenAuto 自动判定账号类型。
|
||||||
|
// 先用个人凭证刷新;若 Google 返回 invalid_client/unauthorized_client(client 不匹配),
|
||||||
|
// 再用企业凭证重试。返回 token 和最终判定的 isEnterprise 标志。
|
||||||
|
//
|
||||||
|
// 其他错误(invalid_grant、网络错误等)直接返回,不重试。
|
||||||
|
func (c *Client) RefreshTokenAuto(ctx context.Context, refreshToken string) (*TokenResponse, bool, error) {
|
||||||
|
tok, err := c.RefreshToken(ctx, refreshToken, false)
|
||||||
|
if err == nil {
|
||||||
|
return tok, false, nil
|
||||||
|
}
|
||||||
|
if !isClientMismatchError(err) {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
tok, err2 := c.RefreshToken(ctx, refreshToken, true)
|
||||||
|
if err2 == nil {
|
||||||
|
return tok, true, nil
|
||||||
|
}
|
||||||
|
// 企业也失败:返回合并后的诊断错误
|
||||||
|
return nil, false, fmt.Errorf("auto-detect refresh failed: personal=%v enterprise=%v", err, err2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isClientMismatchError 判断是否为 OAuth client 不匹配导致的错误。
|
||||||
|
// 只有这种错误才会触发"切换账号类型重试"。
|
||||||
|
func isClientMismatchError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "invalid_client") ||
|
||||||
|
strings.Contains(msg, "unauthorized_client") ||
|
||||||
|
strings.Contains(msg, "client_id")
|
||||||
|
}
|
||||||
|
|
||||||
// GetUserInfo 获取用户信息
|
// GetUserInfo 获取用户信息
|
||||||
func (c *Client) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo, error) {
|
func (c *Client) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, UserInfoURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, UserInfoURL, nil)
|
||||||
@ -440,7 +475,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) {
|
func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadCodeAssistResponse, map[string]any, error) {
|
||||||
reqBody := LoadCodeAssistRequest{}
|
reqBody := LoadCodeAssistRequest{}
|
||||||
reqBody.Metadata.IDEType = "ANTIGRAVITY"
|
reqBody.Metadata.IDEType = "ANTIGRAVITY"
|
||||||
reqBody.Metadata.IDEVersion = "1.107.0"
|
reqBody.Metadata.IDEVersion = "1.20.6"
|
||||||
reqBody.Metadata.IDEName = "antigravity"
|
reqBody.Metadata.IDEName = "antigravity"
|
||||||
|
|
||||||
bodyBytes, err := json.Marshal(reqBody)
|
bodyBytes, err := json.Marshal(reqBody)
|
||||||
|
|||||||
@ -23,16 +23,22 @@ const (
|
|||||||
TokenURL = "https://oauth2.googleapis.com/token"
|
TokenURL = "https://oauth2.googleapis.com/token"
|
||||||
UserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
UserInfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||||
|
|
||||||
// Antigravity OAuth 客户端凭证
|
// 个人账号 OAuth 凭证(isGcpTos=false,免费 Gemini Code Assist)
|
||||||
ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||||
|
|
||||||
// AntigravityOAuthClientSecretEnv 是 Antigravity OAuth client_secret 的环境变量名。
|
// AntigravityOAuthClientSecretEnv 是个人账号 OAuth client_secret 的环境变量名。
|
||||||
AntigravityOAuthClientSecretEnv = "ANTIGRAVITY_OAUTH_CLIENT_SECRET"
|
AntigravityOAuthClientSecretEnv = "ANTIGRAVITY_OAUTH_CLIENT_SECRET"
|
||||||
|
|
||||||
|
// 企业账号 OAuth 凭证(isGcpTos=true,Google Cloud / Workspace 用户)
|
||||||
|
EnterpriseClientID = "884354919052-36trc1jjb3tguiac32ov6cod268c5blh.apps.googleusercontent.com"
|
||||||
|
|
||||||
|
// AntigravityEnterpriseOAuthClientSecretEnv 是企业账号 OAuth client_secret 的环境变量名。
|
||||||
|
AntigravityEnterpriseOAuthClientSecretEnv = "ANTIGRAVITY_ENTERPRISE_OAUTH_CLIENT_SECRET"
|
||||||
|
|
||||||
// 固定的 redirect_uri(用户需手动复制 code)
|
// 固定的 redirect_uri(用户需手动复制 code)
|
||||||
RedirectURI = "http://localhost:8085/callback"
|
RedirectURI = "http://localhost:8085/callback"
|
||||||
|
|
||||||
// OAuth scopes
|
// OAuth scopes(企业和个人共用)
|
||||||
Scopes = "https://www.googleapis.com/auth/cloud-platform " +
|
Scopes = "https://www.googleapis.com/auth/cloud-platform " +
|
||||||
"https://www.googleapis.com/auth/userinfo.email " +
|
"https://www.googleapis.com/auth/userinfo.email " +
|
||||||
"https://www.googleapis.com/auth/userinfo.profile " +
|
"https://www.googleapis.com/auth/userinfo.profile " +
|
||||||
@ -47,15 +53,18 @@ const (
|
|||||||
|
|
||||||
// Antigravity API 端点
|
// Antigravity API 端点
|
||||||
antigravityProdBaseURL = "https://cloudcode-pa.googleapis.com"
|
antigravityProdBaseURL = "https://cloudcode-pa.googleapis.com"
|
||||||
antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
antigravityDailyBaseURL = "https://daily-cloudcode-pa.googleapis.com"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.107.0
|
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.20.6(product.json ideVersion)
|
||||||
var defaultUserAgentVersion = "1.107.0"
|
var defaultUserAgentVersion = "1.20.6"
|
||||||
|
|
||||||
// defaultClientSecret 可通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 覆盖
|
// defaultClientSecret 个人账号 client_secret,可通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 覆盖
|
||||||
var defaultClientSecret = "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"
|
var defaultClientSecret = "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"
|
||||||
|
|
||||||
|
// defaultEnterpriseClientSecret 企业账号 client_secret,可通过环境变量 ANTIGRAVITY_ENTERPRISE_OAUTH_CLIENT_SECRET 覆盖
|
||||||
|
var defaultEnterpriseClientSecret = "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"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// 从环境变量读取版本号,未设置则使用默认值
|
// 从环境变量读取版本号,未设置则使用默认值
|
||||||
if version := os.Getenv("ANTIGRAVITY_USER_AGENT_VERSION"); version != "" {
|
if version := os.Getenv("ANTIGRAVITY_USER_AGENT_VERSION"); version != "" {
|
||||||
@ -65,6 +74,9 @@ func init() {
|
|||||||
if secret := os.Getenv(AntigravityOAuthClientSecretEnv); secret != "" {
|
if secret := os.Getenv(AntigravityOAuthClientSecretEnv); secret != "" {
|
||||||
defaultClientSecret = secret
|
defaultClientSecret = secret
|
||||||
}
|
}
|
||||||
|
if secret := os.Getenv(AntigravityEnterpriseOAuthClientSecretEnv); secret != "" {
|
||||||
|
defaultEnterpriseClientSecret = secret
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserAgent 返回当前配置的 User-Agent(自动检测平台,匹配真实 IDE 行为)
|
// GetUserAgent 返回当前配置的 User-Agent(自动检测平台,匹配真实 IDE 行为)
|
||||||
@ -72,6 +84,43 @@ func GetUserAgent() string {
|
|||||||
return fmt.Sprintf("antigravity/%s %s/%s", defaultUserAgentVersion, runtime.GOOS, runtime.GOARCH)
|
return fmt.Sprintf("antigravity/%s %s/%s", defaultUserAgentVersion, runtime.GOOS, runtime.GOARCH)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClientCredentials 持有一对 OAuth client_id/secret
|
||||||
|
type ClientCredentials struct {
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientCredentials 根据账号类型返回对应的 OAuth 凭证。
|
||||||
|
// isEnterprise=true 时使用企业凭证(isGcpTos=true),否则使用个人凭证。
|
||||||
|
func GetClientCredentials(isEnterprise bool) (ClientCredentials, error) {
|
||||||
|
if isEnterprise {
|
||||||
|
secret := strings.TrimSpace(os.Getenv(AntigravityEnterpriseOAuthClientSecretEnv))
|
||||||
|
if secret == "" {
|
||||||
|
secret = strings.TrimSpace(defaultEnterpriseClientSecret)
|
||||||
|
}
|
||||||
|
if secret == "" {
|
||||||
|
return ClientCredentials{}, infraerrors.Newf(http.StatusBadRequest,
|
||||||
|
"ANTIGRAVITY_ENTERPRISE_OAUTH_CLIENT_SECRET_MISSING",
|
||||||
|
"missing enterprise oauth client_secret; set %s", AntigravityEnterpriseOAuthClientSecretEnv)
|
||||||
|
}
|
||||||
|
return ClientCredentials{ClientID: EnterpriseClientID, ClientSecret: secret}, nil
|
||||||
|
}
|
||||||
|
secret, err := getClientSecret()
|
||||||
|
if err != nil {
|
||||||
|
return ClientCredentials{}, err
|
||||||
|
}
|
||||||
|
return ClientCredentials{ClientID: ClientID, ClientSecret: secret}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseURLsForAccount 根据 isGcpTos 返回有序 URL 列表。
|
||||||
|
// 企业账号(isGcpTos=true)优先走 prod;个人账号优先走 daily(与真实 IDE 一致)。
|
||||||
|
func BaseURLsForAccount(isGcpTos bool) []string {
|
||||||
|
if isGcpTos {
|
||||||
|
return []string{antigravityProdBaseURL, antigravityDailyBaseURL}
|
||||||
|
}
|
||||||
|
return []string{antigravityDailyBaseURL, antigravityProdBaseURL}
|
||||||
|
}
|
||||||
|
|
||||||
func getClientSecret() (string, error) {
|
func getClientSecret() (string, error) {
|
||||||
if secret := strings.TrimSpace(os.Getenv(AntigravityOAuthClientSecretEnv)); secret != "" {
|
if secret := strings.TrimSpace(os.Getenv(AntigravityOAuthClientSecretEnv)); secret != "" {
|
||||||
defaultClientSecret = secret
|
defaultClientSecret = secret
|
||||||
@ -216,6 +265,7 @@ type OAuthSession struct {
|
|||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
CodeVerifier string `json:"code_verifier"`
|
CodeVerifier string `json:"code_verifier"`
|
||||||
ProxyURL string `json:"proxy_url,omitempty"`
|
ProxyURL string `json:"proxy_url,omitempty"`
|
||||||
|
IsEnterprise bool `json:"is_enterprise,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -330,10 +380,15 @@ func base64URLEncode(data []byte) string {
|
|||||||
return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
|
return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildAuthorizationURL 构建 Google OAuth 授权 URL
|
// BuildAuthorizationURL 构建 Google OAuth 授权 URL。
|
||||||
func BuildAuthorizationURL(state, codeChallenge string) string {
|
// isEnterprise=true 时使用企业 client_id;否则使用个人 client_id。
|
||||||
|
func BuildAuthorizationURL(state, codeChallenge string, isEnterprise bool) string {
|
||||||
|
clientID := ClientID
|
||||||
|
if isEnterprise {
|
||||||
|
clientID = EnterpriseClientID
|
||||||
|
}
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("client_id", ClientID)
|
params.Set("client_id", clientID)
|
||||||
params.Set("redirect_uri", RedirectURI)
|
params.Set("redirect_uri", RedirectURI)
|
||||||
params.Set("response_type", "code")
|
params.Set("response_type", "code")
|
||||||
params.Set("scope", Scopes)
|
params.Set("scope", Scopes)
|
||||||
|
|||||||
32
backend/internal/pkg/windsurf/LICENSE
Normal file
32
backend/internal/pkg/windsurf/LICENSE
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
Portions of code in this directory are derived from the open-source project:
|
||||||
|
|
||||||
|
https://github.com/seven7763/windsurf-tools (MIT License)
|
||||||
|
Copyright (c) 2025 shaoyu521
|
||||||
|
|
||||||
|
Original MIT License text follows. The same MIT terms apply to derivative
|
||||||
|
portions in this directory; the wider sub2api project license still governs
|
||||||
|
all other code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 shaoyu521
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
436
backend/internal/pkg/windsurf/auth_client.go
Normal file
436
backend/internal/pkg/windsurf/auth_client.go
Normal file
@ -0,0 +1,436 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/imroc/req/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthClient struct {
|
||||||
|
Auth1BaseURL string
|
||||||
|
SeatServiceBaseURL string
|
||||||
|
CodeiumRegisterURL string
|
||||||
|
FirebaseAPIKey string
|
||||||
|
RequestTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResult struct {
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
IDToken string `json:"id_token,omitempty"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
SessionToken string `json:"session_token,omitempty"`
|
||||||
|
Auth1Token string `json:"auth1_token,omitempty"`
|
||||||
|
APIServerURL string `json:"api_server_url,omitempty"`
|
||||||
|
AuthMethod string `json:"auth_method"`
|
||||||
|
ExpiresIn int `json:"expires_in,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshResult struct {
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterResult struct {
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
APIServerURL string `json:"api_server_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthError struct {
|
||||||
|
Message string
|
||||||
|
IsAuthFail bool
|
||||||
|
FirebaseCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *AuthError) Error() string { return e.Message }
|
||||||
|
|
||||||
|
var (
|
||||||
|
osVersions = []string{
|
||||||
|
"Windows NT 10.0; Win64; x64",
|
||||||
|
"Macintosh; Intel Mac OS X 10_15_7",
|
||||||
|
"Macintosh; Intel Mac OS X 13_4_1",
|
||||||
|
"Macintosh; Intel Mac OS X 14_2_1",
|
||||||
|
"X11; Linux x86_64",
|
||||||
|
}
|
||||||
|
chromeVersions = []string{
|
||||||
|
"120.0.0.0", "122.0.0.0", "124.0.0.0", "126.0.0.0",
|
||||||
|
"128.0.0.0", "130.0.0.0", "132.0.0.0", "134.0.0.0",
|
||||||
|
}
|
||||||
|
acceptLanguages = []string{
|
||||||
|
"en-US,en;q=0.9", "zh-CN,zh;q=0.9,en;q=0.8",
|
||||||
|
"ja,en-US;q=0.9,en;q=0.8", "de,en-US;q=0.9,en;q=0.8",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func pick(arr []string) string { return arr[rand.Intn(len(arr))] }
|
||||||
|
|
||||||
|
func generateFingerprint() http.Header {
|
||||||
|
os := pick(osVersions)
|
||||||
|
cv := pick(chromeVersions)
|
||||||
|
major := strings.Split(cv, ".")[0]
|
||||||
|
ua := fmt.Sprintf("Mozilla/5.0 (%s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36", os, cv)
|
||||||
|
|
||||||
|
h := http.Header{}
|
||||||
|
h.Set("User-Agent", ua)
|
||||||
|
h.Set("Accept-Language", pick(acceptLanguages))
|
||||||
|
h.Set("Accept", "application/json, text/plain, */*")
|
||||||
|
h.Set("Accept-Encoding", "identity")
|
||||||
|
h.Set("sec-ch-ua", fmt.Sprintf(`"Chromium";v="%s", "Google Chrome";v="%s", "Not-A.Brand";v="99"`, major, major))
|
||||||
|
h.Set("sec-ch-ua-mobile", "?0")
|
||||||
|
if strings.Contains(os, "Windows") {
|
||||||
|
h.Set("sec-ch-ua-platform", `"Windows"`)
|
||||||
|
} else if strings.Contains(os, "Mac") {
|
||||||
|
h.Set("sec-ch-ua-platform", `"macOS"`)
|
||||||
|
} else {
|
||||||
|
h.Set("sec-ch-ua-platform", `"Linux"`)
|
||||||
|
}
|
||||||
|
h.Set("Sec-Fetch-Dest", "empty")
|
||||||
|
h.Set("Sec-Fetch-Mode", "cors")
|
||||||
|
h.Set("Sec-Fetch-Site", "cross-site")
|
||||||
|
h.Set("Origin", "https://windsurf.com")
|
||||||
|
h.Set("Referer", "https://windsurf.com/")
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(timeout time.Duration, proxyURL string) *req.Client {
|
||||||
|
c := req.C().SetTimeout(timeout).ImpersonateChrome()
|
||||||
|
if proxyURL != "" {
|
||||||
|
c.SetProxyURL(proxyURL)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthClient) Login(ctx context.Context, email, password, proxyURL string) (*LoginResult, error) {
|
||||||
|
fp := generateFingerprint()
|
||||||
|
|
||||||
|
connData, _ := a.fetchAuth1Connections(ctx, email, fp, proxyURL)
|
||||||
|
authMethod, _ := extractString(connData, "auth_method", "method")
|
||||||
|
|
||||||
|
if authMethod == "auth1" {
|
||||||
|
hasPassword, _ := extractBool(connData, "auth_method", "has_password")
|
||||||
|
if !hasPassword {
|
||||||
|
return nil, &AuthError{
|
||||||
|
Message: "该账号未设置密码登录方式",
|
||||||
|
IsAuthFail: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.loginViaAuth1(ctx, email, password, fp, proxyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, fbErr := a.loginViaFirebase(ctx, email, password, fp, proxyURL)
|
||||||
|
if fbErr == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if ae, ok := fbErr.(*AuthError); ok && ae.IsAuthFail {
|
||||||
|
result2, a1Err := a.loginViaAuth1(ctx, email, password, fp, proxyURL)
|
||||||
|
if a1Err == nil {
|
||||||
|
return result2, nil
|
||||||
|
}
|
||||||
|
if ae2, ok2 := a1Err.(*AuthError); ok2 && ae2.IsAuthFail {
|
||||||
|
return nil, fbErr
|
||||||
|
}
|
||||||
|
return nil, a1Err
|
||||||
|
}
|
||||||
|
return nil, fbErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthClient) fetchAuth1Connections(ctx context.Context, email string, fp http.Header, proxyURL string) (map[string]any, error) {
|
||||||
|
body := map[string]string{"product": "windsurf", "email": email}
|
||||||
|
var result map[string]any
|
||||||
|
c := newClient(a.RequestTimeout, proxyURL)
|
||||||
|
resp, err := c.R().SetContext(ctx).SetHeaders(headerMap(fp)).SetBody(body).SetSuccessResult(&result).Post(a.Auth1BaseURL + "/_devin-auth/connections")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.IsErrorState() {
|
||||||
|
return nil, fmt.Errorf("auth1 connections: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthClient) loginViaAuth1(ctx context.Context, email, password string, fp http.Header, proxyURL string) (*LoginResult, error) {
|
||||||
|
c := newClient(a.RequestTimeout, proxyURL)
|
||||||
|
|
||||||
|
var loginResp map[string]any
|
||||||
|
resp, err := c.R().SetContext(ctx).SetHeaders(headerMap(fp)).
|
||||||
|
SetBody(map[string]string{"email": email, "password": password}).
|
||||||
|
SetSuccessResult(&loginResp).
|
||||||
|
Post(a.Auth1BaseURL + "/_devin-auth/password/login")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("auth1 login: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.IsErrorState() || loginResp["detail"] != nil {
|
||||||
|
detail, _ := loginResp["detail"].(string)
|
||||||
|
return nil, classifyAuthError("Auth1 登录失败", detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth1Token, _ := loginResp["token"].(string)
|
||||||
|
if auth1Token == "" {
|
||||||
|
return nil, fmt.Errorf("auth1 login: no token in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
hdrs := headerMap(fp)
|
||||||
|
hdrs["Connect-Protocol-Version"] = "1"
|
||||||
|
|
||||||
|
var bridgeResp map[string]any
|
||||||
|
resp, err = c.R().SetContext(ctx).SetHeaders(hdrs).
|
||||||
|
SetBody(map[string]string{"auth1Token": auth1Token, "orgId": ""}).
|
||||||
|
SetSuccessResult(&bridgeResp).
|
||||||
|
Post(a.SeatServiceBaseURL + "/WindsurfPostAuth")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("windsurf post auth: %w", err)
|
||||||
|
}
|
||||||
|
if resp.IsErrorState() {
|
||||||
|
return nil, fmt.Errorf("windsurf post auth: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionToken, _ := bridgeResp["sessionToken"].(string)
|
||||||
|
if sessionToken == "" {
|
||||||
|
return nil, fmt.Errorf("windsurf post auth: no sessionToken")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ottResp map[string]any
|
||||||
|
resp, err = c.R().SetContext(ctx).SetHeaders(hdrs).
|
||||||
|
SetBody(map[string]string{"authToken": sessionToken}).
|
||||||
|
SetSuccessResult(&ottResp).
|
||||||
|
Post(a.SeatServiceBaseURL + "/GetOneTimeAuthToken")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get one-time token: %w", err)
|
||||||
|
}
|
||||||
|
if resp.IsErrorState() {
|
||||||
|
return nil, fmt.Errorf("get one-time token: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
oneTimeToken, _ := ottResp["authToken"].(string)
|
||||||
|
if oneTimeToken == "" {
|
||||||
|
return nil, fmt.Errorf("get one-time token: no authToken")
|
||||||
|
}
|
||||||
|
|
||||||
|
reg, err := a.RegisterWithCodeium(ctx, oneTimeToken, fp, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("codeium register (auth1): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LoginResult{
|
||||||
|
APIKey: reg.APIKey,
|
||||||
|
Name: reg.Name,
|
||||||
|
Email: email,
|
||||||
|
APIServerURL: reg.APIServerURL,
|
||||||
|
SessionToken: sessionToken,
|
||||||
|
Auth1Token: auth1Token,
|
||||||
|
AuthMethod: "auth1",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthClient) loginViaFirebase(ctx context.Context, email, password string, fp http.Header, proxyURL string) (*LoginResult, error) {
|
||||||
|
c := newClient(a.RequestTimeout, proxyURL)
|
||||||
|
|
||||||
|
firebaseURL := fmt.Sprintf("https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=%s", a.FirebaseAPIKey)
|
||||||
|
body := map[string]any{"email": email, "password": password, "returnSecureToken": true}
|
||||||
|
|
||||||
|
var fbResp map[string]any
|
||||||
|
resp, err := c.R().SetContext(ctx).SetHeaders(headerMap(fp)).SetBody(body).SetSuccessResult(&fbResp).Post(firebaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("firebase login: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errObj, ok := fbResp["error"].(map[string]any); ok {
|
||||||
|
msg, _ := errObj["message"].(string)
|
||||||
|
return nil, classifyAuthError("Firebase 登录失败", msg)
|
||||||
|
}
|
||||||
|
if resp.IsErrorState() {
|
||||||
|
return nil, fmt.Errorf("firebase login: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, _ := fbResp["idToken"].(string)
|
||||||
|
if idToken == "" {
|
||||||
|
return nil, fmt.Errorf("firebase login: no idToken")
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, _ := fbResp["refreshToken"].(string)
|
||||||
|
|
||||||
|
reg, err := a.RegisterWithCodeium(ctx, idToken, fp, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("codeium register (firebase): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LoginResult{
|
||||||
|
APIKey: reg.APIKey,
|
||||||
|
Name: reg.Name,
|
||||||
|
Email: email,
|
||||||
|
IDToken: idToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
APIServerURL: reg.APIServerURL,
|
||||||
|
AuthMethod: "firebase",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
var regResp map[string]any
|
||||||
|
resp, err := c.R().SetContext(ctx).SetHeaders(headerMap(fp)).SetBody(body).SetSuccessResult(®Resp).Post(a.CodeiumRegisterURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.IsErrorState() {
|
||||||
|
data, _ := json.Marshal(regResp)
|
||||||
|
return nil, fmt.Errorf("codeium register: status %d: %s", resp.StatusCode, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey, _ := regResp["api_key"].(string)
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, fmt.Errorf("codeium register: no api_key in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
name, _ := regResp["name"].(string)
|
||||||
|
apiServerURL, _ := regResp["api_server_url"].(string)
|
||||||
|
|
||||||
|
return &RegisterResult{APIKey: apiKey, Name: name, APIServerURL: apiServerURL}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthClient) RefreshFirebaseToken(ctx context.Context, refreshToken, proxyURL string) (*RefreshResult, error) {
|
||||||
|
if refreshToken == "" {
|
||||||
|
return nil, fmt.Errorf("no refresh token available")
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshURL := fmt.Sprintf("https://securetoken.googleapis.com/v1/token?key=%s", a.FirebaseAPIKey)
|
||||||
|
postBody := fmt.Sprintf("grant_type=refresh_token&refresh_token=%s", url.QueryEscape(refreshToken))
|
||||||
|
|
||||||
|
c := newClient(a.RequestTimeout, proxyURL)
|
||||||
|
var result map[string]any
|
||||||
|
resp, err := c.R().SetContext(ctx).
|
||||||
|
SetHeader("Content-Type", "application/x-www-form-urlencoded").
|
||||||
|
SetHeader("Referer", "https://windsurf.com/").
|
||||||
|
SetHeader("Origin", "https://windsurf.com").
|
||||||
|
SetBodyString(postBody).
|
||||||
|
SetSuccessResult(&result).
|
||||||
|
Post(refreshURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("firebase refresh: %w", err)
|
||||||
|
}
|
||||||
|
if resp.IsErrorState() {
|
||||||
|
if errObj, ok := result["error"].(map[string]any); ok {
|
||||||
|
msg, _ := errObj["message"].(string)
|
||||||
|
return nil, fmt.Errorf("firebase refresh: %s", msg)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("firebase refresh: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken := firstString(result, "id_token", "idToken")
|
||||||
|
if idToken == "" {
|
||||||
|
return nil, fmt.Errorf("firebase refresh: no idToken in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
newRefresh := firstString(result, "refresh_token", "refreshToken")
|
||||||
|
if newRefresh == "" {
|
||||||
|
newRefresh = refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresIn := 3600
|
||||||
|
if v, ok := result["expires_in"].(string); ok {
|
||||||
|
fmt.Sscanf(v, "%d", &expiresIn)
|
||||||
|
} else if v, ok := result["expiresIn"].(string); ok {
|
||||||
|
fmt.Sscanf(v, "%d", &expiresIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RefreshResult{IDToken: idToken, RefreshToken: newRefresh, ExpiresIn: expiresIn}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AuthClient) ReRegisterWithCodeium(ctx context.Context, idToken, proxyURL string) (*RegisterResult, error) {
|
||||||
|
fp := generateFingerprint()
|
||||||
|
return a.RegisterWithCodeium(ctx, idToken, fp, proxyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyAuthError(prefix, detail string) *AuthError {
|
||||||
|
authFails := map[string]bool{
|
||||||
|
"EMAIL_NOT_FOUND": true,
|
||||||
|
"INVALID_PASSWORD": true,
|
||||||
|
"INVALID_LOGIN_CREDENTIALS": true,
|
||||||
|
"Invalid email or password": true,
|
||||||
|
"No password set. Please log in with Google or GitHub.": true,
|
||||||
|
"No password set": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
friendly := map[string]string{
|
||||||
|
"EMAIL_NOT_FOUND": "该邮箱未注册",
|
||||||
|
"INVALID_PASSWORD": "密码错误",
|
||||||
|
"INVALID_LOGIN_CREDENTIALS": "邮箱或密码错误",
|
||||||
|
"Invalid email or password": "邮箱或密码错误",
|
||||||
|
"USER_DISABLED": "账号已被停用",
|
||||||
|
"TOO_MANY_ATTEMPTS_TRY_LATER": "尝试太多次,请稍后再试",
|
||||||
|
"INVALID_EMAIL": "邮箱格式错误",
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := detail
|
||||||
|
if f, ok := friendly[detail]; ok {
|
||||||
|
msg = f
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AuthError{
|
||||||
|
Message: fmt.Sprintf("%s: %s", prefix, msg),
|
||||||
|
IsAuthFail: authFails[detail],
|
||||||
|
FirebaseCode: detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func headerMap(h http.Header) map[string]string {
|
||||||
|
m := make(map[string]string, len(h))
|
||||||
|
for k := range h {
|
||||||
|
m[k] = h.Get(k)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractString(data map[string]any, keys ...string) (string, bool) {
|
||||||
|
current := data
|
||||||
|
for i, k := range keys {
|
||||||
|
if i == len(keys)-1 {
|
||||||
|
v, ok := current[k].(string)
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
next, ok := current[k].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBool(data map[string]any, keys ...string) (bool, bool) {
|
||||||
|
current := data
|
||||||
|
for i, k := range keys {
|
||||||
|
if i == len(keys)-1 {
|
||||||
|
v, ok := current[k].(bool)
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
next, ok := current[k].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
current = next
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstString(m map[string]any, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v, ok := m[k].(string); ok && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
264
backend/internal/pkg/windsurf/client.go
Normal file
264
backend/internal/pkg/windsurf/client.go
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
// HTTP client for Windsurf upstream JSON/Connect-RPC endpoints.
|
||||||
|
// Portions derived from windsurf-tools (MIT 2025 shaoyu521). See ./LICENSE.
|
||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client wraps an *http.Client and the Windsurf base URL.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
HTTP *http.Client
|
||||||
|
CSRFToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient builds a Client. proxyURL may be empty.
|
||||||
|
func NewClient(baseURL, proxyURL string, csrfToken ...string) (*Client, error) {
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = DefaultBaseURL
|
||||||
|
}
|
||||||
|
transport := &http.Transport{
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 60 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
}
|
||||||
|
if proxyURL != "" {
|
||||||
|
u, err := url.Parse(proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse proxy: %w", err)
|
||||||
|
}
|
||||||
|
transport.Proxy = http.ProxyURL(u)
|
||||||
|
}
|
||||||
|
var csrf string
|
||||||
|
if len(csrfToken) > 0 {
|
||||||
|
csrf = csrfToken[0]
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
BaseURL: baseURL,
|
||||||
|
CSRFToken: csrf,
|
||||||
|
HTTP: &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: 180 * time.Second,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckChatCapacity returns hasCapacity flag from server.
|
||||||
|
func (c *Client) CheckChatCapacity(ctx context.Context, token string) (bool, string, error) {
|
||||||
|
rawJWT := StripDevinPrefix(token)
|
||||||
|
body := map[string]any{
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"apiKey": token,
|
||||||
|
"ideName": AppName,
|
||||||
|
"ideVersion": AppVersion,
|
||||||
|
"extensionName": AppName,
|
||||||
|
"extensionVersion": "0.2.0",
|
||||||
|
"sessionId": generateUUID(),
|
||||||
|
"requestId": randomUint64String(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := c.unaryJSON(ctx, "/exa.api_server_pb.ApiServerService/CheckChatCapacity", body, rawJWT)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
HasCapacity bool `json:"hasCapacity"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp, &out); err != nil {
|
||||||
|
return false, string(resp), fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
return out.HasCapacity, string(resp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserStatus holds the fields from GetUserStatus.
|
||||||
|
type UserStatus struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
TeamID string `json:"teamId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
|
||||||
|
PlanName string `json:"planName,omitempty"`
|
||||||
|
DailyPercent *float64 `json:"dailyPercent,omitempty"`
|
||||||
|
WeeklyPercent *float64 `json:"weeklyPercent,omitempty"`
|
||||||
|
MonthlyPromptCredits *float64 `json:"monthlyPromptCredits,omitempty"`
|
||||||
|
UsedPromptCredits *float64 `json:"usedPromptCredits,omitempty"`
|
||||||
|
MonthlyFlexCredits *float64 `json:"monthlyFlexCredits,omitempty"`
|
||||||
|
UsedFlexCredits *float64 `json:"usedFlexCredits,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserStatus fetches the user's plan status from server.codeium.com.
|
||||||
|
func (c *Client) GetUserStatus(ctx context.Context, token string) (*UserStatus, error) {
|
||||||
|
rawJWT := StripDevinPrefix(token)
|
||||||
|
body := map[string]any{
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"apiKey": token,
|
||||||
|
"ideName": AppName,
|
||||||
|
"ideVersion": AppVersion,
|
||||||
|
"extensionName": AppName,
|
||||||
|
"extensionVersion": "0.2.0",
|
||||||
|
"sessionId": generateUUID(),
|
||||||
|
"requestId": randomUint64String(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := c.unaryJSONURL(ctx, "https://server.codeium.com/exa.api_server_pb.ApiServerService/GetUserStatus", body, rawJWT)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
UserStatus struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
TeamID string `json:"teamId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PlanStatus struct {
|
||||||
|
PlanInfo struct {
|
||||||
|
PlanName json.Number `json:"planName"`
|
||||||
|
MonthlyPromptCredits json.Number `json:"monthlyPromptCredits"`
|
||||||
|
MonthlyFlexCredits json.Number `json:"monthlyFlexCreditPurchaseAmount"`
|
||||||
|
} `json:"planInfo"`
|
||||||
|
DailyQuotaRemainingPercent *float64 `json:"dailyQuotaRemainingPercent"`
|
||||||
|
WeeklyQuotaRemainingPercent *float64 `json:"weeklyQuotaRemainingPercent"`
|
||||||
|
UsedPromptCredits json.Number `json:"usedPromptCredits"`
|
||||||
|
UsedFlexCredits json.Number `json:"usedFlexCredits"`
|
||||||
|
} `json:"planStatus"`
|
||||||
|
} `json:"userStatus"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w (body=%s)", err, truncate(string(resp), 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
us := out.UserStatus
|
||||||
|
ps := us.PlanStatus
|
||||||
|
|
||||||
|
numPtr := func(n json.Number) *float64 {
|
||||||
|
if n.String() == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v, err := n.Float64()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Legacy values come in hundredths
|
||||||
|
v /= 100
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UserStatus{
|
||||||
|
UserID: us.UserID,
|
||||||
|
TeamID: us.TeamID,
|
||||||
|
Name: us.Name,
|
||||||
|
Email: us.Email,
|
||||||
|
PlanName: ps.PlanInfo.PlanName.String(),
|
||||||
|
DailyPercent: ps.DailyQuotaRemainingPercent,
|
||||||
|
WeeklyPercent: ps.WeeklyQuotaRemainingPercent,
|
||||||
|
MonthlyPromptCredits: numPtr(ps.PlanInfo.MonthlyPromptCredits),
|
||||||
|
UsedPromptCredits: numPtr(ps.UsedPromptCredits),
|
||||||
|
MonthlyFlexCredits: numPtr(ps.PlanInfo.MonthlyFlexCredits),
|
||||||
|
UsedFlexCredits: numPtr(ps.UsedFlexCredits),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelInfo is one entry of GetCascadeModelConfigs response.
|
||||||
|
type ModelInfo struct {
|
||||||
|
ModelUID string `json:"modelUid"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
CreditMultiplier float64 `json:"creditMultiplier"`
|
||||||
|
IsRecommended bool `json:"isRecommended"`
|
||||||
|
IsNew bool `json:"isNew"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels returns the cascade model catalog.
|
||||||
|
func (c *Client) ListModels(ctx context.Context, token string) ([]ModelInfo, error) {
|
||||||
|
rawJWT := StripDevinPrefix(token)
|
||||||
|
body := map[string]any{
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"apiKey": token,
|
||||||
|
"ideName": AppName,
|
||||||
|
"ideVersion": AppVersion,
|
||||||
|
"extensionName": AppName,
|
||||||
|
"extensionVersion": "0.2.0",
|
||||||
|
"sessionId": generateUUID(),
|
||||||
|
"requestId": randomUint64String(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := c.unaryJSON(ctx, "/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs", body, rawJWT)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
ClientModelConfigs []ModelInfo `json:"clientModelConfigs"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp, &out); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w (body=%s)", err, truncate(string(resp), 300))
|
||||||
|
}
|
||||||
|
return out.ClientModelConfigs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasModel reports whether models contains the given uid.
|
||||||
|
func HasModel(models []ModelInfo, uid string) bool {
|
||||||
|
for _, m := range models {
|
||||||
|
if strings.EqualFold(m.ModelUID, uid) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) unaryJSON(ctx context.Context, path string, body any, rawJWT string) ([]byte, error) {
|
||||||
|
return c.unaryJSONURL(ctx, c.BaseURL+path, body, rawJWT)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) unaryJSONURL(ctx context.Context, fullURL string, body any, rawJWT string) ([]byte, error) {
|
||||||
|
jsonBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Connect-Protocol-Version", "1")
|
||||||
|
req.Header.Set("User-Agent", UserAgent)
|
||||||
|
if rawJWT != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+rawJWT)
|
||||||
|
}
|
||||||
|
resp, err := c.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return respBody, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(respBody), 300))
|
||||||
|
}
|
||||||
|
return respBody, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomUint64String() string {
|
||||||
|
var b [8]byte
|
||||||
|
_, _ = readRandom(b[:])
|
||||||
|
var v uint64
|
||||||
|
for _, x := range b {
|
||||||
|
v = (v << 8) | uint64(x)
|
||||||
|
}
|
||||||
|
v &^= 1 << 63
|
||||||
|
return fmt.Sprintf("%d", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "...(truncated)"
|
||||||
|
}
|
||||||
92
backend/internal/pkg/windsurf/codec.go
Normal file
92
backend/internal/pkg/windsurf/codec.go
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
// Package windsurf is a minimal Go client for the Windsurf LanguageServerService (local gRPC)
|
||||||
|
// and upstream Connect-RPC JSON endpoints.
|
||||||
|
//
|
||||||
|
// Portions of this file derive from https://github.com/seven7763/windsurf-tools (MIT, 2025 shaoyu521).
|
||||||
|
// See ./LICENSE for full attribution.
|
||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultBaseURL = "https://server.self-serve.windsurf.com"
|
||||||
|
|
||||||
|
AppName = "windsurf"
|
||||||
|
AppVersion = "1.48.2"
|
||||||
|
ExtensionVersion = "1.9600.41"
|
||||||
|
IDEVersion = ExtensionVersion
|
||||||
|
RuntimeOS = "linux"
|
||||||
|
HardwareArch = "x86_64"
|
||||||
|
ClientVersion = "2.0.63"
|
||||||
|
UserAgent = "connect-go/1.18.1 (go1.26.1)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Protobuf wire encoding ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func writeVarint(value uint64) []byte {
|
||||||
|
var parts []byte
|
||||||
|
for value > 0x7F {
|
||||||
|
parts = append(parts, byte(value&0x7F)|0x80)
|
||||||
|
value >>= 7
|
||||||
|
}
|
||||||
|
parts = append(parts, byte(value))
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeBytesField(fieldNum uint64, data []byte) []byte {
|
||||||
|
tag := writeVarint((fieldNum << 3) | 2)
|
||||||
|
length := writeVarint(uint64(len(data)))
|
||||||
|
out := make([]byte, 0, len(tag)+len(length)+len(data))
|
||||||
|
out = append(out, tag...)
|
||||||
|
out = append(out, length...)
|
||||||
|
out = append(out, data...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeStringField(fieldNum uint64, s string) []byte {
|
||||||
|
return encodeBytesField(fieldNum, []byte(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeVarintField(fieldNum uint64, value uint64) []byte {
|
||||||
|
tag := writeVarint((fieldNum << 3) | 0)
|
||||||
|
val := writeVarint(value)
|
||||||
|
out := make([]byte, 0, len(tag)+len(val))
|
||||||
|
out = append(out, tag...)
|
||||||
|
out = append(out, val...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadVarint reads a varint from data starting at pos.
|
||||||
|
func ReadVarint(data []byte, pos int) (val uint64, newPos int, ok bool) {
|
||||||
|
var shift uint
|
||||||
|
for pos < len(data) {
|
||||||
|
b := data[pos]
|
||||||
|
pos++
|
||||||
|
val |= uint64(b&0x7F) << shift
|
||||||
|
shift += 7
|
||||||
|
if (b & 0x80) == 0 {
|
||||||
|
return val, pos, true
|
||||||
|
}
|
||||||
|
if shift >= 64 {
|
||||||
|
return 0, pos, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, pos, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UUID ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func generateUUID() string {
|
||||||
|
var buf [16]byte
|
||||||
|
_, _ = readRandom(buf[:])
|
||||||
|
buf[6] = (buf[6] & 0x0f) | 0x40
|
||||||
|
buf[8] = (buf[8] & 0x3f) | 0x80
|
||||||
|
return hex.EncodeToString(buf[0:4]) + "-" +
|
||||||
|
hex.EncodeToString(buf[4:6]) + "-" +
|
||||||
|
hex.EncodeToString(buf[6:8]) + "-" +
|
||||||
|
hex.EncodeToString(buf[8:10]) + "-" +
|
||||||
|
hex.EncodeToString(buf[10:16])
|
||||||
|
}
|
||||||
158
backend/internal/pkg/windsurf/connector.go
Normal file
158
backend/internal/pkg/windsurf/connector.go
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LSConnector interface {
|
||||||
|
Mode() string
|
||||||
|
Acquire(ctx context.Context, proxyURL string) (*LSLease, error)
|
||||||
|
Health(ctx context.Context) error
|
||||||
|
Status() *LSConnectorStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type LSLease struct {
|
||||||
|
Mode string
|
||||||
|
Endpoint string
|
||||||
|
Client *LocalLSClient
|
||||||
|
Release func()
|
||||||
|
}
|
||||||
|
|
||||||
|
type LSConnectorStatus struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
Instances int `json:"instances"`
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DockerConnector struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
csrfToken string
|
||||||
|
client *LocalLSClient
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerConnector(host string, port int, csrfToken string) *DockerConnector {
|
||||||
|
return &DockerConnector{host: host, port: port, csrfToken: csrfToken}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DockerConnector) Mode() string { return "docker" }
|
||||||
|
|
||||||
|
func (d *DockerConnector) Acquire(_ context.Context, _ string) (*LSLease, error) {
|
||||||
|
d.once.Do(func() {
|
||||||
|
d.client = NewLocalLSClient(d.port, d.csrfToken)
|
||||||
|
d.client.BaseURL = fmt.Sprintf("http://%s:%d", d.host, d.port)
|
||||||
|
})
|
||||||
|
return &LSLease{
|
||||||
|
Mode: "docker",
|
||||||
|
Endpoint: fmt.Sprintf("%s:%d", d.host, d.port),
|
||||||
|
Client: d.client,
|
||||||
|
Release: func() {},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DockerConnector) Health(ctx context.Context) error {
|
||||||
|
_, err := d.Acquire(ctx, "")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DockerConnector) Status() *LSConnectorStatus {
|
||||||
|
return &LSConnectorStatus{
|
||||||
|
Mode: "docker",
|
||||||
|
Healthy: d.client != nil,
|
||||||
|
Instances: 1,
|
||||||
|
Endpoint: fmt.Sprintf("%s:%d", d.host, d.port),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmbeddedConnector struct {
|
||||||
|
pool *LSPool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmbeddedConnector(pool *LSPool) *EmbeddedConnector {
|
||||||
|
return &EmbeddedConnector{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmbeddedConnector) Mode() string { return "embedded" }
|
||||||
|
|
||||||
|
func (e *EmbeddedConnector) Acquire(ctx context.Context, proxyURL string) (*LSLease, error) {
|
||||||
|
entry, err := e.pool.Ensure(ctx, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &LSLease{
|
||||||
|
Mode: "embedded",
|
||||||
|
Endpoint: fmt.Sprintf("localhost:%d", entry.Port),
|
||||||
|
Client: entry.Client,
|
||||||
|
Release: func() {},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmbeddedConnector) Health(_ context.Context) error {
|
||||||
|
status := e.pool.Status()
|
||||||
|
if !status.Running {
|
||||||
|
return fmt.Errorf("no LS instances running")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmbeddedConnector) Status() *LSConnectorStatus {
|
||||||
|
status := e.pool.Status()
|
||||||
|
readyCount := 0
|
||||||
|
for _, inst := range status.Instances {
|
||||||
|
if inst.Ready {
|
||||||
|
readyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &LSConnectorStatus{
|
||||||
|
Mode: "embedded",
|
||||||
|
Healthy: readyCount > 0,
|
||||||
|
Instances: len(status.Instances),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalConnector struct {
|
||||||
|
baseURL string
|
||||||
|
port int
|
||||||
|
csrfToken string
|
||||||
|
client *LocalLSClient
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExternalConnector(baseURL string, port int, csrfToken string) *ExternalConnector {
|
||||||
|
return &ExternalConnector{baseURL: baseURL, port: port, csrfToken: csrfToken}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExternalConnector) Mode() string { return "external" }
|
||||||
|
|
||||||
|
func (x *ExternalConnector) Acquire(_ context.Context, _ string) (*LSLease, error) {
|
||||||
|
x.once.Do(func() {
|
||||||
|
x.client = NewLocalLSClient(x.port, x.csrfToken)
|
||||||
|
if x.baseURL != "" {
|
||||||
|
x.client.BaseURL = x.baseURL
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return &LSLease{
|
||||||
|
Mode: "external",
|
||||||
|
Endpoint: x.baseURL,
|
||||||
|
Client: x.client,
|
||||||
|
Release: func() {},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExternalConnector) Health(ctx context.Context) error {
|
||||||
|
_, err := x.Acquire(ctx, "")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExternalConnector) Status() *LSConnectorStatus {
|
||||||
|
return &LSConnectorStatus{
|
||||||
|
Mode: "external",
|
||||||
|
Healthy: x.client != nil,
|
||||||
|
Instances: 1,
|
||||||
|
Endpoint: x.baseURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
185
backend/internal/pkg/windsurf/conversation_pool.go
Normal file
185
backend/internal/pkg/windsurf/conversation_pool.go
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
poolTTL = 30 * time.Minute
|
||||||
|
poolMax = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConversationEntry struct {
|
||||||
|
CascadeID string
|
||||||
|
SessionID string
|
||||||
|
LSPort int
|
||||||
|
APIKey string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastAccess time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConversationPool struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
pool map[string]*ConversationEntry
|
||||||
|
stats poolStats
|
||||||
|
}
|
||||||
|
|
||||||
|
type poolStats struct {
|
||||||
|
Hits int `json:"hits"`
|
||||||
|
Misses int `json:"misses"`
|
||||||
|
Stores int `json:"stores"`
|
||||||
|
Evictions int `json:"evictions"`
|
||||||
|
Expired int `json:"expired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConversationPool() *ConversationPool {
|
||||||
|
cp := &ConversationPool{
|
||||||
|
pool: make(map[string]*ConversationEntry),
|
||||||
|
}
|
||||||
|
go cp.pruneLoop()
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cp *ConversationPool) Checkout(fingerprint string) *ConversationEntry {
|
||||||
|
if fingerprint == "" {
|
||||||
|
cp.mu.Lock()
|
||||||
|
cp.stats.Misses++
|
||||||
|
cp.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp.mu.Lock()
|
||||||
|
defer cp.mu.Unlock()
|
||||||
|
entry, ok := cp.pool[fingerprint]
|
||||||
|
if !ok {
|
||||||
|
cp.stats.Misses++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
delete(cp.pool, fingerprint)
|
||||||
|
if time.Since(entry.LastAccess) > poolTTL {
|
||||||
|
cp.stats.Expired++
|
||||||
|
cp.stats.Misses++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp.stats.Hits++
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cp *ConversationPool) Checkin(fingerprint string, entry *ConversationEntry) {
|
||||||
|
if fingerprint == "" || entry == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
cp.mu.Lock()
|
||||||
|
defer cp.mu.Unlock()
|
||||||
|
if entry.CreatedAt.IsZero() {
|
||||||
|
entry.CreatedAt = now
|
||||||
|
}
|
||||||
|
entry.LastAccess = now
|
||||||
|
cp.pool[fingerprint] = entry
|
||||||
|
cp.stats.Stores++
|
||||||
|
cp.pruneLocked(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cp *ConversationPool) InvalidateFor(apiKey string, lsPort int) int {
|
||||||
|
cp.mu.Lock()
|
||||||
|
defer cp.mu.Unlock()
|
||||||
|
dropped := 0
|
||||||
|
for fp, e := range cp.pool {
|
||||||
|
if (apiKey != "" && e.APIKey == apiKey) || (lsPort > 0 && e.LSPort == lsPort) {
|
||||||
|
delete(cp.pool, fp)
|
||||||
|
dropped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dropped
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cp *ConversationPool) pruneLocked(now time.Time) {
|
||||||
|
for fp, e := range cp.pool {
|
||||||
|
if now.Sub(e.LastAccess) > poolTTL {
|
||||||
|
delete(cp.pool, fp)
|
||||||
|
cp.stats.Expired++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cp.pool) <= poolMax {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// LRU eviction: find oldest entries
|
||||||
|
type fpTime struct {
|
||||||
|
fp string
|
||||||
|
t time.Time
|
||||||
|
}
|
||||||
|
entries := make([]fpTime, 0, len(cp.pool))
|
||||||
|
for fp, e := range cp.pool {
|
||||||
|
entries = append(entries, fpTime{fp, e.LastAccess})
|
||||||
|
}
|
||||||
|
// Simple sort by time
|
||||||
|
for i := 0; i < len(entries)-1; i++ {
|
||||||
|
for j := i + 1; j < len(entries); j++ {
|
||||||
|
if entries[j].t.Before(entries[i].t) {
|
||||||
|
entries[i], entries[j] = entries[j], entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toDrop := len(entries) - poolMax
|
||||||
|
for i := 0; i < toDrop; i++ {
|
||||||
|
delete(cp.pool, entries[i].fp)
|
||||||
|
cp.stats.Evictions++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cp *ConversationPool) pruneLoop() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
cp.mu.Lock()
|
||||||
|
cp.pruneLocked(time.Now())
|
||||||
|
cp.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FingerprintBefore computes the fingerprint for resuming a conversation.
|
||||||
|
// Hash only user/tool turns (excluding the last one) for lookup.
|
||||||
|
func FingerprintBefore(messages []ChatMessage, modelKey string) string {
|
||||||
|
turns := stableTurns(messages)
|
||||||
|
if len(turns) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hashFingerprint(modelKey, turns[:len(turns)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// FingerprintAfter computes the fingerprint after a successful turn.
|
||||||
|
func FingerprintAfter(messages []ChatMessage, modelKey string) string {
|
||||||
|
turns := stableTurns(messages)
|
||||||
|
if len(turns) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hashFingerprint(modelKey, turns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stableTurns(messages []ChatMessage) []ChatMessage {
|
||||||
|
var turns []ChatMessage
|
||||||
|
for _, m := range messages {
|
||||||
|
if m.Role == "user" || m.Role == "tool" {
|
||||||
|
turns = append(turns, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return turns
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashFingerprint(modelKey string, turns []ChatMessage) string {
|
||||||
|
type canonical struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
cans := make([]canonical, len(turns))
|
||||||
|
for i, t := range turns {
|
||||||
|
cans[i] = canonical{Role: t.Role, Content: t.Content}
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(cans)
|
||||||
|
h := sha256.Sum256([]byte(fmt.Sprintf("%s\x00\x00%s", modelKey, data)))
|
||||||
|
return fmt.Sprintf("%x", h)
|
||||||
|
}
|
||||||
493
backend/internal/pkg/windsurf/docker_discovery.go
Normal file
493
backend/internal/pkg/windsurf/docker_discovery.go
Normal file
@ -0,0 +1,493 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DockerDiscoveryConfig struct {
|
||||||
|
// ContainerNamePrefix filters containers whose name starts with this prefix.
|
||||||
|
// Default: "sub2api-windsurf-ls"
|
||||||
|
ContainerNamePrefix string
|
||||||
|
|
||||||
|
// FallbackHost is used when Docker hostnames can't be resolved (local dev).
|
||||||
|
// Default: "127.0.0.1"
|
||||||
|
FallbackHost string
|
||||||
|
|
||||||
|
// DefaultCSRFToken is the CSRF token for LS gRPC calls.
|
||||||
|
DefaultCSRFToken string
|
||||||
|
|
||||||
|
// ProbeInterval controls how often health probes run.
|
||||||
|
// Default: 30s
|
||||||
|
ProbeInterval time.Duration
|
||||||
|
|
||||||
|
// ProbeTimeout is the TCP dial timeout for health checks.
|
||||||
|
// Default: 3s
|
||||||
|
ProbeTimeout time.Duration
|
||||||
|
|
||||||
|
// DiscoverInterval controls how often Docker API is polled for new containers.
|
||||||
|
// Default: 60s
|
||||||
|
DiscoverInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerDiscoveryConfig) defaults() {
|
||||||
|
if c.ContainerNamePrefix == "" {
|
||||||
|
c.ContainerNamePrefix = "sub2api-windsurf-ls"
|
||||||
|
}
|
||||||
|
if c.FallbackHost == "" {
|
||||||
|
c.FallbackHost = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if c.DefaultCSRFToken == "" {
|
||||||
|
c.DefaultCSRFToken = DefaultCSRF
|
||||||
|
}
|
||||||
|
if c.ProbeInterval <= 0 {
|
||||||
|
c.ProbeInterval = 30 * time.Second
|
||||||
|
}
|
||||||
|
if c.ProbeTimeout <= 0 {
|
||||||
|
c.ProbeTimeout = 3 * time.Second
|
||||||
|
}
|
||||||
|
if c.DiscoverInterval <= 0 {
|
||||||
|
c.DiscoverInterval = 60 * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type lsInstance struct {
|
||||||
|
ContainerID string
|
||||||
|
ContainerName string
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
CSRFToken string
|
||||||
|
Client *LocalLSClient
|
||||||
|
Healthy atomic.Bool
|
||||||
|
DiscoveredAt time.Time
|
||||||
|
LastProbeAt time.Time
|
||||||
|
LastProbeErr string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DockerDiscoveryConnector struct {
|
||||||
|
cfg DockerDiscoveryConfig
|
||||||
|
mu sync.RWMutex
|
||||||
|
instances []*lsInstance
|
||||||
|
robin atomic.Uint64
|
||||||
|
cancel context.CancelFunc
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDockerDiscoveryConnector(cfg DockerDiscoveryConfig) *DockerDiscoveryConnector {
|
||||||
|
cfg.defaults()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
c := &DockerDiscoveryConnector{
|
||||||
|
cfg: cfg,
|
||||||
|
cancel: cancel,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go c.loop(ctx)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerDiscoveryConnector) Mode() string { return "docker" }
|
||||||
|
|
||||||
|
func (c *DockerDiscoveryConnector) Acquire(_ context.Context, _ string) (*LSLease, error) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
healthy := c.healthyInstances()
|
||||||
|
if len(healthy) == 0 {
|
||||||
|
return nil, fmt.Errorf("no healthy LS instances available")
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := c.robin.Add(1) - 1
|
||||||
|
inst := healthy[idx%uint64(len(healthy))]
|
||||||
|
|
||||||
|
return &LSLease{
|
||||||
|
Mode: "docker",
|
||||||
|
Endpoint: fmt.Sprintf("%s:%d", inst.Host, inst.Port),
|
||||||
|
Client: inst.Client,
|
||||||
|
Release: func() {},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcquireByID returns the LS instance matching containerID. Falls back to round-robin if not found.
|
||||||
|
func (c *DockerDiscoveryConnector) AcquireByID(containerID string) (*LSLease, error) {
|
||||||
|
if containerID == "" {
|
||||||
|
return c.Acquire(context.Background(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, inst := range c.instances {
|
||||||
|
if inst.ContainerID == containerID || inst.ContainerName == containerID {
|
||||||
|
if !inst.Healthy.Load() {
|
||||||
|
slog.Warn("windsurf_ls_bound_unhealthy", "container", containerID)
|
||||||
|
}
|
||||||
|
return &LSLease{
|
||||||
|
Mode: "docker",
|
||||||
|
Endpoint: fmt.Sprintf("%s:%d", inst.Host, inst.Port),
|
||||||
|
Client: inst.Client,
|
||||||
|
Release: func() {},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Warn("windsurf_ls_bound_not_found", "container", containerID, "fallback", "round-robin")
|
||||||
|
return c.acquireRoundRobin()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerDiscoveryConnector) acquireRoundRobin() (*LSLease, error) {
|
||||||
|
healthy := c.healthyInstances()
|
||||||
|
if len(healthy) == 0 {
|
||||||
|
return nil, fmt.Errorf("no healthy LS instances available")
|
||||||
|
}
|
||||||
|
idx := c.robin.Add(1) - 1
|
||||||
|
inst := healthy[idx%uint64(len(healthy))]
|
||||||
|
return &LSLease{
|
||||||
|
Mode: "docker",
|
||||||
|
Endpoint: fmt.Sprintf("%s:%d", inst.Host, inst.Port),
|
||||||
|
Client: inst.Client,
|
||||||
|
Release: func() {},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerDiscoveryConnector) Health(_ context.Context) error {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
if len(c.healthyInstances()) == 0 {
|
||||||
|
return fmt.Errorf("no healthy LS instances")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerDiscoveryConnector) Status() *LSConnectorStatus {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
healthy := c.healthyInstances()
|
||||||
|
return &LSConnectorStatus{
|
||||||
|
Mode: "docker",
|
||||||
|
Healthy: len(healthy) > 0,
|
||||||
|
Instances: len(c.instances),
|
||||||
|
Endpoint: c.endpointSummary(healthy),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerDiscoveryConnector) Shutdown() {
|
||||||
|
c.cancel()
|
||||||
|
<-c.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthyInstances returns instances where Healthy is true. Caller must hold at least RLock.
|
||||||
|
func (c *DockerDiscoveryConnector) healthyInstances() []*lsInstance {
|
||||||
|
var result []*lsInstance
|
||||||
|
for _, inst := range c.instances {
|
||||||
|
if inst.Healthy.Load() {
|
||||||
|
result = append(result, inst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerDiscoveryConnector) endpointSummary(healthy []*lsInstance) string {
|
||||||
|
if len(healthy) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
parts := make([]string, len(healthy))
|
||||||
|
for i, inst := range healthy {
|
||||||
|
parts[i] = fmt.Sprintf("%s:%d", inst.Host, inst.Port)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerDiscoveryConnector) loop(ctx context.Context) {
|
||||||
|
defer close(c.done)
|
||||||
|
|
||||||
|
c.discover(ctx)
|
||||||
|
c.probeAll(ctx)
|
||||||
|
|
||||||
|
discoverTick := time.NewTicker(c.cfg.DiscoverInterval)
|
||||||
|
probeTick := time.NewTicker(c.cfg.ProbeInterval)
|
||||||
|
defer discoverTick.Stop()
|
||||||
|
defer probeTick.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-discoverTick.C:
|
||||||
|
c.discover(ctx)
|
||||||
|
case <-probeTick.C:
|
||||||
|
c.probeAll(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerDiscoveryConnector) discover(ctx context.Context) {
|
||||||
|
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("windsurf_ls_docker_client_error", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer cli.Close()
|
||||||
|
|
||||||
|
containers, err := cli.ContainerList(ctx, container.ListOptions{
|
||||||
|
Filters: filters.NewArgs(
|
||||||
|
filters.Arg("status", "running"),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("windsurf_ls_docker_list_error", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var found []*lsInstance
|
||||||
|
for _, ctr := range containers {
|
||||||
|
name := containerName(ctr.Names)
|
||||||
|
if !strings.Contains(name, "windsurf-ls") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
host, port, csrfToken := c.extractEndpoint(ctr)
|
||||||
|
if port == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
found = append(found, &lsInstance{
|
||||||
|
ContainerID: ctr.ID[:12],
|
||||||
|
ContainerName: name,
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
CSRFToken: csrfToken,
|
||||||
|
DiscoveredAt: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.reconcile(found)
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
slog.Info("windsurf_ls_discovery", "found", len(found), "total", len(c.instances))
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerName(names []string) string {
|
||||||
|
for _, n := range names {
|
||||||
|
return strings.TrimPrefix(n, "/")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerDiscoveryConnector) extractEndpoint(ctr container.Summary) (string, int, string) {
|
||||||
|
host := containerName(ctr.Names)
|
||||||
|
csrfToken := c.cfg.DefaultCSRFToken
|
||||||
|
|
||||||
|
for _, env := range ctr.Labels {
|
||||||
|
// labels can carry csrf overrides if needed
|
||||||
|
_ = env
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := net.LookupHost(host); err != nil {
|
||||||
|
host = c.cfg.FallbackHost
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range ctr.Ports {
|
||||||
|
if p.PrivatePort == 42099 || p.PrivatePort == 42100 || (p.PublicPort >= 42099 && p.PublicPort <= 42200) {
|
||||||
|
port := int(p.PublicPort)
|
||||||
|
if port == 0 {
|
||||||
|
port = int(p.PrivatePort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When port has a host-bound IP (e.g. 127.0.0.1:42100->42100),
|
||||||
|
// use that IP instead of the container name. This ensures the
|
||||||
|
// backend can reach the LS when running on the host (go run)
|
||||||
|
// rather than inside the Docker network.
|
||||||
|
if p.IP != "" && p.PublicPort > 0 {
|
||||||
|
host = p.IP
|
||||||
|
port = int(p.PublicPort)
|
||||||
|
} else if host == c.cfg.FallbackHost && p.PublicPort > 0 {
|
||||||
|
port = int(p.PublicPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range envFromLabels(ctr.Labels) {
|
||||||
|
if strings.HasPrefix(e, "LS_CSRF_TOKEN=") {
|
||||||
|
csrfToken = strings.TrimPrefix(e, "LS_CSRF_TOKEN=")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return host, port, csrfToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return host, 0, csrfToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func envFromLabels(labels map[string]string) []string {
|
||||||
|
var result []string
|
||||||
|
for k, v := range labels {
|
||||||
|
if strings.HasPrefix(k, "windsurf.") {
|
||||||
|
result = append(result, strings.TrimPrefix(k, "windsurf.")+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcile merges discovered containers into the existing pool. Caller must hold Lock.
|
||||||
|
func (c *DockerDiscoveryConnector) reconcile(found []*lsInstance) {
|
||||||
|
existing := make(map[string]*lsInstance)
|
||||||
|
for _, inst := range c.instances {
|
||||||
|
existing[inst.ContainerID] = inst
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged []*lsInstance
|
||||||
|
for _, f := range found {
|
||||||
|
if old, ok := existing[f.ContainerID]; ok {
|
||||||
|
old.Host = f.Host
|
||||||
|
old.Port = f.Port
|
||||||
|
merged = append(merged, old)
|
||||||
|
} else {
|
||||||
|
f.Client = NewLocalLSClient(f.Port, f.CSRFToken)
|
||||||
|
if f.Host != "localhost" && f.Host != "127.0.0.1" {
|
||||||
|
f.Client.BaseURL = fmt.Sprintf("http://%s:%d", f.Host, f.Port)
|
||||||
|
}
|
||||||
|
merged = append(merged, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(merged) == 0 && len(c.instances) > 0 {
|
||||||
|
slog.Warn("windsurf_ls_discovery_empty", "keeping_old", len(c.instances))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.instances = merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerDiscoveryConnector) probeAll(ctx context.Context) {
|
||||||
|
c.mu.RLock()
|
||||||
|
snapshot := make([]*lsInstance, len(c.instances))
|
||||||
|
copy(snapshot, c.instances)
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, inst := range snapshot {
|
||||||
|
healthy := c.probeOne(ctx, inst)
|
||||||
|
inst.Healthy.Store(healthy)
|
||||||
|
inst.LastProbeAt = time.Now()
|
||||||
|
if healthy {
|
||||||
|
inst.LastProbeErr = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DockerDiscoveryConnector) probeOne(_ context.Context, inst *lsInstance) bool {
|
||||||
|
addr := fmt.Sprintf("%s:%d", inst.Host, inst.Port)
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, c.cfg.ProbeTimeout)
|
||||||
|
if err != nil {
|
||||||
|
inst.LastProbeErr = err.Error()
|
||||||
|
if inst.Healthy.Load() {
|
||||||
|
slog.Warn("windsurf_ls_unhealthy", "container", inst.ContainerName, "addr", addr, "error", err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
if !inst.Healthy.Load() {
|
||||||
|
slog.Info("windsurf_ls_healthy", "container", inst.ContainerName, "addr", addr)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceStatuses returns detailed status for each discovered instance (for admin API).
|
||||||
|
func (c *DockerDiscoveryConnector) InstanceStatuses() []DockerLSInstanceStatus {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]DockerLSInstanceStatus, len(c.instances))
|
||||||
|
for i, inst := range c.instances {
|
||||||
|
result[i] = DockerLSInstanceStatus{
|
||||||
|
ContainerID: inst.ContainerID,
|
||||||
|
ContainerName: inst.ContainerName,
|
||||||
|
Host: inst.Host,
|
||||||
|
Port: inst.Port,
|
||||||
|
Healthy: inst.Healthy.Load(),
|
||||||
|
DiscoveredAt: inst.DiscoveredAt,
|
||||||
|
LastProbeAt: inst.LastProbeAt,
|
||||||
|
LastProbeErr: inst.LastProbeErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type DockerLSInstanceStatus struct {
|
||||||
|
ContainerID string `json:"container_id"`
|
||||||
|
ContainerName string `json:"container_name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Healthy bool `json:"healthy"`
|
||||||
|
DiscoveredAt time.Time `json:"discovered_at"`
|
||||||
|
LastProbeAt time.Time `json:"last_probe_at"`
|
||||||
|
LastProbeErr string `json:"last_probe_err,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCompatDockerConnector creates a discovery connector with a static fallback entry.
|
||||||
|
// It uses the legacy host/port/csrf config as an initial static instance, then overlays
|
||||||
|
// Docker API auto-discovery. If the configured host can't resolve, it falls back to 127.0.0.1.
|
||||||
|
func NewCompatDockerConnector(host string, port int, discoveryCfg DockerDiscoveryConfig) *DockerDiscoveryConnector {
|
||||||
|
resolvedHost := host
|
||||||
|
if _, err := net.LookupHost(host); err != nil {
|
||||||
|
resolvedHost = "127.0.0.1"
|
||||||
|
slog.Info("windsurf_ls_host_fallback", "original", host, "resolved", resolvedHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if discoveryCfg.DefaultCSRFToken == "" {
|
||||||
|
discoveryCfg.DefaultCSRFToken = DefaultCSRF
|
||||||
|
}
|
||||||
|
discoveryCfg.FallbackHost = "127.0.0.1"
|
||||||
|
discoveryCfg.defaults()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
c := &DockerDiscoveryConnector{
|
||||||
|
cfg: discoveryCfg,
|
||||||
|
cancel: cancel,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
csrfToken := discoveryCfg.DefaultCSRFToken
|
||||||
|
staticInst := &lsInstance{
|
||||||
|
ContainerID: "static",
|
||||||
|
ContainerName: fmt.Sprintf("static-%s-%d", host, port),
|
||||||
|
Host: resolvedHost,
|
||||||
|
Port: port,
|
||||||
|
CSRFToken: csrfToken,
|
||||||
|
Client: NewLocalLSClient(port, csrfToken),
|
||||||
|
DiscoveredAt: time.Now(),
|
||||||
|
}
|
||||||
|
if resolvedHost != "localhost" && resolvedHost != "127.0.0.1" {
|
||||||
|
staticInst.Client.BaseURL = fmt.Sprintf("http://%s:%d", resolvedHost, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.instances = []*lsInstance{staticInst}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
go c.loop(ctx)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePortFromEnv extracts port from LS_PORT environment variable value.
|
||||||
|
func parsePortFromEnv(envVars []string) int {
|
||||||
|
for _, e := range envVars {
|
||||||
|
if strings.HasPrefix(e, "LS_PORT=") {
|
||||||
|
p, err := strconv.Atoi(strings.TrimPrefix(e, "LS_PORT="))
|
||||||
|
if err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
228
backend/internal/pkg/windsurf/legacy_chat.go
Normal file
228
backend/internal/pkg/windsurf/legacy_chat.go
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
RawGetChatMessageRPC = "/exa.language_server_pb.LanguageServerService/RawGetChatMessage"
|
||||||
|
|
||||||
|
SourceUser = 1
|
||||||
|
SourceSystem = 2
|
||||||
|
SourceAssistant = 3
|
||||||
|
SourceTool = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LegacyChatDelta struct {
|
||||||
|
Text string
|
||||||
|
InProgress bool
|
||||||
|
IsError bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeTimestamp() []byte {
|
||||||
|
now := time.Now()
|
||||||
|
secs := uint64(now.Unix())
|
||||||
|
nanos := uint64(now.Nanosecond())
|
||||||
|
out := encodeVarintField(1, secs)
|
||||||
|
if nanos > 0 {
|
||||||
|
out = append(out, encodeVarintField(2, nanos)...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildChatMessage(content string, source int, conversationID string) []byte {
|
||||||
|
var parts []byte
|
||||||
|
parts = append(parts, encodeStringField(1, generateUUID())...)
|
||||||
|
parts = append(parts, encodeVarintField(2, uint64(source))...)
|
||||||
|
parts = append(parts, encodeBytesField(3, encodeTimestamp())...)
|
||||||
|
parts = append(parts, encodeStringField(4, conversationID)...)
|
||||||
|
|
||||||
|
if source == SourceAssistant {
|
||||||
|
actionGeneric := encodeStringField(1, content)
|
||||||
|
action := encodeBytesField(1, actionGeneric)
|
||||||
|
parts = append(parts, encodeBytesField(6, action)...)
|
||||||
|
} else {
|
||||||
|
intentGeneric := encodeStringField(1, content)
|
||||||
|
intent := encodeBytesField(1, intentGeneric)
|
||||||
|
parts = append(parts, encodeBytesField(5, intent)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildRawGetChatMessageRequest(apiKey string, messages []ChatMessage, modelEnum int, modelName string) []byte {
|
||||||
|
var parts []byte
|
||||||
|
conversationID := generateUUID()
|
||||||
|
|
||||||
|
parts = append(parts, encodeBytesField(1, buildMetadata(apiKey, generateUUID()))...)
|
||||||
|
|
||||||
|
var systemPrompt string
|
||||||
|
for _, msg := range messages {
|
||||||
|
if msg.Role == "system" {
|
||||||
|
if systemPrompt != "" {
|
||||||
|
systemPrompt += "\n"
|
||||||
|
}
|
||||||
|
systemPrompt += msg.Content
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var source int
|
||||||
|
var text string
|
||||||
|
|
||||||
|
switch msg.Role {
|
||||||
|
case "user":
|
||||||
|
source = SourceUser
|
||||||
|
text = msg.Content
|
||||||
|
case "assistant":
|
||||||
|
source = SourceAssistant
|
||||||
|
text = msg.Content
|
||||||
|
case "tool":
|
||||||
|
source = SourceUser
|
||||||
|
text = "[tool result]: " + msg.Content
|
||||||
|
default:
|
||||||
|
source = SourceUser
|
||||||
|
text = msg.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = append(parts, encodeBytesField(2, buildChatMessage(text, source, conversationID))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if systemPrompt != "" {
|
||||||
|
parts = append(parts, encodeStringField(3, systemPrompt)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = append(parts, encodeVarintField(4, uint64(modelEnum))...)
|
||||||
|
|
||||||
|
if modelName != "" {
|
||||||
|
parts = append(parts, encodeStringField(5, modelName)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseRawChatResponse(data []byte) LegacyChatDelta {
|
||||||
|
pos := 0
|
||||||
|
var deltaMsg []byte
|
||||||
|
for pos < len(data) {
|
||||||
|
tag, np, ok := ReadVarint(data, pos)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pos = np
|
||||||
|
fieldNum := tag >> 3
|
||||||
|
wireType := tag & 7
|
||||||
|
|
||||||
|
switch wireType {
|
||||||
|
case 2:
|
||||||
|
length, np2, ok := ReadVarint(data, pos)
|
||||||
|
if !ok {
|
||||||
|
return LegacyChatDelta{}
|
||||||
|
}
|
||||||
|
pos = np2
|
||||||
|
if pos+int(length) > len(data) {
|
||||||
|
return LegacyChatDelta{}
|
||||||
|
}
|
||||||
|
field := data[pos : pos+int(length)]
|
||||||
|
pos += int(length)
|
||||||
|
if fieldNum == 1 {
|
||||||
|
deltaMsg = field
|
||||||
|
}
|
||||||
|
case 0:
|
||||||
|
_, np2, ok := ReadVarint(data, pos)
|
||||||
|
if !ok {
|
||||||
|
return LegacyChatDelta{}
|
||||||
|
}
|
||||||
|
pos = np2
|
||||||
|
case 1:
|
||||||
|
pos += 8
|
||||||
|
case 5:
|
||||||
|
pos += 4
|
||||||
|
default:
|
||||||
|
return LegacyChatDelta{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if deltaMsg == nil {
|
||||||
|
return LegacyChatDelta{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result LegacyChatDelta
|
||||||
|
pos = 0
|
||||||
|
for pos < len(deltaMsg) {
|
||||||
|
tag, np, ok := ReadVarint(deltaMsg, pos)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pos = np
|
||||||
|
fieldNum := tag >> 3
|
||||||
|
wireType := tag & 7
|
||||||
|
|
||||||
|
switch wireType {
|
||||||
|
case 2:
|
||||||
|
length, np2, ok := ReadVarint(deltaMsg, pos)
|
||||||
|
if !ok {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
pos = np2
|
||||||
|
if pos+int(length) > len(deltaMsg) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
field := deltaMsg[pos : pos+int(length)]
|
||||||
|
pos += int(length)
|
||||||
|
if fieldNum == 5 {
|
||||||
|
result.Text = string(field)
|
||||||
|
}
|
||||||
|
case 0:
|
||||||
|
val, np2, ok := ReadVarint(deltaMsg, pos)
|
||||||
|
if !ok {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
pos = np2
|
||||||
|
if fieldNum == 6 {
|
||||||
|
result.InProgress = val != 0
|
||||||
|
} else if fieldNum == 7 {
|
||||||
|
result.IsError = val != 0
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
pos += 8
|
||||||
|
case 5:
|
||||||
|
pos += 4
|
||||||
|
default:
|
||||||
|
pos = len(deltaMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LocalLSClient) StreamLegacyChat(ctx context.Context, token string, messages []ChatMessage, modelEnum int, modelName string) (string, error) {
|
||||||
|
reqBody := BuildRawGetChatMessageRequest(token, messages, modelEnum, modelName)
|
||||||
|
|
||||||
|
respData, err := l.grpcUnaryRaw(ctx, RawGetChatMessageRPC, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "panel state not found") || strings.Contains(err.Error(), "not_found") {
|
||||||
|
_ = l.ForceWarmupCascade(ctx, token)
|
||||||
|
respData, err = l.grpcUnaryRaw(ctx, RawGetChatMessageRPC, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("legacy chat retry: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "", fmt.Errorf("legacy chat: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delta := ParseRawChatResponse(respData)
|
||||||
|
if delta.IsError {
|
||||||
|
return "", fmt.Errorf("legacy chat error: %s", delta.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SanitizePath(delta.Text), nil
|
||||||
|
}
|
||||||
1216
backend/internal/pkg/windsurf/local_ls.go
Normal file
1216
backend/internal/pkg/windsurf/local_ls.go
Normal file
File diff suppressed because it is too large
Load Diff
388
backend/internal/pkg/windsurf/lspool.go
Normal file
388
backend/internal/pkg/windsurf/lspool.go
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultLSBinary = "/opt/windsurf/language_server_linux_x64"
|
||||||
|
DefaultLSPort = 42100
|
||||||
|
DefaultCSRF = "windsurf-api-csrf-fixed-token"
|
||||||
|
DefaultAPIServer = "https://server.self-serve.windsurf.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LSPoolConfig struct {
|
||||||
|
Binary string
|
||||||
|
BasePort int
|
||||||
|
CSRFToken string
|
||||||
|
APIServerURL string
|
||||||
|
DataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LSPoolConfig) defaults() {
|
||||||
|
if c.Binary == "" {
|
||||||
|
c.Binary = os.Getenv("LS_BINARY_PATH")
|
||||||
|
if c.Binary == "" {
|
||||||
|
c.Binary = DefaultLSBinary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.BasePort <= 0 {
|
||||||
|
c.BasePort = DefaultLSPort
|
||||||
|
}
|
||||||
|
if c.CSRFToken == "" {
|
||||||
|
c.CSRFToken = DefaultCSRF
|
||||||
|
}
|
||||||
|
if c.APIServerURL == "" {
|
||||||
|
c.APIServerURL = os.Getenv("CODEIUM_API_URL")
|
||||||
|
if c.APIServerURL == "" {
|
||||||
|
c.APIServerURL = DefaultAPIServer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.DataDir == "" {
|
||||||
|
c.DataDir = "/opt/windsurf/data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LSEntry struct {
|
||||||
|
Cmd *exec.Cmd
|
||||||
|
Port int
|
||||||
|
CSRFToken string
|
||||||
|
Client *LocalLSClient
|
||||||
|
ProxyKey string
|
||||||
|
Ready atomic.Bool
|
||||||
|
StartedAt time.Time
|
||||||
|
done chan struct{} // closed when the process exits
|
||||||
|
}
|
||||||
|
|
||||||
|
type LSPool struct {
|
||||||
|
pool map[string]*LSEntry
|
||||||
|
mu sync.RWMutex
|
||||||
|
sf singleflight.Group
|
||||||
|
nextPort atomic.Int32
|
||||||
|
config LSPoolConfig
|
||||||
|
logFunc func(format string, args ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLSPool(cfg LSPoolConfig, logFn func(string, ...any)) *LSPool {
|
||||||
|
cfg.defaults()
|
||||||
|
p := &LSPool{
|
||||||
|
pool: make(map[string]*LSEntry),
|
||||||
|
config: cfg,
|
||||||
|
logFunc: logFn,
|
||||||
|
}
|
||||||
|
p.nextPort.Store(int32(cfg.BasePort + 1))
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LSPool) log(format string, args ...any) {
|
||||||
|
if p.logFunc != nil {
|
||||||
|
p.logFunc(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonAlphaNum = regexp.MustCompile(`[^a-zA-Z0-9]`)
|
||||||
|
|
||||||
|
// proxyKey produces a pool key from a proxy URL.
|
||||||
|
// Includes auth hash so different credentials on the same host get separate LS instances.
|
||||||
|
func proxyKey(proxyURL string) string {
|
||||||
|
proxyURL = strings.TrimSpace(proxyURL)
|
||||||
|
if proxyURL == "" {
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
u, err := url.Parse(proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return "px_" + nonAlphaNum.ReplaceAllString(proxyURL, "_")
|
||||||
|
}
|
||||||
|
key := u.Hostname()
|
||||||
|
if u.Port() != "" {
|
||||||
|
key += "_" + u.Port()
|
||||||
|
}
|
||||||
|
if u.User != nil {
|
||||||
|
key += "_" + nonAlphaNum.ReplaceAllString(u.User.Username(), "_")
|
||||||
|
}
|
||||||
|
return "px_" + nonAlphaNum.ReplaceAllString(key, "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactProxyURL strips credentials from a proxy URL for safe logging.
|
||||||
|
func redactProxyURL(proxyURL string) string {
|
||||||
|
if proxyURL == "" {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
u, err := url.Parse(proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return "<invalid>"
|
||||||
|
}
|
||||||
|
u.User = nil
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LSPool) Ensure(ctx context.Context, proxyURL string) (*LSEntry, error) {
|
||||||
|
key := proxyKey(proxyURL)
|
||||||
|
|
||||||
|
p.mu.RLock()
|
||||||
|
if e, ok := p.pool[key]; ok && e.Ready.Load() {
|
||||||
|
p.mu.RUnlock()
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
val, err, _ := p.sf.Do(key, func() (any, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
if e, ok := p.pool[key]; ok && e.Ready.Load() {
|
||||||
|
p.mu.RUnlock()
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
p.mu.RUnlock()
|
||||||
|
return p.spawnLS(ctx, key, proxyURL)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return val.(*LSEntry), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LSPool) Get(proxyURL string) *LSEntry {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
return p.pool[proxyKey(proxyURL)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LSPool) Restart(ctx context.Context, proxyURL string) (*LSEntry, error) {
|
||||||
|
key := proxyKey(proxyURL)
|
||||||
|
p.mu.Lock()
|
||||||
|
if old, ok := p.pool[key]; ok {
|
||||||
|
p.stopEntry(old)
|
||||||
|
delete(p.pool, key)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
return p.Ensure(ctx, proxyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LSPool) Shutdown() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for key, entry := range p.pool {
|
||||||
|
p.stopEntry(entry)
|
||||||
|
p.log("LS instance %s stopped", key)
|
||||||
|
}
|
||||||
|
p.pool = make(map[string]*LSEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LSPool) stopEntry(e *LSEntry) {
|
||||||
|
e.Ready.Store(false)
|
||||||
|
if e.Cmd == nil || e.Cmd.Process == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = e.Cmd.Process.Signal(os.Interrupt)
|
||||||
|
select {
|
||||||
|
case <-e.done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
_ = e.Cmd.Process.Kill()
|
||||||
|
<-e.done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LSStatus struct {
|
||||||
|
Running bool
|
||||||
|
Instances []LSInstanceStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type LSInstanceStatus struct {
|
||||||
|
Key string
|
||||||
|
Port int
|
||||||
|
PID int
|
||||||
|
ProxyKey string
|
||||||
|
StartedAt time.Time
|
||||||
|
Ready bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LSPool) Status() LSStatus {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
s := LSStatus{Running: len(p.pool) > 0}
|
||||||
|
for key, e := range p.pool {
|
||||||
|
pid := 0
|
||||||
|
if e.Cmd != nil && e.Cmd.Process != nil {
|
||||||
|
pid = e.Cmd.Process.Pid
|
||||||
|
}
|
||||||
|
s.Instances = append(s.Instances, LSInstanceStatus{
|
||||||
|
Key: key, Port: e.Port, PID: pid,
|
||||||
|
ProxyKey: e.ProxyKey, StartedAt: e.StartedAt, Ready: e.Ready.Load(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LSPool) allocPort(isDefault bool) (int, error) {
|
||||||
|
if isDefault {
|
||||||
|
return p.config.BasePort, nil
|
||||||
|
}
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
port := int(p.nextPort.Add(1)) - 1
|
||||||
|
if !isPortInUse(port) {
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
p.log("LS port %d busy, advancing", port)
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("no free port for LS in 50 attempts starting from %d", p.config.BasePort+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPortInUse(port int) bool {
|
||||||
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitPortReady(port int, timeout time.Duration) error {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
h2t := &http2.Transport{
|
||||||
|
AllowHTTP: true,
|
||||||
|
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{Timeout: 2 * time.Second}).DialContext(ctx, network, addr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer h2t.CloseIdleConnections()
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
conn, err := h2t.DialTLSContext(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", port), nil)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("LS port %d not ready after %v", port, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LSPool) spawnLS(ctx context.Context, key, proxyURL string) (*LSEntry, error) {
|
||||||
|
isDefault := key == "default"
|
||||||
|
|
||||||
|
if isDefault && isPortInUse(p.config.BasePort) {
|
||||||
|
p.log("LS default port %d already in use — adopting existing instance", p.config.BasePort)
|
||||||
|
entry := &LSEntry{
|
||||||
|
Port: p.config.BasePort,
|
||||||
|
CSRFToken: p.config.CSRFToken,
|
||||||
|
Client: NewLocalLSClient(p.config.BasePort, p.config.CSRFToken),
|
||||||
|
ProxyKey: key,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
entry.Ready.Store(true)
|
||||||
|
close(entry.done)
|
||||||
|
p.mu.Lock()
|
||||||
|
p.pool[key] = entry
|
||||||
|
p.mu.Unlock()
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := p.allocPort(isDefault)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dataDir := filepath.Join(p.config.DataDir, key)
|
||||||
|
if err := os.MkdirAll(filepath.Join(dataDir, "db"), 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("mkdirAll %s/db: %w", dataDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
fmt.Sprintf("--api_server_url=%s", p.config.APIServerURL),
|
||||||
|
fmt.Sprintf("--server_port=%d", port),
|
||||||
|
fmt.Sprintf("--csrf_token=%s", p.config.CSRFToken),
|
||||||
|
"--register_user_url=https://api.codeium.com/register_user/",
|
||||||
|
fmt.Sprintf("--codeium_dir=%s", dataDir),
|
||||||
|
fmt.Sprintf("--database_dir=%s/db", dataDir),
|
||||||
|
"--enable_local_search=false",
|
||||||
|
"--enable_index_service=false",
|
||||||
|
"--enable_lsp=false",
|
||||||
|
"--detect_proxy=false",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't bind LS process lifetime to request context — use background context for the process.
|
||||||
|
cmd := exec.Command(p.config.Binary, args...)
|
||||||
|
cmd.Env = append(os.Environ(), "HOME=/root")
|
||||||
|
if proxyURL != "" {
|
||||||
|
cmd.Env = append(cmd.Env,
|
||||||
|
"HTTPS_PROXY="+proxyURL,
|
||||||
|
"HTTP_PROXY="+proxyURL,
|
||||||
|
"https_proxy="+proxyURL,
|
||||||
|
"http_proxy="+proxyURL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
|
||||||
|
p.log("Starting LS instance key=%s port=%d proxy=%s", key, port, redactProxyURL(proxyURL))
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("spawn LS %s: %w", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &LSEntry{
|
||||||
|
Cmd: cmd,
|
||||||
|
Port: port,
|
||||||
|
CSRFToken: p.config.CSRFToken,
|
||||||
|
Client: NewLocalLSClient(port, p.config.CSRFToken),
|
||||||
|
ProxyKey: key,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.pool[key] = entry
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
go p.monitorProcess(key, entry)
|
||||||
|
|
||||||
|
if err := waitPortReady(port, 25*time.Second); err != nil {
|
||||||
|
p.log("LS instance %s failed to become ready: %v", key, err)
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
p.mu.Lock()
|
||||||
|
delete(p.pool, key)
|
||||||
|
p.mu.Unlock()
|
||||||
|
<-entry.done
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Ready.Store(true)
|
||||||
|
p.log("LS instance %s ready on port %d", key, port)
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitorProcess is the sole reaper for the LS process.
|
||||||
|
func (p *LSPool) monitorProcess(key string, entry *LSEntry) {
|
||||||
|
err := entry.Cmd.Wait()
|
||||||
|
close(entry.done)
|
||||||
|
entry.Ready.Store(false)
|
||||||
|
|
||||||
|
exitMsg := "nil"
|
||||||
|
if err != nil {
|
||||||
|
exitMsg = err.Error()
|
||||||
|
}
|
||||||
|
p.log("LS instance %s exited: %s", key, exitMsg)
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
if cur, ok := p.pool[key]; ok && cur == entry {
|
||||||
|
delete(p.pool, key)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
53
backend/internal/pkg/windsurf/metadata.go
Normal file
53
backend/internal/pkg/windsurf/metadata.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// JWT decoding helpers.
|
||||||
|
// Portions derived from windsurf-tools (MIT 2025 shaoyu521). See ./LICENSE.
|
||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readRandom abstracts crypto/rand.Read for testability.
|
||||||
|
func readRandom(b []byte) (int, error) { return rand.Read(b) }
|
||||||
|
|
||||||
|
// JWTClaims holds the fields we care about from the Windsurf session JWT.
|
||||||
|
type JWTClaims struct {
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
TeamID string `json:"team_id,omitempty"`
|
||||||
|
AuthUID string `json:"auth_uid,omitempty"`
|
||||||
|
Exp int64 `json:"exp,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripDevinPrefix returns the raw JWT (without the "devin-session-token$" prefix).
|
||||||
|
func StripDevinPrefix(token string) string {
|
||||||
|
if i := strings.Index(token, "$"); i >= 0 && strings.HasPrefix(token, "devin-session-token$") {
|
||||||
|
return token[i+1:]
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeJWTClaims parses the payload portion of a JWT (after stripping the
|
||||||
|
// optional "devin-session-token$" prefix). It does NOT verify the signature.
|
||||||
|
func DecodeJWTClaims(token string) (*JWTClaims, error) {
|
||||||
|
jwt := StripDevinPrefix(token)
|
||||||
|
parts := strings.Split(jwt, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, fmt.Errorf("jwt: expected 3 segments, got %d", len(parts))
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
payload, err = base64.URLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("jwt payload base64: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var claims JWTClaims
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return nil, fmt.Errorf("jwt payload json: %w", err)
|
||||||
|
}
|
||||||
|
return &claims, nil
|
||||||
|
}
|
||||||
338
backend/internal/pkg/windsurf/models.go
Normal file
338
backend/internal/pkg/windsurf/models.go
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelMeta struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
EnumValue int `json:"enum_value"`
|
||||||
|
ModelUID string `json:"model_uid,omitempty"`
|
||||||
|
Credit float64 `json:"credit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelListEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var catalog = map[string]ModelMeta{
|
||||||
|
// Anthropic
|
||||||
|
"claude-3.5-sonnet": {Name: "claude-3.5-sonnet", Provider: "anthropic", EnumValue: 166, Credit: 2},
|
||||||
|
"claude-3.7-sonnet": {Name: "claude-3.7-sonnet", Provider: "anthropic", EnumValue: 226, Credit: 2},
|
||||||
|
"claude-3.7-sonnet-thinking": {Name: "claude-3.7-sonnet-thinking", Provider: "anthropic", EnumValue: 227, Credit: 3},
|
||||||
|
"claude-4-sonnet": {Name: "claude-4-sonnet", Provider: "anthropic", EnumValue: 281, ModelUID: "MODEL_CLAUDE_4_SONNET", Credit: 2},
|
||||||
|
"claude-4-sonnet-thinking": {Name: "claude-4-sonnet-thinking", Provider: "anthropic", EnumValue: 282, ModelUID: "MODEL_CLAUDE_4_SONNET_THINKING", Credit: 3},
|
||||||
|
"claude-4-opus": {Name: "claude-4-opus", Provider: "anthropic", EnumValue: 290, ModelUID: "MODEL_CLAUDE_4_OPUS", Credit: 4},
|
||||||
|
"claude-4-opus-thinking": {Name: "claude-4-opus-thinking", Provider: "anthropic", EnumValue: 291, ModelUID: "MODEL_CLAUDE_4_OPUS_THINKING", Credit: 5},
|
||||||
|
"claude-4.1-opus": {Name: "claude-4.1-opus", Provider: "anthropic", EnumValue: 328, ModelUID: "MODEL_CLAUDE_4_1_OPUS", Credit: 4},
|
||||||
|
"claude-4.1-opus-thinking": {Name: "claude-4.1-opus-thinking", Provider: "anthropic", EnumValue: 329, ModelUID: "MODEL_CLAUDE_4_1_OPUS_THINKING", Credit: 5},
|
||||||
|
"claude-4.5-haiku": {Name: "claude-4.5-haiku", Provider: "anthropic", ModelUID: "MODEL_PRIVATE_11", Credit: 1},
|
||||||
|
"claude-4.5-sonnet": {Name: "claude-4.5-sonnet", Provider: "anthropic", EnumValue: 353, ModelUID: "MODEL_PRIVATE_2", Credit: 2},
|
||||||
|
"claude-4.5-sonnet-thinking": {Name: "claude-4.5-sonnet-thinking", Provider: "anthropic", EnumValue: 354, ModelUID: "MODEL_PRIVATE_3", Credit: 3},
|
||||||
|
"claude-4.5-opus": {Name: "claude-4.5-opus", Provider: "anthropic", EnumValue: 391, ModelUID: "MODEL_CLAUDE_4_5_OPUS", Credit: 4},
|
||||||
|
"claude-4.5-opus-thinking": {Name: "claude-4.5-opus-thinking", Provider: "anthropic", EnumValue: 392, ModelUID: "MODEL_CLAUDE_4_5_OPUS_THINKING", Credit: 5},
|
||||||
|
"claude-sonnet-4.6": {Name: "claude-sonnet-4.6", Provider: "anthropic", ModelUID: "claude-sonnet-4-6", Credit: 4},
|
||||||
|
"claude-sonnet-4.6-thinking": {Name: "claude-sonnet-4.6-thinking", Provider: "anthropic", ModelUID: "claude-sonnet-4-6-thinking", Credit: 6},
|
||||||
|
"claude-sonnet-4.6-1m": {Name: "claude-sonnet-4.6-1m", Provider: "anthropic", ModelUID: "claude-sonnet-4-6-1m", Credit: 12},
|
||||||
|
"claude-sonnet-4.6-thinking-1m": {Name: "claude-sonnet-4.6-thinking-1m", Provider: "anthropic", ModelUID: "claude-sonnet-4-6-thinking-1m", Credit: 16},
|
||||||
|
"claude-opus-4.6": {Name: "claude-opus-4.6", Provider: "anthropic", ModelUID: "claude-opus-4-6", Credit: 6},
|
||||||
|
"claude-opus-4.6-thinking": {Name: "claude-opus-4.6-thinking", Provider: "anthropic", ModelUID: "claude-opus-4-6-thinking", Credit: 8},
|
||||||
|
"claude-opus-4-7-medium": {Name: "claude-opus-4-7-medium", Provider: "anthropic", ModelUID: "claude-opus-4-7-medium", Credit: 8},
|
||||||
|
|
||||||
|
// OpenAI GPT
|
||||||
|
"gpt-4o": {Name: "gpt-4o", Provider: "openai", EnumValue: 109, ModelUID: "MODEL_CHAT_GPT_4O_2024_08_06", Credit: 1},
|
||||||
|
"gpt-4o-mini": {Name: "gpt-4o-mini", Provider: "openai", EnumValue: 113, Credit: 0.5},
|
||||||
|
"gpt-4.1": {Name: "gpt-4.1", Provider: "openai", EnumValue: 259, ModelUID: "MODEL_CHAT_GPT_4_1_2025_04_14", Credit: 1},
|
||||||
|
"gpt-4.1-mini": {Name: "gpt-4.1-mini", Provider: "openai", EnumValue: 260, Credit: 0.5},
|
||||||
|
"gpt-4.1-nano": {Name: "gpt-4.1-nano", Provider: "openai", EnumValue: 261, Credit: 0.25},
|
||||||
|
"gpt-5": {Name: "gpt-5", Provider: "openai", EnumValue: 340, ModelUID: "MODEL_PRIVATE_6", Credit: 0.5},
|
||||||
|
"gpt-5-medium": {Name: "gpt-5-medium", Provider: "openai", ModelUID: "MODEL_PRIVATE_7", Credit: 1},
|
||||||
|
"gpt-5-high": {Name: "gpt-5-high", Provider: "openai", ModelUID: "MODEL_PRIVATE_8", Credit: 2},
|
||||||
|
"gpt-5-mini": {Name: "gpt-5-mini", Provider: "openai", EnumValue: 337, Credit: 0.25},
|
||||||
|
"gpt-5-codex": {Name: "gpt-5-codex", Provider: "openai", EnumValue: 346, ModelUID: "MODEL_CHAT_GPT_5_CODEX", Credit: 0.5},
|
||||||
|
"gpt-5.2": {Name: "gpt-5.2", Provider: "openai", EnumValue: 401, ModelUID: "MODEL_GPT_5_2_MEDIUM", Credit: 2},
|
||||||
|
"gpt-5.2-low": {Name: "gpt-5.2-low", Provider: "openai", EnumValue: 400, ModelUID: "MODEL_GPT_5_2_LOW", Credit: 1},
|
||||||
|
"gpt-5.2-high": {Name: "gpt-5.2-high", Provider: "openai", EnumValue: 402, ModelUID: "MODEL_GPT_5_2_HIGH", Credit: 3},
|
||||||
|
"gpt-5.2-xhigh": {Name: "gpt-5.2-xhigh", Provider: "openai", EnumValue: 403, ModelUID: "MODEL_GPT_5_2_XHIGH", Credit: 8},
|
||||||
|
|
||||||
|
// O-series
|
||||||
|
"o3-mini": {Name: "o3-mini", Provider: "openai", EnumValue: 207, Credit: 0.5},
|
||||||
|
"o3": {Name: "o3", Provider: "openai", EnumValue: 218, ModelUID: "MODEL_CHAT_O3", Credit: 1},
|
||||||
|
"o3-high": {Name: "o3-high", Provider: "openai", ModelUID: "MODEL_CHAT_O3_HIGH", Credit: 1},
|
||||||
|
"o3-pro": {Name: "o3-pro", Provider: "openai", EnumValue: 294, Credit: 4},
|
||||||
|
"o4-mini": {Name: "o4-mini", Provider: "openai", EnumValue: 264, Credit: 0.5},
|
||||||
|
|
||||||
|
// Gemini
|
||||||
|
"gemini-2.5-pro": {Name: "gemini-2.5-pro", Provider: "google", EnumValue: 246, ModelUID: "MODEL_GOOGLE_GEMINI_2_5_PRO", Credit: 1},
|
||||||
|
"gemini-2.5-flash": {Name: "gemini-2.5-flash", Provider: "google", EnumValue: 312, ModelUID: "MODEL_GOOGLE_GEMINI_2_5_FLASH", Credit: 0.5},
|
||||||
|
"gemini-3.0-pro": {Name: "gemini-3.0-pro", Provider: "google", EnumValue: 412, ModelUID: "MODEL_GOOGLE_GEMINI_3_0_PRO_LOW", Credit: 1},
|
||||||
|
"gemini-3.0-flash": {Name: "gemini-3.0-flash", Provider: "google", EnumValue: 415, ModelUID: "MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM", Credit: 1},
|
||||||
|
|
||||||
|
// DeepSeek
|
||||||
|
"deepseek-v3": {Name: "deepseek-v3", Provider: "deepseek", EnumValue: 205, Credit: 0.5},
|
||||||
|
"deepseek-v3-2": {Name: "deepseek-v3-2", Provider: "deepseek", EnumValue: 409, Credit: 0.5},
|
||||||
|
"deepseek-r1": {Name: "deepseek-r1", Provider: "deepseek", EnumValue: 206, Credit: 1},
|
||||||
|
|
||||||
|
// Grok
|
||||||
|
"grok-3": {Name: "grok-3", Provider: "xai", EnumValue: 217, ModelUID: "MODEL_XAI_GROK_3", Credit: 1},
|
||||||
|
"grok-3-mini": {Name: "grok-3-mini", Provider: "xai", EnumValue: 234, Credit: 0.5},
|
||||||
|
|
||||||
|
// Qwen
|
||||||
|
"qwen-3": {Name: "qwen-3", Provider: "alibaba", EnumValue: 324, Credit: 0.5},
|
||||||
|
|
||||||
|
// Kimi
|
||||||
|
"kimi-k2": {Name: "kimi-k2", Provider: "moonshot", EnumValue: 323, ModelUID: "MODEL_KIMI_K2", Credit: 0.5},
|
||||||
|
|
||||||
|
// GLM
|
||||||
|
"glm-4.7": {Name: "glm-4.7", Provider: "zhipu", EnumValue: 417, ModelUID: "MODEL_GLM_4_7", Credit: 0.25},
|
||||||
|
|
||||||
|
// Windsurf SWE
|
||||||
|
"swe-1.5": {Name: "swe-1.5", Provider: "windsurf", EnumValue: 369, ModelUID: "MODEL_SWE_1_5_SLOW", Credit: 0.5},
|
||||||
|
"swe-1.5-fast": {Name: "swe-1.5-fast", Provider: "windsurf", EnumValue: 359, ModelUID: "MODEL_SWE_1_5", Credit: 0.5},
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
lookupOnce sync.Once
|
||||||
|
lookupMap map[string]string
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildLookup() {
|
||||||
|
lookupMap = make(map[string]string, len(catalog)*4)
|
||||||
|
for id, info := range catalog {
|
||||||
|
lookupMap[id] = id
|
||||||
|
lookupMap[strings.ToLower(id)] = id
|
||||||
|
if info.ModelUID != "" {
|
||||||
|
lookupMap[info.ModelUID] = id
|
||||||
|
lookupMap[strings.ToLower(info.ModelUID)] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aliases := map[string]string{
|
||||||
|
// Anthropic dated names
|
||||||
|
"claude-3-5-sonnet-20240620": "claude-3.5-sonnet",
|
||||||
|
"claude-3-5-sonnet-20241022": "claude-3.5-sonnet",
|
||||||
|
"claude-3-5-sonnet-latest": "claude-3.5-sonnet",
|
||||||
|
"claude-3-7-sonnet-20250219": "claude-3.7-sonnet",
|
||||||
|
"claude-3-7-sonnet-latest": "claude-3.7-sonnet",
|
||||||
|
"claude-sonnet-4-20250514": "claude-4-sonnet",
|
||||||
|
"claude-sonnet-4-0": "claude-4-sonnet",
|
||||||
|
"claude-opus-4-20250514": "claude-4-opus",
|
||||||
|
"claude-opus-4-0": "claude-4-opus",
|
||||||
|
"claude-opus-4-1": "claude-4.1-opus",
|
||||||
|
"claude-opus-4-1-20250805": "claude-4.1-opus",
|
||||||
|
"claude-sonnet-4-5": "claude-4.5-sonnet",
|
||||||
|
"claude-sonnet-4-5-20250929": "claude-4.5-sonnet",
|
||||||
|
"claude-opus-4-5": "claude-4.5-opus",
|
||||||
|
"claude-opus-4-5-20251101": "claude-4.5-opus",
|
||||||
|
"claude-opus-4-7": "claude-opus-4-7-medium",
|
||||||
|
"claude-opus-4-7-latest": "claude-opus-4-7-medium",
|
||||||
|
"claude-opus-4.7": "claude-opus-4-7-medium",
|
||||||
|
"claude-opus-4.7-thinking": "claude-opus-4-7-medium",
|
||||||
|
"claude-sonnet-4-6": "claude-sonnet-4.6",
|
||||||
|
"claude-opus-4-6": "claude-opus-4.6",
|
||||||
|
"claude-sonnet-4-6-thinking": "claude-sonnet-4.6-thinking",
|
||||||
|
"claude-opus-4-6-thinking": "claude-opus-4.6-thinking",
|
||||||
|
"MODEL_CLAUDE_4_5_SONNET": "claude-4.5-sonnet",
|
||||||
|
"MODEL_CLAUDE_4_5_SONNET_THINKING": "claude-4.5-sonnet-thinking",
|
||||||
|
|
||||||
|
// OpenAI dated names
|
||||||
|
"gpt-4o-2024-11-20": "gpt-4o",
|
||||||
|
"gpt-4o-2024-08-06": "gpt-4o",
|
||||||
|
"gpt-4o-2024-05-13": "gpt-4o",
|
||||||
|
"gpt-4o-mini-2024-07-18": "gpt-4o-mini",
|
||||||
|
"gpt-4.1-2025-04-14": "gpt-4.1",
|
||||||
|
"gpt-4.1-mini-2025-04-14": "gpt-4.1-mini",
|
||||||
|
"gpt-4.1-nano-2025-04-14": "gpt-4.1-nano",
|
||||||
|
"gpt-5-2025-08-07": "gpt-5",
|
||||||
|
|
||||||
|
// Cursor-friendly aliases
|
||||||
|
"opus-4.6": "claude-opus-4.6",
|
||||||
|
"opus-4.6-thinking": "claude-opus-4.6-thinking",
|
||||||
|
"opus-4-7": "claude-opus-4-7-medium",
|
||||||
|
"opus-4.7": "claude-opus-4-7-medium",
|
||||||
|
"sonnet-4.6": "claude-sonnet-4.6",
|
||||||
|
"sonnet-4.6-thinking": "claude-sonnet-4.6-thinking",
|
||||||
|
"sonnet-4.6-1m": "claude-sonnet-4.6-1m",
|
||||||
|
"sonnet-4.5": "claude-4.5-sonnet",
|
||||||
|
"sonnet-4.5-thinking": "claude-4.5-sonnet-thinking",
|
||||||
|
"haiku-4.5": "claude-4.5-haiku",
|
||||||
|
"sonnet-4": "claude-4-sonnet",
|
||||||
|
"opus-4": "claude-4-opus",
|
||||||
|
"opus-4.1": "claude-4.1-opus",
|
||||||
|
"sonnet-3.7": "claude-3.7-sonnet",
|
||||||
|
"sonnet-3.5": "claude-3.5-sonnet",
|
||||||
|
"ws-opus": "claude-opus-4.6",
|
||||||
|
"ws-sonnet": "claude-sonnet-4.6",
|
||||||
|
"ws-opus-thinking": "claude-opus-4.6-thinking",
|
||||||
|
"ws-sonnet-thinking": "claude-sonnet-4.6-thinking",
|
||||||
|
"ws-haiku": "claude-4.5-haiku",
|
||||||
|
}
|
||||||
|
for k, v := range aliases {
|
||||||
|
lookupMap[k] = v
|
||||||
|
lookupMap[strings.ToLower(k)] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureLookup() {
|
||||||
|
lookupOnce.Do(buildLookup)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveModel(name string) string {
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ensureLookup()
|
||||||
|
if id, ok := lookupMap[name]; ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
if id, ok := lookupMap[strings.ToLower(name)]; ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetModelInfo(id string) *ModelMeta {
|
||||||
|
if m, ok := catalog[id]; ok {
|
||||||
|
return &m
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetChatMode(m *ModelMeta, legacyEnumCutoff int) string {
|
||||||
|
if m == nil {
|
||||||
|
return "cascade"
|
||||||
|
}
|
||||||
|
if m.ModelUID != "" {
|
||||||
|
return "cascade"
|
||||||
|
}
|
||||||
|
if m.EnumValue > 0 {
|
||||||
|
if legacyEnumCutoff > 0 && m.EnumValue <= legacyEnumCutoff {
|
||||||
|
return "legacy"
|
||||||
|
}
|
||||||
|
return "cascade"
|
||||||
|
}
|
||||||
|
return "cascade"
|
||||||
|
}
|
||||||
|
|
||||||
|
var freeTierModels = []string{"gpt-4o-mini", "gemini-2.5-flash"}
|
||||||
|
|
||||||
|
func GetTierModels(tier string) []string {
|
||||||
|
switch tier {
|
||||||
|
case "pro":
|
||||||
|
keys := make([]string, 0, len(catalog))
|
||||||
|
for k := range catalog {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
case "free", "unknown":
|
||||||
|
return freeTierModels
|
||||||
|
case "expired":
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return freeTierModels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListModelsOpenAI() []ModelListEntry {
|
||||||
|
ts := time.Now().Unix()
|
||||||
|
entries := make([]ModelListEntry, 0, len(catalog))
|
||||||
|
for _, info := range catalog {
|
||||||
|
entries = append(entries, ModelListEntry{
|
||||||
|
ID: info.Name,
|
||||||
|
Object: "model",
|
||||||
|
Created: ts,
|
||||||
|
OwnedBy: info.Provider,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
var cloudModelsMu sync.Mutex
|
||||||
|
|
||||||
|
func MergeCloudModels(configs []ModelInfo) int {
|
||||||
|
cloudModelsMu.Lock()
|
||||||
|
defer cloudModelsMu.Unlock()
|
||||||
|
ensureLookup()
|
||||||
|
|
||||||
|
providerMap := map[string]string{
|
||||||
|
"MODEL_PROVIDER_ANTHROPIC": "anthropic",
|
||||||
|
"MODEL_PROVIDER_OPENAI": "openai",
|
||||||
|
"MODEL_PROVIDER_GOOGLE": "google",
|
||||||
|
"MODEL_PROVIDER_DEEPSEEK": "deepseek",
|
||||||
|
"MODEL_PROVIDER_XAI": "xai",
|
||||||
|
"MODEL_PROVIDER_WINDSURF": "windsurf",
|
||||||
|
"MODEL_PROVIDER_MOONSHOT": "moonshot",
|
||||||
|
}
|
||||||
|
|
||||||
|
added := 0
|
||||||
|
for _, m := range configs {
|
||||||
|
if m.ModelUID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := lookupMap[m.ModelUID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := lookupMap[strings.ToLower(m.ModelUID)]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.ToLower(strings.ReplaceAll(m.ModelUID, "_", "-"))
|
||||||
|
if _, exists := catalog[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := providerMap[m.Label]
|
||||||
|
if provider == "" {
|
||||||
|
provider = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
credit := m.CreditMultiplier
|
||||||
|
if credit == 0 {
|
||||||
|
credit = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog[key] = ModelMeta{
|
||||||
|
Name: key,
|
||||||
|
Provider: provider,
|
||||||
|
ModelUID: m.ModelUID,
|
||||||
|
Credit: credit,
|
||||||
|
}
|
||||||
|
lookupMap[key] = key
|
||||||
|
lookupMap[m.ModelUID] = key
|
||||||
|
lookupMap[strings.ToLower(m.ModelUID)] = key
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
return added
|
||||||
|
}
|
||||||
|
|
||||||
|
func MapTeamsTier(t int) string {
|
||||||
|
if t == 0 || t == 6 || t == 19 {
|
||||||
|
return "free"
|
||||||
|
}
|
||||||
|
if t > 0 {
|
||||||
|
return "pro"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TeamsTierLabel(t int) string {
|
||||||
|
labels := map[int]string{
|
||||||
|
0: "Unspecified", 1: "Teams", 2: "Pro", 3: "Enterprise (SaaS)",
|
||||||
|
4: "Hybrid", 5: "Enterprise (Self-Hosted)", 6: "Waitlist Pro",
|
||||||
|
7: "Teams Ultimate", 8: "Pro Ultimate", 9: "Trial",
|
||||||
|
10: "Enterprise (Self-Serve)", 11: "Enterprise (SaaS Pooled)",
|
||||||
|
12: "Devin Enterprise", 14: "Devin Teams", 15: "Devin Teams V2",
|
||||||
|
16: "Devin Pro", 17: "Devin Max", 18: "Max",
|
||||||
|
19: "Devin Free", 20: "Devin Trial",
|
||||||
|
}
|
||||||
|
if l, ok := labels[t]; ok {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
40
backend/internal/pkg/windsurf/sanitize.go
Normal file
40
backend/internal/pkg/windsurf/sanitize.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// SanitizePath scrubs server-internal filesystem paths from model output.
|
||||||
|
// /tmp/windsurf-workspace/foo → [unmounted-workspace]/foo, /opt/windsurf/… → [internal].
|
||||||
|
func SanitizePath(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s = replacePathPrefix(s, "/tmp/windsurf-workspace", "[unmounted-workspace]")
|
||||||
|
s = replacePathPrefix(s, "/opt/windsurf", "[internal]")
|
||||||
|
s = replacePathPrefix(s, "/root/WindsurfAPI", "[internal]")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func replacePathPrefix(s, prefix, replacement string) string {
|
||||||
|
for {
|
||||||
|
idx := strings.Index(s, prefix)
|
||||||
|
if idx < 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
end := idx + len(prefix)
|
||||||
|
if end < len(s) && s[end] == '/' {
|
||||||
|
s = s[:idx] + replacement + s[end:]
|
||||||
|
} else if end == len(s) || isPathTerminator(s[end]) {
|
||||||
|
s = s[:idx] + replacement + s[end:]
|
||||||
|
} else {
|
||||||
|
s = s[:idx] + replacement + s[end:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPathTerminator(b byte) bool {
|
||||||
|
switch b {
|
||||||
|
case ' ', '"', '\'', '`', '<', '>', ')', '}', ']', ',', '*', ';', '\n', '\r', '\t':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
17
backend/internal/pkg/windsurf/token_estimate.go
Normal file
17
backend/internal/pkg/windsurf/token_estimate.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
func EstimateTokens(chars int) int {
|
||||||
|
t := (chars + 3) / 4
|
||||||
|
if t < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func EstimateInputTokensFromMessages(msgs []ChatMessage) int {
|
||||||
|
chars := 0
|
||||||
|
for _, m := range msgs {
|
||||||
|
chars += len(m.Content)
|
||||||
|
}
|
||||||
|
return EstimateTokens(chars)
|
||||||
|
}
|
||||||
159
backend/internal/pkg/windsurf/tool_bridge_test.go
Normal file
159
backend/internal/pkg/windsurf/tool_bridge_test.go
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildToolPreambleForProtoCanonicalizesToolsAndChoice(t *testing.T) {
|
||||||
|
tools := []OpenAITool{
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: OpenAIFunction{
|
||||||
|
Name: "list_files",
|
||||||
|
Description: "List files in the repository",
|
||||||
|
Parameters: json.RawMessage(`{"type":"object"}`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: OpenAIFunction{
|
||||||
|
Name: "glob",
|
||||||
|
Description: "Duplicate alias should be deduped",
|
||||||
|
Parameters: json.RawMessage(`{"type":"object"}`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := BuildToolPreambleForProto(tools, map[string]any{
|
||||||
|
"type": "tool",
|
||||||
|
"name": "search_files",
|
||||||
|
})
|
||||||
|
|
||||||
|
if strings.Contains(got, "### list_files") {
|
||||||
|
t.Fatalf("preamble should not expose alias tool names: %s", got)
|
||||||
|
}
|
||||||
|
if count := strings.Count(got, "### glob"); count != 1 {
|
||||||
|
t.Fatalf("expected exactly one canonical glob tool, got %d in %s", count, got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `You MUST call the function "grep"`) {
|
||||||
|
t.Fatalf("forced tool choice should be canonicalized to grep: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMessagesForCascadePreservesStructuredToolResultPayload(t *testing.T) {
|
||||||
|
messages := []AnthropicMessage{
|
||||||
|
{
|
||||||
|
Role: "tool",
|
||||||
|
ToolCallID: "call-1",
|
||||||
|
Content: json.RawMessage(`[
|
||||||
|
{"type":"text","text":"partial listing"},
|
||||||
|
{"type":"json","value":{"entries":["a.go","b.go"]}}
|
||||||
|
]`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := NormalizeMessagesForCascade(messages, nil)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("NormalizeMessagesForCascade() returned %d messages, want 1", len(got))
|
||||||
|
}
|
||||||
|
if !strings.Contains(got[0].Content, `"type":"json"`) {
|
||||||
|
t.Fatalf("structured tool_result payload should be preserved, got %q", got[0].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseToolCallsFromTextNormalizesAliases(t *testing.T) {
|
||||||
|
text := strings.Join([]string{
|
||||||
|
`<tool_call>{"name":"list_files","arguments":{"path":"."}}</tool_call>`,
|
||||||
|
`{"name":"search_files","arguments":{"pattern":"TODO"}}`,
|
||||||
|
`{"tool_code":"apply_patch(\"*** Begin Patch\")"}`,
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
got := ParseToolCallsFromText(text)
|
||||||
|
if len(got.ToolCalls) != 3 {
|
||||||
|
t.Fatalf("ParseToolCallsFromText() returned %d tool calls, want 3", len(got.ToolCalls))
|
||||||
|
}
|
||||||
|
|
||||||
|
wantNames := []string{"glob", "grep", "edit"}
|
||||||
|
for i, want := range wantNames {
|
||||||
|
if got.ToolCalls[i].Name != want {
|
||||||
|
t.Fatalf("tool call %d name = %q, want %q", i, got.ToolCalls[i].Name, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizePathMarksUnmountedWorkspace(t *testing.T) {
|
||||||
|
got := SanitizePath("/tmp/windsurf-workspace/pkg/main.go")
|
||||||
|
if got != "[unmounted-workspace]/pkg/main.go" {
|
||||||
|
t.Fatalf("SanitizePath() = %q, want %q", got, "[unmounted-workspace]/pkg/main.go")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarmupCascadeSkipsTrackedWorkspaceByDefault(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewLocalLSClient(42099, "csrf")
|
||||||
|
client.BaseURL = server.URL
|
||||||
|
client.HTTP = server.Client()
|
||||||
|
|
||||||
|
if err := client.WarmupCascade(context.Background(), "token"); err != nil {
|
||||||
|
t.Fatalf("WarmupCascade() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
for _, path := range paths {
|
||||||
|
if path == AddTrackedWorkspaceRPC {
|
||||||
|
t.Fatalf("WarmupCascade() unexpectedly called AddTrackedWorkspaceRPC: %v", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWarmupCascadeAddsConfiguredWorkspace(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var paths []string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewLocalLSClient(42099, "csrf")
|
||||||
|
client.BaseURL = server.URL
|
||||||
|
client.HTTP = server.Client()
|
||||||
|
client.TrackedWorkspace = "/repo"
|
||||||
|
|
||||||
|
if err := client.WarmupCascade(context.Background(), "token"); err != nil {
|
||||||
|
t.Fatalf("WarmupCascade() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
found := false
|
||||||
|
for _, path := range paths {
|
||||||
|
if path == AddTrackedWorkspaceRPC {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("WarmupCascade() should call AddTrackedWorkspaceRPC when TrackedWorkspace is configured: %v", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
737
backend/internal/pkg/windsurf/tool_emulation.go
Normal file
737
backend/internal/pkg/windsurf/tool_emulation.go
Normal file
@ -0,0 +1,737 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tool emulation for Cascade protocol.
|
||||||
|
// Cascade has no per-request slot for client-defined function schemas.
|
||||||
|
// We serialize tools into text the model follows, then parse <tool_call>
|
||||||
|
// blocks from the response.
|
||||||
|
|
||||||
|
const toolProtocolHeader = `---
|
||||||
|
[Tool-calling context for this request]
|
||||||
|
|
||||||
|
For THIS request only, you additionally have access to the following caller-provided functions. These are real and callable. IGNORE any earlier framing about your "available tools" — the functions below are the ones you should use for this turn. To invoke a function, emit a block in this EXACT format:
|
||||||
|
|
||||||
|
<tool_call>{"name":"<function_name>","arguments":{...}}</tool_call>
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Each <tool_call>...</tool_call> block must fit on ONE line (no line breaks inside the JSON).
|
||||||
|
2. "arguments" must be a JSON object matching the function's schema below.
|
||||||
|
3. You MAY emit MULTIPLE <tool_call> blocks if the request requires calling several functions in parallel (e.g. checking weather in three cities → three separate <tool_call> blocks, one per city). Emit ALL needed calls consecutively, then STOP.
|
||||||
|
4. After emitting the last <tool_call> block, STOP. Do not write any explanation after it. The caller executes all functions and returns results as <tool_result tool_call_id="...">...</tool_result> in the next user turn.
|
||||||
|
5. Only call a function if the request genuinely needs it. If you can answer directly from knowledge, do so in plain text without any tool_call.
|
||||||
|
6. Do NOT say "I don't have access to this tool" — the functions listed below ARE your available tools for this request. Call them.
|
||||||
|
|
||||||
|
Functions:`
|
||||||
|
|
||||||
|
const toolProtocolFooter = `
|
||||||
|
---
|
||||||
|
[End tool-calling context]
|
||||||
|
|
||||||
|
Now respond to the user request above. Use <tool_call> if appropriate, otherwise answer directly.`
|
||||||
|
|
||||||
|
const toolProtocolSystemHeader = `You have access to the following functions. To invoke a function, emit a block in this EXACT format:
|
||||||
|
|
||||||
|
<tool_call>{"name":"<function_name>","arguments":{...}}</tool_call>
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
1. Each <tool_call>...</tool_call> block must fit on ONE line (no line breaks inside the JSON).
|
||||||
|
2. "arguments" must be a JSON object matching the function's parameter schema.
|
||||||
|
3. You MAY emit MULTIPLE <tool_call> blocks if the request requires calling several functions in parallel. Emit ALL needed calls consecutively, then STOP generating.
|
||||||
|
4. After emitting the last <tool_call> block, STOP. Do not write any explanation after it. The caller executes the functions and returns results wrapped in <tool_result tool_call_id="...">...</tool_result> tags in the next user turn.
|
||||||
|
5. NEVER say "I don't have access to tools" or "I cannot perform that action" — the functions listed below ARE your available tools.`
|
||||||
|
|
||||||
|
var toolChoiceSuffix = map[string]string{
|
||||||
|
"auto": `
|
||||||
|
6. When a function is relevant to the user's request, you SHOULD call it rather than answering from memory. Prefer using a tool over guessing.`,
|
||||||
|
"required": `
|
||||||
|
6. You MUST call at least one function for every request. Do NOT answer directly in plain text — always use a <tool_call>.`,
|
||||||
|
"none": `
|
||||||
|
6. Do NOT call any functions. Answer the user's question directly in plain text.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAITool represents an OpenAI-format tool definition.
|
||||||
|
type OpenAITool struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function OpenAIFunction `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenAIFunction struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolCall represents a parsed tool call from model output.
|
||||||
|
type ToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ArgumentsJSON string `json:"arguments_json"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAIToolCall is a tool_call in assistant messages (input format).
|
||||||
|
type OpenAIToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function OpenAIToolCallFunc `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenAIToolCallFunc struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatToolSchema(params json.RawMessage) string {
|
||||||
|
if len(params) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var pretty json.RawMessage
|
||||||
|
if json.Unmarshal(params, &pretty) == nil {
|
||||||
|
indented, err := json.MarshalIndent(pretty, "", " ")
|
||||||
|
if err == nil {
|
||||||
|
return string(indented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildToolPreamble serializes tools into a text preamble for user-message injection.
|
||||||
|
func BuildToolPreamble(tools []OpenAITool) string {
|
||||||
|
tools = canonicalizeOpenAITools(tools)
|
||||||
|
if len(tools) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, toolProtocolHeader)
|
||||||
|
for _, t := range tools {
|
||||||
|
if t.Type != "function" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, "### "+t.Function.Name)
|
||||||
|
if t.Function.Description != "" {
|
||||||
|
lines = append(lines, t.Function.Description)
|
||||||
|
}
|
||||||
|
if len(t.Function.Parameters) > 0 {
|
||||||
|
lines = append(lines, "parameters schema:")
|
||||||
|
lines = append(lines, "```json")
|
||||||
|
lines = append(lines, formatToolSchema(t.Function.Parameters))
|
||||||
|
lines = append(lines, "```")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines = append(lines, toolProtocolFooter)
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildToolPreambleForProto builds a system-prompt-level preamble for
|
||||||
|
// injection via CascadeConversationalPlannerConfig.tool_calling_section.
|
||||||
|
func BuildToolPreambleForProto(tools []OpenAITool, toolChoice interface{}) string {
|
||||||
|
tools = canonicalizeOpenAITools(tools)
|
||||||
|
if len(tools) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
mode, forceName := resolveToolChoice(toolChoice)
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, toolProtocolSystemHeader)
|
||||||
|
|
||||||
|
suffix, ok := toolChoiceSuffix[mode]
|
||||||
|
if !ok {
|
||||||
|
suffix = toolChoiceSuffix["auto"]
|
||||||
|
}
|
||||||
|
lines = append(lines, suffix)
|
||||||
|
if forceName != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf(`7. You MUST call the function "%s". No other function and no direct answer.`, forceName))
|
||||||
|
}
|
||||||
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, "Available functions:")
|
||||||
|
for _, t := range tools {
|
||||||
|
if t.Type != "function" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, "")
|
||||||
|
lines = append(lines, "### "+t.Function.Name)
|
||||||
|
if t.Function.Description != "" {
|
||||||
|
lines = append(lines, t.Function.Description)
|
||||||
|
}
|
||||||
|
if len(t.Function.Parameters) > 0 {
|
||||||
|
lines = append(lines, "Parameters:")
|
||||||
|
lines = append(lines, "```json")
|
||||||
|
lines = append(lines, formatToolSchema(t.Function.Parameters))
|
||||||
|
lines = append(lines, "```")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveToolChoice(tc interface{}) (string, string) {
|
||||||
|
if tc == nil {
|
||||||
|
return "auto", ""
|
||||||
|
}
|
||||||
|
switch v := tc.(type) {
|
||||||
|
case string:
|
||||||
|
switch v {
|
||||||
|
case "required", "any":
|
||||||
|
return "required", ""
|
||||||
|
case "none":
|
||||||
|
return "none", ""
|
||||||
|
default:
|
||||||
|
return "auto", ""
|
||||||
|
}
|
||||||
|
case map[string]interface{}:
|
||||||
|
fn, ok := v["function"].(map[string]interface{})
|
||||||
|
if ok {
|
||||||
|
name, _ := fn["name"].(string)
|
||||||
|
if name != "" {
|
||||||
|
return "required", NormalizeToolName(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name, _ := v["name"].(string)
|
||||||
|
if name != "" {
|
||||||
|
return "required", NormalizeToolName(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "auto", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicMessage represents a message in Anthropic Messages API format.
|
||||||
|
type AnthropicMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeMessagesForCascade rewrites messages for Cascade compatibility:
|
||||||
|
// - role:"tool" messages become user turns with <tool_result> wrappers
|
||||||
|
// - assistant messages with tool_calls get rewritten to <tool_call> format
|
||||||
|
// - tool preamble is injected into the last user message
|
||||||
|
func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool) []ChatMessage {
|
||||||
|
var out []ChatMessage
|
||||||
|
|
||||||
|
for _, m := range messages {
|
||||||
|
if m.Role == "tool" {
|
||||||
|
id := m.ToolCallID
|
||||||
|
if id == "" {
|
||||||
|
id = "unknown"
|
||||||
|
}
|
||||||
|
content := extractToolResultPayload(m.Content)
|
||||||
|
out = append(out, ChatMessage{
|
||||||
|
Role: "user",
|
||||||
|
Content: fmt.Sprintf("<tool_result tool_call_id=\"%s\">\n%s\n</tool_result>", id, content),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
||||||
|
var parts []string
|
||||||
|
text := extractRawContentText(m.Content)
|
||||||
|
if text != "" {
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
for _, tc := range m.ToolCalls {
|
||||||
|
name := NormalizeToolName(tc.Function.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = "unknown"
|
||||||
|
}
|
||||||
|
args := tc.Function.Arguments
|
||||||
|
parsed := safeParseJSON(args)
|
||||||
|
if parsed == nil {
|
||||||
|
parsed = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
callJSON, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
"arguments": parsed,
|
||||||
|
})
|
||||||
|
parts = append(parts, "<tool_call>"+string(callJSON)+"</tool_call>")
|
||||||
|
}
|
||||||
|
out = append(out, ChatMessage{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: strings.Join(parts, "\n"),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, ChatMessage{
|
||||||
|
Role: m.Role,
|
||||||
|
Content: extractRawContentText(m.Content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject preamble into the LAST user message
|
||||||
|
preamble := BuildToolPreamble(tools)
|
||||||
|
if preamble != "" {
|
||||||
|
for i := len(out) - 1; i >= 0; i-- {
|
||||||
|
if out[i].Role == "user" {
|
||||||
|
out[i].Content = preamble + "\n\n" + out[i].Content
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractRawContentText(raw json.RawMessage) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var blocks []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &blocks) == nil {
|
||||||
|
var parts []string
|
||||||
|
for _, b := range blocks {
|
||||||
|
if b.Type == "text" {
|
||||||
|
parts = append(parts, b.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "")
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractToolResultPayload(raw json.RawMessage) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var blocks []map[string]any
|
||||||
|
if json.Unmarshal(raw, &blocks) == nil {
|
||||||
|
textOnly := len(blocks) > 0
|
||||||
|
var parts []string
|
||||||
|
for _, block := range blocks {
|
||||||
|
blockType, _ := block["type"].(string)
|
||||||
|
if blockType != "text" {
|
||||||
|
textOnly = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
text, _ := block["text"].(string)
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
if textOnly {
|
||||||
|
return strings.Join(parts, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeParseJSON(s string) interface{} {
|
||||||
|
var v interface{}
|
||||||
|
if json.Unmarshal([]byte(s), &v) == nil {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolCallStreamParser parses <tool_call>...</tool_call> blocks from streaming text deltas.
|
||||||
|
type ToolCallStreamParser struct {
|
||||||
|
buffer string
|
||||||
|
inToolCall bool
|
||||||
|
inToolResult bool
|
||||||
|
inToolCode bool
|
||||||
|
inBareCall bool
|
||||||
|
totalSeen int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewToolCallStreamParser creates a new parser instance.
|
||||||
|
func NewToolCallStreamParser() *ToolCallStreamParser {
|
||||||
|
return &ToolCallStreamParser{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedResult holds the output of a Feed or Flush call.
|
||||||
|
type FeedResult struct {
|
||||||
|
Text string
|
||||||
|
ToolCalls []ToolCall
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
tcOpen = "<tool_call>"
|
||||||
|
tcClose = "</tool_call>"
|
||||||
|
trPrefix = "<tool_result"
|
||||||
|
trClose = "</tool_result>"
|
||||||
|
tcCode = `{"tool_code"`
|
||||||
|
tcBare = `{"name"`
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *ToolCallStreamParser) findClosingBrace() int {
|
||||||
|
depth := 0
|
||||||
|
inStr := false
|
||||||
|
escaped := false
|
||||||
|
for i := 0; i < len(p.buffer); i++ {
|
||||||
|
ch := p.buffer[i]
|
||||||
|
if escaped {
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '\\' && inStr {
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '"' {
|
||||||
|
inStr = !inStr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inStr {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '{' {
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
if ch == '}' {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ToolCallStreamParser) genCallID(prefix string) string {
|
||||||
|
return fmt.Sprintf("%s_%d_%s", prefix, p.totalSeen, fmt.Sprintf("%x", time.Now().UnixMilli()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ToolCallStreamParser) parseToolCodeJSON(jsonStr string) *ToolCall {
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
if json.Unmarshal([]byte(jsonStr), &parsed) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
toolCode, ok := parsed["tool_code"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
re := regexp.MustCompile(`^([^(]+)\(([\s\S]*)\)$`)
|
||||||
|
m := re.FindStringSubmatch(toolCode)
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(m[1])
|
||||||
|
rawArgs := strings.TrimSpace(m[2])
|
||||||
|
var args string
|
||||||
|
if strings.HasPrefix(rawArgs, `"`) && strings.HasSuffix(rawArgs, `"`) {
|
||||||
|
args = `{"input":` + rawArgs + `}`
|
||||||
|
} else if !strings.HasPrefix(rawArgs, "{") {
|
||||||
|
if rawArgs != "" {
|
||||||
|
args = `{"input":"` + rawArgs + `"}`
|
||||||
|
} else {
|
||||||
|
args = "{}"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args = rawArgs
|
||||||
|
}
|
||||||
|
var parsedArgs interface{}
|
||||||
|
if json.Unmarshal([]byte(args), &parsedArgs) != nil {
|
||||||
|
parsedArgs = map[string]interface{}{"input": rawArgs}
|
||||||
|
}
|
||||||
|
argsJSON, _ := json.Marshal(parsedArgs)
|
||||||
|
return &ToolCall{
|
||||||
|
ID: p.genCallID("call_tc"),
|
||||||
|
Name: NormalizeToolName(name),
|
||||||
|
ArgumentsJSON: string(argsJSON),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ToolCallStreamParser) parseBareToolCallJSON(jsonStr string) *ToolCall {
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
if json.Unmarshal([]byte(jsonStr), &parsed) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name, ok := parsed["name"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, hasArgs := parsed["arguments"]; !hasArgs {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
argsJSON, _ := json.Marshal(parsed["arguments"])
|
||||||
|
return &ToolCall{
|
||||||
|
ID: p.genCallID("call"),
|
||||||
|
Name: NormalizeToolName(name),
|
||||||
|
ArgumentsJSON: string(argsJSON),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ToolCallStreamParser) consumeJSONBlock(parseFn func(string) *ToolCall) (*ToolCall, string, bool) {
|
||||||
|
endIdx := p.findClosingBrace()
|
||||||
|
if endIdx == -1 {
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
jsonStr := p.buffer[:endIdx+1]
|
||||||
|
p.buffer = p.buffer[endIdx+1:]
|
||||||
|
tc := parseFn(jsonStr)
|
||||||
|
if tc != nil {
|
||||||
|
p.totalSeen++
|
||||||
|
return tc, "", true
|
||||||
|
}
|
||||||
|
return nil, jsonStr, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed processes a text delta and returns safe text and any completed tool calls.
|
||||||
|
func (p *ToolCallStreamParser) Feed(delta string) FeedResult {
|
||||||
|
if delta == "" {
|
||||||
|
return FeedResult{}
|
||||||
|
}
|
||||||
|
p.buffer += delta
|
||||||
|
var safeParts []string
|
||||||
|
var doneCalls []ToolCall
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Inside a <tool_result>...</tool_result> — discard body
|
||||||
|
if p.inToolResult {
|
||||||
|
closeIdx := strings.Index(p.buffer, trClose)
|
||||||
|
if closeIdx == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
p.buffer = p.buffer[closeIdx+len(trClose):]
|
||||||
|
p.inToolResult = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside a <tool_call>...</tool_call> — parse JSON body
|
||||||
|
if p.inToolCall {
|
||||||
|
closeIdx := strings.Index(p.buffer, tcClose)
|
||||||
|
if closeIdx == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(p.buffer[:closeIdx])
|
||||||
|
p.buffer = p.buffer[closeIdx+len(tcClose):]
|
||||||
|
p.inToolCall = false
|
||||||
|
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
if json.Unmarshal([]byte(body), &parsed) == nil {
|
||||||
|
name, _ := parsed["name"].(string)
|
||||||
|
if name != "" {
|
||||||
|
argsJSON, _ := json.Marshal(parsed["arguments"])
|
||||||
|
doneCalls = append(doneCalls, ToolCall{
|
||||||
|
ID: p.genCallID("call"),
|
||||||
|
Name: NormalizeToolName(name),
|
||||||
|
ArgumentsJSON: string(argsJSON),
|
||||||
|
})
|
||||||
|
p.totalSeen++
|
||||||
|
} else {
|
||||||
|
safeParts = append(safeParts, tcOpen+body+tcClose)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
safeParts = append(safeParts, tcOpen+body+tcClose)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside a {"tool_code": "…"} block
|
||||||
|
if p.inToolCode {
|
||||||
|
tc, fallback, ok := p.consumeJSONBlock(p.parseToolCodeJSON)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
p.inToolCode = false
|
||||||
|
if tc != nil {
|
||||||
|
doneCalls = append(doneCalls, *tc)
|
||||||
|
} else if fallback != "" {
|
||||||
|
safeParts = append(safeParts, fallback)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside a bare {"name":"…","arguments":{…}} block
|
||||||
|
if p.inBareCall {
|
||||||
|
tc, fallback, ok := p.consumeJSONBlock(p.parseBareToolCallJSON)
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
p.inBareCall = false
|
||||||
|
if tc != nil {
|
||||||
|
doneCalls = append(doneCalls, *tc)
|
||||||
|
} else if fallback != "" {
|
||||||
|
safeParts = append(safeParts, fallback)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal mode — scan for next opening tag
|
||||||
|
tcIdx := strings.Index(p.buffer, tcOpen)
|
||||||
|
trIdx := strings.Index(p.buffer, trPrefix)
|
||||||
|
tcCodeIdx := strings.Index(p.buffer, tcCode)
|
||||||
|
tcBareIdx := strings.Index(p.buffer, tcBare)
|
||||||
|
|
||||||
|
type candidate struct {
|
||||||
|
idx int
|
||||||
|
tagType string
|
||||||
|
}
|
||||||
|
var candidates []candidate
|
||||||
|
if tcIdx != -1 {
|
||||||
|
candidates = append(candidates, candidate{tcIdx, "tc"})
|
||||||
|
}
|
||||||
|
if trIdx != -1 {
|
||||||
|
candidates = append(candidates, candidate{trIdx, "tr"})
|
||||||
|
}
|
||||||
|
if tcCodeIdx != -1 {
|
||||||
|
candidates = append(candidates, candidate{tcCodeIdx, "code"})
|
||||||
|
}
|
||||||
|
if tcBareIdx != -1 && tcBareIdx != tcCodeIdx {
|
||||||
|
candidates = append(candidates, candidate{tcBareIdx, "bare"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
// No tags found — emit safe text, hold back partial tag prefixes
|
||||||
|
holdLen := 0
|
||||||
|
for _, prefix := range []string{tcOpen, trPrefix, tcCode, tcBare} {
|
||||||
|
maxHold := len(prefix) - 1
|
||||||
|
if maxHold > len(p.buffer) {
|
||||||
|
maxHold = len(p.buffer)
|
||||||
|
}
|
||||||
|
for l := maxHold; l > 0; l-- {
|
||||||
|
if strings.HasSuffix(p.buffer, prefix[:l]) {
|
||||||
|
if l > holdLen {
|
||||||
|
holdLen = l
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitUpto := len(p.buffer) - holdLen
|
||||||
|
if emitUpto > 0 {
|
||||||
|
safeParts = append(safeParts, p.buffer[:emitUpto])
|
||||||
|
}
|
||||||
|
p.buffer = p.buffer[emitUpto:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find earliest tag
|
||||||
|
best := candidates[0]
|
||||||
|
for _, c := range candidates[1:] {
|
||||||
|
if c.idx < best.idx {
|
||||||
|
best = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best.idx > 0 {
|
||||||
|
safeParts = append(safeParts, p.buffer[:best.idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
switch best.tagType {
|
||||||
|
case "tc":
|
||||||
|
p.buffer = p.buffer[best.idx+len(tcOpen):]
|
||||||
|
p.inToolCall = true
|
||||||
|
case "tr":
|
||||||
|
closeAngle := strings.Index(p.buffer[best.idx+len(trPrefix):], ">")
|
||||||
|
if closeAngle == -1 {
|
||||||
|
p.buffer = p.buffer[best.idx:]
|
||||||
|
goto done
|
||||||
|
}
|
||||||
|
p.buffer = p.buffer[best.idx+len(trPrefix)+closeAngle+1:]
|
||||||
|
p.inToolResult = true
|
||||||
|
case "code":
|
||||||
|
p.buffer = p.buffer[best.idx:]
|
||||||
|
p.inToolCode = true
|
||||||
|
case "bare":
|
||||||
|
p.buffer = p.buffer[best.idx:]
|
||||||
|
p.inBareCall = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
done:
|
||||||
|
return FeedResult{
|
||||||
|
Text: strings.Join(safeParts, ""),
|
||||||
|
ToolCalls: doneCalls,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush drains any remaining buffer content.
|
||||||
|
func (p *ToolCallStreamParser) Flush() FeedResult {
|
||||||
|
remaining := p.buffer
|
||||||
|
p.buffer = ""
|
||||||
|
|
||||||
|
if p.inToolCall {
|
||||||
|
p.inToolCall = false
|
||||||
|
return FeedResult{Text: tcOpen + remaining}
|
||||||
|
}
|
||||||
|
if p.inToolResult {
|
||||||
|
p.inToolResult = false
|
||||||
|
return FeedResult{}
|
||||||
|
}
|
||||||
|
if p.inToolCode {
|
||||||
|
p.inToolCode = false
|
||||||
|
tc := p.parseToolCodeJSON(remaining)
|
||||||
|
if tc != nil {
|
||||||
|
p.totalSeen++
|
||||||
|
return FeedResult{ToolCalls: []ToolCall{*tc}}
|
||||||
|
}
|
||||||
|
return FeedResult{Text: remaining}
|
||||||
|
}
|
||||||
|
if p.inBareCall {
|
||||||
|
p.inBareCall = false
|
||||||
|
tc := p.parseBareToolCallJSON(remaining)
|
||||||
|
if tc != nil {
|
||||||
|
p.totalSeen++
|
||||||
|
return FeedResult{ToolCalls: []ToolCall{*tc}}
|
||||||
|
}
|
||||||
|
return FeedResult{Text: remaining}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: detect tool_code patterns in leftover
|
||||||
|
re := regexp.MustCompile(`\{"tool_code"\s*:\s*"([^"]+?)\(([\s\S]*?)\)"\s*\}`)
|
||||||
|
var toolCalls []ToolCall
|
||||||
|
cleaned := re.ReplaceAllStringFunc(remaining, func(match string) string {
|
||||||
|
sub := re.FindStringSubmatch(match)
|
||||||
|
if len(sub) < 3 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
name := sub[1]
|
||||||
|
rawArgs := strings.ReplaceAll(sub[2], `\"`, `"`)
|
||||||
|
rawArgs = strings.TrimSpace(rawArgs)
|
||||||
|
var args string
|
||||||
|
if strings.HasPrefix(rawArgs, `"`) && strings.HasSuffix(rawArgs, `"`) {
|
||||||
|
args = `{"input":` + rawArgs + `}`
|
||||||
|
} else if !strings.HasPrefix(rawArgs, "{") {
|
||||||
|
args = `{"input":"` + rawArgs + `"}`
|
||||||
|
} else {
|
||||||
|
args = rawArgs
|
||||||
|
}
|
||||||
|
var parsedArgs interface{}
|
||||||
|
if json.Unmarshal([]byte(args), &parsedArgs) != nil {
|
||||||
|
parsedArgs = map[string]interface{}{"input": rawArgs}
|
||||||
|
}
|
||||||
|
argsJSON, _ := json.Marshal(parsedArgs)
|
||||||
|
toolCalls = append(toolCalls, ToolCall{
|
||||||
|
ID: p.genCallID("call_tc"),
|
||||||
|
Name: NormalizeToolName(name),
|
||||||
|
ArgumentsJSON: string(argsJSON),
|
||||||
|
})
|
||||||
|
p.totalSeen++
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(toolCalls) > 0 {
|
||||||
|
return FeedResult{Text: strings.TrimSpace(cleaned), ToolCalls: toolCalls}
|
||||||
|
}
|
||||||
|
return FeedResult{Text: remaining}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseToolCallsFromText runs text through the parser in one shot.
|
||||||
|
func ParseToolCallsFromText(text string) FeedResult {
|
||||||
|
parser := NewToolCallStreamParser()
|
||||||
|
a := parser.Feed(text)
|
||||||
|
b := parser.Flush()
|
||||||
|
var toolCalls []ToolCall
|
||||||
|
toolCalls = append(toolCalls, a.ToolCalls...)
|
||||||
|
toolCalls = append(toolCalls, b.ToolCalls...)
|
||||||
|
return FeedResult{
|
||||||
|
Text: a.Text + b.Text,
|
||||||
|
ToolCalls: toolCalls,
|
||||||
|
}
|
||||||
|
}
|
||||||
87
backend/internal/pkg/windsurf/tool_names.go
Normal file
87
backend/internal/pkg/windsurf/tool_names.go
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
var canonicalToolAliases = map[string]string{
|
||||||
|
"read": "read",
|
||||||
|
"read_file": "read",
|
||||||
|
"readfile": "read",
|
||||||
|
"write": "write",
|
||||||
|
"write_file": "write",
|
||||||
|
"writefile": "write",
|
||||||
|
"edit": "edit",
|
||||||
|
"apply_patch": "edit",
|
||||||
|
"applypatch": "edit",
|
||||||
|
"bash": "bash",
|
||||||
|
"execute_bash": "bash",
|
||||||
|
"executebash": "bash",
|
||||||
|
"exec_bash": "bash",
|
||||||
|
"execbash": "bash",
|
||||||
|
"glob": "glob",
|
||||||
|
"list_files": "glob",
|
||||||
|
"listfiles": "glob",
|
||||||
|
"grep": "grep",
|
||||||
|
"search_files": "grep",
|
||||||
|
"searchfiles": "grep",
|
||||||
|
"webfetch": "webfetch",
|
||||||
|
"web_fetch": "webfetch",
|
||||||
|
"fetch": "webfetch",
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeToolName canonicalizes known tool aliases while preserving unknown tool names.
|
||||||
|
func NormalizeToolName(name string) string {
|
||||||
|
trimmed := strings.TrimSpace(name)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if canonical, ok := canonicalToolAliases[strings.ToLower(trimmed)]; ok {
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOpenAITool(tool OpenAITool) OpenAITool {
|
||||||
|
if tool.Type != "function" {
|
||||||
|
return tool
|
||||||
|
}
|
||||||
|
tool.Function.Name = NormalizeToolName(tool.Function.Name)
|
||||||
|
return tool
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeOpenAITools(tools []OpenAITool) []OpenAITool {
|
||||||
|
if len(tools) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]OpenAITool, 0, len(tools))
|
||||||
|
seen := make(map[string]int, len(tools))
|
||||||
|
|
||||||
|
for _, tool := range tools {
|
||||||
|
normalized := normalizeOpenAITool(tool)
|
||||||
|
if normalized.Type != "function" {
|
||||||
|
out = append(out, normalized)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(normalized.Function.Name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(name)
|
||||||
|
|
||||||
|
if idx, ok := seen[key]; ok {
|
||||||
|
if out[idx].Function.Description == "" {
|
||||||
|
out[idx].Function.Description = normalized.Function.Description
|
||||||
|
}
|
||||||
|
if len(out[idx].Function.Parameters) == 0 {
|
||||||
|
out[idx].Function.Parameters = normalized.Function.Parameters
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[key] = len(out)
|
||||||
|
out = append(out, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
302
backend/internal/pkg/windsurf/windsurf_test.go
Normal file
302
backend/internal/pkg/windsurf/windsurf_test.go
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
package windsurf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProxyKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"empty string", "", "default"},
|
||||||
|
{"whitespace only", " ", "default"},
|
||||||
|
{"simple http proxy", "http://1.2.3.4:8080", "px_1_2_3_4_8080"},
|
||||||
|
{"https proxy", "https://proxy.example.com:3128", "px_proxy_example_com_3128"},
|
||||||
|
{"socks5 proxy", "socks5://10.0.0.1:1080", "px_10_0_0_1_1080"},
|
||||||
|
{"proxy with auth", "http://user:pass@1.2.3.4:8080", "px_1_2_3_4_8080_user"},
|
||||||
|
{"different auth same host", "http://other:secret@1.2.3.4:8080", "px_1_2_3_4_8080_other"},
|
||||||
|
{"no port", "http://proxy.local", "px_proxy_local"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := proxyKey(tt.input)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("proxyKey(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyKeyDifferentAuthGetsDifferentKey(t *testing.T) {
|
||||||
|
k1 := proxyKey("http://alice:pw1@host:8080")
|
||||||
|
k2 := proxyKey("http://bob:pw2@host:8080")
|
||||||
|
if k1 == k2 {
|
||||||
|
t.Errorf("different credentials on same host should produce different keys: %q vs %q", k1, k2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedactProxyURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"empty", "", "none"},
|
||||||
|
{"no auth", "http://1.2.3.4:8080", "http://1.2.3.4:8080"},
|
||||||
|
{"with auth stripped", "http://user:secret@1.2.3.4:8080", "http://1.2.3.4:8080"},
|
||||||
|
{"invalid url", "://bad", "<invalid>"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := redactProxyURL(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("redactProxyURL(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPortInUse(t *testing.T) {
|
||||||
|
if isPortInUse(59999) {
|
||||||
|
t.Skip("port 59999 unexpectedly in use")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteVarint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
val uint64
|
||||||
|
expect []byte
|
||||||
|
}{
|
||||||
|
{0, []byte{0}},
|
||||||
|
{1, []byte{1}},
|
||||||
|
{127, []byte{127}},
|
||||||
|
{128, []byte{0x80, 0x01}},
|
||||||
|
{300, []byte{0xAC, 0x02}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := writeVarint(tt.val)
|
||||||
|
if len(got) != len(tt.expect) {
|
||||||
|
t.Errorf("writeVarint(%d) len=%d, want %d", tt.val, len(got), len(tt.expect))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.expect[i] {
|
||||||
|
t.Errorf("writeVarint(%d)[%d] = 0x%02x, want 0x%02x", tt.val, i, got[i], tt.expect[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadVarintRoundtrip(t *testing.T) {
|
||||||
|
values := []uint64{0, 1, 127, 128, 300, 16384, 1<<32 - 1}
|
||||||
|
for _, v := range values {
|
||||||
|
encoded := writeVarint(v)
|
||||||
|
decoded, pos, ok := ReadVarint(encoded, 0)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("ReadVarint failed for %d", v)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if decoded != v {
|
||||||
|
t.Errorf("ReadVarint roundtrip: got %d, want %d", decoded, v)
|
||||||
|
}
|
||||||
|
if pos != len(encoded) {
|
||||||
|
t.Errorf("ReadVarint pos=%d, want %d", pos, len(encoded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeStringField(t *testing.T) {
|
||||||
|
data := encodeStringField(1, "hello")
|
||||||
|
tag, pos, ok := ReadVarint(data, 0)
|
||||||
|
if !ok || tag != (1<<3|2) {
|
||||||
|
t.Fatalf("bad tag: %d ok=%v", tag, ok)
|
||||||
|
}
|
||||||
|
length, pos, ok := ReadVarint(data, pos)
|
||||||
|
if !ok || length != 5 {
|
||||||
|
t.Fatalf("bad length: %d ok=%v", length, ok)
|
||||||
|
}
|
||||||
|
if string(data[pos:pos+int(length)]) != "hello" {
|
||||||
|
t.Fatalf("payload mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeVarintField(t *testing.T) {
|
||||||
|
data := encodeVarintField(3, 42)
|
||||||
|
tag, pos, ok := ReadVarint(data, 0)
|
||||||
|
if !ok || tag != (3<<3|0) {
|
||||||
|
t.Fatalf("bad tag: %d ok=%v", tag, ok)
|
||||||
|
}
|
||||||
|
val, _, ok := ReadVarint(data, pos)
|
||||||
|
if !ok || val != 42 {
|
||||||
|
t.Fatalf("bad value: %d ok=%v", val, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStringField1(t *testing.T) {
|
||||||
|
data := encodeStringField(1, "cascade-123")
|
||||||
|
got, err := parseStringField1(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "cascade-123" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "cascade-123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStringField1WithOtherFields(t *testing.T) {
|
||||||
|
var data []byte
|
||||||
|
data = append(data, encodeVarintField(2, 99)...)
|
||||||
|
data = append(data, encodeStringField(1, "target")...)
|
||||||
|
data = append(data, encodeStringField(3, "noise")...)
|
||||||
|
got, err := parseStringField1(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "target" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "target")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVarintField2(t *testing.T) {
|
||||||
|
var data []byte
|
||||||
|
data = append(data, encodeVarintField(1, 10)...)
|
||||||
|
data = append(data, encodeVarintField(2, 42)...)
|
||||||
|
got, err := parseVarintField2(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != 42 {
|
||||||
|
t.Fatalf("got %d, want 42", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveModelEnum(t *testing.T) {
|
||||||
|
if v := resolveModelEnum("MODEL_CLAUDE_4_SONNET"); v != 281 {
|
||||||
|
t.Errorf("got %d, want 281", v)
|
||||||
|
}
|
||||||
|
if v := resolveModelEnum("NONEXISTENT"); v != 0 {
|
||||||
|
t.Errorf("got %d, want 0", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPanelStateNotFound(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
msg string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"gRPC status 5: panel state not found for session abc", true},
|
||||||
|
{"NOT_FOUND: panel xyz is missing", true},
|
||||||
|
{"connection refused", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
var err error
|
||||||
|
if tt.msg != "" {
|
||||||
|
err = fmt.Errorf("%s", tt.msg)
|
||||||
|
}
|
||||||
|
got := isPanelStateNotFound(err)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("isPanelStateNotFound(%q) = %v, want %v", tt.msg, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateUUID(t *testing.T) {
|
||||||
|
u := generateUUID()
|
||||||
|
if len(u) != 36 {
|
||||||
|
t.Fatalf("UUID length = %d, want 36", len(u))
|
||||||
|
}
|
||||||
|
if u[8] != '-' || u[13] != '-' || u[18] != '-' || u[23] != '-' {
|
||||||
|
t.Fatalf("UUID format wrong: %s", u)
|
||||||
|
}
|
||||||
|
if u[14] != '4' {
|
||||||
|
t.Fatalf("UUID version not 4: %s", u)
|
||||||
|
}
|
||||||
|
u2 := generateUUID()
|
||||||
|
if u == u2 {
|
||||||
|
t.Fatal("two UUIDs should not be equal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMetadata(t *testing.T) {
|
||||||
|
meta := buildMetadata("test-token-123", "session-abc")
|
||||||
|
got, err := parseStringField1(meta)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != AppName {
|
||||||
|
t.Errorf("field 1 (ide_name) = %q, want %q", got, AppName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCascadeConfig(t *testing.T) {
|
||||||
|
cfg := buildCascadeConfig("MODEL_CLAUDE_4_SONNET", 281, "")
|
||||||
|
if len(cfg) == 0 {
|
||||||
|
t.Fatal("buildCascadeConfig returned empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripGRPCFrame(t *testing.T) {
|
||||||
|
payload := []byte("hello world")
|
||||||
|
frame := make([]byte, 5+len(payload))
|
||||||
|
frame[0] = 0
|
||||||
|
frame[1] = 0
|
||||||
|
frame[2] = 0
|
||||||
|
frame[3] = 0
|
||||||
|
frame[4] = byte(len(payload))
|
||||||
|
copy(frame[5:], payload)
|
||||||
|
got := stripGRPCFrame(frame)
|
||||||
|
if string(got) != "hello world" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "hello world")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripGRPCFrameShortData(t *testing.T) {
|
||||||
|
got := stripGRPCFrame([]byte{0, 0})
|
||||||
|
if string(got) != string([]byte{0, 0}) {
|
||||||
|
t.Fatal("short data should be returned as-is")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLSPoolDefaults(t *testing.T) {
|
||||||
|
pool := NewLSPool(LSPoolConfig{}, nil)
|
||||||
|
if pool.config.Binary == "" {
|
||||||
|
t.Error("default binary should not be empty")
|
||||||
|
}
|
||||||
|
if pool.config.BasePort != DefaultLSPort {
|
||||||
|
t.Errorf("default port = %d, want %d", pool.config.BasePort, DefaultLSPort)
|
||||||
|
}
|
||||||
|
if pool.config.CSRFToken != DefaultCSRF {
|
||||||
|
t.Errorf("default CSRF = %q, want %q", pool.config.CSRFToken, DefaultCSRF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLSPoolGetNonExistent(t *testing.T) {
|
||||||
|
pool := NewLSPool(LSPoolConfig{}, nil)
|
||||||
|
if e := pool.Get("http://nonexistent:9999"); e != nil {
|
||||||
|
t.Error("Get on empty pool should return nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLSPoolStatus(t *testing.T) {
|
||||||
|
pool := NewLSPool(LSPoolConfig{}, nil)
|
||||||
|
s := pool.Status()
|
||||||
|
if s.Running {
|
||||||
|
t.Error("empty pool should not be running")
|
||||||
|
}
|
||||||
|
if len(s.Instances) != 0 {
|
||||||
|
t.Error("empty pool should have no instances")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLSPoolShutdownEmpty(t *testing.T) {
|
||||||
|
pool := NewLSPool(LSPoolConfig{}, nil)
|
||||||
|
pool.Shutdown()
|
||||||
|
s := pool.Status()
|
||||||
|
if s.Running {
|
||||||
|
t.Error("shutdown pool should not be running")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1840,6 +1840,23 @@ func (r *accountRepository) FindByExtraField(ctx context.Context, key string, va
|
|||||||
return r.accountsToService(ctx, accounts)
|
return r.accountsToService(ctx, accounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *accountRepository) FindByCredentialField(ctx context.Context, platform, key, value string) ([]service.Account, error) {
|
||||||
|
accounts, err := r.client.Account.Query().
|
||||||
|
Where(
|
||||||
|
dbaccount.DeletedAtIsNil(),
|
||||||
|
dbaccount.Platform(platform),
|
||||||
|
func(s *entsql.Selector) {
|
||||||
|
s.Where(sqljson.ValueEQ(dbaccount.FieldCredentials, value, sqljson.Path(key)))
|
||||||
|
},
|
||||||
|
).
|
||||||
|
All(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, translatePersistenceError(err, service.ErrAccountNotFound, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.accountsToService(ctx, accounts)
|
||||||
|
}
|
||||||
|
|
||||||
// nowUTC is a SQL expression to generate a UTC RFC3339 timestamp string.
|
// nowUTC is a SQL expression to generate a UTC RFC3339 timestamp string.
|
||||||
const nowUTC = `to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`
|
const nowUTC = `to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`
|
||||||
|
|
||||||
|
|||||||
@ -47,9 +47,9 @@ func (s *httpUpstreamService) shouldRouteWithTLSFingerprint(req *http.Request) b
|
|||||||
if len(hosts) == 0 {
|
if len(hosts) == 0 {
|
||||||
// 默认白名单:api.anthropic.com 和 Antigravity API 主机
|
// 默认白名单:api.anthropic.com 和 Antigravity API 主机
|
||||||
defaultHosts := map[string]bool{
|
defaultHosts := map[string]bool{
|
||||||
"api.anthropic.com": true,
|
"api.anthropic.com": true,
|
||||||
"cloudcode-pa.googleapis.com": true,
|
"cloudcode-pa.googleapis.com": true,
|
||||||
"daily-cloudcode-pa.sandbox.googleapis.com": true,
|
"daily-cloudcode-pa.googleapis.com": true,
|
||||||
}
|
}
|
||||||
return defaultHosts[reqHost]
|
return defaultHosts[reqHost]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ func ensureSimpleModeDefaultGroups(ctx context.Context, client *dbent.Client) er
|
|||||||
service.PlatformOpenAI: 1,
|
service.PlatformOpenAI: 1,
|
||||||
service.PlatformGemini: 1,
|
service.PlatformGemini: 1,
|
||||||
service.PlatformAntigravity: 2,
|
service.PlatformAntigravity: 2,
|
||||||
|
service.PlatformWindsurf: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
for platform, minCount := range requiredByPlatform {
|
for platform, minCount := range requiredByPlatform {
|
||||||
|
|||||||
@ -1445,6 +1445,9 @@ func (s *stubAccountRepo) GetByCRSAccountID(ctx context.Context, crsAccountID st
|
|||||||
func (s *stubAccountRepo) FindByExtraField(ctx context.Context, key string, value any) ([]service.Account, error) {
|
func (s *stubAccountRepo) FindByExtraField(ctx context.Context, key string, value any) ([]service.Account, error) {
|
||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
func (s *stubAccountRepo) FindByCredentialField(ctx context.Context, platform, key, value string) ([]service.Account, error) {
|
||||||
|
return nil, errors.New("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *stubAccountRepo) Update(ctx context.Context, account *service.Account) error {
|
func (s *stubAccountRepo) Update(ctx context.Context, account *service.Account) error {
|
||||||
return errors.New("not implemented")
|
return errors.New("not implemented")
|
||||||
|
|||||||
@ -117,6 +117,9 @@ func registerRoutes(
|
|||||||
routes.RegisterAdminRoutes(v1, h, adminAuth)
|
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)
|
||||||
|
|
||||||
|
// Windsurf gateway routes
|
||||||
|
routes.RegisterWindsurfGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg)
|
||||||
|
|
||||||
// 注册 Antigravity HTTP API 路由
|
// 注册 Antigravity HTTP API 路由
|
||||||
routes.RegisterAntigravityHTTPRoutes(v1, langServerService)
|
routes.RegisterAntigravityHTTPRoutes(v1, langServerService)
|
||||||
|
|
||||||
|
|||||||
@ -88,6 +88,9 @@ func RegisterAdminRoutes(
|
|||||||
|
|
||||||
// 渠道管理
|
// 渠道管理
|
||||||
registerChannelRoutes(admin, h)
|
registerChannelRoutes(admin, h)
|
||||||
|
|
||||||
|
// Windsurf 账号管理
|
||||||
|
registerWindsurfRoutes(admin, h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -564,3 +567,21 @@ func registerChannelRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
channels.DELETE("/:id", h.Admin.Channel.Delete)
|
channels.DELETE("/:id", h.Admin.Channel.Delete)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registerWindsurfRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||||
|
if h.Admin.Windsurf == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ws := admin.Group("/windsurf")
|
||||||
|
{
|
||||||
|
ws.POST("/accounts/login", h.Admin.Windsurf.Login)
|
||||||
|
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)
|
||||||
|
ws.POST("/accounts/:id/refresh-token", h.Admin.Windsurf.RefreshToken)
|
||||||
|
ws.POST("/accounts/batch-refresh-tokens", h.Admin.Windsurf.BatchRefreshTokens)
|
||||||
|
ws.GET("/accounts/:id/runtime", h.Admin.Windsurf.GetRuntime)
|
||||||
|
ws.GET("/ls/status", h.Admin.Windsurf.GetLSStatus)
|
||||||
|
ws.GET("/models", h.Admin.Windsurf.ListModels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
45
backend/internal/server/routes/windsurf_gateway.go
Normal file
45
backend/internal/server/routes/windsurf_gateway.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterWindsurfGatewayRoutes(
|
||||||
|
r *gin.Engine,
|
||||||
|
h *handler.Handlers,
|
||||||
|
apiKeyAuth middleware.APIKeyAuthMiddleware,
|
||||||
|
apiKeyService *service.APIKeyService,
|
||||||
|
subscriptionService *service.SubscriptionService,
|
||||||
|
opsService *service.OpsService,
|
||||||
|
settingService *service.SettingService,
|
||||||
|
cfg *config.Config,
|
||||||
|
) {
|
||||||
|
if h.Gateway == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyLimit := middleware.RequestBodyLimit(cfg.Gateway.MaxBodySize)
|
||||||
|
clientRequestID := middleware.ClientRequestID()
|
||||||
|
opsErrorLogger := handler.OpsErrorLoggerMiddleware(opsService)
|
||||||
|
endpointNorm := handler.InboundEndpointMiddleware()
|
||||||
|
requireGroupAnthropic := middleware.RequireGroupAssignment(settingService, middleware.AnthropicErrorWriter)
|
||||||
|
|
||||||
|
windsurfV1 := r.Group("/windsurf/v1")
|
||||||
|
windsurfV1.Use(bodyLimit)
|
||||||
|
windsurfV1.Use(clientRequestID)
|
||||||
|
windsurfV1.Use(opsErrorLogger)
|
||||||
|
windsurfV1.Use(endpointNorm)
|
||||||
|
windsurfV1.Use(middleware.ForcePlatform(service.PlatformWindsurf))
|
||||||
|
windsurfV1.Use(gin.HandlerFunc(apiKeyAuth))
|
||||||
|
windsurfV1.Use(requireGroupAnthropic)
|
||||||
|
{
|
||||||
|
windsurfV1.POST("/messages", h.Gateway.Messages)
|
||||||
|
windsurfV1.POST("/chat/completions", h.Gateway.ChatCompletions)
|
||||||
|
windsurfV1.GET("/models", h.Gateway.Models)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -274,6 +274,26 @@ func (a *Account) GetCredentialAsInt64(key string) int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCredentialAsBool 解析凭证中的 bool 字段,支持 bool 和 "true"/"false" 字符串
|
||||||
|
func (a *Account) GetCredentialAsBool(key string) bool {
|
||||||
|
if a == nil || a.Credentials == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val, ok := a.Credentials[key]
|
||||||
|
if !ok || val == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch v := val.(type) {
|
||||||
|
case bool:
|
||||||
|
return v
|
||||||
|
case string:
|
||||||
|
return strings.EqualFold(strings.TrimSpace(v), "true")
|
||||||
|
case float64:
|
||||||
|
return v != 0
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Account) IsTempUnschedulableEnabled() bool {
|
func (a *Account) IsTempUnschedulableEnabled() bool {
|
||||||
if a.Credentials == nil {
|
if a.Credentials == nil {
|
||||||
return false
|
return false
|
||||||
@ -598,6 +618,26 @@ func (a *Account) ResolveMappedModel(requestedModel string) (mappedModel string,
|
|||||||
return requestedModel, false
|
return requestedModel, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AntigravityUpstreamType 标识 Antigravity APIKey 账号对接的上游形态。
|
||||||
|
//
|
||||||
|
// - "sub2api"(默认):对接另一个 sub2api 实例,路径需要追加 /antigravity 前缀
|
||||||
|
// - "newapi":对接 newapi/one-api 风格的中转,直接使用 /v1/messages
|
||||||
|
//
|
||||||
|
// 旧账号 credentials 中若缺失该字段,按 sub2api 处理以保持向后兼容。
|
||||||
|
const (
|
||||||
|
AntigravityUpstreamTypeSub2Api = "sub2api"
|
||||||
|
AntigravityUpstreamTypeNewAPI = "newapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetAntigravityUpstreamType 返回该账号的上游类型(仅对 Antigravity+APIKey 有意义)。
|
||||||
|
func (a *Account) GetAntigravityUpstreamType() string {
|
||||||
|
t := strings.ToLower(strings.TrimSpace(a.GetCredential("upstream_type")))
|
||||||
|
if t == AntigravityUpstreamTypeNewAPI {
|
||||||
|
return AntigravityUpstreamTypeNewAPI
|
||||||
|
}
|
||||||
|
return AntigravityUpstreamTypeSub2Api
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Account) GetBaseURL() string {
|
func (a *Account) GetBaseURL() string {
|
||||||
if a.Type != AccountTypeAPIKey {
|
if a.Type != AccountTypeAPIKey {
|
||||||
return ""
|
return ""
|
||||||
@ -606,23 +646,25 @@ func (a *Account) GetBaseURL() string {
|
|||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
return "https://api.anthropic.com"
|
return "https://api.anthropic.com"
|
||||||
}
|
}
|
||||||
if a.Platform == PlatformAntigravity {
|
if a.Platform == PlatformAntigravity && a.GetAntigravityUpstreamType() == AntigravityUpstreamTypeSub2Api {
|
||||||
return strings.TrimRight(baseURL, "/") + "/antigravity"
|
return strings.TrimRight(baseURL, "/") + "/antigravity"
|
||||||
}
|
}
|
||||||
return baseURL
|
return strings.TrimRight(baseURL, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetGeminiBaseURL 返回 Gemini 兼容端点的 base URL。
|
// GetGeminiBaseURL 返回 Gemini 兼容端点的 base URL。
|
||||||
// Antigravity 平台的 APIKey 账号自动拼接 /antigravity。
|
// Antigravity 平台的 APIKey 账号默认自动拼接 /antigravity;
|
||||||
|
// 若 upstream_type=newapi 则直接使用用户配置的 base_url。
|
||||||
func (a *Account) GetGeminiBaseURL(defaultBaseURL string) string {
|
func (a *Account) GetGeminiBaseURL(defaultBaseURL string) string {
|
||||||
baseURL := strings.TrimSpace(a.GetCredential("base_url"))
|
baseURL := strings.TrimSpace(a.GetCredential("base_url"))
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
return defaultBaseURL
|
return defaultBaseURL
|
||||||
}
|
}
|
||||||
if a.Platform == PlatformAntigravity && a.Type == AccountTypeAPIKey {
|
if a.Platform == PlatformAntigravity && a.Type == AccountTypeAPIKey &&
|
||||||
|
a.GetAntigravityUpstreamType() == AntigravityUpstreamTypeSub2Api {
|
||||||
return strings.TrimRight(baseURL, "/") + "/antigravity"
|
return strings.TrimRight(baseURL, "/") + "/antigravity"
|
||||||
}
|
}
|
||||||
return baseURL
|
return strings.TrimRight(baseURL, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Account) GetExtraString(key string) string {
|
func (a *Account) GetExtraString(key string) string {
|
||||||
|
|||||||
@ -56,6 +56,54 @@ func TestGetBaseURL(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "https://upstream.example.com/antigravity",
|
expected: "https://upstream.example.com/antigravity",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "antigravity apikey explicit sub2api upstream_type appends /antigravity",
|
||||||
|
account: Account{
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"base_url": "https://upstream.example.com",
|
||||||
|
"upstream_type": "sub2api",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "https://upstream.example.com/antigravity",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "antigravity apikey newapi upstream_type does NOT append /antigravity",
|
||||||
|
account: Account{
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"base_url": "https://api.opusclaw.me",
|
||||||
|
"upstream_type": "newapi",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "https://api.opusclaw.me",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "antigravity apikey newapi upstream_type trims trailing slash",
|
||||||
|
account: Account{
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"base_url": "https://api.opusclaw.me/",
|
||||||
|
"upstream_type": "newapi",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "https://api.opusclaw.me",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "antigravity apikey upstream_type case-insensitive",
|
||||||
|
account: Account{
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"base_url": "https://api.opusclaw.me",
|
||||||
|
"upstream_type": "NewAPI",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "https://api.opusclaw.me",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "antigravity non-apikey returns empty",
|
name: "antigravity non-apikey returns empty",
|
||||||
account: Account{
|
account: Account{
|
||||||
@ -121,6 +169,18 @@ func TestGetGeminiBaseURL(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "https://upstream.example.com/antigravity",
|
expected: "https://upstream.example.com/antigravity",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "antigravity apikey newapi upstream_type does NOT append /antigravity (gemini)",
|
||||||
|
account: Account{
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"base_url": "https://api.opusclaw.me",
|
||||||
|
"upstream_type": "newapi",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "https://api.opusclaw.me",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "antigravity oauth does NOT append /antigravity",
|
name: "antigravity oauth does NOT append /antigravity",
|
||||||
account: Account{
|
account: Account{
|
||||||
|
|||||||
@ -30,6 +30,7 @@ type AccountRepository interface {
|
|||||||
GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error)
|
GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error)
|
||||||
// FindByExtraField 根据 extra 字段中的键值对查找账号
|
// FindByExtraField 根据 extra 字段中的键值对查找账号
|
||||||
FindByExtraField(ctx context.Context, key string, value any) ([]Account, error)
|
FindByExtraField(ctx context.Context, key string, value any) ([]Account, error)
|
||||||
|
FindByCredentialField(ctx context.Context, platform, key, value string) ([]Account, error)
|
||||||
// ListCRSAccountIDs returns a map of crs_account_id -> local account ID
|
// ListCRSAccountIDs returns a map of crs_account_id -> local account ID
|
||||||
// for all accounts that have been synced from CRS.
|
// for all accounts that have been synced from CRS.
|
||||||
ListCRSAccountIDs(ctx context.Context) (map[string]int64, error)
|
ListCRSAccountIDs(ctx context.Context) (map[string]int64, error)
|
||||||
@ -180,7 +181,7 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if g.RequireOAuthOnly && (g.Platform == PlatformOpenAI || g.Platform == PlatformAntigravity || g.Platform == PlatformAnthropic || g.Platform == PlatformGemini) {
|
if g.RequireOAuthOnly && (g.Platform == PlatformOpenAI || g.Platform == PlatformAntigravity || g.Platform == PlatformAnthropic || g.Platform == PlatformGemini || g.Platform == PlatformWindsurf) {
|
||||||
return nil, fmt.Errorf("分组 [%s] 仅允许 OAuth 账号,apikey 类型账号无法加入", g.Name)
|
return nil, fmt.Errorf("分组 [%s] 仅允许 OAuth 账号,apikey 类型账号无法加入", g.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -296,7 +297,7 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if g.RequireOAuthOnly && (g.Platform == PlatformOpenAI || g.Platform == PlatformAntigravity || g.Platform == PlatformAnthropic || g.Platform == PlatformGemini) {
|
if g.RequireOAuthOnly && (g.Platform == PlatformOpenAI || g.Platform == PlatformAntigravity || g.Platform == PlatformAnthropic || g.Platform == PlatformGemini || g.Platform == PlatformWindsurf) {
|
||||||
return nil, fmt.Errorf("分组 [%s] 仅允许 OAuth 账号,apikey 类型账号无法加入", g.Name)
|
return nil, fmt.Errorf("分组 [%s] 仅允许 OAuth 账号,apikey 类型账号无法加入", g.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,6 +57,9 @@ func (s *accountRepoStub) GetByCRSAccountID(ctx context.Context, crsAccountID st
|
|||||||
func (s *accountRepoStub) FindByExtraField(ctx context.Context, key string, value any) ([]Account, error) {
|
func (s *accountRepoStub) FindByExtraField(ctx context.Context, key string, value any) ([]Account, error) {
|
||||||
panic("unexpected FindByExtraField call")
|
panic("unexpected FindByExtraField call")
|
||||||
}
|
}
|
||||||
|
func (s *accountRepoStub) FindByCredentialField(ctx context.Context, platform, key, value string) ([]Account, error) {
|
||||||
|
panic("unexpected FindByCredentialField call")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *accountRepoStub) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
func (s *accountRepoStub) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
||||||
panic("unexpected ListCRSAccountIDs call")
|
panic("unexpected ListCRSAccountIDs call")
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@ -66,6 +67,7 @@ type AccountTestService struct {
|
|||||||
accountRepo AccountRepository
|
accountRepo AccountRepository
|
||||||
geminiTokenProvider *GeminiTokenProvider
|
geminiTokenProvider *GeminiTokenProvider
|
||||||
antigravityGatewayService *AntigravityGatewayService
|
antigravityGatewayService *AntigravityGatewayService
|
||||||
|
windsurfChatService *WindsurfChatService
|
||||||
httpUpstream HTTPUpstream
|
httpUpstream HTTPUpstream
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
tlsFPProfileService *TLSFingerprintProfileService
|
tlsFPProfileService *TLSFingerprintProfileService
|
||||||
@ -76,6 +78,7 @@ func NewAccountTestService(
|
|||||||
accountRepo AccountRepository,
|
accountRepo AccountRepository,
|
||||||
geminiTokenProvider *GeminiTokenProvider,
|
geminiTokenProvider *GeminiTokenProvider,
|
||||||
antigravityGatewayService *AntigravityGatewayService,
|
antigravityGatewayService *AntigravityGatewayService,
|
||||||
|
windsurfChatService *WindsurfChatService,
|
||||||
httpUpstream HTTPUpstream,
|
httpUpstream HTTPUpstream,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
tlsFPProfileService *TLSFingerprintProfileService,
|
tlsFPProfileService *TLSFingerprintProfileService,
|
||||||
@ -84,6 +87,7 @@ func NewAccountTestService(
|
|||||||
accountRepo: accountRepo,
|
accountRepo: accountRepo,
|
||||||
geminiTokenProvider: geminiTokenProvider,
|
geminiTokenProvider: geminiTokenProvider,
|
||||||
antigravityGatewayService: antigravityGatewayService,
|
antigravityGatewayService: antigravityGatewayService,
|
||||||
|
windsurfChatService: windsurfChatService,
|
||||||
httpUpstream: httpUpstream,
|
httpUpstream: httpUpstream,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
tlsFPProfileService: tlsFPProfileService,
|
tlsFPProfileService: tlsFPProfileService,
|
||||||
@ -188,6 +192,10 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
|
|||||||
return s.routeAntigravityTest(c, account, modelID, prompt)
|
return s.routeAntigravityTest(c, account, modelID, prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if account.Platform == PlatformWindsurf {
|
||||||
|
return s.testWindsurfAccountConnection(c, account, modelID)
|
||||||
|
}
|
||||||
|
|
||||||
return s.testClaudeAccountConnection(c, account, modelID)
|
return s.testClaudeAccountConnection(c, account, modelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -674,6 +682,44 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AccountTestService) testWindsurfAccountConnection(c *gin.Context, account *Account, modelID string) error {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
testModelID := modelID
|
||||||
|
if testModelID == "" {
|
||||||
|
testModelID = "claude-sonnet-4.6"
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.windsurfChatService == nil {
|
||||||
|
return s.sendErrorAndEnd(c, "Windsurf chat service not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||||
|
c.Writer.Header().Set("Connection", "keep-alive")
|
||||||
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
c.Writer.Flush()
|
||||||
|
|
||||||
|
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
|
||||||
|
|
||||||
|
resp, err := s.windsurfChatService.Chat(ctx, &WindsurfChatRequest{
|
||||||
|
AccountID: account.ID,
|
||||||
|
Model: testModelID,
|
||||||
|
Messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}},
|
||||||
|
Stream: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Text != "" {
|
||||||
|
s.sendEvent(c, TestEvent{Type: "content", Text: resp.Text})
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// buildGeminiAPIKeyRequest builds request for Gemini API Key accounts
|
// buildGeminiAPIKeyRequest builds request for Gemini API Key accounts
|
||||||
func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) {
|
func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) {
|
||||||
apiKey := account.GetCredential("api_key")
|
apiKey := account.GetCredential("api_key")
|
||||||
|
|||||||
@ -1321,7 +1321,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
// require_oauth_only: 过滤掉 apikey 类型账号
|
// require_oauth_only: 过滤掉 apikey 类型账号
|
||||||
if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini) && len(accountIDsToCopy) > 0 {
|
if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformWindsurf) && len(accountIDsToCopy) > 0 {
|
||||||
accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy)
|
accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err)
|
return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err)
|
||||||
@ -1411,8 +1411,8 @@ func (s *adminServiceImpl) validateFallbackGroup(ctx context.Context, currentGro
|
|||||||
// platform/subscriptionType: 当前分组的有效平台/订阅类型
|
// platform/subscriptionType: 当前分组的有效平台/订阅类型
|
||||||
// fallbackGroupID: 兜底分组 ID
|
// fallbackGroupID: 兜底分组 ID
|
||||||
func (s *adminServiceImpl) validateFallbackGroupOnInvalidRequest(ctx context.Context, currentGroupID int64, platform, subscriptionType string, fallbackGroupID int64) error {
|
func (s *adminServiceImpl) validateFallbackGroupOnInvalidRequest(ctx context.Context, currentGroupID int64, platform, subscriptionType string, fallbackGroupID int64) error {
|
||||||
if platform != PlatformAnthropic && platform != PlatformAntigravity {
|
if platform != PlatformAnthropic && platform != PlatformAntigravity && platform != PlatformWindsurf {
|
||||||
return fmt.Errorf("invalid request fallback only supported for anthropic or antigravity groups")
|
return fmt.Errorf("invalid request fallback only supported for anthropic, antigravity or windsurf groups")
|
||||||
}
|
}
|
||||||
if subscriptionType == SubscriptionTypeSubscription {
|
if subscriptionType == SubscriptionTypeSubscription {
|
||||||
return fmt.Errorf("subscription groups cannot set invalid request fallback")
|
return fmt.Errorf("subscription groups cannot set invalid request fallback")
|
||||||
@ -1594,7 +1594,7 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
|||||||
}
|
}
|
||||||
|
|
||||||
// require_oauth_only: 过滤掉 apikey 类型账号
|
// require_oauth_only: 过滤掉 apikey 类型账号
|
||||||
if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini) && len(accountIDsToCopy) > 0 {
|
if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformWindsurf) && len(accountIDsToCopy) > 0 {
|
||||||
accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy)
|
accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err)
|
return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err)
|
||||||
@ -3024,6 +3024,8 @@ func getAccountPlatform(accountPlatform string) string {
|
|||||||
switch strings.ToLower(strings.TrimSpace(accountPlatform)) {
|
switch strings.ToLower(strings.TrimSpace(accountPlatform)) {
|
||||||
case PlatformAntigravity:
|
case PlatformAntigravity:
|
||||||
return "Antigravity"
|
return "Antigravity"
|
||||||
|
case PlatformWindsurf:
|
||||||
|
return "Windsurf"
|
||||||
case PlatformAnthropic, "claude":
|
case PlatformAnthropic, "claude":
|
||||||
return "Anthropic"
|
return "Anthropic"
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -86,7 +86,7 @@ func TestAccount68FullE2E(t *testing.T) {
|
|||||||
accessTokenStr := account.GetCredential("access_token")
|
accessTokenStr := account.GetCredential("access_token")
|
||||||
|
|
||||||
t.Logf(" 📤 API 请求:")
|
t.Logf(" 📤 API 请求:")
|
||||||
t.Logf(" URL: https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist")
|
t.Logf(" URL: https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist")
|
||||||
t.Logf(" Token: %s... (长度: %d)", accessTokenStr[:30], len(accessTokenStr))
|
t.Logf(" Token: %s... (长度: %d)", accessTokenStr[:30], len(accessTokenStr))
|
||||||
t.Logf(" Proxy: %s", proxyAddr)
|
t.Logf(" Proxy: %s", proxyAddr)
|
||||||
t.Log("")
|
t.Log("")
|
||||||
@ -100,7 +100,7 @@ func TestAccount68FullE2E(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST",
|
req, err := http.NewRequestWithContext(ctx, "POST",
|
||||||
"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist",
|
"https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||||
bytes.NewReader([]byte(`{}`)))
|
bytes.NewReader([]byte(`{}`)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("❌ 创建请求失败: %v", err)
|
t.Fatalf("❌ 创建请求失败: %v", err)
|
||||||
|
|||||||
@ -206,18 +206,28 @@ type antigravityRetryLoopResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveAntigravityForwardBaseURL 解析转发用 base URL。
|
// resolveAntigravityForwardBaseURL 解析转发用 base URL。
|
||||||
// 默认使用 prod(BaseURLs[0]);daily 端点 Claude 模型容量有限,容易触发 503。
|
// 根据账号类型选择优先 URL:企业账号(isGcpTos=true)→ prod;个人账号 → daily(与真实 IDE 一致)。
|
||||||
// 可通过环境变量 GATEWAY_ANTIGRAVITY_FORWARD_BASE_URL=daily 显式切换到 daily sandbox。
|
// 可通过环境变量 GATEWAY_ANTIGRAVITY_FORWARD_BASE_URL=daily 或 =prod 强制覆盖。
|
||||||
func resolveAntigravityForwardBaseURL() string {
|
func resolveAntigravityForwardBaseURL(account *Account) string {
|
||||||
baseURLs := antigravity.BaseURLs // prod 优先(BaseURLs[0]=prod, [1]=daily)
|
mode := strings.ToLower(strings.TrimSpace(os.Getenv(antigravityForwardBaseURLEnv)))
|
||||||
if len(baseURLs) == 0 {
|
if mode == "daily" {
|
||||||
|
return "https://daily-cloudcode-pa.googleapis.com"
|
||||||
|
}
|
||||||
|
if mode == "prod" {
|
||||||
|
return "https://cloudcode-pa.googleapis.com"
|
||||||
|
}
|
||||||
|
// 按账号类型选择优先 URL
|
||||||
|
isGcpTos := account != nil && account.GetCredentialAsBool("is_gcp_tos")
|
||||||
|
urls := antigravity.BaseURLsForAccount(isGcpTos)
|
||||||
|
if len(urls) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
mode := strings.ToLower(strings.TrimSpace(os.Getenv(antigravityForwardBaseURLEnv)))
|
// 返回可用列表中的第一个(URLAvailability 动态优先级在调用方处理)
|
||||||
if mode == "daily" && len(baseURLs) > 1 {
|
available := antigravity.DefaultURLAvailability.GetAvailableURLsWithBase(urls)
|
||||||
return baseURLs[1] // daily sandbox
|
if len(available) > 0 {
|
||||||
|
return available[0]
|
||||||
}
|
}
|
||||||
return baseURLs[0] // prod(默认)
|
return urls[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// smartRetryAction 智能重试的处理结果
|
// smartRetryAction 智能重试的处理结果
|
||||||
@ -668,7 +678,7 @@ func (s *AntigravityGatewayService) antigravityRetryLoop(p antigravityRetryLoopP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
baseURL := resolveAntigravityForwardBaseURL()
|
baseURL := resolveAntigravityForwardBaseURL(p.account)
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
return nil, errors.New("no antigravity forward base url configured")
|
return nil, errors.New("no antigravity forward base url configured")
|
||||||
}
|
}
|
||||||
@ -1836,6 +1846,13 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
|
|||||||
firstTokenMs = streamRes.firstTokenMs
|
firstTokenMs = streamRes.firstTokenMs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DEBUG: 追踪 OAuth Claude 路径的 Usage 在 Forward 返回点的值。
|
||||||
|
// 若这里 output>0 而 DB 记录为 0,说明 bug 在下游(billing/record 层);
|
||||||
|
// 若这里 output=0,说明 bug 在 handleClaudeStreamingResponse 或更上游。
|
||||||
|
logger.LegacyPrintf("service.antigravity_gateway",
|
||||||
|
"%s DEBUG_USAGE_FORWARD_RETURN input=%d output=%d cache_read=%d cache_creation=%d stream=%v model=%s account=%d",
|
||||||
|
prefix, usage.InputTokens, usage.OutputTokens, usage.CacheReadInputTokens, usage.CacheCreationInputTokens, claudeReq.Stream, originalModel, account.ID)
|
||||||
|
|
||||||
return &ForwardResult{
|
return &ForwardResult{
|
||||||
RequestID: requestID,
|
RequestID: requestID,
|
||||||
Usage: *usage,
|
Usage: *usage,
|
||||||
@ -4110,6 +4127,9 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
|
|||||||
if !ok {
|
if !ok {
|
||||||
// 上游完成,发送结束事件
|
// 上游完成,发送结束事件
|
||||||
finalEvents, agUsage := processor.Finish()
|
finalEvents, agUsage := processor.Finish()
|
||||||
|
logger.LegacyPrintf("service.antigravity_gateway",
|
||||||
|
"DEBUG_USAGE_PROCESSOR_FINISH input=%d output=%d cache_read=%d image_output=%d final_events_len=%d",
|
||||||
|
agUsage.InputTokens, agUsage.OutputTokens, agUsage.CacheReadInputTokens, agUsage.ImageOutputTokens, len(finalEvents))
|
||||||
if len(finalEvents) > 0 {
|
if len(finalEvents) > 0 {
|
||||||
cw.Write(finalEvents)
|
cw.Write(finalEvents)
|
||||||
} else if !processor.MessageStartSent() && !cw.Disconnected() {
|
} else if !processor.MessageStartSent() && !cw.Disconnected() {
|
||||||
@ -4126,10 +4146,11 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
|
|||||||
}
|
}
|
||||||
if ev.err != nil {
|
if ev.err != nil {
|
||||||
if disconnect, handled := handleStreamReadError(ev.err, cw.Disconnected(), "antigravity claude"); handled {
|
if disconnect, handled := handleStreamReadError(ev.err, cw.Disconnected(), "antigravity claude"); handled {
|
||||||
|
logger.LegacyPrintf("service.antigravity_gateway", "DEBUG_USAGE_CLAUDE_STREAM_EARLY_RETURN path=handleStreamReadError disconnect=%v", disconnect)
|
||||||
return &antigravityStreamResult{usage: finishUsage(), firstTokenMs: firstTokenMs, clientDisconnect: disconnect}, nil
|
return &antigravityStreamResult{usage: finishUsage(), firstTokenMs: firstTokenMs, clientDisconnect: disconnect}, nil
|
||||||
}
|
}
|
||||||
if errors.Is(ev.err, bufio.ErrTooLong) {
|
if errors.Is(ev.err, bufio.ErrTooLong) {
|
||||||
logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (antigravity): max_size=%d error=%v", maxLineSize, ev.err)
|
logger.LegacyPrintf("service.antigravity_gateway", "DEBUG_USAGE_CLAUDE_STREAM_EARLY_RETURN path=ErrTooLong max_size=%d error=%v (usage WILL BE ZEROED)", maxLineSize, ev.err)
|
||||||
sendErrorEvent("api_error", "Response too large")
|
sendErrorEvent("api_error", "Response too large")
|
||||||
return &antigravityStreamResult{usage: convertUsage(nil), firstTokenMs: firstTokenMs}, ev.err
|
return &antigravityStreamResult{usage: convertUsage(nil), firstTokenMs: firstTokenMs}, ev.err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,8 +29,9 @@ type AntigravityAuthURLResult struct {
|
|||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateAuthURL 生成 Google OAuth 授权链接
|
// GenerateAuthURL 生成 Google OAuth 授权链接。
|
||||||
func (s *AntigravityOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64) (*AntigravityAuthURLResult, error) {
|
// isEnterprise=true 时生成企业账号授权链接(使用企业 client_id)。
|
||||||
|
func (s *AntigravityOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64, isEnterprise bool) (*AntigravityAuthURLResult, error) {
|
||||||
state, err := antigravity.GenerateState()
|
state, err := antigravity.GenerateState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("生成 state 失败: %w", err)
|
return nil, fmt.Errorf("生成 state 失败: %w", err)
|
||||||
@ -58,12 +59,13 @@ func (s *AntigravityOAuthService) GenerateAuthURL(ctx context.Context, proxyID *
|
|||||||
State: state,
|
State: state,
|
||||||
CodeVerifier: codeVerifier,
|
CodeVerifier: codeVerifier,
|
||||||
ProxyURL: proxyURL,
|
ProxyURL: proxyURL,
|
||||||
|
IsEnterprise: isEnterprise,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
s.sessionStore.Set(sessionID, session)
|
s.sessionStore.Set(sessionID, session)
|
||||||
|
|
||||||
codeChallenge := antigravity.GenerateCodeChallenge(codeVerifier)
|
codeChallenge := antigravity.GenerateCodeChallenge(codeVerifier)
|
||||||
authURL := antigravity.BuildAuthorizationURL(state, codeChallenge)
|
authURL := antigravity.BuildAuthorizationURL(state, codeChallenge, isEnterprise)
|
||||||
|
|
||||||
return &AntigravityAuthURLResult{
|
return &AntigravityAuthURLResult{
|
||||||
AuthURL: authURL,
|
AuthURL: authURL,
|
||||||
@ -89,6 +91,7 @@ type AntigravityTokenInfo struct {
|
|||||||
TokenType string `json:"token_type"`
|
TokenType string `json:"token_type"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
ProjectID string `json:"project_id,omitempty"`
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
|
IsEnterprise bool `json:"is_enterprise,omitempty"`
|
||||||
ProjectIDMissing bool `json:"-"`
|
ProjectIDMissing bool `json:"-"`
|
||||||
PlanType string `json:"-"`
|
PlanType string `json:"-"`
|
||||||
PrivacyMode string `json:"-"`
|
PrivacyMode string `json:"-"`
|
||||||
@ -119,8 +122,8 @@ func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *Antig
|
|||||||
return nil, fmt.Errorf("create antigravity client failed: %w", err)
|
return nil, fmt.Errorf("create antigravity client failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 交换 token
|
// 交换 token(使用 session 中记录的账号类型)
|
||||||
tokenResp, err := client.ExchangeCode(ctx, input.Code, session.CodeVerifier)
|
tokenResp, err := client.ExchangeCode(ctx, input.Code, session.CodeVerifier, session.IsEnterprise)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("token 交换失败: %w", err)
|
return nil, fmt.Errorf("token 交换失败: %w", err)
|
||||||
}
|
}
|
||||||
@ -137,6 +140,7 @@ func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *Antig
|
|||||||
ExpiresIn: tokenResp.ExpiresIn,
|
ExpiresIn: tokenResp.ExpiresIn,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
TokenType: tokenResp.TokenType,
|
TokenType: tokenResp.TokenType,
|
||||||
|
IsEnterprise: session.IsEnterprise,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
@ -166,8 +170,9 @@ func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *Antig
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken 刷新 token
|
// RefreshToken 刷新 token。
|
||||||
func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*AntigravityTokenInfo, error) {
|
// isEnterprise=true 时使用企业 OAuth client_id/secret。
|
||||||
|
func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string, isEnterprise bool) (*AntigravityTokenInfo, error) {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
for attempt := 0; attempt <= 3; attempt++ {
|
for attempt := 0; attempt <= 3; attempt++ {
|
||||||
@ -183,7 +188,7 @@ func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create antigravity client failed: %w", err)
|
return nil, fmt.Errorf("create antigravity client failed: %w", err)
|
||||||
}
|
}
|
||||||
tokenResp, err := client.RefreshToken(ctx, refreshToken)
|
tokenResp, err := client.RefreshToken(ctx, refreshToken, isEnterprise)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
expiresAt := now.Unix() + tokenResp.ExpiresIn - 300
|
expiresAt := now.Unix() + tokenResp.ExpiresIn - 300
|
||||||
@ -195,6 +200,7 @@ func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken
|
|||||||
ExpiresIn: tokenResp.ExpiresIn,
|
ExpiresIn: tokenResp.ExpiresIn,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
TokenType: tokenResp.TokenType,
|
TokenType: tokenResp.TokenType,
|
||||||
|
IsEnterprise: isEnterprise,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,8 +217,9 @@ func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken
|
|||||||
return nil, fmt.Errorf("token 刷新失败 (重试后): %w", lastErr)
|
return nil, fmt.Errorf("token 刷新失败 (重试后): %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateRefreshToken 用 refresh token 验证并获取完整的 token 信息(含 email 和 project_id)
|
// ValidateRefreshToken 用 refresh token 验证并获取完整的 token 信息(含 email 和 project_id)。
|
||||||
func (s *AntigravityOAuthService) ValidateRefreshToken(ctx context.Context, refreshToken string, proxyID *int64) (*AntigravityTokenInfo, error) {
|
// isEnterprise=true 时使用企业 OAuth client 刷新。
|
||||||
|
func (s *AntigravityOAuthService) ValidateRefreshToken(ctx context.Context, refreshToken string, proxyID *int64, isEnterprise bool) (*AntigravityTokenInfo, error) {
|
||||||
var proxyURL string
|
var proxyURL string
|
||||||
if proxyID != nil {
|
if proxyID != nil {
|
||||||
proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
|
proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
|
||||||
@ -221,8 +228,8 @@ func (s *AntigravityOAuthService) ValidateRefreshToken(ctx context.Context, refr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新 token
|
// 刷新 token:先按调用方指定类型刷新;若报 client 不匹配再尝试另一侧。
|
||||||
tokenInfo, err := s.RefreshToken(ctx, refreshToken, proxyURL)
|
tokenInfo, err := s.refreshTokenAutoFallback(ctx, refreshToken, proxyURL, isEnterprise)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -274,6 +281,32 @@ func isNonRetryableAntigravityOAuthError(err error) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isClientMismatchOAuthError 判断是否为 OAuth client 不匹配错误(用于触发个人/企业切换)。
|
||||||
|
// 与 isNonRetryableAntigravityOAuthError 不同:这里只识别 client 相关错误,不包含 invalid_grant。
|
||||||
|
func isClientMismatchOAuthError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "invalid_client") ||
|
||||||
|
strings.Contains(msg, "unauthorized_client")
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshTokenAutoFallback 先按指定类型刷新;若遇 client 不匹配错误则切换到另一侧。
|
||||||
|
// 本函数不承担网络层重试(由内部 RefreshToken 处理)。
|
||||||
|
func (s *AntigravityOAuthService) refreshTokenAutoFallback(ctx context.Context, refreshToken, proxyURL string, preferEnterprise bool) (*AntigravityTokenInfo, error) {
|
||||||
|
tokenInfo, err := s.RefreshToken(ctx, refreshToken, proxyURL, preferEnterprise)
|
||||||
|
if err == nil {
|
||||||
|
return tokenInfo, nil
|
||||||
|
}
|
||||||
|
if !isClientMismatchOAuthError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 切换另一侧账号类型重试
|
||||||
|
fmt.Printf("[AntigravityOAuth] client 不匹配,切换账号类型重试:%v → %v\n", preferEnterprise, !preferEnterprise)
|
||||||
|
return s.RefreshToken(ctx, refreshToken, proxyURL, !preferEnterprise)
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshAccountToken 刷新账户的 token
|
// RefreshAccountToken 刷新账户的 token
|
||||||
func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*AntigravityTokenInfo, error) {
|
func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*AntigravityTokenInfo, error) {
|
||||||
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
|
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
|
||||||
@ -285,6 +318,8 @@ func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, accou
|
|||||||
return nil, fmt.Errorf("无可用的 refresh_token")
|
return nil, fmt.Errorf("无可用的 refresh_token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isEnterprise := account.GetCredentialAsBool("is_gcp_tos")
|
||||||
|
|
||||||
var proxyURL string
|
var proxyURL string
|
||||||
if account.ProxyID != nil {
|
if account.ProxyID != nil {
|
||||||
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
|
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
|
||||||
@ -293,7 +328,7 @@ func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, accou
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenInfo, err := s.RefreshToken(ctx, refreshToken, proxyURL)
|
tokenInfo, err := s.RefreshToken(ctx, refreshToken, proxyURL, isEnterprise)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -460,6 +495,7 @@ func (s *AntigravityOAuthService) BuildAccountCredentials(tokenInfo *Antigravity
|
|||||||
creds := map[string]any{
|
creds := map[string]any{
|
||||||
"access_token": tokenInfo.AccessToken,
|
"access_token": tokenInfo.AccessToken,
|
||||||
"expires_at": strconv.FormatInt(tokenInfo.ExpiresAt, 10),
|
"expires_at": strconv.FormatInt(tokenInfo.ExpiresAt, 10),
|
||||||
|
"is_gcp_tos": tokenInfo.IsEnterprise,
|
||||||
}
|
}
|
||||||
if tokenInfo.RefreshToken != "" {
|
if tokenInfo.RefreshToken != "" {
|
||||||
creds["refresh_token"] = tokenInfo.RefreshToken
|
creds["refresh_token"] = tokenInfo.RefreshToken
|
||||||
|
|||||||
@ -125,7 +125,7 @@ func TestWithSOCKS5Proxy(t *testing.T) {
|
|||||||
t.Log("")
|
t.Log("")
|
||||||
|
|
||||||
// 直接构造 API 请求
|
// 直接构造 API 请求
|
||||||
apiURL := "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist"
|
apiURL := "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const (
|
|||||||
PlatformOpenAI = domain.PlatformOpenAI
|
PlatformOpenAI = domain.PlatformOpenAI
|
||||||
PlatformGemini = domain.PlatformGemini
|
PlatformGemini = domain.PlatformGemini
|
||||||
PlatformAntigravity = domain.PlatformAntigravity
|
PlatformAntigravity = domain.PlatformAntigravity
|
||||||
|
PlatformWindsurf = domain.PlatformWindsurf
|
||||||
)
|
)
|
||||||
|
|
||||||
// Account type constants
|
// Account type constants
|
||||||
|
|||||||
@ -81,6 +81,9 @@ func (m *mockAccountRepoForPlatform) GetByCRSAccountID(ctx context.Context, crsA
|
|||||||
func (m *mockAccountRepoForPlatform) FindByExtraField(ctx context.Context, key string, value any) ([]Account, error) {
|
func (m *mockAccountRepoForPlatform) FindByExtraField(ctx context.Context, key string, value any) ([]Account, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
func (m *mockAccountRepoForPlatform) FindByCredentialField(ctx context.Context, platform, key, value string) ([]Account, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockAccountRepoForPlatform) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
func (m *mockAccountRepoForPlatform) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claudemask"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claudemask"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||||
@ -2042,6 +2043,14 @@ func (s *GatewayService) IsSingleAntigravityAccountGroup(ctx context.Context, gr
|
|||||||
return len(accounts) == 1
|
return len(accounts) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *GatewayService) IsSingleWindsurfAccountGroup(ctx context.Context, groupID *int64) bool {
|
||||||
|
accounts, _, err := s.listSchedulableAccounts(ctx, groupID, PlatformWindsurf, true)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(accounts) == 1
|
||||||
|
}
|
||||||
|
|
||||||
func (s *GatewayService) isAccountAllowedForPlatform(account *Account, platform string, useMixed bool) bool {
|
func (s *GatewayService) isAccountAllowedForPlatform(account *Account, platform string, useMixed bool) bool {
|
||||||
if account == nil {
|
if account == nil {
|
||||||
return false
|
return false
|
||||||
@ -3397,6 +3406,12 @@ func summarizeSelectionFailureStats(stats selectionFailureStats) string {
|
|||||||
// isModelSupportedByAccountWithContext 根据账户平台检查模型支持(带 context)
|
// isModelSupportedByAccountWithContext 根据账户平台检查模型支持(带 context)
|
||||||
// 对于 Antigravity 平台,会先获取映射后的最终模型名(包括 thinking 后缀)再检查支持
|
// 对于 Antigravity 平台,会先获取映射后的最终模型名(包括 thinking 后缀)再检查支持
|
||||||
func (s *GatewayService) isModelSupportedByAccountWithContext(ctx context.Context, account *Account, requestedModel string) bool {
|
func (s *GatewayService) isModelSupportedByAccountWithContext(ctx context.Context, account *Account, requestedModel string) bool {
|
||||||
|
if account.Platform == PlatformWindsurf {
|
||||||
|
if strings.TrimSpace(requestedModel) == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return windsurf.ResolveModel(requestedModel) != ""
|
||||||
|
}
|
||||||
if account.Platform == PlatformAntigravity {
|
if account.Platform == PlatformAntigravity {
|
||||||
if strings.TrimSpace(requestedModel) == "" {
|
if strings.TrimSpace(requestedModel) == "" {
|
||||||
return true
|
return true
|
||||||
@ -3421,6 +3436,12 @@ func (s *GatewayService) isModelSupportedByAccountWithContext(ctx context.Contex
|
|||||||
|
|
||||||
// isModelSupportedByAccount 根据账户平台检查模型支持(无 context,用于非 Antigravity 平台)
|
// isModelSupportedByAccount 根据账户平台检查模型支持(无 context,用于非 Antigravity 平台)
|
||||||
func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedModel string) bool {
|
func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedModel string) bool {
|
||||||
|
if account.Platform == PlatformWindsurf {
|
||||||
|
if strings.TrimSpace(requestedModel) == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return windsurf.ResolveModel(requestedModel) != ""
|
||||||
|
}
|
||||||
if account.Platform == PlatformAntigravity {
|
if account.Platform == PlatformAntigravity {
|
||||||
if strings.TrimSpace(requestedModel) == "" {
|
if strings.TrimSpace(requestedModel) == "" {
|
||||||
return true
|
return true
|
||||||
@ -8230,6 +8251,9 @@ func (s *GatewayService) isUpstreamModelRestrictedByChannel(ctx context.Context,
|
|||||||
|
|
||||||
// resolveAccountUpstreamModel 确定账号将请求模型映射为什么上游模型。
|
// resolveAccountUpstreamModel 确定账号将请求模型映射为什么上游模型。
|
||||||
func resolveAccountUpstreamModel(account *Account, requestedModel string) string {
|
func resolveAccountUpstreamModel(account *Account, requestedModel string) string {
|
||||||
|
if account.Platform == PlatformWindsurf {
|
||||||
|
return windsurf.ResolveModel(requestedModel)
|
||||||
|
}
|
||||||
if account.Platform == PlatformAntigravity {
|
if account.Platform == PlatformAntigravity {
|
||||||
return mapAntigravityModel(account, requestedModel)
|
return mapAntigravityModel(account, requestedModel)
|
||||||
}
|
}
|
||||||
@ -8306,7 +8330,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
|||||||
|
|
||||||
// Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。
|
// Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。
|
||||||
// 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。
|
// 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。
|
||||||
if account.Platform == PlatformAntigravity {
|
if account.Platform == PlatformAntigravity || account.Platform == PlatformWindsurf {
|
||||||
s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported for this platform")
|
s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported for this platform")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,6 +70,9 @@ func (m *mockAccountRepoForGemini) GetByCRSAccountID(ctx context.Context, crsAcc
|
|||||||
func (m *mockAccountRepoForGemini) FindByExtraField(ctx context.Context, key string, value any) ([]Account, error) {
|
func (m *mockAccountRepoForGemini) FindByExtraField(ctx context.Context, key string, value any) ([]Account, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
func (m *mockAccountRepoForGemini) FindByCredentialField(ctx context.Context, platform, key, value string) ([]Account, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockAccountRepoForGemini) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
func (m *mockAccountRepoForGemini) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
)
|
)
|
||||||
|
|
||||||
const modelRateLimitsKey = "model_rate_limits"
|
const modelRateLimitsKey = "model_rate_limits"
|
||||||
@ -35,6 +37,8 @@ func (a *Account) isModelRateLimitedWithContext(ctx context.Context, requestedMo
|
|||||||
modelKey := a.GetMappedModel(requestedModel)
|
modelKey := a.GetMappedModel(requestedModel)
|
||||||
if a.Platform == PlatformAntigravity {
|
if a.Platform == PlatformAntigravity {
|
||||||
modelKey = resolveFinalAntigravityModelKey(ctx, a, requestedModel)
|
modelKey = resolveFinalAntigravityModelKey(ctx, a, requestedModel)
|
||||||
|
} else if a.Platform == PlatformWindsurf {
|
||||||
|
modelKey = windsurf.ResolveModel(requestedModel)
|
||||||
}
|
}
|
||||||
modelKey = strings.TrimSpace(modelKey)
|
modelKey = strings.TrimSpace(modelKey)
|
||||||
if modelKey == "" {
|
if modelKey == "" {
|
||||||
@ -57,6 +61,8 @@ func (a *Account) GetModelRateLimitRemainingTimeWithContext(ctx context.Context,
|
|||||||
modelKey := a.GetMappedModel(requestedModel)
|
modelKey := a.GetMappedModel(requestedModel)
|
||||||
if a.Platform == PlatformAntigravity {
|
if a.Platform == PlatformAntigravity {
|
||||||
modelKey = resolveFinalAntigravityModelKey(ctx, a, requestedModel)
|
modelKey = resolveFinalAntigravityModelKey(ctx, a, requestedModel)
|
||||||
|
} else if a.Platform == PlatformWindsurf {
|
||||||
|
modelKey = windsurf.ResolveModel(requestedModel)
|
||||||
}
|
}
|
||||||
modelKey = strings.TrimSpace(modelKey)
|
modelKey = strings.TrimSpace(modelKey)
|
||||||
if modelKey == "" {
|
if modelKey == "" {
|
||||||
|
|||||||
@ -73,6 +73,9 @@ func (m *sessionWindowMockRepo) GetByCRSAccountID(context.Context, string) (*Acc
|
|||||||
func (m *sessionWindowMockRepo) FindByExtraField(context.Context, string, any) ([]Account, error) {
|
func (m *sessionWindowMockRepo) FindByExtraField(context.Context, string, any) ([]Account, error) {
|
||||||
panic("unexpected")
|
panic("unexpected")
|
||||||
}
|
}
|
||||||
|
func (m *sessionWindowMockRepo) FindByCredentialField(context.Context, string, string, string) ([]Account, error) {
|
||||||
|
panic("unexpected")
|
||||||
|
}
|
||||||
func (m *sessionWindowMockRepo) ListCRSAccountIDs(context.Context) (map[string]int64, error) {
|
func (m *sessionWindowMockRepo) ListCRSAccountIDs(context.Context) (map[string]int64, error) {
|
||||||
panic("unexpected")
|
panic("unexpected")
|
||||||
}
|
}
|
||||||
|
|||||||
262
backend/internal/service/windsurf_chat_service.go
Normal file
262
backend/internal/service/windsurf_chat_service.go
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WindsurfChatService struct {
|
||||||
|
cfg config.WindsurfConfig
|
||||||
|
lsService *WindsurfLSService
|
||||||
|
tokenProvider *WindsurfTokenProvider
|
||||||
|
pool *windsurf.ConversationPool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWindsurfChatService(
|
||||||
|
cfg config.WindsurfConfig,
|
||||||
|
lsService *WindsurfLSService,
|
||||||
|
tokenProvider *WindsurfTokenProvider,
|
||||||
|
) *WindsurfChatService {
|
||||||
|
return &WindsurfChatService{
|
||||||
|
cfg: cfg,
|
||||||
|
lsService: lsService,
|
||||||
|
tokenProvider: tokenProvider,
|
||||||
|
pool: windsurf.NewConversationPool(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfChatRequest struct {
|
||||||
|
AccountID int64
|
||||||
|
Model string
|
||||||
|
Messages []windsurf.ChatMessage
|
||||||
|
Stream bool
|
||||||
|
Tools []windsurf.OpenAITool
|
||||||
|
ToolChoice interface{}
|
||||||
|
ToolPreamble string // computed by handler, passed through to Cascade
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfChatResponse struct {
|
||||||
|
Text string
|
||||||
|
Thinking string
|
||||||
|
Model string
|
||||||
|
Mode string
|
||||||
|
Usage *windsurf.StepUsage // server-reported; nil if unavailable
|
||||||
|
FirstTextAt time.Time // when first text appeared (zero if no text)
|
||||||
|
ToolCalls []windsurf.NativeToolCall
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfChatService) Chat(ctx context.Context, req *WindsurfChatRequest) (*WindsurfChatResponse, error) {
|
||||||
|
token, err := s.tokenProvider.GetToken(ctx, req.AccountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modelKey := windsurf.ResolveModel(req.Model)
|
||||||
|
meta := windsurf.GetModelInfo(modelKey)
|
||||||
|
|
||||||
|
mode := s.resolveMode(meta)
|
||||||
|
|
||||||
|
var lease *windsurf.LSLease
|
||||||
|
if token.LSBinding.ContainerID != "" || token.LSBinding.ContainerName != "" {
|
||||||
|
lease, err = s.lsService.AcquireByBinding(token.LSBinding)
|
||||||
|
} else {
|
||||||
|
lease, err = s.lsService.Acquire(ctx, token.ProxyURL)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("acquire LS: %w", err)
|
||||||
|
}
|
||||||
|
defer lease.Release()
|
||||||
|
|
||||||
|
var resp *WindsurfChatResponse
|
||||||
|
switch mode {
|
||||||
|
case "cascade":
|
||||||
|
resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint)
|
||||||
|
case "legacy":
|
||||||
|
resp, err = s.chatLegacy(ctx, lease.Client, token.APIKey, meta, req.Messages, modelKey)
|
||||||
|
default:
|
||||||
|
resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if mode == "cascade" && s.cfg.Chat.AllowModeFallback && meta != nil && meta.EnumValue > 0 {
|
||||||
|
slog.Warn("windsurf_cascade_fallback_to_legacy", "model", modelKey, "error", err)
|
||||||
|
resp, err = s.chatLegacy(ctx, lease.Client, token.APIKey, meta, req.Messages, modelKey)
|
||||||
|
if err == nil {
|
||||||
|
resp.Mode = "legacy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("chat (%s): %w", mode, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfChatService) resolveMode(meta *windsurf.ModelMeta) string {
|
||||||
|
configMode := s.cfg.Chat.DefaultMode
|
||||||
|
if configMode == "cascade" || configMode == "legacy" {
|
||||||
|
return configMode
|
||||||
|
}
|
||||||
|
return windsurf.GetChatMode(meta, int(s.cfg.Chat.LegacyEnumCutoff))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfChatService) chatCascade(ctx context.Context, client *windsurf.LocalLSClient, apiKey string, meta *windsurf.ModelMeta, messages []windsurf.ChatMessage, toolPreamble string, modelKey string, lsEndpoint string) (*WindsurfChatResponse, error) {
|
||||||
|
modelUID := ""
|
||||||
|
if meta != nil {
|
||||||
|
modelUID = meta.ModelUID
|
||||||
|
}
|
||||||
|
|
||||||
|
fpBefore := windsurf.FingerprintBefore(messages, modelKey)
|
||||||
|
entry := s.pool.Checkout(fpBefore)
|
||||||
|
isResume := entry != nil && entry.CascadeID != ""
|
||||||
|
|
||||||
|
var reuseCascadeID string
|
||||||
|
if isResume {
|
||||||
|
reuseCascadeID = entry.CascadeID
|
||||||
|
slog.Info("windsurf_cascade_reuse_hit", "cascade_id", reuseCascadeID[:8], "model", modelKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
userText := buildCascadeText(messages, modelUID, isResume)
|
||||||
|
|
||||||
|
result, err := client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, reuseCascadeID)
|
||||||
|
if err != nil && isResume {
|
||||||
|
slog.Warn("windsurf_cascade_reuse_failed", "error", err, "model", modelKey)
|
||||||
|
userText = buildCascadeText(messages, modelUID, false)
|
||||||
|
result, err = client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, "")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.CascadeID != "" && result.Text != "" {
|
||||||
|
fpAfter := windsurf.FingerprintAfter(messages, modelKey)
|
||||||
|
s.pool.Checkin(fpAfter, &windsurf.ConversationEntry{
|
||||||
|
CascadeID: result.CascadeID,
|
||||||
|
APIKey: apiKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WindsurfChatResponse{
|
||||||
|
Text: result.Text,
|
||||||
|
Thinking: result.Thinking,
|
||||||
|
Model: modelKey,
|
||||||
|
Mode: "cascade",
|
||||||
|
Usage: result.Usage,
|
||||||
|
FirstTextAt: result.FirstTextAt,
|
||||||
|
ToolCalls: result.ToolCalls,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfChatService) chatLegacy(ctx context.Context, client *windsurf.LocalLSClient, apiKey string, meta *windsurf.ModelMeta, messages []windsurf.ChatMessage, modelKey string) (*WindsurfChatResponse, error) {
|
||||||
|
modelEnum := 0
|
||||||
|
modelName := ""
|
||||||
|
if meta != nil {
|
||||||
|
modelEnum = meta.EnumValue
|
||||||
|
modelName = meta.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
text, err := client.StreamLegacyChat(ctx, apiKey, messages, modelEnum, modelName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &WindsurfChatResponse{
|
||||||
|
Text: text,
|
||||||
|
Model: modelKey,
|
||||||
|
Mode: "legacy",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
cascadeMaxHistoryBytes = 200_000
|
||||||
|
cascade1MHistoryBytes = 900_000
|
||||||
|
cascadeMultiTurnPreamble = "The following is a multi-turn conversation. You MUST remember and use all information from prior turns."
|
||||||
|
)
|
||||||
|
|
||||||
|
func cascadeHistoryBudget(modelUID string) int {
|
||||||
|
if strings.Contains(strings.ToLower(modelUID), "1m") {
|
||||||
|
return cascade1MHistoryBytes
|
||||||
|
}
|
||||||
|
return cascadeMaxHistoryBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCascadeText constructs the full text payload for SendUserCascadeMessage.
|
||||||
|
// If isResume is true, only the last user message is sent (cascade already has context).
|
||||||
|
// Otherwise: system prompt wrapped in <system_instructions>, multi-turn history
|
||||||
|
// with <human>/<assistant> tags, and a budget cap to trim old turns.
|
||||||
|
func buildCascadeText(messages []windsurf.ChatMessage, modelUID string, isResume bool) string {
|
||||||
|
var systemParts []string
|
||||||
|
var convo []windsurf.ChatMessage
|
||||||
|
|
||||||
|
for _, m := range messages {
|
||||||
|
if m.Role == "system" {
|
||||||
|
systemParts = append(systemParts, m.Content)
|
||||||
|
} else if m.Role == "user" || m.Role == "assistant" {
|
||||||
|
convo = append(convo, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(convo) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume: cascade already has context, only send last user message
|
||||||
|
if isResume {
|
||||||
|
return convo[len(convo)-1].Content
|
||||||
|
}
|
||||||
|
|
||||||
|
sysText := strings.TrimSpace(strings.Join(systemParts, "\n"))
|
||||||
|
if sysText != "" {
|
||||||
|
sysText = "<system_instructions>\n" + sysText + "\n</system_instructions>"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single turn: system + last message
|
||||||
|
if len(convo) <= 1 {
|
||||||
|
text := convo[len(convo)-1].Content
|
||||||
|
if sysText != "" {
|
||||||
|
text = sysText + "\n\n" + text
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-turn: build history with budget trimming
|
||||||
|
maxBytes := cascadeHistoryBudget(modelUID)
|
||||||
|
historyBytes := len(sysText)
|
||||||
|
|
||||||
|
// Walk backward from second-to-last, collecting turns that fit
|
||||||
|
var lines []string
|
||||||
|
for i := len(convo) - 2; i >= 0; i-- {
|
||||||
|
m := convo[i]
|
||||||
|
tag := "human"
|
||||||
|
if m.Role == "assistant" {
|
||||||
|
tag = "assistant"
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf("<%s>\n%s\n</%s>", tag, m.Content, tag)
|
||||||
|
if historyBytes+len(line) > maxBytes && len(lines) > 0 {
|
||||||
|
slog.Info("windsurf_cascade_history_trimmed",
|
||||||
|
"turn", i,
|
||||||
|
"total_turns", len(convo),
|
||||||
|
"kept_kb", historyBytes/1024,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lines = append([]string{line}, lines...)
|
||||||
|
historyBytes += len(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
latest := convo[len(convo)-1]
|
||||||
|
text := cascadeMultiTurnPreamble + "\n\n" +
|
||||||
|
strings.Join(lines, "\n\n") + "\n\n" +
|
||||||
|
"<human>\n" + latest.Content + "\n</human>"
|
||||||
|
|
||||||
|
if sysText != "" {
|
||||||
|
text = sysText + "\n\n" + text
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
177
backend/internal/service/windsurf_credentials.go
Normal file
177
backend/internal/service/windsurf_credentials.go
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WindsurfCredentials struct {
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
APIKey string `json:"api_key,omitempty"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
IDToken string `json:"id_token,omitempty"`
|
||||||
|
SessionToken string `json:"session_token,omitempty"`
|
||||||
|
Auth1Token string `json:"auth1_token,omitempty"`
|
||||||
|
APIServerURL string `json:"api_server_url,omitempty"`
|
||||||
|
AuthMethod string `json:"auth_method,omitempty"`
|
||||||
|
Tier string `json:"tier,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
RegisteredAt string `json:"registered_at,omitempty"`
|
||||||
|
LastRefreshAt string `json:"last_refresh_at,omitempty"`
|
||||||
|
LastReregisterAt string `json:"last_reregister_at,omitempty"`
|
||||||
|
LastErrorCode string `json:"last_error_code,omitempty"`
|
||||||
|
TokenVersion int64 `json:"_token_version,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfExtra struct {
|
||||||
|
Profile WindsurfProfileSnapshot `json:"profile,omitempty"`
|
||||||
|
UserStatus WindsurfUserStatusSnapshot `json:"user_status,omitempty"`
|
||||||
|
Quota WindsurfQuotaSnapshot `json:"quota,omitempty"`
|
||||||
|
Refresh WindsurfRefreshState `json:"refresh,omitempty"`
|
||||||
|
Probe WindsurfProbeState `json:"probe,omitempty"`
|
||||||
|
Capabilities map[string]WindsurfModelCapability `json:"capabilities,omitempty"`
|
||||||
|
ModelMatrix map[string]WindsurfModelAvail `json:"model_matrix,omitempty"`
|
||||||
|
LSBinding WindsurfLSBinding `json:"ls_binding,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfLSBinding struct {
|
||||||
|
ContainerID string `json:"container_id,omitempty"`
|
||||||
|
ContainerName string `json:"container_name,omitempty"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfProfileSnapshot struct {
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
TeamID string `json:"team_id,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
PlanName string `json:"plan_name,omitempty"`
|
||||||
|
TeamsTier string `json:"teams_tier,omitempty"`
|
||||||
|
TierSource string `json:"tier_source,omitempty"`
|
||||||
|
TrialEndAt string `json:"trial_end_at,omitempty"`
|
||||||
|
IsTeams bool `json:"is_teams,omitempty"`
|
||||||
|
IsEnterprise bool `json:"is_enterprise,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfUserStatusSnapshot struct {
|
||||||
|
AllowedModels []WindsurfAllowedModel `json:"allowed_models,omitempty"`
|
||||||
|
MonthlyPromptCredits int64 `json:"monthly_prompt_credits,omitempty"`
|
||||||
|
MonthlyFlowCredits int64 `json:"monthly_flow_credits,omitempty"`
|
||||||
|
UserUsedPromptCredits int64 `json:"user_used_prompt_credits,omitempty"`
|
||||||
|
UserUsedFlowCredits int64 `json:"user_used_flow_credits,omitempty"`
|
||||||
|
MaxPremiumChatMessages int64 `json:"max_premium_chat_messages,omitempty"`
|
||||||
|
LastFetchedAt string `json:"last_fetched_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfAllowedModel struct {
|
||||||
|
ModelKey string `json:"model_key,omitempty"`
|
||||||
|
ModelEnum int32 `json:"model_enum,omitempty"`
|
||||||
|
ModelUID string `json:"model_uid,omitempty"`
|
||||||
|
Alias string `json:"alias,omitempty"`
|
||||||
|
CreditMultiplier float64 `json:"credit_multiplier,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfQuotaSnapshot struct {
|
||||||
|
DailyPercent *float64 `json:"daily_percent,omitempty"`
|
||||||
|
WeeklyPercent *float64 `json:"weekly_percent,omitempty"`
|
||||||
|
PromptUsed *float64 `json:"prompt_used,omitempty"`
|
||||||
|
PromptLimit *float64 `json:"prompt_limit,omitempty"`
|
||||||
|
FlexUsed *float64 `json:"flex_used,omitempty"`
|
||||||
|
FlexLimit *float64 `json:"flex_limit,omitempty"`
|
||||||
|
LastCheckedAt string `json:"last_checked_at,omitempty"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfRefreshState struct {
|
||||||
|
LastTokenRefreshAt string `json:"last_token_refresh_at,omitempty"`
|
||||||
|
LastStatusRefreshAt string `json:"last_status_refresh_at,omitempty"`
|
||||||
|
TokenRefreshFailures int `json:"token_refresh_failures,omitempty"`
|
||||||
|
StatusRefreshFailures int `json:"status_refresh_failures,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfProbeState struct {
|
||||||
|
LastProbeAt string `json:"last_probe_at,omitempty"`
|
||||||
|
LastCanaryAt string `json:"last_canary_at,omitempty"`
|
||||||
|
LastProbeError string `json:"last_probe_error,omitempty"`
|
||||||
|
ModelCatalogEtag string `json:"model_catalog_etag,omitempty"`
|
||||||
|
ModelCatalogAt string `json:"model_catalog_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfModelCapability struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
CheckedAt string `json:"checked_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfModelAvail struct {
|
||||||
|
Visible bool `json:"visible"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Blocked bool `json:"blocked"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadWindsurfCredentials(m map[string]any) WindsurfCredentials {
|
||||||
|
data, _ := json.Marshal(m)
|
||||||
|
var creds WindsurfCredentials
|
||||||
|
_ = json.Unmarshal(data, &creds)
|
||||||
|
return creds
|
||||||
|
}
|
||||||
|
|
||||||
|
func StoreWindsurfCredentials(creds WindsurfCredentials) map[string]any {
|
||||||
|
data, _ := json.Marshal(creds)
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(data, &m)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadWindsurfExtra(m map[string]any) WindsurfExtra {
|
||||||
|
data, _ := json.Marshal(m)
|
||||||
|
var extra WindsurfExtra
|
||||||
|
_ = json.Unmarshal(data, &extra)
|
||||||
|
return extra
|
||||||
|
}
|
||||||
|
|
||||||
|
func StoreWindsurfExtra(extra WindsurfExtra) map[string]any {
|
||||||
|
data, _ := json.Marshal(extra)
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(data, &m)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WindsurfCredentials) IsExpired() bool {
|
||||||
|
if c.ExpiresAt == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, c.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().After(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WindsurfCredentials) NeedsRefresh(beforeExpiry time.Duration) bool {
|
||||||
|
if c.ExpiresAt == "" || c.RefreshToken == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, c.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().Add(beforeExpiry).After(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WindsurfBaseRPM(tier string, cfg struct{ RPMPro, RPMFree, RPMUnknown, RPMExpired int }) int {
|
||||||
|
switch tier {
|
||||||
|
case "pro":
|
||||||
|
return cfg.RPMPro
|
||||||
|
case "free":
|
||||||
|
return cfg.RPMFree
|
||||||
|
case "expired":
|
||||||
|
return cfg.RPMExpired
|
||||||
|
default:
|
||||||
|
return cfg.RPMUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
684
backend/internal/service/windsurf_gateway_service.go
Normal file
684
backend/internal/service/windsurf_gateway_service.go
Normal file
@ -0,0 +1,684 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WindsurfGatewayService struct {
|
||||||
|
chatService *WindsurfChatService
|
||||||
|
cfg config.WindsurfConfig
|
||||||
|
accountRepo AccountRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWindsurfGatewayService(
|
||||||
|
chatService *WindsurfChatService,
|
||||||
|
cfg config.WindsurfConfig,
|
||||||
|
accountRepo AccountRepository,
|
||||||
|
) *WindsurfGatewayService {
|
||||||
|
return &WindsurfGatewayService{
|
||||||
|
chatService: chatService,
|
||||||
|
cfg: cfg,
|
||||||
|
accountRepo: accountRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte, _ bool) (*ForwardResult, error) {
|
||||||
|
startTime := time.Now()
|
||||||
|
reqLog := windsurfLogger(c, "windsurf_gateway.forward",
|
||||||
|
zap.Int64("account_id", account.ID),
|
||||||
|
)
|
||||||
|
|
||||||
|
var req windsurfMessagesRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Invalid request body")
|
||||||
|
return nil, fmt.Errorf("unmarshal request: %w", err)
|
||||||
|
}
|
||||||
|
normalizeWindsurfRequest(&req)
|
||||||
|
if strings.TrimSpace(req.Model) == "" {
|
||||||
|
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Missing model")
|
||||||
|
return nil, fmt.Errorf("missing model")
|
||||||
|
}
|
||||||
|
|
||||||
|
reqLog = reqLog.With(zap.String("model", req.Model), zap.Bool("stream", req.Stream), zap.Int("tools_count", len(req.Tools)))
|
||||||
|
|
||||||
|
// Convert Anthropic tools to OpenAI format
|
||||||
|
var openAITools []windsurf.OpenAITool
|
||||||
|
for _, t := range req.Tools {
|
||||||
|
openAITools = append(openAITools, windsurf.OpenAITool{
|
||||||
|
Type: "function",
|
||||||
|
Function: windsurf.OpenAIFunction{
|
||||||
|
Name: t.Name,
|
||||||
|
Description: t.Description,
|
||||||
|
Parameters: t.InputSchema,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTools := len(openAITools) > 0
|
||||||
|
|
||||||
|
// Convert Anthropic messages to intermediate form
|
||||||
|
var anthropicMsgs []windsurf.AnthropicMessage
|
||||||
|
hasToolHistory := false
|
||||||
|
|
||||||
|
if len(req.System) > 0 {
|
||||||
|
anthropicMsgs = append(anthropicMsgs, windsurf.AnthropicMessage{
|
||||||
|
Role: "system",
|
||||||
|
Content: req.System,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
contentBlocks := windsurfParseContentBlocks(m.Content)
|
||||||
|
|
||||||
|
var toolResultMsgs []windsurf.AnthropicMessage
|
||||||
|
var toolUseMsgs []windsurf.OpenAIToolCall
|
||||||
|
var textParts []string
|
||||||
|
|
||||||
|
for _, block := range contentBlocks {
|
||||||
|
switch block.Type {
|
||||||
|
case "tool_result":
|
||||||
|
hasToolHistory = true
|
||||||
|
resultContent := ""
|
||||||
|
if block.Content != nil {
|
||||||
|
resultContent = windsurfExtractContentTextFromRaw(block.Content)
|
||||||
|
}
|
||||||
|
contentJSON, _ := json.Marshal(resultContent)
|
||||||
|
toolResultMsgs = append(toolResultMsgs, windsurf.AnthropicMessage{
|
||||||
|
Role: "tool",
|
||||||
|
Content: contentJSON,
|
||||||
|
ToolCallID: block.ToolUseID,
|
||||||
|
})
|
||||||
|
case "tool_use":
|
||||||
|
hasToolHistory = true
|
||||||
|
inputJSON, _ := json.Marshal(block.Input)
|
||||||
|
toolUseMsgs = append(toolUseMsgs, windsurf.OpenAIToolCall{
|
||||||
|
ID: block.ID,
|
||||||
|
Type: "function",
|
||||||
|
Function: windsurf.OpenAIToolCallFunc{
|
||||||
|
Name: block.Name,
|
||||||
|
Arguments: string(inputJSON),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "text":
|
||||||
|
textParts = append(textParts, block.Text)
|
||||||
|
case "thinking":
|
||||||
|
// skip
|
||||||
|
default:
|
||||||
|
if block.Text != "" {
|
||||||
|
textParts = append(textParts, block.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toolUseMsgs) > 0 {
|
||||||
|
contentJSON, _ := json.Marshal(strings.Join(textParts, "\n"))
|
||||||
|
anthropicMsgs = append(anthropicMsgs, windsurf.AnthropicMessage{
|
||||||
|
Role: m.Role,
|
||||||
|
Content: contentJSON,
|
||||||
|
ToolCalls: toolUseMsgs,
|
||||||
|
})
|
||||||
|
} else if len(toolResultMsgs) > 0 {
|
||||||
|
for _, tr := range toolResultMsgs {
|
||||||
|
anthropicMsgs = append(anthropicMsgs, tr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text := windsurfExtractContentText(m.Content)
|
||||||
|
contentJSON, _ := json.Marshal(text)
|
||||||
|
anthropicMsgs = append(anthropicMsgs, windsurf.AnthropicMessage{
|
||||||
|
Role: m.Role,
|
||||||
|
Content: contentJSON,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emulateTools := hasTools || hasToolHistory
|
||||||
|
|
||||||
|
var chatMessages []windsurf.ChatMessage
|
||||||
|
var toolPreamble string
|
||||||
|
|
||||||
|
if emulateTools {
|
||||||
|
toolPreamble = windsurf.BuildToolPreambleForProto(openAITools, req.ToolChoice)
|
||||||
|
chatMessages = windsurf.NormalizeMessagesForCascade(anthropicMsgs, []windsurf.OpenAITool{})
|
||||||
|
reqLog.Info("windsurf_gateway.tool_emulation",
|
||||||
|
zap.Int("tools_count", len(openAITools)),
|
||||||
|
zap.Int("preamble_len", len(toolPreamble)),
|
||||||
|
zap.Int("messages_count", len(chatMessages)),
|
||||||
|
zap.Bool("has_tool_history", hasToolHistory),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
for _, m := range anthropicMsgs {
|
||||||
|
text := windsurfExtractContentText(json.RawMessage(m.Content))
|
||||||
|
chatMessages = append(chatMessages, windsurf.ChatMessage{
|
||||||
|
Role: m.Role,
|
||||||
|
Content: text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatReq := &WindsurfChatRequest{
|
||||||
|
AccountID: account.ID,
|
||||||
|
Model: req.Model,
|
||||||
|
Messages: chatMessages,
|
||||||
|
Stream: req.Stream,
|
||||||
|
Tools: openAITools,
|
||||||
|
ToolPreamble: toolPreamble,
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamStart := time.Now()
|
||||||
|
resp, err := s.chatService.Chat(ctx, chatReq)
|
||||||
|
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||||
|
if err != nil {
|
||||||
|
reqLog.Error("windsurf_gateway.chat_failed", zap.Error(err))
|
||||||
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||||
|
Platform: PlatformWindsurf,
|
||||||
|
AccountID: account.ID,
|
||||||
|
AccountName: account.Name,
|
||||||
|
Kind: "http_error",
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
// CascadeModelError → set model rate limit + trigger account failover
|
||||||
|
var modelErr *windsurf.CascadeModelError
|
||||||
|
if errors.As(err, &modelErr) {
|
||||||
|
modelKey := windsurf.ResolveModel(req.Model)
|
||||||
|
cooldown := 5 * time.Minute
|
||||||
|
if strings.Contains(modelErr.Msg, "stall") {
|
||||||
|
cooldown = 60 * time.Second
|
||||||
|
}
|
||||||
|
resetAt := time.Now().Add(cooldown)
|
||||||
|
if s.accountRepo != nil {
|
||||||
|
if rlErr := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, resetAt); rlErr != nil {
|
||||||
|
reqLog.Error("windsurf_gateway.set_model_rate_limit_failed", zap.Error(rlErr))
|
||||||
|
} else {
|
||||||
|
reqLog.Info("windsurf_gateway.model_rate_limited",
|
||||||
|
zap.String("model_key", modelKey),
|
||||||
|
zap.Duration("cooldown", cooldown),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOpsUpstreamError(c, 502, modelErr.Msg, "")
|
||||||
|
return nil, &UpstreamFailoverError{
|
||||||
|
StatusCode: 502,
|
||||||
|
ResponseBody: []byte(modelErr.Msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOpsUpstreamError(c, http.StatusBadGateway, "Upstream LS request failed", err.Error())
|
||||||
|
s.writeClaudeError(c, http.StatusBadGateway, "api_error", "Upstream LS request failed")
|
||||||
|
return nil, fmt.Errorf("chat: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
durationMs := time.Since(startTime).Milliseconds()
|
||||||
|
if !resp.FirstTextAt.IsZero() {
|
||||||
|
SetOpsLatencyMs(c, OpsTimeToFirstTokenMsKey, resp.FirstTextAt.Sub(startTime).Milliseconds())
|
||||||
|
}
|
||||||
|
msgID := fmt.Sprintf("msg_ws_%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
// Prefer native structured tool calls from trajectory steps;
|
||||||
|
// fallback to text-based parsing when none found.
|
||||||
|
var parsed windsurf.FeedResult
|
||||||
|
if len(resp.ToolCalls) > 0 {
|
||||||
|
parsed.Text = resp.Text
|
||||||
|
for _, tc := range resp.ToolCalls {
|
||||||
|
parsed.ToolCalls = append(parsed.ToolCalls, windsurf.ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: tc.Name,
|
||||||
|
ArgumentsJSON: tc.ArgumentsJSON,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reqLog.Info("windsurf_gateway.native_tool_calls",
|
||||||
|
zap.Int("count", len(resp.ToolCalls)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parsed = windsurf.ParseToolCallsFromText(resp.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer server-reported usage; fallback to chars/4 estimate
|
||||||
|
var inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int
|
||||||
|
if resp.Usage != nil && (resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0) {
|
||||||
|
inputTokens = resp.Usage.InputTokens
|
||||||
|
outputTokens = resp.Usage.OutputTokens
|
||||||
|
cacheReadTokens = resp.Usage.CacheReadTokens
|
||||||
|
cacheWriteTokens = resp.Usage.CacheWriteTokens
|
||||||
|
} else {
|
||||||
|
inputTokens = windsurf.EstimateInputTokensFromMessages(chatMessages)
|
||||||
|
outputTokens = windsurf.EstimateTokens(len(parsed.Text) + len(resp.Thinking))
|
||||||
|
}
|
||||||
|
|
||||||
|
reqLog.Info("windsurf_gateway.completed",
|
||||||
|
zap.Int64("duration_ms", durationMs),
|
||||||
|
zap.String("upstream_model", resp.Model),
|
||||||
|
zap.Int("text_len", len(parsed.Text)),
|
||||||
|
zap.Int("thinking_len", len(resp.Thinking)),
|
||||||
|
zap.Int("tool_calls_count", len(parsed.ToolCalls)),
|
||||||
|
zap.Bool("native_tools", len(resp.ToolCalls) > 0),
|
||||||
|
zap.Int("input_tokens", inputTokens),
|
||||||
|
zap.Int("output_tokens", outputTokens),
|
||||||
|
)
|
||||||
|
|
||||||
|
if req.Stream {
|
||||||
|
s.streamAnthropicResponse(c, msgID, resp, parsed, inputTokens, outputTokens)
|
||||||
|
} else {
|
||||||
|
s.writeAnthropicResponse(c, msgID, resp, parsed, inputTokens, outputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamModel := resp.Model
|
||||||
|
if upstreamModel == req.Model {
|
||||||
|
upstreamModel = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstTokenMs *int
|
||||||
|
if !resp.FirstTextAt.IsZero() {
|
||||||
|
ms := int(resp.FirstTextAt.Sub(startTime).Milliseconds())
|
||||||
|
firstTokenMs = &ms
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ForwardResult{
|
||||||
|
RequestID: msgID,
|
||||||
|
Usage: ClaudeUsage{
|
||||||
|
InputTokens: inputTokens,
|
||||||
|
OutputTokens: outputTokens,
|
||||||
|
CacheReadInputTokens: cacheReadTokens,
|
||||||
|
CacheCreationInputTokens: cacheWriteTokens,
|
||||||
|
},
|
||||||
|
Model: req.Model,
|
||||||
|
UpstreamModel: upstreamModel,
|
||||||
|
Stream: req.Stream,
|
||||||
|
Duration: time.Since(startTime),
|
||||||
|
FirstTokenMs: firstTokenMs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfGatewayService) writeClaudeError(c *gin.Context, status int, errType, message string) {
|
||||||
|
c.JSON(status, gin.H{
|
||||||
|
"type": "error",
|
||||||
|
"error": gin.H{"type": errType, "message": message},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfGatewayService) writeAnthropicResponse(c *gin.Context, id string, resp *WindsurfChatResponse, parsed windsurf.FeedResult, inputTokens, outputTokens int) {
|
||||||
|
var content []gin.H
|
||||||
|
if resp.Thinking != "" {
|
||||||
|
content = append(content, gin.H{"type": "thinking", "thinking": resp.Thinking})
|
||||||
|
}
|
||||||
|
if parsed.Text != "" {
|
||||||
|
content = append(content, gin.H{"type": "text", "text": parsed.Text})
|
||||||
|
}
|
||||||
|
for _, tc := range parsed.ToolCalls {
|
||||||
|
var input interface{}
|
||||||
|
if err := json.Unmarshal([]byte(tc.ArgumentsJSON), &input); err != nil {
|
||||||
|
input = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
content = append(content, gin.H{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tc.ID,
|
||||||
|
"name": tc.Name,
|
||||||
|
"input": input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(content) == 0 {
|
||||||
|
content = append(content, gin.H{"type": "text", "text": ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
stopReason := "end_turn"
|
||||||
|
if len(parsed.ToolCalls) > 0 {
|
||||||
|
stopReason = "tool_use"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": id,
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": resp.Model,
|
||||||
|
"content": content,
|
||||||
|
"stop_reason": stopReason,
|
||||||
|
"stop_sequence": nil,
|
||||||
|
"usage": gin.H{
|
||||||
|
"input_tokens": inputTokens,
|
||||||
|
"output_tokens": outputTokens,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfGatewayService) streamAnthropicResponse(c *gin.Context, id string, resp *WindsurfChatResponse, parsed windsurf.FeedResult, inputTokens, outputTokens int) {
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
|
||||||
|
writeSSE := func(event string, data any) {
|
||||||
|
b, _ := json.Marshal(data)
|
||||||
|
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, b)
|
||||||
|
c.Writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
stopReason := "end_turn"
|
||||||
|
if len(parsed.ToolCalls) > 0 {
|
||||||
|
stopReason = "tool_use"
|
||||||
|
}
|
||||||
|
|
||||||
|
writeSSE("message_start", gin.H{
|
||||||
|
"type": "message_start",
|
||||||
|
"message": gin.H{
|
||||||
|
"id": id,
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": resp.Model,
|
||||||
|
"content": []any{},
|
||||||
|
"usage": gin.H{
|
||||||
|
"input_tokens": inputTokens,
|
||||||
|
"output_tokens": outputTokens,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
blockIndex := 0
|
||||||
|
|
||||||
|
// Thinking block (reasoning_content)
|
||||||
|
if resp.Thinking != "" {
|
||||||
|
writeSSE("content_block_start", gin.H{
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": blockIndex,
|
||||||
|
"content_block": gin.H{"type": "thinking", "thinking": ""},
|
||||||
|
})
|
||||||
|
writeSSE("content_block_delta", gin.H{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": blockIndex,
|
||||||
|
"delta": gin.H{"type": "thinking_delta", "thinking": resp.Thinking},
|
||||||
|
})
|
||||||
|
writeSSE("content_block_stop", gin.H{
|
||||||
|
"type": "content_block_stop",
|
||||||
|
"index": blockIndex,
|
||||||
|
})
|
||||||
|
blockIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Text != "" {
|
||||||
|
writeSSE("content_block_start", gin.H{
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": blockIndex,
|
||||||
|
"content_block": gin.H{"type": "text", "text": ""},
|
||||||
|
})
|
||||||
|
writeSSE("content_block_delta", gin.H{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": blockIndex,
|
||||||
|
"delta": gin.H{"type": "text_delta", "text": parsed.Text},
|
||||||
|
})
|
||||||
|
writeSSE("content_block_stop", gin.H{
|
||||||
|
"type": "content_block_stop",
|
||||||
|
"index": blockIndex,
|
||||||
|
})
|
||||||
|
blockIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range parsed.ToolCalls {
|
||||||
|
var input interface{}
|
||||||
|
if err := json.Unmarshal([]byte(tc.ArgumentsJSON), &input); err != nil {
|
||||||
|
input = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
writeSSE("content_block_start", gin.H{
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": blockIndex,
|
||||||
|
"content_block": gin.H{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tc.ID,
|
||||||
|
"name": tc.Name,
|
||||||
|
"input": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
writeSSE("content_block_delta", gin.H{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": blockIndex,
|
||||||
|
"delta": gin.H{"type": "input_json_delta", "partial_json": tc.ArgumentsJSON},
|
||||||
|
})
|
||||||
|
writeSSE("content_block_stop", gin.H{
|
||||||
|
"type": "content_block_stop",
|
||||||
|
"index": blockIndex,
|
||||||
|
})
|
||||||
|
blockIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if blockIndex == 0 {
|
||||||
|
writeSSE("content_block_start", gin.H{
|
||||||
|
"type": "content_block_start",
|
||||||
|
"index": 0,
|
||||||
|
"content_block": gin.H{"type": "text", "text": ""},
|
||||||
|
})
|
||||||
|
writeSSE("content_block_stop", gin.H{
|
||||||
|
"type": "content_block_stop",
|
||||||
|
"index": 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeSSE("message_delta", gin.H{
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": gin.H{"stop_reason": stopReason, "stop_sequence": nil},
|
||||||
|
"usage": gin.H{"output_tokens": outputTokens},
|
||||||
|
})
|
||||||
|
|
||||||
|
writeSSE("message_stop", gin.H{
|
||||||
|
"type": "message_stop",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Request types ----
|
||||||
|
|
||||||
|
type windsurfMessagesRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
System json.RawMessage `json:"system"`
|
||||||
|
Messages []windsurfRequestMessage `json:"messages"`
|
||||||
|
Tools []windsurfRequestTool `json:"tools,omitempty"`
|
||||||
|
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type windsurfRequestMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type windsurfRequestTool struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
InputSchema json.RawMessage `json:"input_schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helper functions (prefixed to avoid collision with windsurf_gateway_handler.go) ----
|
||||||
|
|
||||||
|
type windsurfContentBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Input interface{} `json:"input,omitempty"`
|
||||||
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||||
|
Content json.RawMessage `json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func windsurfParseContentBlocks(raw json.RawMessage) []windsurfContentBlock {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
return []windsurfContentBlock{{Type: "text", Text: s}}
|
||||||
|
}
|
||||||
|
var blocks []windsurfContentBlock
|
||||||
|
if json.Unmarshal(raw, &blocks) == nil {
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
return []windsurfContentBlock{{Type: "text", Text: string(raw)}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWindsurfRequest(req *windsurfMessagesRequest) {
|
||||||
|
if req == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Tools = normalizeWindsurfRequestTools(req.Tools)
|
||||||
|
req.ToolChoice = normalizeWindsurfToolChoice(req.ToolChoice)
|
||||||
|
for i := range req.Messages {
|
||||||
|
req.Messages[i].Content = normalizeWindsurfMessageContent(req.Messages[i].Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWindsurfRequestTools(tools []windsurfRequestTool) []windsurfRequestTool {
|
||||||
|
if len(tools) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]windsurfRequestTool, 0, len(tools))
|
||||||
|
seen := make(map[string]int, len(tools))
|
||||||
|
for _, tool := range tools {
|
||||||
|
tool.Name = windsurf.NormalizeToolName(tool.Name)
|
||||||
|
key := strings.ToLower(strings.TrimSpace(tool.Name))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if idx, ok := seen[key]; ok {
|
||||||
|
if out[idx].Description == "" {
|
||||||
|
out[idx].Description = tool.Description
|
||||||
|
}
|
||||||
|
if len(out[idx].InputSchema) == 0 {
|
||||||
|
out[idx].InputSchema = tool.InputSchema
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = len(out)
|
||||||
|
out = append(out, tool)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWindsurfToolChoice(toolChoice interface{}) interface{} {
|
||||||
|
switch tc := toolChoice.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
normalized := make(map[string]interface{}, len(tc))
|
||||||
|
for key, value := range tc {
|
||||||
|
normalized[key] = value
|
||||||
|
}
|
||||||
|
if name, ok := normalized["name"].(string); ok {
|
||||||
|
normalized["name"] = windsurf.NormalizeToolName(name)
|
||||||
|
}
|
||||||
|
if fn, ok := normalized["function"].(map[string]interface{}); ok {
|
||||||
|
nextFn := make(map[string]interface{}, len(fn))
|
||||||
|
for key, value := range fn {
|
||||||
|
nextFn[key] = value
|
||||||
|
}
|
||||||
|
if name, ok := nextFn["name"].(string); ok {
|
||||||
|
nextFn["name"] = windsurf.NormalizeToolName(name)
|
||||||
|
}
|
||||||
|
normalized["function"] = nextFn
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
default:
|
||||||
|
return toolChoice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWindsurfMessageContent(raw json.RawMessage) json.RawMessage {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
var text string
|
||||||
|
if json.Unmarshal(raw, &text) == nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
var blocks []windsurfContentBlock
|
||||||
|
if json.Unmarshal(raw, &blocks) != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
for i := range blocks {
|
||||||
|
if blocks[i].Type == "tool_use" {
|
||||||
|
normalized := windsurf.NormalizeToolName(blocks[i].Name)
|
||||||
|
if normalized != blocks[i].Name {
|
||||||
|
blocks[i].Name = normalized
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := json.Marshal(blocks)
|
||||||
|
if err != nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
func windsurfExtractContentText(raw json.RawMessage) string {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var blocks []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &blocks) == nil {
|
||||||
|
var out string
|
||||||
|
for _, b := range blocks {
|
||||||
|
if b.Type == "text" {
|
||||||
|
out += b.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func windsurfExtractContentTextFromRaw(raw json.RawMessage) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var blocks []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &blocks) == nil {
|
||||||
|
textOnly := len(blocks) > 0
|
||||||
|
var parts []string
|
||||||
|
for _, b := range blocks {
|
||||||
|
if b.Type != "text" {
|
||||||
|
textOnly = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parts = append(parts, b.Text)
|
||||||
|
}
|
||||||
|
if textOnly {
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func windsurfLogger(c *gin.Context, component string, fields ...zap.Field) *zap.Logger {
|
||||||
|
l := zap.L().With(zap.String("component", component))
|
||||||
|
if c != nil {
|
||||||
|
if reqID := c.GetHeader("X-Request-ID"); reqID != "" {
|
||||||
|
l = l.With(zap.String("request_id", reqID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l.With(fields...)
|
||||||
|
}
|
||||||
82
backend/internal/service/windsurf_gateway_service_test.go
Normal file
82
backend/internal/service/windsurf_gateway_service_test.go
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeWindsurfRequestCanonicalizesToolsChoiceAndHistory(t *testing.T) {
|
||||||
|
req := windsurfMessagesRequest{
|
||||||
|
Tools: []windsurfRequestTool{
|
||||||
|
{
|
||||||
|
Name: "list_files",
|
||||||
|
Description: "List files",
|
||||||
|
InputSchema: json.RawMessage(`{"type":"object"}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "glob",
|
||||||
|
Description: "Duplicate canonical alias",
|
||||||
|
InputSchema: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "applyPatch",
|
||||||
|
Description: "Patch files",
|
||||||
|
InputSchema: json.RawMessage(`{"type":"object"}`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ToolChoice: map[string]any{
|
||||||
|
"type": "tool",
|
||||||
|
"name": "searchFiles",
|
||||||
|
},
|
||||||
|
Messages: []windsurfRequestMessage{
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: json.RawMessage(`[
|
||||||
|
{"type":"tool_use","id":"call-1","name":"read_file","input":{"filePath":"a.go"}},
|
||||||
|
{"type":"text","text":"done"}
|
||||||
|
]`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeWindsurfRequest(&req)
|
||||||
|
|
||||||
|
if len(req.Tools) != 2 {
|
||||||
|
t.Fatalf("normalized tools len = %d, want 2", len(req.Tools))
|
||||||
|
}
|
||||||
|
if req.Tools[0].Name != "glob" {
|
||||||
|
t.Fatalf("first tool name = %q, want glob", req.Tools[0].Name)
|
||||||
|
}
|
||||||
|
if req.Tools[1].Name != "edit" {
|
||||||
|
t.Fatalf("second tool name = %q, want edit", req.Tools[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolChoice, ok := req.ToolChoice.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("normalized tool choice type = %T, want map[string]any", req.ToolChoice)
|
||||||
|
}
|
||||||
|
if toolChoice["name"] != "grep" {
|
||||||
|
t.Fatalf("tool choice name = %v, want grep", toolChoice["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
var blocks []windsurfContentBlock
|
||||||
|
if err := json.Unmarshal(req.Messages[0].Content, &blocks); err != nil {
|
||||||
|
t.Fatalf("unmarshal normalized message content: %v", err)
|
||||||
|
}
|
||||||
|
if len(blocks) == 0 || blocks[0].Name != "read" {
|
||||||
|
t.Fatalf("tool_use name = %q, want read", blocks[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWindsurfExtractContentTextFromRawPreservesStructuredToolResult(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`[
|
||||||
|
{"type":"text","text":"summary"},
|
||||||
|
{"type":"json","value":{"entries":["main.go"]}}
|
||||||
|
]`)
|
||||||
|
|
||||||
|
got := windsurfExtractContentTextFromRaw(raw)
|
||||||
|
if !strings.Contains(got, `"type":"json"`) {
|
||||||
|
t.Fatalf("structured tool_result content should be preserved, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
217
backend/internal/service/windsurf_probe_service.go
Normal file
217
backend/internal/service/windsurf_probe_service.go
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WindsurfProbeService struct {
|
||||||
|
cfg config.WindsurfConfig
|
||||||
|
accountRepo AccountRepository
|
||||||
|
proxyRepo ProxyRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWindsurfProbeService(
|
||||||
|
cfg config.WindsurfConfig,
|
||||||
|
accountRepo AccountRepository,
|
||||||
|
proxyRepo ProxyRepository,
|
||||||
|
) *WindsurfProbeService {
|
||||||
|
return &WindsurfProbeService{
|
||||||
|
cfg: cfg,
|
||||||
|
accountRepo: accountRepo,
|
||||||
|
proxyRepo: proxyRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfProbeResult struct {
|
||||||
|
AccountID int64
|
||||||
|
Tier string
|
||||||
|
Profile WindsurfProfileSnapshot
|
||||||
|
Status WindsurfUserStatusSnapshot
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfProbeService) ProbeAccount(ctx context.Context, accountID int64) (*WindsurfProbeResult, error) {
|
||||||
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get account: %w", err)
|
||||||
|
}
|
||||||
|
if account.Platform != domain.PlatformWindsurf {
|
||||||
|
return nil, fmt.Errorf("account %d is not a windsurf account", accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := LoadWindsurfCredentials(account.Credentials)
|
||||||
|
if creds.APIKey == "" {
|
||||||
|
return nil, fmt.Errorf("account %d has no api_key", accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyURL := ""
|
||||||
|
if account.ProxyID != nil {
|
||||||
|
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
|
||||||
|
if err == nil {
|
||||||
|
proxyURL = proxy.URL()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := s.cfg.UserStatusBaseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://server.codeium.com"
|
||||||
|
}
|
||||||
|
client, err := windsurf.NewClient(baseURL, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userStatus, err := client.GetUserStatus(ctx, creds.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
extra := LoadWindsurfExtra(account.Extra)
|
||||||
|
extra.Probe.LastProbeAt = time.Now().Format(time.RFC3339)
|
||||||
|
extra.Probe.LastProbeError = err.Error()
|
||||||
|
account.Extra = StoreWindsurfExtra(extra)
|
||||||
|
_ = s.accountRepo.Update(ctx, account)
|
||||||
|
return &WindsurfProbeResult{
|
||||||
|
AccountID: accountID,
|
||||||
|
Error: err.Error(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
extra := LoadWindsurfExtra(account.Extra)
|
||||||
|
extra.Profile.UserID = userStatus.UserID
|
||||||
|
extra.Profile.TeamID = userStatus.TeamID
|
||||||
|
extra.Profile.Email = userStatus.Email
|
||||||
|
extra.Profile.DisplayName = userStatus.Name
|
||||||
|
extra.Profile.PlanName = userStatus.PlanName
|
||||||
|
extra.Profile.TierSource = "probe"
|
||||||
|
extra.Probe.LastProbeAt = time.Now().Format(time.RFC3339)
|
||||||
|
extra.Probe.LastProbeError = ""
|
||||||
|
|
||||||
|
extra.Quota.LastCheckedAt = time.Now().Format(time.RFC3339)
|
||||||
|
extra.Quota.LastError = ""
|
||||||
|
extra.Quota.DailyPercent = userStatus.DailyPercent
|
||||||
|
extra.Quota.WeeklyPercent = userStatus.WeeklyPercent
|
||||||
|
extra.Quota.PromptLimit = userStatus.MonthlyPromptCredits
|
||||||
|
extra.Quota.PromptUsed = userStatus.UsedPromptCredits
|
||||||
|
extra.Quota.FlexLimit = userStatus.MonthlyFlexCredits
|
||||||
|
extra.Quota.FlexUsed = userStatus.UsedFlexCredits
|
||||||
|
|
||||||
|
if userStatus.MonthlyPromptCredits != nil && *userStatus.MonthlyPromptCredits > 0 {
|
||||||
|
used := float64(0)
|
||||||
|
if userStatus.UsedPromptCredits != nil {
|
||||||
|
used = *userStatus.UsedPromptCredits
|
||||||
|
}
|
||||||
|
pct := (used / *userStatus.MonthlyPromptCredits) * 100
|
||||||
|
extra.UserStatus.MonthlyPromptCredits = int64(*userStatus.MonthlyPromptCredits)
|
||||||
|
extra.UserStatus.UserUsedPromptCredits = int64(used)
|
||||||
|
if extra.Quota.DailyPercent == nil {
|
||||||
|
extra.Quota.DailyPercent = &pct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extra.UserStatus.LastFetchedAt = time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
|
account.Extra = StoreWindsurfExtra(extra)
|
||||||
|
if err := s.accountRepo.Update(ctx, account); err != nil {
|
||||||
|
slog.Warn("windsurf_probe_save_failed", "account_id", accountID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WindsurfProbeResult{
|
||||||
|
AccountID: accountID,
|
||||||
|
Tier: creds.Tier,
|
||||||
|
Profile: extra.Profile,
|
||||||
|
Status: extra.UserStatus,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfProbeService) ProbeModelCatalog(ctx context.Context, accountID int64) ([]windsurf.ModelInfo, error) {
|
||||||
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := LoadWindsurfCredentials(account.Credentials)
|
||||||
|
if creds.APIKey == "" {
|
||||||
|
return nil, fmt.Errorf("account %d has no api_key", accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyURL := ""
|
||||||
|
if account.ProxyID != nil {
|
||||||
|
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
|
||||||
|
if err == nil {
|
||||||
|
proxyURL = proxy.URL()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := s.cfg.UserStatusBaseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://server.codeium.com"
|
||||||
|
}
|
||||||
|
client, err := windsurf.NewClient(baseURL, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
models, err := client.ListModels(ctx, creds.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list models: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
extra := LoadWindsurfExtra(account.Extra)
|
||||||
|
extra.Probe.ModelCatalogAt = time.Now().Format(time.RFC3339)
|
||||||
|
account.Extra = StoreWindsurfExtra(extra)
|
||||||
|
_ = s.accountRepo.Update(ctx, account)
|
||||||
|
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfProbeService) GetRuntime(ctx context.Context, accountID int64) (*WindsurfRuntimeInfo, error) {
|
||||||
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get account: %w", err)
|
||||||
|
}
|
||||||
|
if account.Platform != domain.PlatformWindsurf {
|
||||||
|
return nil, fmt.Errorf("account %d is not a windsurf account", accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := LoadWindsurfCredentials(account.Credentials)
|
||||||
|
extra := LoadWindsurfExtra(account.Extra)
|
||||||
|
|
||||||
|
info := &WindsurfRuntimeInfo{
|
||||||
|
AccountID: accountID,
|
||||||
|
Tier: creds.Tier,
|
||||||
|
Capabilities: extra.Capabilities,
|
||||||
|
ModelMatrix: extra.ModelMatrix,
|
||||||
|
}
|
||||||
|
|
||||||
|
if extra.Probe.LastProbeAt != "" {
|
||||||
|
info.LastProbeAt = &extra.Probe.LastProbeAt
|
||||||
|
}
|
||||||
|
if extra.Refresh.LastStatusRefreshAt != "" {
|
||||||
|
info.LastStatusRefreshAt = &extra.Refresh.LastStatusRefreshAt
|
||||||
|
}
|
||||||
|
if extra.Quota.DailyPercent != nil {
|
||||||
|
info.UsagePercent = extra.Quota.DailyPercent
|
||||||
|
}
|
||||||
|
if extra.UserStatus.MonthlyPromptCredits > 0 {
|
||||||
|
info.MonthlyCredits = extra.UserStatus.MonthlyPromptCredits
|
||||||
|
info.UsedCredits = extra.UserStatus.UserUsedPromptCredits
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfRuntimeInfo struct {
|
||||||
|
AccountID int64 `json:"account_id"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
UsagePercent *float64 `json:"usage_percent,omitempty"`
|
||||||
|
MonthlyCredits int64 `json:"monthly_credits,omitempty"`
|
||||||
|
UsedCredits int64 `json:"used_credits,omitempty"`
|
||||||
|
Capabilities map[string]WindsurfModelCapability `json:"capabilities,omitempty"`
|
||||||
|
ModelMatrix map[string]WindsurfModelAvail `json:"model_matrix,omitempty"`
|
||||||
|
LastProbeAt *string `json:"last_probe_at,omitempty"`
|
||||||
|
LastStatusRefreshAt *string `json:"last_status_refresh_at,omitempty"`
|
||||||
|
}
|
||||||
273
backend/internal/service/windsurf_refresh_service.go
Normal file
273
backend/internal/service/windsurf_refresh_service.go
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WindsurfRefreshService struct {
|
||||||
|
cfg config.WindsurfConfig
|
||||||
|
accountRepo AccountRepository
|
||||||
|
proxyRepo ProxyRepository
|
||||||
|
authClient *windsurf.AuthClient
|
||||||
|
|
||||||
|
stopCh chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWindsurfRefreshService(
|
||||||
|
cfg config.WindsurfConfig,
|
||||||
|
accountRepo AccountRepository,
|
||||||
|
proxyRepo ProxyRepository,
|
||||||
|
authClient *windsurf.AuthClient,
|
||||||
|
) *WindsurfRefreshService {
|
||||||
|
return &WindsurfRefreshService{
|
||||||
|
cfg: cfg,
|
||||||
|
accountRepo: accountRepo,
|
||||||
|
proxyRepo: proxyRepo,
|
||||||
|
authClient: authClient,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfRefreshService) Start() {
|
||||||
|
if !s.cfg.Refresh.Enabled {
|
||||||
|
slog.Info("windsurf_refresh_disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
interval := s.cfg.Refresh.TokenScanInterval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 5 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
s.tokenRefreshLoop(interval)
|
||||||
|
}()
|
||||||
|
|
||||||
|
statusInterval := s.cfg.Refresh.StatusRefreshInterval
|
||||||
|
if statusInterval > 0 {
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
s.statusRefreshLoop(statusInterval)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("windsurf_refresh_started",
|
||||||
|
"token_interval", interval,
|
||||||
|
"status_interval", statusInterval,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfRefreshService) Stop() {
|
||||||
|
close(s.stopCh)
|
||||||
|
s.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfRefreshService) tokenRefreshLoop(interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.scanAndRefreshTokens()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfRefreshService) statusRefreshLoop(interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.scanAndRefreshStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfRefreshService) scanAndRefreshTokens() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
accounts, err := s.accountRepo.ListByPlatform(ctx, domain.PlatformWindsurf)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("windsurf_refresh_list_failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeExpiry := s.cfg.Refresh.RefreshBeforeExpiry
|
||||||
|
if beforeExpiry <= 0 {
|
||||||
|
beforeExpiry = 10 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
concurrency := s.cfg.Refresh.WorkerConcurrency
|
||||||
|
if concurrency <= 0 {
|
||||||
|
concurrency = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
sem := make(chan struct{}, concurrency)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := range accounts {
|
||||||
|
acct := accounts[i]
|
||||||
|
creds := LoadWindsurfCredentials(acct.Credentials)
|
||||||
|
|
||||||
|
if !creds.NeedsRefresh(beforeExpiry) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if creds.AuthMethod != "firebase" || creds.RefreshToken == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
|
||||||
|
s.refreshOneToken(ctx, &acct, creds)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfRefreshService) refreshOneToken(ctx context.Context, account *Account, creds WindsurfCredentials) {
|
||||||
|
proxyURL := ""
|
||||||
|
if account.ProxyID != nil {
|
||||||
|
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
|
||||||
|
if err == nil {
|
||||||
|
proxyURL = proxy.URL()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.authClient.RefreshFirebaseToken(ctx, creds.RefreshToken, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
extra := LoadWindsurfExtra(account.Extra)
|
||||||
|
extra.Refresh.TokenRefreshFailures++
|
||||||
|
account.Extra = StoreWindsurfExtra(extra)
|
||||||
|
_ = s.accountRepo.Update(ctx, account)
|
||||||
|
slog.Warn("windsurf_token_refresh_failed", "account_id", account.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creds.IDToken = result.IDToken
|
||||||
|
creds.RefreshToken = result.RefreshToken
|
||||||
|
creds.ExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second).Format(time.RFC3339)
|
||||||
|
creds.LastRefreshAt = time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
|
regResult, err := s.authClient.ReRegisterWithCodeium(ctx, result.IDToken, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("windsurf_reregister_failed", "account_id", account.ID, "error", err)
|
||||||
|
} else {
|
||||||
|
creds.APIKey = regResult.APIKey
|
||||||
|
creds.LastReregisterAt = time.Now().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
account.Credentials = StoreWindsurfCredentials(creds)
|
||||||
|
extra := LoadWindsurfExtra(account.Extra)
|
||||||
|
extra.Refresh.LastTokenRefreshAt = time.Now().Format(time.RFC3339)
|
||||||
|
extra.Refresh.TokenRefreshFailures = 0
|
||||||
|
account.Extra = StoreWindsurfExtra(extra)
|
||||||
|
|
||||||
|
if err := s.accountRepo.Update(ctx, account); err != nil {
|
||||||
|
slog.Error("windsurf_refresh_save_failed", "account_id", account.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfRefreshService) scanAndRefreshStatus() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
accounts, err := s.accountRepo.ListByPlatform(ctx, domain.PlatformWindsurf)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("windsurf_status_refresh_list_failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
concurrency := s.cfg.Refresh.WorkerConcurrency
|
||||||
|
if concurrency <= 0 {
|
||||||
|
concurrency = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
sem := make(chan struct{}, concurrency)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := range accounts {
|
||||||
|
acct := accounts[i]
|
||||||
|
creds := LoadWindsurfCredentials(acct.Credentials)
|
||||||
|
if creds.APIKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
|
||||||
|
s.refreshOneStatus(ctx, &acct, creds)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfRefreshService) refreshOneStatus(ctx context.Context, account *Account, creds WindsurfCredentials) {
|
||||||
|
proxyURL := ""
|
||||||
|
if account.ProxyID != nil {
|
||||||
|
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
|
||||||
|
if err == nil {
|
||||||
|
proxyURL = proxy.URL()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := s.cfg.UserStatusBaseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://server.codeium.com"
|
||||||
|
}
|
||||||
|
client, err := windsurf.NewClient(baseURL, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("windsurf_status_client_failed", "account_id", account.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userStatus, err := client.GetUserStatus(ctx, creds.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
extra := LoadWindsurfExtra(account.Extra)
|
||||||
|
extra.Refresh.StatusRefreshFailures++
|
||||||
|
account.Extra = StoreWindsurfExtra(extra)
|
||||||
|
_ = s.accountRepo.Update(ctx, account)
|
||||||
|
slog.Warn("windsurf_status_refresh_failed", "account_id", account.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
extra := LoadWindsurfExtra(account.Extra)
|
||||||
|
extra.Profile.UserID = userStatus.UserID
|
||||||
|
extra.Profile.TeamID = userStatus.TeamID
|
||||||
|
extra.Profile.Email = userStatus.Email
|
||||||
|
extra.Profile.DisplayName = userStatus.Name
|
||||||
|
extra.Refresh.LastStatusRefreshAt = time.Now().Format(time.RFC3339)
|
||||||
|
extra.Refresh.StatusRefreshFailures = 0
|
||||||
|
account.Extra = StoreWindsurfExtra(extra)
|
||||||
|
|
||||||
|
if err := s.accountRepo.Update(ctx, account); err != nil {
|
||||||
|
slog.Error("windsurf_status_save_failed", "account_id", account.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
357
backend/internal/service/windsurf_services.go
Normal file
357
backend/internal/service/windsurf_services.go
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WindsurfLSService struct {
|
||||||
|
cfg config.WindsurfConfig
|
||||||
|
connector windsurf.LSConnector
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWindsurfLSService(cfg config.WindsurfConfig, pool *windsurf.LSPool) *WindsurfLSService {
|
||||||
|
var connector windsurf.LSConnector
|
||||||
|
|
||||||
|
switch cfg.LSMode {
|
||||||
|
case "docker":
|
||||||
|
connector = windsurf.NewCompatDockerConnector(
|
||||||
|
cfg.Docker.Host,
|
||||||
|
cfg.Docker.Port,
|
||||||
|
windsurf.DockerDiscoveryConfig{
|
||||||
|
DefaultCSRFToken: cfg.Docker.CSRFToken,
|
||||||
|
ProbeInterval: cfg.Docker.ProbeInterval,
|
||||||
|
ProbeTimeout: cfg.Docker.ProbeTimeout,
|
||||||
|
DiscoverInterval: cfg.Docker.DiscoverInterval,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
case "embedded":
|
||||||
|
connector = windsurf.NewEmbeddedConnector(pool)
|
||||||
|
case "external":
|
||||||
|
port := 0
|
||||||
|
if cfg.External.BaseURL != "" {
|
||||||
|
port = 443
|
||||||
|
}
|
||||||
|
connector = windsurf.NewExternalConnector(
|
||||||
|
cfg.External.BaseURL,
|
||||||
|
port,
|
||||||
|
cfg.External.CSRFToken,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
connector = windsurf.NewDockerConnector(
|
||||||
|
cfg.Docker.Host,
|
||||||
|
cfg.Docker.Port,
|
||||||
|
cfg.Docker.CSRFToken,
|
||||||
|
)
|
||||||
|
slog.Warn("windsurf_ls_unknown_mode", "mode", cfg.LSMode, "fallback", "docker")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WindsurfLSService{
|
||||||
|
cfg: cfg,
|
||||||
|
connector: connector,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfLSService) Connector() windsurf.LSConnector {
|
||||||
|
return s.connector
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfLSService) Acquire(ctx context.Context, proxyURL string) (*windsurf.LSLease, error) {
|
||||||
|
return s.connector.Acquire(ctx, proxyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfLSService) AcquireByBinding(binding WindsurfLSBinding) (*windsurf.LSLease, error) {
|
||||||
|
if binding.ContainerID == "" && binding.ContainerName == "" {
|
||||||
|
return s.connector.Acquire(context.Background(), "")
|
||||||
|
}
|
||||||
|
if dc, ok := s.connector.(*windsurf.DockerDiscoveryConnector); ok {
|
||||||
|
id := binding.ContainerID
|
||||||
|
if id == "" {
|
||||||
|
id = binding.ContainerName
|
||||||
|
}
|
||||||
|
return dc.AcquireByID(id)
|
||||||
|
}
|
||||||
|
return s.connector.Acquire(context.Background(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfLSService) Health(ctx context.Context) error {
|
||||||
|
return s.connector.Health(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfLSService) Status() *windsurf.LSConnectorStatus {
|
||||||
|
return s.connector.Status()
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfAuthService struct {
|
||||||
|
cfg config.WindsurfConfig
|
||||||
|
authClient *windsurf.AuthClient
|
||||||
|
accountRepo AccountRepository
|
||||||
|
proxyRepo ProxyRepository
|
||||||
|
adminSvc AdminService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWindsurfAuthService(
|
||||||
|
cfg config.WindsurfConfig,
|
||||||
|
accountRepo AccountRepository,
|
||||||
|
proxyRepo ProxyRepository,
|
||||||
|
adminSvc AdminService,
|
||||||
|
) *WindsurfAuthService {
|
||||||
|
authClient := &windsurf.AuthClient{
|
||||||
|
Auth1BaseURL: cfg.Auth1BaseURL,
|
||||||
|
SeatServiceBaseURL: cfg.SeatServiceBaseURL,
|
||||||
|
CodeiumRegisterURL: cfg.CodeiumRegisterURL,
|
||||||
|
FirebaseAPIKey: cfg.FirebaseAPIKey,
|
||||||
|
RequestTimeout: cfg.RequestTimeout,
|
||||||
|
}
|
||||||
|
return &WindsurfAuthService{
|
||||||
|
cfg: cfg,
|
||||||
|
authClient: authClient,
|
||||||
|
accountRepo: accountRepo,
|
||||||
|
proxyRepo: proxyRepo,
|
||||||
|
adminSvc: adminSvc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfLoginInput struct {
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
Name string
|
||||||
|
Notes *string
|
||||||
|
ProxyID *int64
|
||||||
|
GroupIDs []int64
|
||||||
|
Concurrency int
|
||||||
|
Priority int
|
||||||
|
ProbeAfter bool
|
||||||
|
LSInstanceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfLoginOutput struct {
|
||||||
|
AccountID int64 `json:"account_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
AuthMethod string `json:"auth_method"`
|
||||||
|
APIKeyPresent bool `json:"api_key_present"`
|
||||||
|
RefreshTokenPresent bool `json:"refresh_token_present"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfAuthService) Login(ctx context.Context, input *WindsurfLoginInput) (*WindsurfLoginOutput, error) {
|
||||||
|
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, fmt.Errorf("windsurf account with email %s already exists (account_id=%d)", input.Email, existing[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.authClient.Login(ctx, input.Email, input.Password, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := WindsurfCredentials{
|
||||||
|
Email: input.Email,
|
||||||
|
APIKey: result.APIKey,
|
||||||
|
RefreshToken: result.RefreshToken,
|
||||||
|
IDToken: result.IDToken,
|
||||||
|
SessionToken: result.SessionToken,
|
||||||
|
Auth1Token: result.Auth1Token,
|
||||||
|
AuthMethod: result.AuthMethod,
|
||||||
|
APIServerURL: result.APIServerURL,
|
||||||
|
RegisteredAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(50 * time.Minute)
|
||||||
|
creds.ExpiresAt = expiresAt.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 == "" {
|
||||||
|
if result.Name != "" {
|
||||||
|
name = result.Name
|
||||||
|
} else {
|
||||||
|
name = input.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: input.Email,
|
||||||
|
Tier: "unknown",
|
||||||
|
AuthMethod: result.AuthMethod,
|
||||||
|
APIKeyPresent: result.APIKey != "",
|
||||||
|
RefreshTokenPresent: result.RefreshToken != "",
|
||||||
|
}, 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))
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
email, password, err := parseEmailPassword(item)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, WindsurfBatchResult{
|
||||||
|
Email: item,
|
||||||
|
Success: false,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
input := &WindsurfLoginInput{
|
||||||
|
Email: email,
|
||||||
|
Password: password,
|
||||||
|
ProxyID: proxyID,
|
||||||
|
GroupIDs: groupIDs,
|
||||||
|
Concurrency: concurrency,
|
||||||
|
Priority: priority,
|
||||||
|
ProbeAfter: probeAfter,
|
||||||
|
}
|
||||||
|
|
||||||
|
output, loginErr := s.Login(ctx, input)
|
||||||
|
if loginErr != nil {
|
||||||
|
results = append(results, WindsurfBatchResult{
|
||||||
|
Email: email,
|
||||||
|
Success: false,
|
||||||
|
Error: loginErr.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, WindsurfBatchResult{
|
||||||
|
Email: email,
|
||||||
|
Success: true,
|
||||||
|
AccountID: output.AccountID,
|
||||||
|
Output: output,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfBatchResult struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
AccountID int64 `json:"account_id,omitempty"`
|
||||||
|
Output *WindsurfLoginOutput `json:"output,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEmailPassword(item string) (string, string, error) {
|
||||||
|
sep := "----"
|
||||||
|
idx := -1
|
||||||
|
for i := 0; i <= len(item)-len(sep); i++ {
|
||||||
|
if item[i:i+len(sep)] == sep {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return "", "", fmt.Errorf("invalid format: expected email----password")
|
||||||
|
}
|
||||||
|
email := item[:idx]
|
||||||
|
password := item[idx+len(sep):]
|
||||||
|
if email == "" || password == "" {
|
||||||
|
return "", "", fmt.Errorf("email and password cannot be empty")
|
||||||
|
}
|
||||||
|
return email, password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WindsurfAuthService) RefreshToken(ctx context.Context, accountID int64) error {
|
||||||
|
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if account.Platform != domain.PlatformWindsurf {
|
||||||
|
return fmt.Errorf("account %d is not a windsurf account", accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := LoadWindsurfCredentials(account.Credentials)
|
||||||
|
proxyURL := ""
|
||||||
|
if account.ProxyID != nil {
|
||||||
|
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
|
||||||
|
if err == nil {
|
||||||
|
proxyURL = proxy.URL()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if creds.AuthMethod == "firebase" && creds.RefreshToken != "" {
|
||||||
|
refreshResult, err := s.authClient.RefreshFirebaseToken(ctx, creds.RefreshToken, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("firebase refresh: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds.IDToken = refreshResult.IDToken
|
||||||
|
creds.RefreshToken = refreshResult.RefreshToken
|
||||||
|
creds.ExpiresAt = time.Now().Add(time.Duration(refreshResult.ExpiresIn) * time.Second).Format(time.RFC3339)
|
||||||
|
creds.LastRefreshAt = time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
|
regResult, err := s.authClient.ReRegisterWithCodeium(ctx, refreshResult.IDToken, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("windsurf_reregister_failed", "account_id", accountID, "error", err)
|
||||||
|
} else {
|
||||||
|
creds.APIKey = regResult.APIKey
|
||||||
|
creds.LastReregisterAt = time.Now().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
} else if creds.AuthMethod == "auth1" {
|
||||||
|
// Auth1 tokens don't use Firebase refresh; re-login would be needed
|
||||||
|
return fmt.Errorf("auth1 accounts require re-login for token refresh")
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("unknown auth method: %s", creds.AuthMethod)
|
||||||
|
}
|
||||||
|
|
||||||
|
credMap := StoreWindsurfCredentials(creds)
|
||||||
|
account.Credentials = credMap
|
||||||
|
return s.accountRepo.Update(ctx, account)
|
||||||
|
}
|
||||||
114
backend/internal/service/windsurf_token_provider.go
Normal file
114
backend/internal/service/windsurf_token_provider.go
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WindsurfTokenProvider struct {
|
||||||
|
cfg config.WindsurfConfig
|
||||||
|
accountRepo AccountRepository
|
||||||
|
proxyRepo ProxyRepository
|
||||||
|
authClient *windsurf.AuthClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWindsurfTokenProvider(
|
||||||
|
cfg config.WindsurfConfig,
|
||||||
|
accountRepo AccountRepository,
|
||||||
|
proxyRepo ProxyRepository,
|
||||||
|
authClient *windsurf.AuthClient,
|
||||||
|
) *WindsurfTokenProvider {
|
||||||
|
return &WindsurfTokenProvider{
|
||||||
|
cfg: cfg,
|
||||||
|
accountRepo: accountRepo,
|
||||||
|
proxyRepo: proxyRepo,
|
||||||
|
authClient: authClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindsurfToken struct {
|
||||||
|
APIKey string
|
||||||
|
ProxyURL string
|
||||||
|
AccountID int64
|
||||||
|
Tier string
|
||||||
|
LSBinding WindsurfLSBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WindsurfTokenProvider) GetToken(ctx context.Context, accountID int64) (*WindsurfToken, error) {
|
||||||
|
account, err := p.accountRepo.GetByID(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get account: %w", err)
|
||||||
|
}
|
||||||
|
if account.Platform != domain.PlatformWindsurf {
|
||||||
|
return nil, fmt.Errorf("account %d is not a windsurf account", accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := LoadWindsurfCredentials(account.Credentials)
|
||||||
|
if creds.APIKey == "" {
|
||||||
|
return nil, fmt.Errorf("account %d has no api_key", accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyURL := ""
|
||||||
|
if account.ProxyID != nil {
|
||||||
|
proxy, err := p.proxyRepo.GetByID(ctx, *account.ProxyID)
|
||||||
|
if err == nil {
|
||||||
|
proxyURL = proxy.URL()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if creds.NeedsRefresh(p.cfg.Refresh.RefreshBeforeExpiry) {
|
||||||
|
if refreshErr := p.refreshInline(ctx, account, &creds, proxyURL); refreshErr != nil {
|
||||||
|
if !creds.IsExpired() {
|
||||||
|
extra := LoadWindsurfExtra(account.Extra)
|
||||||
|
return &WindsurfToken{
|
||||||
|
APIKey: creds.APIKey,
|
||||||
|
ProxyURL: proxyURL,
|
||||||
|
AccountID: accountID,
|
||||||
|
Tier: creds.Tier,
|
||||||
|
LSBinding: extra.LSBinding,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("token expired and refresh failed: %w", refreshErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extra := LoadWindsurfExtra(account.Extra)
|
||||||
|
|
||||||
|
return &WindsurfToken{
|
||||||
|
APIKey: creds.APIKey,
|
||||||
|
ProxyURL: proxyURL,
|
||||||
|
AccountID: accountID,
|
||||||
|
Tier: creds.Tier,
|
||||||
|
LSBinding: extra.LSBinding,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WindsurfTokenProvider) refreshInline(ctx context.Context, account *Account, creds *WindsurfCredentials, proxyURL string) error {
|
||||||
|
if creds.AuthMethod != "firebase" || creds.RefreshToken == "" {
|
||||||
|
return fmt.Errorf("cannot refresh: auth_method=%s", creds.AuthMethod)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := p.authClient.RefreshFirebaseToken(ctx, creds.RefreshToken, proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
creds.IDToken = result.IDToken
|
||||||
|
creds.RefreshToken = result.RefreshToken
|
||||||
|
creds.ExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second).Format(time.RFC3339)
|
||||||
|
creds.LastRefreshAt = time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
|
regResult, err := p.authClient.ReRegisterWithCodeium(ctx, result.IDToken, proxyURL)
|
||||||
|
if err == nil {
|
||||||
|
creds.APIKey = regResult.APIKey
|
||||||
|
creds.LastReregisterAt = time.Now().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
account.Credentials = StoreWindsurfCredentials(*creds)
|
||||||
|
return p.accountRepo.Update(ctx, account)
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -10,6 +11,7 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
@ -469,6 +471,13 @@ var ProviderSet = wire.NewSet(
|
|||||||
ProvidePaymentOrderExpiryService,
|
ProvidePaymentOrderExpiryService,
|
||||||
ProvideBalanceNotifyService,
|
ProvideBalanceNotifyService,
|
||||||
ProvideLanguageServerService,
|
ProvideLanguageServerService,
|
||||||
|
ProvideWindsurfAuthService,
|
||||||
|
ProvideWindsurfLSService,
|
||||||
|
ProvideWindsurfChatService,
|
||||||
|
ProvideWindsurfGatewayService,
|
||||||
|
ProvideWindsurfTokenProvider,
|
||||||
|
ProvideWindsurfRefreshService,
|
||||||
|
ProvideWindsurfProbeService,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProvideLanguageServerService creates LanguageServerService with injected dependencies
|
// ProvideLanguageServerService creates LanguageServerService with injected dependencies
|
||||||
@ -476,6 +485,90 @@ func ProvideLanguageServerService(httpUpstream HTTPUpstream, antigravitySvc *Ant
|
|||||||
return NewLanguageServerService(slog.Default(), httpUpstream, antigravitySvc, accountRepo)
|
return NewLanguageServerService(slog.Default(), httpUpstream, antigravitySvc, accountRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProvideWindsurfAuthService creates WindsurfAuthService from the main config.
|
||||||
|
func ProvideWindsurfAuthService(cfg *config.Config, accountRepo AccountRepository, proxyRepo ProxyRepository, adminSvc AdminService) *WindsurfAuthService {
|
||||||
|
if !cfg.Windsurf.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return NewWindsurfAuthService(cfg.Windsurf, accountRepo, proxyRepo, adminSvc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvideWindsurfLSService creates WindsurfLSService (nil when windsurf is disabled).
|
||||||
|
func ProvideWindsurfLSService(cfg *config.Config) *WindsurfLSService {
|
||||||
|
if !cfg.Windsurf.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pool *windsurf.LSPool
|
||||||
|
if cfg.Windsurf.LSMode == "embedded" {
|
||||||
|
pool = windsurf.NewLSPool(windsurf.LSPoolConfig{
|
||||||
|
Binary: cfg.Windsurf.Embedded.Binary,
|
||||||
|
BasePort: cfg.Windsurf.Embedded.BasePort,
|
||||||
|
DataDir: cfg.Windsurf.Embedded.DataDir,
|
||||||
|
APIServerURL: cfg.Windsurf.Embedded.APIServerURL,
|
||||||
|
}, func(format string, args ...any) {
|
||||||
|
slog.Info(fmt.Sprintf(format, args...), "component", "windsurf_ls_pool")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return NewWindsurfLSService(cfg.Windsurf, pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
func provideWindsurfAuthClient(cfg *config.Config) *windsurf.AuthClient {
|
||||||
|
if !cfg.Windsurf.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &windsurf.AuthClient{
|
||||||
|
Auth1BaseURL: cfg.Windsurf.Auth1BaseURL,
|
||||||
|
SeatServiceBaseURL: cfg.Windsurf.SeatServiceBaseURL,
|
||||||
|
CodeiumRegisterURL: cfg.Windsurf.CodeiumRegisterURL,
|
||||||
|
FirebaseAPIKey: cfg.Windsurf.FirebaseAPIKey,
|
||||||
|
RequestTimeout: cfg.Windsurf.RequestTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvideWindsurfTokenProvider creates WindsurfTokenProvider (nil when disabled).
|
||||||
|
func ProvideWindsurfTokenProvider(cfg *config.Config, accountRepo AccountRepository, proxyRepo ProxyRepository) *WindsurfTokenProvider {
|
||||||
|
if !cfg.Windsurf.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
authClient := provideWindsurfAuthClient(cfg)
|
||||||
|
return NewWindsurfTokenProvider(cfg.Windsurf, accountRepo, proxyRepo, authClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvideWindsurfChatService creates WindsurfChatService (nil when disabled).
|
||||||
|
func ProvideWindsurfChatService(cfg *config.Config, lsService *WindsurfLSService, tokenProvider *WindsurfTokenProvider) *WindsurfChatService {
|
||||||
|
if !cfg.Windsurf.Enabled || lsService == nil || tokenProvider == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return NewWindsurfChatService(cfg.Windsurf, lsService, tokenProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvideWindsurfGatewayService creates WindsurfGatewayService (nil when disabled).
|
||||||
|
func ProvideWindsurfGatewayService(cfg *config.Config, chatService *WindsurfChatService, accountRepo AccountRepository) *WindsurfGatewayService {
|
||||||
|
if !cfg.Windsurf.Enabled || chatService == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return NewWindsurfGatewayService(chatService, cfg.Windsurf, accountRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvideWindsurfRefreshService creates and starts WindsurfRefreshService (nil when disabled).
|
||||||
|
func ProvideWindsurfRefreshService(cfg *config.Config, accountRepo AccountRepository, proxyRepo ProxyRepository) *WindsurfRefreshService {
|
||||||
|
if !cfg.Windsurf.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
authClient := provideWindsurfAuthClient(cfg)
|
||||||
|
svc := NewWindsurfRefreshService(cfg.Windsurf, accountRepo, proxyRepo, authClient)
|
||||||
|
svc.Start()
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvideWindsurfProbeService creates WindsurfProbeService (nil when disabled).
|
||||||
|
func ProvideWindsurfProbeService(cfg *config.Config, accountRepo AccountRepository, proxyRepo ProxyRepository) *WindsurfProbeService {
|
||||||
|
if !cfg.Windsurf.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return NewWindsurfProbeService(cfg.Windsurf, accountRepo, proxyRepo)
|
||||||
|
}
|
||||||
|
|
||||||
// ProvidePaymentConfigService wraps NewPaymentConfigService to accept the named
|
// ProvidePaymentConfigService wraps NewPaymentConfigService to accept the named
|
||||||
// payment.EncryptionKey type instead of raw []byte, avoiding Wire ambiguity.
|
// payment.EncryptionKey type instead of raw []byte, avoiding Wire ambiguity.
|
||||||
func ProvidePaymentConfigService(entClient *dbent.Client, settingRepo SettingRepository, key payment.EncryptionKey) *PaymentConfigService {
|
func ProvidePaymentConfigService(entClient *dbent.Client, settingRepo SettingRepository, key payment.EncryptionKey) *PaymentConfigService {
|
||||||
|
|||||||
@ -302,6 +302,7 @@ func shouldBypassEmbeddedFrontend(path string) bool {
|
|||||||
strings.HasPrefix(trimmed, "/v1/") ||
|
strings.HasPrefix(trimmed, "/v1/") ||
|
||||||
strings.HasPrefix(trimmed, "/v1beta/") ||
|
strings.HasPrefix(trimmed, "/v1beta/") ||
|
||||||
strings.HasPrefix(trimmed, "/antigravity/") ||
|
strings.HasPrefix(trimmed, "/antigravity/") ||
|
||||||
|
strings.HasPrefix(trimmed, "/windsurf/") ||
|
||||||
strings.HasPrefix(trimmed, "/setup/") ||
|
strings.HasPrefix(trimmed, "/setup/") ||
|
||||||
trimmed == "/health" ||
|
trimmed == "/health" ||
|
||||||
trimmed == "/responses" ||
|
trimmed == "/responses" ||
|
||||||
|
|||||||
76
deploy/Dockerfile.ls
Normal file
76
deploy/Dockerfile.ls
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Windsurf Language Server Docker Image
|
||||||
|
#
|
||||||
|
# Usage (host network — required for CSRF loopback check):
|
||||||
|
# docker build -t windsurf-ls -f deploy/Dockerfile.ls .
|
||||||
|
# docker run -d --name windsurf-ls \
|
||||||
|
# --network host \
|
||||||
|
# -v windsurf_ls_data:/data \
|
||||||
|
# windsurf-ls
|
||||||
|
#
|
||||||
|
# The LS binary is auto-downloaded from Exafunction/codeium releases at build time.
|
||||||
|
# To use a local binary instead, pass --build-arg LS_URL=file:///path or place it
|
||||||
|
# at deploy/language_server_linux_x64 and rebuild.
|
||||||
|
|
||||||
|
FROM alpine:3.21 AS downloader
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl jq
|
||||||
|
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG LS_URL=""
|
||||||
|
|
||||||
|
RUN set -e; \
|
||||||
|
if [ -n "$LS_URL" ]; then \
|
||||||
|
echo "Downloading LS from: $LS_URL"; \
|
||||||
|
curl -fL --progress-bar -o /tmp/language_server "$LS_URL"; \
|
||||||
|
else \
|
||||||
|
case "$TARGETARCH" in \
|
||||||
|
amd64) ASSET="language_server_linux_x64" ;; \
|
||||||
|
arm64) ASSET="language_server_linux_arm" ;; \
|
||||||
|
*) echo "Unsupported arch: $TARGETARCH"; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
echo "Fetching latest Exafunction/codeium release..."; \
|
||||||
|
URL=$(curl -fsSL https://api.github.com/repos/Exafunction/codeium/releases/latest \
|
||||||
|
| jq -r --arg asset "$ASSET" '.assets[] | select(.name == $asset) | .browser_download_url'); \
|
||||||
|
if [ -z "$URL" ] || [ "$URL" = "null" ]; then \
|
||||||
|
echo "ERROR: Could not find asset $ASSET in latest release"; exit 1; \
|
||||||
|
fi; \
|
||||||
|
echo "Downloading: $URL"; \
|
||||||
|
curl -fL --progress-bar -o /tmp/language_server "$URL"; \
|
||||||
|
fi; \
|
||||||
|
chmod +x /tmp/language_server
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates netcat-openbsd && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /opt/windsurf
|
||||||
|
|
||||||
|
COPY --from=downloader /tmp/language_server /opt/windsurf/language_server_linux_x64
|
||||||
|
|
||||||
|
RUN mkdir -p /data/db
|
||||||
|
|
||||||
|
ENV LS_PORT=42099 \
|
||||||
|
LS_CSRF_TOKEN=ad2d9f01-4e7b-8c3a-b5f6-1d8e9a0c7b2f \
|
||||||
|
LS_API_SERVER_URL=https://server.self-serve.windsurf.com \
|
||||||
|
HTTPS_PROXY="" \
|
||||||
|
HTTP_PROXY=""
|
||||||
|
|
||||||
|
EXPOSE ${LS_PORT}
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \
|
||||||
|
CMD nc -z localhost ${LS_PORT} || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/bin/sh", "-c", \
|
||||||
|
"exec /opt/windsurf/language_server_linux_x64 \
|
||||||
|
--api_server_url=${LS_API_SERVER_URL} \
|
||||||
|
--server_port=${LS_PORT} \
|
||||||
|
--csrf_token=${LS_CSRF_TOKEN} \
|
||||||
|
--register_user_url=https://api.codeium.com/register_user/ \
|
||||||
|
--codeium_dir=/data \
|
||||||
|
--database_dir=/data/db \
|
||||||
|
--enable_local_search=false \
|
||||||
|
--enable_index_service=false \
|
||||||
|
--enable_lsp=false \
|
||||||
|
--detect_proxy=false"]
|
||||||
65
deploy/docker-compose.windsurf.yml
Normal file
65
deploy/docker-compose.windsurf.yml
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Windsurf Language Server — 独立 Compose 文件
|
||||||
|
# =============================================================================
|
||||||
|
# 启动方式:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.windsurf.yml up -d
|
||||||
|
#
|
||||||
|
# 构建 LS 镜像:
|
||||||
|
# 1. 将 language_server_linux_x64 放到 deploy/ 目录
|
||||||
|
# 2. docker compose -f docker-compose.yml -f docker-compose.windsurf.yml build windsurf-ls
|
||||||
|
#
|
||||||
|
# Multi-proxy:复制 windsurf-ls 服务并修改 LS_PORT 和 HTTPS_PROXY:
|
||||||
|
# windsurf-ls-proxy1:
|
||||||
|
# extends: { service: windsurf-ls }
|
||||||
|
# environment:
|
||||||
|
# - LS_PORT=42101
|
||||||
|
# - HTTPS_PROXY=http://user:pass@proxy1:8080
|
||||||
|
# - HTTP_PROXY=http://user:pass@proxy1:8080
|
||||||
|
# ports: ["42101:42101"]
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
# 覆盖主服务:注入 LS 连接参数 + 添加依赖
|
||||||
|
sub2api:
|
||||||
|
environment:
|
||||||
|
- WINDSURF_ENABLED=true
|
||||||
|
- WINDSURF_FIREBASE_API_KEY=${WINDSURF_FIREBASE_API_KEY:-AIzaSyDsOl-1XpT5err0Tcnx8FFod1H8gVGIycY}
|
||||||
|
- WINDSURF_DOCKER_HOST=host.docker.internal
|
||||||
|
- WINDSURF_DOCKER_PORT=${WINDSURF_LS_PORT:-42099}
|
||||||
|
- WINDSURF_DOCKER_CSRF_TOKEN=${LS_CSRF_TOKEN:-ad2d9f01-4e7b-8c3a-b5f6-1d8e9a0c7b2f}
|
||||||
|
- WINDSURF_LS_MODE=${WINDSURF_LS_MODE:-docker}
|
||||||
|
depends_on:
|
||||||
|
windsurf-ls:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Windsurf Language Server (local gRPC for Cascade chat)
|
||||||
|
# Must use host network — LS validates CSRF tokens only from loopback.
|
||||||
|
# ===========================================================================
|
||||||
|
windsurf-ls:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile.ls
|
||||||
|
image: windsurf-ls:latest
|
||||||
|
container_name: sub2api-windsurf-ls
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- windsurf_ls_data:/data
|
||||||
|
environment:
|
||||||
|
- LS_PORT=42099
|
||||||
|
- LS_CSRF_TOKEN=${LS_CSRF_TOKEN:-ad2d9f01-4e7b-8c3a-b5f6-1d8e9a0c7b2f}
|
||||||
|
- LS_API_SERVER_URL=${LS_API_SERVER_URL:-https://server.self-serve.windsurf.com}
|
||||||
|
- HTTPS_PROXY=${LS_HTTPS_PROXY:-}
|
||||||
|
- HTTP_PROXY=${LS_HTTP_PROXY:-}
|
||||||
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "nc", "-z", "localhost", "42099"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
windsurf_ls_data:
|
||||||
|
driver: local
|
||||||
@ -6,6 +6,10 @@
|
|||||||
# 2. docker compose up -d
|
# 2. docker compose up -d
|
||||||
# 3. Check logs: docker compose logs -f
|
# 3. Check logs: docker compose logs -f
|
||||||
#
|
#
|
||||||
|
# Windsurf LS (可选):
|
||||||
|
# 需要 Windsurf Cascade 聊天功能时,额外启动 LS 容器:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.windsurf.yml up -d
|
||||||
|
#
|
||||||
# 注意事项:
|
# 注意事项:
|
||||||
# - JWT_SECRET / TOTP_ENCRYPTION_KEY 必须固定,多实例共享同一个值
|
# - JWT_SECRET / TOTP_ENCRYPTION_KEY 必须固定,多实例共享同一个值
|
||||||
# - PostgreSQL / Redis 单实例,不参与水平扩展
|
# - PostgreSQL / Redis 单实例,不参与水平扩展
|
||||||
@ -95,6 +99,15 @@ services:
|
|||||||
# --- Update Proxy(国内机器可配置代理访问 GitHub)---
|
# --- Update Proxy(国内机器可配置代理访问 GitHub)---
|
||||||
- UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-}
|
- UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-}
|
||||||
|
|
||||||
|
# --- Windsurf (账号管理/登录,不依赖 LS) ---
|
||||||
|
- WINDSURF_ENABLED=${WINDSURF_ENABLED:-false}
|
||||||
|
- WINDSURF_FIREBASE_API_KEY=${WINDSURF_FIREBASE_API_KEY:-}
|
||||||
|
|
||||||
|
# --- Windsurf Language Server (可选,需配合 docker-compose.windsurf.yml) ---
|
||||||
|
- WINDSURF_DOCKER_HOST=${WINDSURF_DOCKER_HOST:-}
|
||||||
|
- WINDSURF_DOCKER_PORT=${WINDSURF_DOCKER_PORT:-42099}
|
||||||
|
- WINDSURF_DOCKER_CSRF_TOKEN=${WINDSURF_DOCKER_CSRF_TOKEN:-}
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@ -16,7 +16,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lobehub/icons": "^4.0.2",
|
"@lobehub/icons": "^4.0.2",
|
||||||
"@stripe/stripe-js": "^9.0.1",
|
|
||||||
"@tanstack/vue-virtual": "^3.13.23",
|
"@tanstack/vue-virtual": "^3.13.23",
|
||||||
"@vueuse/core": "^10.7.0",
|
"@vueuse/core": "^10.7.0",
|
||||||
"axios": "^1.15.0",
|
"axios": "^1.15.0",
|
||||||
@ -35,6 +34,7 @@
|
|||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@stripe/stripe-js": "^9.0.1",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
|
|||||||
6
frontend/pnpm-lock.yaml
generated
6
frontend/pnpm-lock.yaml
generated
@ -11,9 +11,6 @@ importers:
|
|||||||
'@lobehub/icons':
|
'@lobehub/icons':
|
||||||
specifier: ^4.0.2
|
specifier: ^4.0.2
|
||||||
version: 4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
'@stripe/stripe-js':
|
|
||||||
specifier: ^9.0.1
|
|
||||||
version: 9.0.1
|
|
||||||
'@tanstack/vue-virtual':
|
'@tanstack/vue-virtual':
|
||||||
specifier: ^3.13.23
|
specifier: ^3.13.23
|
||||||
version: 3.13.23(vue@3.5.26(typescript@5.6.3))
|
version: 3.13.23(vue@3.5.26(typescript@5.6.3))
|
||||||
@ -63,6 +60,9 @@ importers:
|
|||||||
specifier: ^0.18.5
|
specifier: ^0.18.5
|
||||||
version: 0.18.5
|
version: 0.18.5
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@stripe/stripe-js':
|
||||||
|
specifier: ^9.0.1
|
||||||
|
version: 9.0.1
|
||||||
'@types/dompurify':
|
'@types/dompurify':
|
||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
version: 3.2.0
|
version: 3.2.0
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import backupAPI from './backup'
|
|||||||
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
|
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
|
||||||
import channelsAPI from './channels'
|
import channelsAPI from './channels'
|
||||||
import adminPaymentAPI from './payment'
|
import adminPaymentAPI from './payment'
|
||||||
|
import windsurfAPI from './windsurf'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified admin API object for convenient access
|
* Unified admin API object for convenient access
|
||||||
@ -55,7 +56,8 @@ export const adminAPI = {
|
|||||||
backup: backupAPI,
|
backup: backupAPI,
|
||||||
tlsFingerprintProfiles: tlsFingerprintProfileAPI,
|
tlsFingerprintProfiles: tlsFingerprintProfileAPI,
|
||||||
channels: channelsAPI,
|
channels: channelsAPI,
|
||||||
payment: adminPaymentAPI
|
payment: adminPaymentAPI,
|
||||||
|
windsurf: windsurfAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -82,7 +84,8 @@ export {
|
|||||||
backupAPI,
|
backupAPI,
|
||||||
tlsFingerprintProfileAPI,
|
tlsFingerprintProfileAPI,
|
||||||
channelsAPI,
|
channelsAPI,
|
||||||
adminPaymentAPI
|
adminPaymentAPI,
|
||||||
|
windsurfAPI
|
||||||
}
|
}
|
||||||
|
|
||||||
export default adminAPI
|
export default adminAPI
|
||||||
|
|||||||
@ -412,6 +412,7 @@ export interface SystemSettings {
|
|||||||
fallback_model_openai: string;
|
fallback_model_openai: string;
|
||||||
fallback_model_gemini: string;
|
fallback_model_gemini: string;
|
||||||
fallback_model_antigravity: string;
|
fallback_model_antigravity: string;
|
||||||
|
fallback_model_windsurf: string;
|
||||||
|
|
||||||
// Identity patch configuration (Claude -> Gemini)
|
// Identity patch configuration (Claude -> Gemini)
|
||||||
enable_identity_patch: boolean;
|
enable_identity_patch: boolean;
|
||||||
@ -574,6 +575,7 @@ export interface UpdateSettingsRequest {
|
|||||||
fallback_model_openai?: string;
|
fallback_model_openai?: string;
|
||||||
fallback_model_gemini?: string;
|
fallback_model_gemini?: string;
|
||||||
fallback_model_antigravity?: string;
|
fallback_model_antigravity?: string;
|
||||||
|
fallback_model_windsurf?: string;
|
||||||
enable_identity_patch?: boolean;
|
enable_identity_patch?: boolean;
|
||||||
identity_patch_prompt?: string;
|
identity_patch_prompt?: string;
|
||||||
ops_monitoring_enabled?: boolean;
|
ops_monitoring_enabled?: boolean;
|
||||||
|
|||||||
75
frontend/src/api/admin/windsurf.ts
Normal file
75
frontend/src/api/admin/windsurf.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { apiClient } from '../client'
|
||||||
|
import type {
|
||||||
|
WindsurfLoginRequest,
|
||||||
|
WindsurfLoginResponse,
|
||||||
|
WindsurfBatchLoginRequest,
|
||||||
|
WindsurfBatchLoginResponse,
|
||||||
|
WindsurfRefreshTokenResponse,
|
||||||
|
WindsurfLSStatusResponse,
|
||||||
|
WindsurfRuntimeResponse
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
export async function login(req: WindsurfLoginRequest): Promise<WindsurfLoginResponse> {
|
||||||
|
const { data } = await apiClient.post<WindsurfLoginResponse>('/admin/windsurf/accounts/login', req)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchLogin(req: WindsurfBatchLoginRequest): Promise<WindsurfBatchLoginResponse> {
|
||||||
|
const { data } = await apiClient.post<WindsurfBatchLoginResponse>(
|
||||||
|
'/admin/windsurf/accounts/batch-login',
|
||||||
|
req,
|
||||||
|
{ timeout: 120000 }
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshToken(accountId: number): Promise<WindsurfRefreshTokenResponse> {
|
||||||
|
const { data } = await apiClient.post<WindsurfRefreshTokenResponse>(
|
||||||
|
`/admin/windsurf/accounts/${accountId}/refresh-token`
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function batchRefreshTokens(accountIds: number[]): Promise<{
|
||||||
|
total: number
|
||||||
|
success_count: number
|
||||||
|
fail_count: number
|
||||||
|
}> {
|
||||||
|
const { data } = await apiClient.post<{
|
||||||
|
total: number
|
||||||
|
success_count: number
|
||||||
|
fail_count: number
|
||||||
|
}>('/admin/windsurf/accounts/batch-refresh-tokens', { account_ids: accountIds }, {
|
||||||
|
timeout: 120000
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLSStatus(): Promise<WindsurfLSStatusResponse> {
|
||||||
|
const { data } = await apiClient.get<WindsurfLSStatusResponse>('/admin/windsurf/ls/status')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listModels(): Promise<unknown[]> {
|
||||||
|
const { data } = await apiClient.get<unknown[]>('/admin/windsurf/models')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRuntime(accountId: number): Promise<WindsurfRuntimeResponse> {
|
||||||
|
const { data } = await apiClient.get<WindsurfRuntimeResponse>(
|
||||||
|
`/admin/windsurf/accounts/${accountId}/runtime`
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const windsurfAPI = {
|
||||||
|
login,
|
||||||
|
batchLogin,
|
||||||
|
refreshToken,
|
||||||
|
batchRefreshTokens,
|
||||||
|
getLSStatus,
|
||||||
|
listModels,
|
||||||
|
getRuntime
|
||||||
|
}
|
||||||
|
|
||||||
|
export default windsurfAPI
|
||||||
@ -370,6 +370,39 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Windsurf accounts: show tier + usage -->
|
||||||
|
<div ref="rootRef" v-else-if="account.platform === 'windsurf'">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||||
|
windsurfTierClass
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ windsurfTierLabel }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="windsurfAuthMethod"
|
||||||
|
class="text-[9px] text-gray-400"
|
||||||
|
>
|
||||||
|
{{ windsurfAuthMethod }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="windsurfUsagePercent != null" class="mt-1">
|
||||||
|
<div class="flex items-center justify-between text-[9px] text-gray-500">
|
||||||
|
<span>{{ t('admin.windsurf.usagePercent') }}</span>
|
||||||
|
<span>{{ windsurfUsagePercent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all"
|
||||||
|
:class="windsurfUsagePercent > 80 ? 'bg-red-500' : windsurfUsagePercent > 50 ? 'bg-yellow-500' : 'bg-green-500'"
|
||||||
|
:style="{ width: Math.min(windsurfUsagePercent, 100) + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Non-OAuth/Setup-Token accounts -->
|
<!-- Non-OAuth/Setup-Token accounts -->
|
||||||
<div ref="rootRef" v-else>
|
<div ref="rootRef" v-else>
|
||||||
<!-- Gemini API Key accounts: show quota info -->
|
<!-- Gemini API Key accounts: show quota info -->
|
||||||
@ -749,6 +782,38 @@ const geminiTierClass = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Windsurf computed properties
|
||||||
|
const windsurfExtra = computed(() => {
|
||||||
|
if (props.account.platform !== 'windsurf') return null
|
||||||
|
return props.account.extra as Record<string, unknown> | undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const windsurfTierLabel = computed(() => {
|
||||||
|
const profile = windsurfExtra.value?.profile as Record<string, unknown> | undefined
|
||||||
|
return (profile?.plan_name as string) || (profile?.teams_tier as string) || 'Free'
|
||||||
|
})
|
||||||
|
|
||||||
|
const windsurfTierClass = computed(() => {
|
||||||
|
const tier = (windsurfTierLabel.value || '').toLowerCase()
|
||||||
|
if (tier.includes('pro') || tier.includes('premium')) {
|
||||||
|
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'
|
||||||
|
}
|
||||||
|
if (tier.includes('team') || tier.includes('enterprise')) {
|
||||||
|
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300'
|
||||||
|
}
|
||||||
|
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
})
|
||||||
|
|
||||||
|
const windsurfAuthMethod = computed(() => {
|
||||||
|
const creds = props.account.credentials as Record<string, unknown> | undefined
|
||||||
|
return creds?.auth_method as string | undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const windsurfUsagePercent = computed(() => {
|
||||||
|
const quota = windsurfExtra.value?.quota as Record<string, unknown> | undefined
|
||||||
|
return (quota?.daily_percent as number) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
// Gemini 配额政策信息
|
// Gemini 配额政策信息
|
||||||
const geminiQuotaPolicyChannel = computed(() => {
|
const geminiQuotaPolicyChannel = computed(() => {
|
||||||
if (geminiOAuthType.value === 'google_one') {
|
if (geminiOAuthType.value === 'google_one') {
|
||||||
|
|||||||
@ -147,6 +147,19 @@
|
|||||||
<Icon name="cloud" size="sm" />
|
<Icon name="cloud" size="sm" />
|
||||||
Antigravity
|
Antigravity
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="form.platform = 'windsurf'"
|
||||||
|
:class="[
|
||||||
|
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
|
||||||
|
form.platform === 'windsurf'
|
||||||
|
? 'bg-white text-teal-600 shadow-sm dark:bg-dark-600 dark:text-teal-400'
|
||||||
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<PlatformIcon platform="windsurf" size="xs" />
|
||||||
|
Windsurf
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -703,8 +716,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Windsurf login form -->
|
||||||
|
<div v-if="form.platform === 'windsurf'" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.windsurf.email') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="windsurfForm.email"
|
||||||
|
type="email"
|
||||||
|
class="input"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.windsurf.password') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="windsurfForm.password"
|
||||||
|
type="password"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- LS Instance selector -->
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.windsurf.lsInstance') }}</label>
|
||||||
|
<select v-model="windsurfOpts.ls_instance_id" class="input">
|
||||||
|
<option value="">{{ t('admin.windsurf.lsAutoSelect') }}</option>
|
||||||
|
<option
|
||||||
|
v-for="inst in windsurfLSInstances"
|
||||||
|
:key="inst.container_id"
|
||||||
|
:value="inst.container_id"
|
||||||
|
:disabled="!inst.healthy"
|
||||||
|
>
|
||||||
|
{{ inst.container_name }} ({{ inst.host }}:{{ inst.port }})
|
||||||
|
{{ inst.healthy ? '✓' : '✗' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="windsurfLSLoading" class="text-xs text-gray-400 mt-1">{{ t('common.loading') }}...</p>
|
||||||
|
<p v-else-if="windsurfLSInstances.length === 0" class="text-xs text-gray-400 mt-1">{{ t('admin.windsurf.noLSInstances') }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="windsurfOpts.probe_after"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
{{ t('admin.windsurf.probeAfterLogin') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Upstream config (only for Antigravity upstream type) -->
|
<!-- Upstream config (only for Antigravity upstream type) -->
|
||||||
<div v-if="form.platform === 'antigravity' && antigravityAccountType === 'upstream'" class="space-y-4">
|
<div v-if="form.platform === 'antigravity' && antigravityAccountType === 'upstream'" class="space-y-4">
|
||||||
|
<!-- Upstream type selector: Sub2Api vs NewApi -->
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.accounts.upstream.typeLabel') }}</label>
|
||||||
|
<div class="mt-2 grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="upstreamType = 'sub2api'"
|
||||||
|
:class="[
|
||||||
|
'flex flex-col items-start gap-1 rounded-lg border-2 p-3 text-left transition-all',
|
||||||
|
upstreamType === 'sub2api'
|
||||||
|
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||||
|
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.upstream.typeSub2api') }}</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.upstream.typeSub2apiHint') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="upstreamType = 'newapi'"
|
||||||
|
:class="[
|
||||||
|
'flex flex-col items-start gap-1 rounded-lg border-2 p-3 text-left transition-all',
|
||||||
|
upstreamType === 'newapi'
|
||||||
|
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||||
|
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.upstream.typeNewapi') }}</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.upstream.typeNewapiHint') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.upstream.baseUrl') }}</label>
|
<label class="input-label">{{ t('admin.accounts.upstream.baseUrl') }}</label>
|
||||||
<input
|
<input
|
||||||
@ -712,7 +806,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
class="input"
|
class="input"
|
||||||
placeholder="https://cloudcode-pa.googleapis.com"
|
:placeholder="upstreamType === 'newapi' ? 'https://api.opusclaw.me' : 'https://cloudcode-pa.googleapis.com'"
|
||||||
/>
|
/>
|
||||||
<p class="input-hint">{{ t('admin.accounts.upstream.baseUrlHint') }}</p>
|
<p class="input-hint">{{ t('admin.accounts.upstream.baseUrlHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -727,6 +821,55 @@
|
|||||||
/>
|
/>
|
||||||
<p class="input-hint">{{ t('admin.accounts.upstream.apiKeyHint') }}</p>
|
<p class="input-hint">{{ t('admin.accounts.upstream.apiKeyHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pool Mode Section (Antigravity Upstream) -->
|
||||||
|
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.poolMode') }}</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.accounts.poolModeHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="poolModeEnabled = !poolModeEnabled"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
poolModeEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
poolModeEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="poolModeEnabled" class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||||
|
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||||
|
{{ t('admin.accounts.poolModeInfo') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="poolModeEnabled" class="mt-3">
|
||||||
|
<label class="input-label">{{ t('admin.accounts.poolModeRetryCount') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="poolModeRetryCount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<p class="input-hint">
|
||||||
|
{{
|
||||||
|
t('admin.accounts.poolModeRetryCountHint', {
|
||||||
|
default: DEFAULT_POOL_MODE_RETRY_COUNT
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Antigravity model restriction (applies to OAuth + Upstream) -->
|
<!-- Antigravity model restriction (applies to OAuth + Upstream) -->
|
||||||
@ -2611,9 +2754,13 @@
|
|||||||
{{
|
{{
|
||||||
isOAuthFlow
|
isOAuthFlow
|
||||||
? t('common.next')
|
? t('common.next')
|
||||||
: submitting
|
: form.platform === 'windsurf'
|
||||||
? t('admin.accounts.creating')
|
? submitting
|
||||||
: t('common.create')
|
? t('common.loading')
|
||||||
|
: t('common.confirm')
|
||||||
|
: submitting
|
||||||
|
? t('admin.accounts.creating')
|
||||||
|
: t('common.create')
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -2924,6 +3071,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
|
|||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||||
@ -3093,6 +3241,7 @@ loadQuotaNotifyGlobal()
|
|||||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||||
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
|
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
|
||||||
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
|
const antigravityAccountType = ref<'oauth' | 'upstream'>('oauth') // For antigravity: oauth or upstream
|
||||||
|
const upstreamType = ref<'sub2api' | 'newapi'>('sub2api') // For antigravity upstream: sub2api (auto /antigravity) or newapi (raw)
|
||||||
const upstreamBaseUrl = ref('') // For upstream type: base URL
|
const upstreamBaseUrl = ref('') // For upstream type: base URL
|
||||||
const upstreamApiKey = ref('') // For upstream type: API key
|
const upstreamApiKey = ref('') // For upstream type: API key
|
||||||
const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
const antigravityModelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||||
@ -3101,6 +3250,12 @@ const antigravityModelMappings = ref<ModelMapping[]>([])
|
|||||||
const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity'))
|
const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity'))
|
||||||
const bedrockPresets = computed(() => getPresetMappingsByPlatform('bedrock'))
|
const bedrockPresets = computed(() => getPresetMappingsByPlatform('bedrock'))
|
||||||
|
|
||||||
|
// Windsurf state
|
||||||
|
const windsurfForm = reactive({ email: '', password: '' })
|
||||||
|
const windsurfOpts = reactive({ probe_after: true, ls_instance_id: '' })
|
||||||
|
const windsurfLSInstances = ref<Array<{ container_id: string; container_name: string; host: string; port: number; healthy: boolean }>>([])
|
||||||
|
const windsurfLSLoading = ref(false)
|
||||||
|
|
||||||
// Bedrock credentials
|
// Bedrock credentials
|
||||||
const bedrockAuthMode = ref<'sigv4' | 'apikey'>('sigv4')
|
const bedrockAuthMode = ref<'sigv4' | 'apikey'>('sigv4')
|
||||||
const bedrockAccessKeyId = ref('')
|
const bedrockAccessKeyId = ref('')
|
||||||
@ -3279,6 +3434,10 @@ const form = reactive({
|
|||||||
|
|
||||||
// Helper to check if current type needs OAuth flow
|
// Helper to check if current type needs OAuth flow
|
||||||
const isOAuthFlow = computed(() => {
|
const isOAuthFlow = computed(() => {
|
||||||
|
// Windsurf uses email/password login, not OAuth
|
||||||
|
if (form.platform === 'windsurf') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
// Antigravity upstream 类型不需要 OAuth 流程
|
// Antigravity upstream 类型不需要 OAuth 流程
|
||||||
if (form.platform === 'antigravity' && antigravityAccountType.value === 'upstream') {
|
if (form.platform === 'antigravity' && antigravityAccountType.value === 'upstream') {
|
||||||
return false
|
return false
|
||||||
@ -3489,6 +3648,28 @@ const addModelMapping = () => {
|
|||||||
modelMappings.value.push({ from: '', to: '' })
|
modelMappings.value.push({ from: '', to: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch LS instances when switching to windsurf
|
||||||
|
async function fetchWindsurfLSInstances() {
|
||||||
|
windsurfLSLoading.value = true
|
||||||
|
try {
|
||||||
|
const status = await adminAPI.windsurf.getLSStatus()
|
||||||
|
windsurfLSInstances.value = status.details || []
|
||||||
|
} catch {
|
||||||
|
windsurfLSInstances.value = []
|
||||||
|
} finally {
|
||||||
|
windsurfLSLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.platform,
|
||||||
|
(platform) => {
|
||||||
|
if (platform === 'windsurf') {
|
||||||
|
fetchWindsurfLSInstances()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const removeModelMapping = (index: number) => {
|
const removeModelMapping = (index: number) => {
|
||||||
modelMappings.value.splice(index, 1)
|
modelMappings.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
@ -3823,6 +4004,7 @@ const resetForm = () => {
|
|||||||
customBaseUrl.value = ''
|
customBaseUrl.value = ''
|
||||||
allowOverages.value = false
|
allowOverages.value = false
|
||||||
antigravityAccountType.value = 'oauth'
|
antigravityAccountType.value = 'oauth'
|
||||||
|
upstreamType.value = 'sub2api'
|
||||||
upstreamBaseUrl.value = ''
|
upstreamBaseUrl.value = ''
|
||||||
upstreamApiKey.value = ''
|
upstreamApiKey.value = ''
|
||||||
tempUnschedEnabled.value = false
|
tempUnschedEnabled.value = false
|
||||||
@ -3843,6 +4025,10 @@ const resetForm = () => {
|
|||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
antigravityMixedChannelConfirmed.value = false
|
antigravityMixedChannelConfirmed.value = false
|
||||||
clearMixedChannelDialog()
|
clearMixedChannelDialog()
|
||||||
|
windsurfForm.email = ''
|
||||||
|
windsurfForm.password = ''
|
||||||
|
windsurfOpts.ls_instance_id = ''
|
||||||
|
windsurfLSInstances.value = []
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4036,7 +4222,14 @@ const handleSubmit = async () => {
|
|||||||
// Build upstream credentials (and optional model restriction)
|
// Build upstream credentials (and optional model restriction)
|
||||||
const credentials: Record<string, unknown> = {
|
const credentials: Record<string, unknown> = {
|
||||||
base_url: upstreamBaseUrl.value.trim(),
|
base_url: upstreamBaseUrl.value.trim(),
|
||||||
api_key: upstreamApiKey.value.trim()
|
api_key: upstreamApiKey.value.trim(),
|
||||||
|
upstream_type: upstreamType.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool mode (shared with other apikey flows)
|
||||||
|
if (poolModeEnabled.value) {
|
||||||
|
credentials.pool_mode = true
|
||||||
|
credentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Antigravity 只使用映射模式
|
// Antigravity 只使用映射模式
|
||||||
@ -4056,6 +4249,39 @@ const handleSubmit = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Windsurf, call dedicated login API
|
||||||
|
if (form.platform === 'windsurf') {
|
||||||
|
if (!windsurfForm.email.trim() || !windsurfForm.password.trim()) {
|
||||||
|
appStore.showError(t('admin.windsurf.emailPasswordRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const resp = await adminAPI.windsurf.login({
|
||||||
|
email: windsurfForm.email,
|
||||||
|
password: windsurfForm.password,
|
||||||
|
name: form.name || windsurfForm.email,
|
||||||
|
notes: form.notes || undefined,
|
||||||
|
proxy_id: form.proxy_id,
|
||||||
|
group_ids: form.group_ids.length > 0 ? form.group_ids : undefined,
|
||||||
|
concurrency: form.concurrency,
|
||||||
|
priority: form.priority,
|
||||||
|
probe_after: windsurfOpts.probe_after,
|
||||||
|
ls_instance_id: windsurfOpts.ls_instance_id || undefined
|
||||||
|
})
|
||||||
|
appStore.showSuccess(
|
||||||
|
`${t('admin.windsurf.loginSuccess')} — ${resp.email} (${resp.tier})`
|
||||||
|
)
|
||||||
|
emit('created')
|
||||||
|
handleClose()
|
||||||
|
} catch (e: any) {
|
||||||
|
appStore.showError(e?.response?.data?.message || e?.message || t('admin.windsurf.loginFailed'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// For apikey type, create directly
|
// For apikey type, create directly
|
||||||
if (!apiKeyValue.value.trim()) {
|
if (!apiKeyValue.value.trim()) {
|
||||||
appStore.showError(t('admin.accounts.pleaseEnterApiKey'))
|
appStore.showError(t('admin.accounts.pleaseEnterApiKey'))
|
||||||
|
|||||||
@ -28,6 +28,38 @@
|
|||||||
|
|
||||||
<!-- API Key fields (only for apikey type) -->
|
<!-- API Key fields (only for apikey type) -->
|
||||||
<div v-if="account.type === 'apikey'" class="space-y-4">
|
<div v-if="account.type === 'apikey'" class="space-y-4">
|
||||||
|
<!-- Antigravity upstream type selector: Sub2Api vs NewApi -->
|
||||||
|
<div v-if="account.platform === 'antigravity'">
|
||||||
|
<label class="input-label">{{ t('admin.accounts.upstream.typeLabel') }}</label>
|
||||||
|
<div class="mt-2 grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="editUpstreamType = 'sub2api'"
|
||||||
|
:class="[
|
||||||
|
'flex flex-col items-start gap-1 rounded-lg border-2 p-3 text-left transition-all',
|
||||||
|
editUpstreamType === 'sub2api'
|
||||||
|
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||||
|
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.upstream.typeSub2api') }}</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.upstream.typeSub2apiHint') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="editUpstreamType = 'newapi'"
|
||||||
|
:class="[
|
||||||
|
'flex flex-col items-start gap-1 rounded-lg border-2 p-3 text-left transition-all',
|
||||||
|
editUpstreamType === 'newapi'
|
||||||
|
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||||
|
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.upstream.typeNewapi') }}</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.upstream.typeNewapiHint') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
|
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
|
||||||
<input
|
<input
|
||||||
@ -1922,6 +1954,7 @@ interface TempUnschedRuleForm {
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const editBaseUrl = ref('https://api.anthropic.com')
|
const editBaseUrl = ref('https://api.anthropic.com')
|
||||||
const editApiKey = ref('')
|
const editApiKey = ref('')
|
||||||
|
const editUpstreamType = ref<'sub2api' | 'newapi'>('sub2api') // Antigravity apikey: upstream dialect
|
||||||
// Bedrock credentials
|
// Bedrock credentials
|
||||||
const editBedrockAccessKeyId = ref('')
|
const editBedrockAccessKeyId = ref('')
|
||||||
const editBedrockSecretAccessKey = ref('')
|
const editBedrockSecretAccessKey = ref('')
|
||||||
@ -2291,6 +2324,10 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
|||||||
: 'https://api.anthropic.com'
|
: 'https://api.anthropic.com'
|
||||||
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
|
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
|
||||||
|
|
||||||
|
// Antigravity apikey: load upstream_type (default 'sub2api' for backward compat)
|
||||||
|
const rawUpstreamType = String(credentials.upstream_type ?? '').trim().toLowerCase()
|
||||||
|
editUpstreamType.value = rawUpstreamType === 'newapi' ? 'newapi' : 'sub2api'
|
||||||
|
|
||||||
// Load model mappings and detect mode
|
// Load model mappings and detect mode
|
||||||
const existingMappings = credentials.model_mapping as Record<string, string> | undefined
|
const existingMappings = credentials.model_mapping as Record<string, string> | undefined
|
||||||
if (existingMappings && typeof existingMappings === 'object') {
|
if (existingMappings && typeof existingMappings === 'object') {
|
||||||
@ -2888,6 +2925,11 @@ const handleSubmit = async () => {
|
|||||||
base_url: newBaseUrl
|
base_url: newBaseUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Antigravity apikey: persist upstream_type (sub2api default, newapi skips /antigravity suffix)
|
||||||
|
if (props.account.platform === 'antigravity') {
|
||||||
|
newCredentials.upstream_type = editUpstreamType.value
|
||||||
|
}
|
||||||
|
|
||||||
// Handle API key
|
// Handle API key
|
||||||
if (editApiKey.value.trim()) {
|
if (editApiKey.value.trim()) {
|
||||||
// User provided a new API key
|
// User provided a new API key
|
||||||
|
|||||||
286
frontend/src/components/account/WindsurfLoginModal.vue
Normal file
286
frontend/src/components/account/WindsurfLoginModal.vue
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
<template>
|
||||||
|
<BaseDialog
|
||||||
|
:show="show"
|
||||||
|
:title="t('admin.windsurf.loginTitle')"
|
||||||
|
width="wide"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.windsurf.loginDesc') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Tab: Single / Batch -->
|
||||||
|
<div class="mb-5 flex rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="mode = 'single'"
|
||||||
|
:class="[
|
||||||
|
'flex flex-1 items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-all',
|
||||||
|
mode === 'single'
|
||||||
|
? '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.singleLogin') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="mode = 'batch'"
|
||||||
|
:class="[
|
||||||
|
'flex flex-1 items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-all',
|
||||||
|
mode === 'batch'
|
||||||
|
? '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.batchLogin') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
|
<!-- Single Login -->
|
||||||
|
<template v-if="mode === 'single'">
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.windsurf.email') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="singleForm.email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.windsurf.password') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="singleForm.password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.accounts.accountName') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="singleForm.name"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
:placeholder="singleForm.email || 'Windsurf Account'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Batch Login -->
|
||||||
|
<template v-else>
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.windsurf.batchItems') }}</label>
|
||||||
|
<textarea
|
||||||
|
v-model="batchText"
|
||||||
|
rows="8"
|
||||||
|
required
|
||||||
|
class="input font-mono text-sm"
|
||||||
|
:placeholder="t('admin.windsurf.batchItemsPlaceholder')"
|
||||||
|
></textarea>
|
||||||
|
<p class="input-hint">{{ t('admin.windsurf.batchItemsHint') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Common options -->
|
||||||
|
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
|
||||||
|
<select v-model="commonOpts.proxy_id" class="input">
|
||||||
|
<option :value="null">{{ t('admin.accounts.noProxy') }}</option>
|
||||||
|
<option v-for="p in proxies" :key="p.id" :value="p.id">
|
||||||
|
{{ p.name }} ({{ p.host }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="commonOpts.concurrency"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="input-label">{{ t('admin.accounts.groups') }}</label>
|
||||||
|
<select
|
||||||
|
v-model="commonOpts.group_ids"
|
||||||
|
multiple
|
||||||
|
class="input"
|
||||||
|
style="min-height: 60px"
|
||||||
|
>
|
||||||
|
<option v-for="g in groups" :key="g.id" :value="g.id">
|
||||||
|
{{ g.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="commonOpts.probe_after"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
{{ t('admin.windsurf.probeAfterLogin') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" class="btn btn-secondary" @click="handleClose">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
<span v-if="submitting" class="mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||||
|
{{ submitting ? t('common.loading') : t('common.confirm') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Batch Results -->
|
||||||
|
<div v-if="batchResults.length > 0" class="mt-4 rounded-lg border border-gray-200 dark:border-dark-600">
|
||||||
|
<div class="border-b border-gray-200 px-4 py-2 text-sm font-medium dark:border-dark-600">
|
||||||
|
{{ t('admin.windsurf.batchLogin') }} — {{ batchSuccessCount }}/{{ batchResults.length }}
|
||||||
|
</div>
|
||||||
|
<div class="max-h-60 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="(r, i) in batchResults"
|
||||||
|
:key="i"
|
||||||
|
:class="[
|
||||||
|
'flex items-center justify-between px-4 py-2 text-sm',
|
||||||
|
i % 2 === 0 ? 'bg-gray-50 dark:bg-dark-800' : ''
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ r.email }}</span>
|
||||||
|
<span
|
||||||
|
:class="r.success ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
|
||||||
|
>
|
||||||
|
{{ r.success ? (r.account?.tier || 'OK') : r.error }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { adminAPI } from '@/api/admin'
|
||||||
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
|
import type { Proxy, AdminGroup, WindsurfBatchLoginResult } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
show: boolean
|
||||||
|
proxies: Proxy[]
|
||||||
|
groups: AdminGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
created: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const mode = ref<'single' | 'batch'>('single')
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const singleForm = reactive({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
name: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchText = ref('')
|
||||||
|
|
||||||
|
const commonOpts = reactive({
|
||||||
|
proxy_id: null as number | null,
|
||||||
|
group_ids: [] as number[],
|
||||||
|
concurrency: 1,
|
||||||
|
probe_after: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchResults = ref<WindsurfBatchLoginResult[]>([])
|
||||||
|
const batchSuccessCount = computed(() => batchResults.value.filter(r => r.success).length)
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('close')
|
||||||
|
singleForm.email = ''
|
||||||
|
singleForm.password = ''
|
||||||
|
singleForm.name = ''
|
||||||
|
batchText.value = ''
|
||||||
|
batchResults.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (mode.value === 'single') {
|
||||||
|
await handleSingleLogin()
|
||||||
|
} else {
|
||||||
|
await handleBatchLogin()
|
||||||
|
}
|
||||||
|
emit('created')
|
||||||
|
} catch (e: any) {
|
||||||
|
appStore.showError(e?.response?.data?.message || e?.message || t('admin.windsurf.loginFailed'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSingleLogin() {
|
||||||
|
const resp = await adminAPI.windsurf.login({
|
||||||
|
email: singleForm.email,
|
||||||
|
password: singleForm.password,
|
||||||
|
name: singleForm.name || singleForm.email,
|
||||||
|
proxy_id: commonOpts.proxy_id,
|
||||||
|
group_ids: commonOpts.group_ids.length > 0 ? commonOpts.group_ids : undefined,
|
||||||
|
concurrency: commonOpts.concurrency,
|
||||||
|
probe_after: commonOpts.probe_after
|
||||||
|
})
|
||||||
|
appStore.showSuccess(
|
||||||
|
`${t('admin.windsurf.loginSuccess')} — ${resp.email} (${resp.tier})`
|
||||||
|
)
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchLogin() {
|
||||||
|
const items = batchText.value
|
||||||
|
.split('\n')
|
||||||
|
.map(l => l.trim())
|
||||||
|
.filter(l => l.length > 0 && l.includes('----'))
|
||||||
|
|
||||||
|
if (items.length === 0) return
|
||||||
|
|
||||||
|
const resp = await adminAPI.windsurf.batchLogin({
|
||||||
|
items,
|
||||||
|
proxy_id: commonOpts.proxy_id,
|
||||||
|
group_ids: commonOpts.group_ids.length > 0 ? commonOpts.group_ids : undefined,
|
||||||
|
concurrency: commonOpts.concurrency,
|
||||||
|
probe_after: commonOpts.probe_after
|
||||||
|
})
|
||||||
|
|
||||||
|
batchResults.value = resp.results
|
||||||
|
appStore.showInfo(
|
||||||
|
t('admin.windsurf.batchLoginSuccess', {
|
||||||
|
success: resp.success_count,
|
||||||
|
fail: resp.fail_count
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -11,3 +11,4 @@ export { default as AccountTestModal } from './AccountTestModal.vue'
|
|||||||
export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue'
|
export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue'
|
||||||
export { default as TempUnschedStatusModal } from './TempUnschedStatusModal.vue'
|
export { default as TempUnschedStatusModal } from './TempUnschedStatusModal.vue'
|
||||||
export { default as SyncFromCrsModal } from './SyncFromCrsModal.vue'
|
export { default as SyncFromCrsModal } from './SyncFromCrsModal.vue'
|
||||||
|
export { default as WindsurfLoginModal } from './WindsurfLoginModal.vue'
|
||||||
|
|||||||
@ -489,7 +489,8 @@ const platformOptions = [
|
|||||||
{ value: 'anthropic', label: 'Anthropic' },
|
{ value: 'anthropic', label: 'Anthropic' },
|
||||||
{ value: 'openai', label: 'OpenAI' },
|
{ value: 'openai', label: 'OpenAI' },
|
||||||
{ value: 'gemini', label: 'Gemini' },
|
{ value: 'gemini', label: 'Gemini' },
|
||||||
{ value: 'antigravity', label: 'Antigravity' }
|
{ value: 'antigravity', label: 'Antigravity' },
|
||||||
|
{ value: 'windsurf', label: 'Windsurf' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// Load rules when dialog opens
|
// Load rules when dialog opens
|
||||||
|
|||||||
@ -25,8 +25,8 @@ const updateType = (value: string | number | boolean | null) => { emit('update:f
|
|||||||
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
|
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
|
||||||
const updatePrivacyMode = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, privacy_mode: value }) }
|
const updatePrivacyMode = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, privacy_mode: value }) }
|
||||||
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
|
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
|
||||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
|
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'windsurf', label: 'Windsurf' }])
|
||||||
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }])
|
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }, { value: 'windsurf-session', label: 'Windsurf Session' }])
|
||||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }, { value: 'unschedulable', label: t('admin.accounts.status.unschedulable') }])
|
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }, { value: 'unschedulable', label: t('admin.accounts.status.unschedulable') }])
|
||||||
const privacyOpts = computed(() => [
|
const privacyOpts = computed(() => [
|
||||||
{ value: '', label: t('admin.accounts.allPrivacyModes') },
|
{ value: '', label: t('admin.accounts.allPrivacyModes') },
|
||||||
|
|||||||
@ -184,6 +184,7 @@ export function getPlatformTagClass(platform: string): string {
|
|||||||
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||||
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||||
|
case 'windsurf': return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
|
||||||
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
|
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -284,6 +284,7 @@ const platformColorClass = computed(() => {
|
|||||||
case 'anthropic': return 'text-orange-700 dark:text-orange-400'
|
case 'anthropic': return 'text-orange-700 dark:text-orange-400'
|
||||||
case 'openai': return 'text-emerald-700 dark:text-emerald-400'
|
case 'openai': return 'text-emerald-700 dark:text-emerald-400'
|
||||||
case 'antigravity': return 'text-purple-700 dark:text-purple-400'
|
case 'antigravity': return 'text-purple-700 dark:text-purple-400'
|
||||||
|
case 'windsurf': return 'text-teal-700 dark:text-teal-400'
|
||||||
default: return 'text-blue-700 dark:text-blue-400'
|
default: return 'text-blue-700 dark:text-blue-400'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -91,6 +91,8 @@ const ratePillClass = computed(() => {
|
|||||||
return 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
return 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400'
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
return 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
|
return 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
|
||||||
|
case 'windsurf':
|
||||||
|
return 'bg-teal-50 text-teal-700 dark:bg-teal-900/20 dark:text-teal-400'
|
||||||
default: // antigravity and others
|
default: // antigravity and others
|
||||||
return 'bg-violet-50 text-violet-700 dark:bg-violet-900/20 dark:text-violet-400'
|
return 'bg-violet-50 text-violet-700 dark:bg-violet-900/20 dark:text-violet-400'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,12 @@
|
|||||||
<svg v-else-if="platform === 'antigravity'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
<svg v-else-if="platform === 'antigravity'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
|
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
<!-- Windsurf logo (wave/wind) -->
|
||||||
|
<svg v-else-if="platform === 'windsurf'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 13.5C5.33 10.83 8.67 9 12 9s6.67 1.83 9 4.5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<path d="M3 17.5C5.33 14.83 8.67 13 12 13s6.67 1.83 9 4.5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||||
|
<path d="M12 3L10 9h4L12 3z"/>
|
||||||
|
</svg>
|
||||||
<!-- Fallback: generic platform icon -->
|
<!-- Fallback: generic platform icon -->
|
||||||
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24">
|
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@ -29,8 +29,8 @@
|
|||||||
<span>{{ typeLabel }}</span>
|
<span>{{ typeLabel }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Row 2: Plan type + Privacy mode (only if either exists) -->
|
<!-- Row 2: Plan type + Privacy mode + Enterprise/Personal (only if any exists) -->
|
||||||
<div v-if="planLabel || privacyBadge" class="inline-flex items-center overflow-hidden rounded-md">
|
<div v-if="planLabel || privacyBadge || accountKindBadge" class="inline-flex items-center overflow-hidden rounded-md">
|
||||||
<span v-if="planLabel" :class="['inline-flex items-center gap-1 px-1.5 py-1', planBadgeClass]">
|
<span v-if="planLabel" :class="['inline-flex items-center gap-1 px-1.5 py-1', planBadgeClass]">
|
||||||
<span>{{ planLabel }}</span>
|
<span>{{ planLabel }}</span>
|
||||||
</span>
|
</span>
|
||||||
@ -44,6 +44,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{{ privacyBadge.label }}</span>
|
<span>{{ privacyBadge.label }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="accountKindBadge"
|
||||||
|
:class="['inline-flex items-center gap-1 px-1.5 py-1', accountKindBadge.class]"
|
||||||
|
:title="accountKindBadge.title"
|
||||||
|
>
|
||||||
|
<span>{{ accountKindBadge.label }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Row 3: Subscription expiration (non-free paid accounts only) -->
|
<!-- Row 3: Subscription expiration (non-free paid accounts only) -->
|
||||||
<div v-if="expiresLabel" class="text-[10px] leading-tight text-gray-400 dark:text-gray-500 pl-0.5" :title="subscriptionExpiresAt">
|
<div v-if="expiresLabel" class="text-[10px] leading-tight text-gray-400 dark:text-gray-500 pl-0.5" :title="subscriptionExpiresAt">
|
||||||
@ -67,6 +74,7 @@ interface Props {
|
|||||||
planType?: string
|
planType?: string
|
||||||
privacyMode?: string
|
privacyMode?: string
|
||||||
subscriptionExpiresAt?: string
|
subscriptionExpiresAt?: string
|
||||||
|
isEnterprise?: boolean | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@ -75,6 +83,7 @@ const platformLabel = computed(() => {
|
|||||||
if (props.platform === 'anthropic') return 'Anthropic'
|
if (props.platform === 'anthropic') return 'Anthropic'
|
||||||
if (props.platform === 'openai') return 'OpenAI'
|
if (props.platform === 'openai') return 'OpenAI'
|
||||||
if (props.platform === 'antigravity') return 'Antigravity'
|
if (props.platform === 'antigravity') return 'Antigravity'
|
||||||
|
if (props.platform === 'windsurf') return 'Windsurf'
|
||||||
return 'Gemini'
|
return 'Gemini'
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -88,6 +97,8 @@ const typeLabel = computed(() => {
|
|||||||
return 'Key'
|
return 'Key'
|
||||||
case 'bedrock':
|
case 'bedrock':
|
||||||
return 'AWS'
|
return 'AWS'
|
||||||
|
case 'windsurf-session':
|
||||||
|
return 'Session'
|
||||||
default:
|
default:
|
||||||
return props.type
|
return props.type
|
||||||
}
|
}
|
||||||
@ -123,6 +134,9 @@ const platformClass = computed(() => {
|
|||||||
if (props.platform === 'antigravity') {
|
if (props.platform === 'antigravity') {
|
||||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||||
}
|
}
|
||||||
|
if (props.platform === 'windsurf') {
|
||||||
|
return 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'
|
||||||
|
}
|
||||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -136,6 +150,9 @@ const typeClass = computed(() => {
|
|||||||
if (props.platform === 'antigravity') {
|
if (props.platform === 'antigravity') {
|
||||||
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400'
|
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400'
|
||||||
}
|
}
|
||||||
|
if (props.platform === 'windsurf') {
|
||||||
|
return 'bg-cyan-100 text-cyan-600 dark:bg-cyan-900/30 dark:text-cyan-400'
|
||||||
|
}
|
||||||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -187,4 +204,24 @@ const privacyBadge = computed(() => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 个人/企业账号类型(仅 Antigravity OAuth 账号展示)
|
||||||
|
const accountKindBadge = computed(() => {
|
||||||
|
if (props.platform !== 'antigravity' || props.type !== 'oauth') return null
|
||||||
|
const raw = props.isEnterprise
|
||||||
|
if (raw === undefined || raw === null) return null
|
||||||
|
const isEnterprise = typeof raw === 'string' ? raw.toLowerCase() === 'true' : Boolean(raw)
|
||||||
|
if (isEnterprise) {
|
||||||
|
return {
|
||||||
|
label: t('admin.accounts.antigravityKind.enterprise'),
|
||||||
|
title: t('admin.accounts.antigravityKind.enterpriseTitle'),
|
||||||
|
class: 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: t('admin.accounts.antigravityKind.personal'),
|
||||||
|
title: t('admin.accounts.antigravityKind.personalTitle'),
|
||||||
|
class: 'bg-slate-100 text-slate-600 dark:bg-slate-800/40 dark:text-slate-300'
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -185,6 +185,8 @@ const defaultClientTab = computed(() => {
|
|||||||
return 'gemini'
|
return 'gemini'
|
||||||
case 'antigravity':
|
case 'antigravity':
|
||||||
return 'claude'
|
return 'claude'
|
||||||
|
case 'windsurf':
|
||||||
|
return 'claude'
|
||||||
default:
|
default:
|
||||||
return 'claude'
|
return 'claude'
|
||||||
}
|
}
|
||||||
@ -288,6 +290,11 @@ const clientTabs = computed((): TabConfig[] => {
|
|||||||
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
|
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
|
||||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||||
]
|
]
|
||||||
|
case 'windsurf':
|
||||||
|
return [
|
||||||
|
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
|
||||||
|
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||||
|
]
|
||||||
default:
|
default:
|
||||||
return [
|
return [
|
||||||
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
|
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
|
||||||
@ -330,6 +337,8 @@ const platformDescription = computed(() => {
|
|||||||
return t('keys.useKeyModal.gemini.description')
|
return t('keys.useKeyModal.gemini.description')
|
||||||
case 'antigravity':
|
case 'antigravity':
|
||||||
return t('keys.useKeyModal.antigravity.description')
|
return t('keys.useKeyModal.antigravity.description')
|
||||||
|
case 'windsurf':
|
||||||
|
return 'Windsurf 平台 API 端点配置'
|
||||||
default:
|
default:
|
||||||
return t('keys.useKeyModal.description')
|
return t('keys.useKeyModal.description')
|
||||||
}
|
}
|
||||||
@ -350,6 +359,8 @@ const platformNote = computed(() => {
|
|||||||
return activeClientTab.value === 'claude'
|
return activeClientTab.value === 'claude'
|
||||||
? t('keys.useKeyModal.antigravity.claudeNote')
|
? t('keys.useKeyModal.antigravity.claudeNote')
|
||||||
: t('keys.useKeyModal.antigravity.geminiNote')
|
: t('keys.useKeyModal.antigravity.geminiNote')
|
||||||
|
case 'windsurf':
|
||||||
|
return 'Windsurf 端点使用 /windsurf 路径前缀'
|
||||||
default:
|
default:
|
||||||
return t('keys.useKeyModal.note')
|
return t('keys.useKeyModal.note')
|
||||||
}
|
}
|
||||||
@ -385,6 +396,7 @@ const currentFiles = computed((): FileConfig[] => {
|
|||||||
}
|
}
|
||||||
const apiBase = ensureV1(baseRoot)
|
const apiBase = ensureV1(baseRoot)
|
||||||
const antigravityBase = ensureV1(`${baseRoot}/antigravity`)
|
const antigravityBase = ensureV1(`${baseRoot}/antigravity`)
|
||||||
|
const windsurfBase = ensureV1(`${baseRoot}/windsurf`)
|
||||||
const antigravityGeminiBase = (() => {
|
const antigravityGeminiBase = (() => {
|
||||||
const trimmed = `${baseRoot}/antigravity`.replace(/\/+$/, '')
|
const trimmed = `${baseRoot}/antigravity`.replace(/\/+$/, '')
|
||||||
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
|
return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta`
|
||||||
@ -407,6 +419,8 @@ const currentFiles = computed((): FileConfig[] => {
|
|||||||
generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'),
|
generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'),
|
||||||
generateOpenCodeConfig('antigravity-gemini', antigravityGeminiBase, apiKey, 'opencode.json (Gemini)')
|
generateOpenCodeConfig('antigravity-gemini', antigravityGeminiBase, apiKey, 'opencode.json (Gemini)')
|
||||||
]
|
]
|
||||||
|
case 'windsurf':
|
||||||
|
return [generateOpenCodeConfig('windsurf', windsurfBase, apiKey)]
|
||||||
default:
|
default:
|
||||||
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
|
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
|
||||||
}
|
}
|
||||||
@ -428,6 +442,8 @@ const currentFiles = computed((): FileConfig[] => {
|
|||||||
return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)]
|
return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)]
|
||||||
}
|
}
|
||||||
return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
|
return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
|
||||||
|
case 'windsurf':
|
||||||
|
return generateAnthropicFiles(`${baseUrl}/windsurf`, apiKey)
|
||||||
default:
|
default:
|
||||||
return generateAnthropicFiles(baseUrl, apiKey)
|
return generateAnthropicFiles(baseUrl, apiKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,6 +86,31 @@ const antigravityModels = [
|
|||||||
'tab_flash_lite_preview'
|
'tab_flash_lite_preview'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Windsurf 官方支持的模型
|
||||||
|
const windsurfModels = [
|
||||||
|
'claude-sonnet-4-6',
|
||||||
|
'claude-sonnet-4-5',
|
||||||
|
'claude-sonnet-4-5-thinking',
|
||||||
|
'claude-3.5-sonnet',
|
||||||
|
'claude-3.5-haiku',
|
||||||
|
'gpt-4.1',
|
||||||
|
'gpt-4.1-mini',
|
||||||
|
'gpt-4.1-nano',
|
||||||
|
'gpt-4o',
|
||||||
|
'gpt-4o-mini',
|
||||||
|
'o3',
|
||||||
|
'o3-mini',
|
||||||
|
'o4-mini',
|
||||||
|
'gemini-2.5-pro',
|
||||||
|
'gemini-2.5-flash',
|
||||||
|
'gemini-2.0-flash',
|
||||||
|
'deepseek-v3',
|
||||||
|
'deepseek-r1',
|
||||||
|
'grok-3',
|
||||||
|
'grok-3-mini',
|
||||||
|
'windsurf-swe-1',
|
||||||
|
]
|
||||||
|
|
||||||
// 智谱 GLM
|
// 智谱 GLM
|
||||||
const zhipuModels = [
|
const zhipuModels = [
|
||||||
'glm-4', 'glm-4v', 'glm-4-plus', 'glm-4-0520',
|
'glm-4', 'glm-4v', 'glm-4-plus', 'glm-4-0520',
|
||||||
@ -307,6 +332,9 @@ const antigravityPresetMappings = [
|
|||||||
{ label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'claude-opus-4-7', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }
|
{ label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'claude-opus-4-7', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Windsurf 预设映射
|
||||||
|
const windsurfPresetMappings: { label: string; from: string; to: string; color: string }[] = []
|
||||||
|
|
||||||
// Bedrock 预设映射(与后端 DefaultBedrockModelMapping 保持一致)
|
// Bedrock 预设映射(与后端 DefaultBedrockModelMapping 保持一致)
|
||||||
const bedrockPresetMappings = [
|
const bedrockPresetMappings = [
|
||||||
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'us.anthropic.claude-opus-4-6-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'us.anthropic.claude-opus-4-6-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
|
||||||
@ -363,6 +391,7 @@ export function getModelsByPlatform(platform: string): string[] {
|
|||||||
case 'claude': return claudeModels
|
case 'claude': return claudeModels
|
||||||
case 'gemini': return geminiModels
|
case 'gemini': return geminiModels
|
||||||
case 'antigravity': return antigravityModels
|
case 'antigravity': return antigravityModels
|
||||||
|
case 'windsurf': return windsurfModels
|
||||||
case 'zhipu': return zhipuModels
|
case 'zhipu': return zhipuModels
|
||||||
case 'qwen': return qwenModels
|
case 'qwen': return qwenModels
|
||||||
case 'deepseek': return deepseekModels
|
case 'deepseek': return deepseekModels
|
||||||
@ -387,6 +416,7 @@ export function getPresetMappingsByPlatform(platform: string) {
|
|||||||
if (platform === 'openai') return openaiPresetMappings
|
if (platform === 'openai') return openaiPresetMappings
|
||||||
if (platform === 'gemini') return geminiPresetMappings
|
if (platform === 'gemini') return geminiPresetMappings
|
||||||
if (platform === 'antigravity') return antigravityPresetMappings
|
if (platform === 'antigravity') return antigravityPresetMappings
|
||||||
|
if (platform === 'windsurf') return windsurfPresetMappings
|
||||||
if (platform === 'bedrock') return bedrockPresetMappings
|
if (platform === 'bedrock') return bedrockPresetMappings
|
||||||
return anthropicPresetMappings
|
return anthropicPresetMappings
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,6 +97,7 @@ export default {
|
|||||||
claude: 'Claude',
|
claude: 'Claude',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
antigravity: 'Antigravity',
|
antigravity: 'Antigravity',
|
||||||
|
windsurf: 'Windsurf',
|
||||||
more: 'More'
|
more: 'More'
|
||||||
},
|
},
|
||||||
// CTA section
|
// CTA section
|
||||||
@ -1785,6 +1786,7 @@ export default {
|
|||||||
openai: 'OpenAI',
|
openai: 'OpenAI',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
antigravity: 'Antigravity',
|
antigravity: 'Antigravity',
|
||||||
|
windsurf: 'Windsurf',
|
||||||
},
|
},
|
||||||
deleteConfirm:
|
deleteConfirm:
|
||||||
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
|
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
|
||||||
@ -2218,6 +2220,7 @@ export default {
|
|||||||
openai: 'OpenAI',
|
openai: 'OpenAI',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
antigravity: 'Antigravity',
|
antigravity: 'Antigravity',
|
||||||
|
windsurf: 'Windsurf',
|
||||||
},
|
},
|
||||||
types: {
|
types: {
|
||||||
oauth: 'OAuth',
|
oauth: 'OAuth',
|
||||||
@ -2228,7 +2231,8 @@ export default {
|
|||||||
antigravityOauth: 'Antigravity OAuth',
|
antigravityOauth: 'Antigravity OAuth',
|
||||||
antigravityApikey: 'Connect via Base URL + API Key',
|
antigravityApikey: 'Connect via Base URL + API Key',
|
||||||
upstream: 'Upstream',
|
upstream: 'Upstream',
|
||||||
upstreamDesc: 'Connect via Base URL + API Key'
|
upstreamDesc: 'Connect via Base URL + API Key',
|
||||||
|
windsurfSession: 'Windsurf Session'
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
active: 'Active',
|
active: 'Active',
|
||||||
@ -2281,6 +2285,12 @@ export default {
|
|||||||
setPrivacy: 'Set Privacy',
|
setPrivacy: 'Set Privacy',
|
||||||
subscriptionAbnormal: 'Abnormal',
|
subscriptionAbnormal: 'Abnormal',
|
||||||
subscriptionExpires: 'Expires',
|
subscriptionExpires: 'Expires',
|
||||||
|
antigravityKind: {
|
||||||
|
personal: 'Personal',
|
||||||
|
personalTitle: 'Personal account (isGcpTos=false)',
|
||||||
|
enterprise: 'Enterprise',
|
||||||
|
enterpriseTitle: 'Enterprise account (isGcpTos=true, GCP / Workspace)'
|
||||||
|
},
|
||||||
// Capacity status tooltips
|
// Capacity status tooltips
|
||||||
capacity: {
|
capacity: {
|
||||||
windowCost: {
|
windowCost: {
|
||||||
@ -2703,7 +2713,12 @@ export default {
|
|||||||
apiKey: 'Upstream API Key',
|
apiKey: 'Upstream API Key',
|
||||||
apiKeyHint: 'API Key for the upstream service',
|
apiKeyHint: 'API Key for the upstream service',
|
||||||
pleaseEnterBaseUrl: 'Please enter upstream Base URL',
|
pleaseEnterBaseUrl: 'Please enter upstream Base URL',
|
||||||
pleaseEnterApiKey: 'Please enter upstream API Key'
|
pleaseEnterApiKey: 'Please enter upstream API Key',
|
||||||
|
typeLabel: 'Upstream Type',
|
||||||
|
typeSub2api: 'Sub2Api',
|
||||||
|
typeSub2apiHint: 'Connect to another Sub2Api instance (auto-appends /antigravity)',
|
||||||
|
typeNewapi: 'NewApi',
|
||||||
|
typeNewapiHint: 'Connect to a NewApi / One-Api style relay (uses /v1/messages directly)'
|
||||||
},
|
},
|
||||||
// OAuth flow
|
// OAuth flow
|
||||||
oauth: {
|
oauth: {
|
||||||
@ -5250,7 +5265,40 @@ export default {
|
|||||||
loadFailed: 'Failed to load profiles',
|
loadFailed: 'Failed to load profiles',
|
||||||
saveFailed: 'Failed to save profile',
|
saveFailed: 'Failed to save profile',
|
||||||
deleteFailed: 'Failed to delete profile'
|
deleteFailed: 'Failed to delete profile'
|
||||||
}
|
},
|
||||||
|
windsurf: {
|
||||||
|
loginTitle: 'Windsurf Account Login',
|
||||||
|
loginDesc: 'Login with email and password for Windsurf',
|
||||||
|
singleLogin: 'Single Login',
|
||||||
|
batchLogin: 'Batch Login',
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password',
|
||||||
|
batchItems: 'Batch Accounts',
|
||||||
|
batchItemsHint: 'One per line, format: email----password',
|
||||||
|
batchItemsPlaceholder: 'user1@example.com----password1\nuser2@example.com----password2',
|
||||||
|
probeAfterLogin: 'Probe after login',
|
||||||
|
loginSuccess: 'Windsurf login successful',
|
||||||
|
loginFailed: 'Windsurf login failed',
|
||||||
|
emailPasswordRequired: 'Email and password are required',
|
||||||
|
lsInstance: 'Bind LS Instance',
|
||||||
|
lsAutoSelect: 'Auto select (round-robin)',
|
||||||
|
noLSInstances: 'No LS instances found',
|
||||||
|
batchLoginSuccess: 'Batch login done: {success} succeeded, {fail} failed',
|
||||||
|
refreshToken: 'Refresh Token',
|
||||||
|
refreshTokenSuccess: 'Token refreshed',
|
||||||
|
refreshTokenFailed: 'Token refresh failed',
|
||||||
|
batchRefreshSuccess: 'Batch refresh done: {success} succeeded, {fail} failed',
|
||||||
|
lsStatus: 'LS Service Status',
|
||||||
|
lsMode: 'Mode',
|
||||||
|
lsHealthy: 'Healthy',
|
||||||
|
lsUnhealthy: 'Unhealthy',
|
||||||
|
lsInstances: 'Instances',
|
||||||
|
tier: 'Tier',
|
||||||
|
authMethod: 'Auth Method',
|
||||||
|
models: 'Models',
|
||||||
|
usagePercent: 'Usage',
|
||||||
|
noWindsurfAccounts: 'No Windsurf accounts',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Subscription Progress (Header component)
|
// Subscription Progress (Header component)
|
||||||
|
|||||||
@ -97,6 +97,7 @@ export default {
|
|||||||
claude: 'Claude',
|
claude: 'Claude',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
antigravity: 'Antigravity',
|
antigravity: 'Antigravity',
|
||||||
|
windsurf: 'Windsurf',
|
||||||
more: '更多'
|
more: '更多'
|
||||||
},
|
},
|
||||||
// CTA 区块
|
// CTA 区块
|
||||||
@ -1821,6 +1822,7 @@ export default {
|
|||||||
openai: 'OpenAI',
|
openai: 'OpenAI',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
antigravity: 'Antigravity',
|
antigravity: 'Antigravity',
|
||||||
|
windsurf: 'Windsurf',
|
||||||
},
|
},
|
||||||
saving: '保存中...',
|
saving: '保存中...',
|
||||||
noGroups: '暂无分组',
|
noGroups: '暂无分组',
|
||||||
@ -2321,6 +2323,12 @@ export default {
|
|||||||
setPrivacy: '设置隐私',
|
setPrivacy: '设置隐私',
|
||||||
subscriptionAbnormal: '异常',
|
subscriptionAbnormal: '异常',
|
||||||
subscriptionExpires: '到期',
|
subscriptionExpires: '到期',
|
||||||
|
antigravityKind: {
|
||||||
|
personal: '个人',
|
||||||
|
personalTitle: '个人账号(isGcpTos=false)',
|
||||||
|
enterprise: '企业',
|
||||||
|
enterpriseTitle: '企业账号(isGcpTos=true,GCP / Workspace)'
|
||||||
|
},
|
||||||
// 容量状态提示
|
// 容量状态提示
|
||||||
capacity: {
|
capacity: {
|
||||||
windowCost: {
|
windowCost: {
|
||||||
@ -2405,6 +2413,7 @@ export default {
|
|||||||
anthropic: 'Anthropic',
|
anthropic: 'Anthropic',
|
||||||
gemini: 'Gemini',
|
gemini: 'Gemini',
|
||||||
antigravity: 'Antigravity',
|
antigravity: 'Antigravity',
|
||||||
|
windsurf: 'Windsurf',
|
||||||
},
|
},
|
||||||
types: {
|
types: {
|
||||||
oauth: 'OAuth',
|
oauth: 'OAuth',
|
||||||
@ -2417,7 +2426,8 @@ export default {
|
|||||||
upstream: '对接上游',
|
upstream: '对接上游',
|
||||||
upstreamDesc: '通过 Base URL + API Key 连接上游',
|
upstreamDesc: '通过 Base URL + API Key 连接上游',
|
||||||
api_key: 'API Key',
|
api_key: 'API Key',
|
||||||
cookie: 'Cookie'
|
cookie: 'Cookie',
|
||||||
|
windsurfSession: 'Windsurf 会话'
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
active: '正常',
|
active: '正常',
|
||||||
@ -2846,7 +2856,12 @@ export default {
|
|||||||
apiKey: '上游 API Key',
|
apiKey: '上游 API Key',
|
||||||
apiKeyHint: '上游服务的 API Key',
|
apiKeyHint: '上游服务的 API Key',
|
||||||
pleaseEnterBaseUrl: '请输入上游 Base URL',
|
pleaseEnterBaseUrl: '请输入上游 Base URL',
|
||||||
pleaseEnterApiKey: '请输入上游 API Key'
|
pleaseEnterApiKey: '请输入上游 API Key',
|
||||||
|
typeLabel: '上游类型',
|
||||||
|
typeSub2api: 'Sub2Api',
|
||||||
|
typeSub2apiHint: '对接另一个 Sub2Api 实例,自动拼接 /antigravity 路径',
|
||||||
|
typeNewapi: 'NewApi',
|
||||||
|
typeNewapiHint: '对接 NewApi / One-Api 风格中转,直接使用 /v1/messages'
|
||||||
},
|
},
|
||||||
// OAuth flow
|
// OAuth flow
|
||||||
oauth: {
|
oauth: {
|
||||||
@ -5413,7 +5428,40 @@ export default {
|
|||||||
loadFailed: '加载模板失败',
|
loadFailed: '加载模板失败',
|
||||||
saveFailed: '保存模板失败',
|
saveFailed: '保存模板失败',
|
||||||
deleteFailed: '删除模板失败'
|
deleteFailed: '删除模板失败'
|
||||||
}
|
},
|
||||||
|
windsurf: {
|
||||||
|
loginTitle: 'Windsurf 账号登录',
|
||||||
|
loginDesc: '使用邮箱和密码登录 Windsurf 账号',
|
||||||
|
singleLogin: '单个登录',
|
||||||
|
batchLogin: '批量登录',
|
||||||
|
email: '邮箱',
|
||||||
|
password: '密码',
|
||||||
|
batchItems: '批量账号',
|
||||||
|
batchItemsHint: '每行一个,格式:email----password',
|
||||||
|
batchItemsPlaceholder: 'user1@example.com----password1\nuser2@example.com----password2',
|
||||||
|
probeAfterLogin: '登录后自动探测',
|
||||||
|
loginSuccess: 'Windsurf 登录成功',
|
||||||
|
loginFailed: 'Windsurf 登录失败',
|
||||||
|
emailPasswordRequired: '请输入邮箱和密码',
|
||||||
|
lsInstance: '绑定 LS 实例',
|
||||||
|
lsAutoSelect: '自动选择(轮询)',
|
||||||
|
noLSInstances: '未发现可用的 LS 实例',
|
||||||
|
batchLoginSuccess: '批量登录完成:{success} 成功,{fail} 失败',
|
||||||
|
refreshToken: '刷新令牌',
|
||||||
|
refreshTokenSuccess: '令牌刷新成功',
|
||||||
|
refreshTokenFailed: '令牌刷新失败',
|
||||||
|
batchRefreshSuccess: '批量刷新完成:{success} 成功,{fail} 失败',
|
||||||
|
lsStatus: 'LS 服务状态',
|
||||||
|
lsMode: '模式',
|
||||||
|
lsHealthy: '健康',
|
||||||
|
lsUnhealthy: '不健康',
|
||||||
|
lsInstances: '实例数',
|
||||||
|
tier: '等级',
|
||||||
|
authMethod: '认证方式',
|
||||||
|
models: '模型列表',
|
||||||
|
usagePercent: '使用率',
|
||||||
|
noWindsurfAccounts: '暂无 Windsurf 账号',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Subscription Progress (Header component)
|
// Subscription Progress (Header component)
|
||||||
|
|||||||
@ -436,7 +436,7 @@ export interface PaginationConfig {
|
|||||||
|
|
||||||
// ==================== API Key & Group Types ====================
|
// ==================== API Key & Group Types ====================
|
||||||
|
|
||||||
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'windsurf'
|
||||||
|
|
||||||
export type SubscriptionType = 'standard' | 'subscription'
|
export type SubscriptionType = 'standard' | 'subscription'
|
||||||
|
|
||||||
@ -609,8 +609,8 @@ export interface UpdateGroupRequest {
|
|||||||
|
|
||||||
// ==================== Account & Proxy Types ====================
|
// ==================== Account & Proxy Types ====================
|
||||||
|
|
||||||
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
|
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'windsurf'
|
||||||
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock'
|
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock' | 'windsurf-session'
|
||||||
export type OAuthAddMethod = 'oauth' | 'setup-token'
|
export type OAuthAddMethod = 'oauth' | 'setup-token'
|
||||||
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'
|
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'
|
||||||
|
|
||||||
@ -711,6 +711,164 @@ export interface GeminiCredentials {
|
|||||||
model_mapping?: Record<string, string>
|
model_mapping?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Windsurf credentials structure (matches backend WindsurfCredentials JSON tags)
|
||||||
|
export interface WindsurfCredentials {
|
||||||
|
email?: string
|
||||||
|
api_key?: string
|
||||||
|
refresh_token?: string
|
||||||
|
id_token?: string
|
||||||
|
session_token?: string
|
||||||
|
auth1_token?: string
|
||||||
|
api_server_url?: string
|
||||||
|
auth_method?: string
|
||||||
|
tier?: string
|
||||||
|
expires_at?: string
|
||||||
|
registered_at?: string
|
||||||
|
last_refresh_at?: string
|
||||||
|
last_reregister_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windsurf account extra fields (matches backend WindsurfExtra JSON structure)
|
||||||
|
export interface WindsurfAccountExtra {
|
||||||
|
profile?: {
|
||||||
|
user_id?: string
|
||||||
|
display_name?: string
|
||||||
|
plan_name?: string
|
||||||
|
teams_tier?: string
|
||||||
|
tier_source?: string
|
||||||
|
is_teams?: boolean
|
||||||
|
is_enterprise?: boolean
|
||||||
|
}
|
||||||
|
user_status?: {
|
||||||
|
monthly_prompt_credits?: number
|
||||||
|
monthly_flow_credits?: number
|
||||||
|
user_used_prompt_credits?: number
|
||||||
|
user_used_flow_credits?: number
|
||||||
|
max_premium_chat_messages?: number
|
||||||
|
last_fetched_at?: string
|
||||||
|
}
|
||||||
|
quota?: {
|
||||||
|
daily_percent?: number
|
||||||
|
weekly_percent?: number
|
||||||
|
prompt_used?: number
|
||||||
|
prompt_limit?: number
|
||||||
|
flex_used?: number
|
||||||
|
flex_limit?: number
|
||||||
|
last_checked_at?: string
|
||||||
|
}
|
||||||
|
refresh?: {
|
||||||
|
last_token_refresh_at?: string
|
||||||
|
last_status_refresh_at?: string
|
||||||
|
token_refresh_failures?: number
|
||||||
|
status_refresh_failures?: number
|
||||||
|
}
|
||||||
|
probe?: {
|
||||||
|
last_probe_at?: string
|
||||||
|
last_probe_error?: string
|
||||||
|
}
|
||||||
|
capabilities?: Record<string, WindsurfModelCapability>
|
||||||
|
model_matrix?: Record<string, WindsurfModelAvailability>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindsurfModelCapability {
|
||||||
|
available: boolean
|
||||||
|
mode?: string
|
||||||
|
reason?: string
|
||||||
|
checked_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindsurfModelAvailability {
|
||||||
|
visible: boolean
|
||||||
|
available: boolean
|
||||||
|
blocked: boolean
|
||||||
|
mode?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindsurfLoginRequest {
|
||||||
|
name?: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
notes?: string
|
||||||
|
proxy_id?: number | null
|
||||||
|
group_ids?: number[]
|
||||||
|
concurrency?: number
|
||||||
|
priority?: number
|
||||||
|
probe_after?: boolean
|
||||||
|
ls_instance_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindsurfBatchLoginRequest {
|
||||||
|
items: string[]
|
||||||
|
proxy_id?: number | null
|
||||||
|
group_ids?: number[]
|
||||||
|
concurrency?: number
|
||||||
|
priority?: number
|
||||||
|
probe_after?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindsurfLoginResponse {
|
||||||
|
account_id: number
|
||||||
|
platform: string
|
||||||
|
type: string
|
||||||
|
email: string
|
||||||
|
tier: string
|
||||||
|
auth_method: string
|
||||||
|
api_key_present: boolean
|
||||||
|
refresh_token_present: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindsurfBatchLoginResult {
|
||||||
|
email: string
|
||||||
|
success: boolean
|
||||||
|
account?: WindsurfLoginResponse
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindsurfBatchLoginResponse {
|
||||||
|
results: WindsurfBatchLoginResult[]
|
||||||
|
total: number
|
||||||
|
success_count: number
|
||||||
|
fail_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindsurfRefreshTokenResponse {
|
||||||
|
refreshed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindsurfLSStatusResponse {
|
||||||
|
mode: string
|
||||||
|
healthy: boolean
|
||||||
|
instances: number
|
||||||
|
endpoint?: string
|
||||||
|
details?: WindsurfLSInstanceDetail[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindsurfLSInstanceDetail {
|
||||||
|
container_id: string
|
||||||
|
container_name: string
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
healthy: boolean
|
||||||
|
discovered_at: string
|
||||||
|
last_probe_at?: string
|
||||||
|
last_probe_err?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WindsurfRuntimeResponse {
|
||||||
|
account_id: number
|
||||||
|
tier: string
|
||||||
|
rpm_limit: number
|
||||||
|
current_rpm: number
|
||||||
|
rpm_usage_percent: number
|
||||||
|
current_concurrency: number
|
||||||
|
max_concurrency: number
|
||||||
|
capabilities?: Record<string, WindsurfModelCapability>
|
||||||
|
model_matrix?: Record<string, WindsurfModelAvailability>
|
||||||
|
last_probe_at?: string
|
||||||
|
last_status_refresh_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TempUnschedulableRule {
|
export interface TempUnschedulableRule {
|
||||||
error_code: number
|
error_code: number
|
||||||
keywords: string[]
|
keywords: string[]
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
* instead of defining their own color mappings.
|
* instead of defining their own color mappings.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini'
|
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini' | 'windsurf'
|
||||||
|
|
||||||
// ── Badge (bg + text + border, for inline badges with border) ───────
|
// ── Badge (bg + text + border, for inline badges with border) ───────
|
||||||
const BADGE: Record<Platform, string> = {
|
const BADGE: Record<Platform, string> = {
|
||||||
@ -13,6 +13,7 @@ const BADGE: Record<Platform, string> = {
|
|||||||
openai: 'bg-green-500/10 text-green-600 border-green-500/30 dark:text-green-400',
|
openai: 'bg-green-500/10 text-green-600 border-green-500/30 dark:text-green-400',
|
||||||
antigravity: 'bg-purple-500/10 text-purple-600 border-purple-500/30 dark:text-purple-400',
|
antigravity: 'bg-purple-500/10 text-purple-600 border-purple-500/30 dark:text-purple-400',
|
||||||
gemini: 'bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400',
|
gemini: 'bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400',
|
||||||
|
windsurf: 'bg-teal-500/10 text-teal-600 border-teal-500/30 dark:text-teal-400',
|
||||||
}
|
}
|
||||||
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
|
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ const BADGE_LIGHT: Record<Platform, string> = {
|
|||||||
openai: 'bg-green-500/10 text-green-600 dark:bg-green-500/10 dark:text-green-300',
|
openai: 'bg-green-500/10 text-green-600 dark:bg-green-500/10 dark:text-green-300',
|
||||||
antigravity: 'bg-purple-500/10 text-purple-600 dark:bg-purple-500/10 dark:text-purple-300',
|
antigravity: 'bg-purple-500/10 text-purple-600 dark:bg-purple-500/10 dark:text-purple-300',
|
||||||
gemini: 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300',
|
gemini: 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300',
|
||||||
|
windsurf: 'bg-teal-500/10 text-teal-600 dark:bg-teal-500/10 dark:text-teal-300',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Border ──────────────────────────────────────────────────────────
|
// ── Border ──────────────────────────────────────────────────────────
|
||||||
@ -30,6 +32,7 @@ const BORDER: Record<Platform, string> = {
|
|||||||
openai: 'border-green-500/20 dark:border-green-500/20',
|
openai: 'border-green-500/20 dark:border-green-500/20',
|
||||||
antigravity: 'border-purple-500/20 dark:border-purple-500/20',
|
antigravity: 'border-purple-500/20 dark:border-purple-500/20',
|
||||||
gemini: 'border-blue-500/20 dark:border-blue-500/20',
|
gemini: 'border-blue-500/20 dark:border-blue-500/20',
|
||||||
|
windsurf: 'border-teal-500/20 dark:border-teal-500/20',
|
||||||
}
|
}
|
||||||
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
|
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
|
||||||
|
|
||||||
@ -39,6 +42,7 @@ const ACCENT_BAR: Record<Platform, string> = {
|
|||||||
openai: 'bg-gradient-to-r from-emerald-400 to-emerald-500',
|
openai: 'bg-gradient-to-r from-emerald-400 to-emerald-500',
|
||||||
antigravity: 'bg-gradient-to-r from-purple-400 to-purple-500',
|
antigravity: 'bg-gradient-to-r from-purple-400 to-purple-500',
|
||||||
gemini: 'bg-gradient-to-r from-blue-400 to-blue-500',
|
gemini: 'bg-gradient-to-r from-blue-400 to-blue-500',
|
||||||
|
windsurf: 'bg-gradient-to-r from-teal-400 to-teal-500',
|
||||||
}
|
}
|
||||||
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
|
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
|
||||||
|
|
||||||
@ -48,6 +52,7 @@ const TEXT: Record<Platform, string> = {
|
|||||||
openai: 'text-emerald-600 dark:text-emerald-400',
|
openai: 'text-emerald-600 dark:text-emerald-400',
|
||||||
antigravity: 'text-purple-600 dark:text-purple-400',
|
antigravity: 'text-purple-600 dark:text-purple-400',
|
||||||
gemini: 'text-blue-600 dark:text-blue-400',
|
gemini: 'text-blue-600 dark:text-blue-400',
|
||||||
|
windsurf: 'text-teal-600 dark:text-teal-400',
|
||||||
}
|
}
|
||||||
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
|
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
|
||||||
|
|
||||||
@ -57,6 +62,7 @@ const ICON: Record<Platform, string> = {
|
|||||||
openai: 'text-emerald-500 dark:text-emerald-400',
|
openai: 'text-emerald-500 dark:text-emerald-400',
|
||||||
antigravity: 'text-purple-500 dark:text-purple-400',
|
antigravity: 'text-purple-500 dark:text-purple-400',
|
||||||
gemini: 'text-blue-500 dark:text-blue-400',
|
gemini: 'text-blue-500 dark:text-blue-400',
|
||||||
|
windsurf: 'text-teal-500 dark:text-teal-400',
|
||||||
}
|
}
|
||||||
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
|
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
|
||||||
|
|
||||||
@ -66,6 +72,7 @@ const BUTTON: Record<Platform, string> = {
|
|||||||
openai: 'bg-green-600 text-white hover:bg-green-700 active:bg-green-800 dark:bg-green-600/80 dark:hover:bg-green-600',
|
openai: 'bg-green-600 text-white hover:bg-green-700 active:bg-green-800 dark:bg-green-600/80 dark:hover:bg-green-600',
|
||||||
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 active:bg-purple-700 dark:bg-purple-500/80 dark:hover:bg-purple-500',
|
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 active:bg-purple-700 dark:bg-purple-500/80 dark:hover:bg-purple-500',
|
||||||
gemini: 'bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-500/80 dark:hover:bg-blue-500',
|
gemini: 'bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-500/80 dark:hover:bg-blue-500',
|
||||||
|
windsurf: 'bg-teal-500 text-white hover:bg-teal-600 active:bg-teal-700 dark:bg-teal-500/80 dark:hover:bg-teal-500',
|
||||||
}
|
}
|
||||||
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
|
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
|
||||||
|
|
||||||
@ -75,6 +82,7 @@ const DISCOUNT: Record<Platform, string> = {
|
|||||||
openai: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
openai: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||||
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||||
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
windsurf: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
||||||
}
|
}
|
||||||
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||||
|
|
||||||
@ -84,6 +92,7 @@ const GRADIENT: Record<Platform, string> = {
|
|||||||
openai: 'from-emerald-500 to-emerald-600',
|
openai: 'from-emerald-500 to-emerald-600',
|
||||||
antigravity: 'from-purple-500 to-purple-600',
|
antigravity: 'from-purple-500 to-purple-600',
|
||||||
gemini: 'from-blue-500 to-blue-600',
|
gemini: 'from-blue-500 to-blue-600',
|
||||||
|
windsurf: 'from-teal-500 to-teal-600',
|
||||||
}
|
}
|
||||||
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
|
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
|
||||||
|
|
||||||
@ -93,6 +102,7 @@ const GRADIENT_TEXT: Record<Platform, string> = {
|
|||||||
openai: 'text-emerald-100',
|
openai: 'text-emerald-100',
|
||||||
antigravity: 'text-purple-100',
|
antigravity: 'text-purple-100',
|
||||||
gemini: 'text-blue-100',
|
gemini: 'text-blue-100',
|
||||||
|
windsurf: 'text-teal-100',
|
||||||
}
|
}
|
||||||
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
|
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
|
||||||
|
|
||||||
@ -101,13 +111,14 @@ const GRADIENT_SUBTEXT: Record<Platform, string> = {
|
|||||||
openai: 'text-emerald-200',
|
openai: 'text-emerald-200',
|
||||||
antigravity: 'text-purple-200',
|
antigravity: 'text-purple-200',
|
||||||
gemini: 'text-blue-200',
|
gemini: 'text-blue-200',
|
||||||
|
windsurf: 'text-teal-200',
|
||||||
}
|
}
|
||||||
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
|
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
|
||||||
|
|
||||||
// ── Public API ──────────────────────────────────────────────────────
|
// ── Public API ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function isPlatform(p: string): p is Platform {
|
function isPlatform(p: string): p is Platform {
|
||||||
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini'
|
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini' || p === 'windsurf'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function platformBadgeClass(p: string): string {
|
export function platformBadgeClass(p: string): string {
|
||||||
@ -160,6 +171,7 @@ export function platformLabel(p: string): string {
|
|||||||
case 'openai': return 'OpenAI'
|
case 'openai': return 'OpenAI'
|
||||||
case 'antigravity': return 'Antigravity'
|
case 'antigravity': return 'Antigravity'
|
||||||
case 'gemini': return 'Gemini'
|
case 'gemini': return 'Gemini'
|
||||||
|
case 'windsurf': return 'Windsurf'
|
||||||
default: return p || 'API'
|
default: return p || 'API'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user