chore(wip): save windsurf changes before upstream v0.1.118 merge

This commit is contained in:
win 2026-04-25 21:56:42 +08:00
parent 9156585a23
commit cbf696bc82
42 changed files with 2306 additions and 98 deletions

View File

@ -67,3 +67,28 @@ jobs:
version: v2.9
args: --timeout=30m
working-directory: backend
# Cross-platform smoke for the windsurf package: verify the code compiles
# on macOS and Windows and run only the platform-detection/discovery/datadir
# unit tests (which do not require launching a real LS binary).
windsurf-platform:
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: backend/go.mod
check-latest: false
cache: true
cache-dependency-path: backend/go.sum
- name: Build windsurf package
working-directory: backend
run: go build ./internal/pkg/windsurf/...
- name: Platform-only unit tests
working-directory: backend
run: go test -race -count=1 -run 'Platform|Discovery|DataDir|Metadata|NewLSPool|LSPool|ScanLSOutput' ./internal/pkg/windsurf/...

View File

@ -99,6 +99,7 @@ func provideCleanup(
paymentOrderExpiry *service.PaymentOrderExpiryService,
windsurfRefresh *service.WindsurfRefreshService,
channelMonitorRunner *service.ChannelMonitorRunner,
windsurfLS *service.WindsurfLSService,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@ -253,6 +254,12 @@ func provideCleanup(
}
return nil
}},
{"WindsurfLSService", func() error {
if windsurfLS != nil {
windsurfLS.Stop()
}
return nil
}},
}
infraSteps := []cleanupStep{

View File

@ -273,7 +273,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
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, channelMonitorRunner)
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, channelMonitorRunner, windsurfLSService)
application := &Application{
Server: httpServer,
Cleanup: v,
@ -329,6 +329,7 @@ func provideCleanup(
paymentOrderExpiry *service.PaymentOrderExpiryService,
windsurfRefresh *service.WindsurfRefreshService,
channelMonitorRunner *service.ChannelMonitorRunner,
windsurfLS *service.WindsurfLSService,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

View File

@ -78,6 +78,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // paymentOrderExpiry
nil, // windsurfRefresh
nil, // channelMonitorRunner
nil, // windsurfLS
)
require.NotPanics(t, func() {

View File

@ -279,7 +279,7 @@ func main() {
// SendUserCascadeMessage
{
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
newCID, err := lsClient.SendUserCascadeMessage(ctx, f.jwt, cascadeID, f.prompt, pickedModel, "", 0)
newCID, err := lsClient.SendUserCascadeMessage(ctx, f.jwt, cascadeID, f.prompt, pickedModel, "", 0, nil, true)
if err == nil && newCID != "" {
cascadeID = newCID
}

View File

@ -158,7 +158,7 @@ func main() {
fmt.Printf("✅ StartCascade cascade_id=%s\n", cascadeID)
// Call StreamCascadeChat (full flow incl. trajectory polling)
res, err := lsClient.StreamCascadeChat(ctx, f.jwt, pickedModel, f.prompt, preamble, cascadeID, 0)
res, err := lsClient.StreamCascadeChat(ctx, f.jwt, pickedModel, f.prompt, preamble, cascadeID, 0, nil)
if err != nil {
fmt.Fprintln(os.Stderr, "StreamCascadeChat:", err)
os.Exit(1)
@ -209,7 +209,7 @@ func main() {
tc.ID, fakeResult)
ctx2, cancel2 := context.WithTimeout(context.Background(), f.timeout)
defer cancel2()
res2, err := lsClient.StreamCascadeChat(ctx2, f.jwt, pickedModel, turn2, preamble, cascadeID, 0)
res2, err := lsClient.StreamCascadeChat(ctx2, f.jwt, pickedModel, turn2, preamble, cascadeID, 0, nil)
if err != nil {
fmt.Fprintln(os.Stderr, "\n❌ Turn2 StreamCascadeChat:", err)
os.Exit(1)

View File

@ -1852,10 +1852,10 @@ func setDefaults() {
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.embedded.binary", DefaultWindsurfEmbeddedBinary)
viper.SetDefault("windsurf.embedded.base_port", DefaultWindsurfEmbeddedBasePort)
viper.SetDefault("windsurf.embedded.data_dir", DefaultWindsurfEmbeddedDataDir)
viper.SetDefault("windsurf.embedded.api_server_url", DefaultWindsurfEmbeddedAPIServerURL)
viper.SetDefault("windsurf.refresh.enabled", true)
viper.SetDefault("windsurf.refresh.token_scan_interval", "5m")
viper.SetDefault("windsurf.refresh.refresh_before_expiry", "10m")

View File

@ -94,10 +94,10 @@ func DefaultWindsurfConfig() WindsurfConfig {
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",
Binary: DefaultWindsurfEmbeddedBinary,
BasePort: DefaultWindsurfEmbeddedBasePort,
DataDir: DefaultWindsurfEmbeddedDataDir,
APIServerURL: DefaultWindsurfEmbeddedAPIServerURL,
},
External: WindsurfExternalConfig{},
Refresh: WindsurfRefreshConfig{

View File

@ -0,0 +1,18 @@
package config
// Windsurf embedded-mode defaults. Defined once here so that both the struct
// defaults in DefaultWindsurfConfig() (windsurf.go) and the viper.SetDefault
// calls in config.go reference a single source of truth — this prevents
// silent drift when one side is updated without the other.
//
// Binary and DataDir are intentionally left empty: the cross-platform
// discovery in backend/internal/pkg/windsurf/lspool.go (via DiscoverBinary
// and resolveDataDir) picks the correct path at runtime based on GOOS/GOARCH
// and the user's install layout. Hardcoding /opt/windsurf/... here would
// override that discovery on non-Linux platforms.
const (
DefaultWindsurfEmbeddedBinary = ""
DefaultWindsurfEmbeddedBasePort = 42100
DefaultWindsurfEmbeddedDataDir = ""
DefaultWindsurfEmbeddedAPIServerURL = "https://server.self-serve.windsurf.com"
)

View File

@ -0,0 +1,164 @@
package windsurf
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
)
// CascadeImage 是发给 Windsurf Cascade gRPC 的图像载体。
// 对应 protomessage CodeiumImage { string base64Data = 1; string mimeType = 2; string caption = 3; }
// 通过静态分析 /Applications/Windsurf.app/Contents/Resources/app/node_modules/@exa/chat-client/index.js 得到。
type CascadeImage struct {
// Base64Data 原始 base64 字符串(不含 data: 前缀)。仅用于 replay/发送;不参与指纹。
Base64Data string `json:"base64_data,omitempty"`
// MimeType 例如 image/png。参与指纹。
MimeType string `json:"mime_type"`
// Caption 可选辅助说明,参与指纹。
Caption string `json:"caption,omitempty"`
// SHA256 是 decoded 二进制字节的 sha256 hex指纹使用。
SHA256 string `json:"sha256,omitempty"`
// ByteLen decoded 字节数;指纹使用。
ByteLen int `json:"byte_len,omitempty"`
}
// ImageDigest 是 CascadeImage 的摘要视图。日志/指纹/conversation pool 使用这个,
// 永远不要把 Base64Data 带进哈希/持久化。
type ImageDigest struct {
MimeType string `json:"mime_type"`
SHA256 string `json:"sha256"`
ByteLen int `json:"byte_len"`
Caption string `json:"caption,omitempty"`
}
// 支持的图像 MIME与 Windsurf 客户端一致)
var cascadeImageAllowedMime = map[string]struct{}{
"image/png": {},
"image/jpeg": {},
"image/gif": {},
"image/svg+xml": {},
}
// CascadeImageMaxPerTurn 单次 user turn 最多图片数Windsurf 客户端限制)。
const CascadeImageMaxPerTurn = 5
// CascadeImageMaxBytes 单张解码后字节上限Windsurf 客户端压缩目标 1MB
const CascadeImageMaxBytes = 1 * 1024 * 1024
// CascadeImageValidationOptions 校验开关,便于不同场景选择严格度。
type CascadeImageValidationOptions struct {
// EnforceByteSize 是否对单张做 1MB 上限校验。默认 true。
EnforceByteSize bool
// EnforceCountPerTurn 是否对张数做 <=5 校验。默认 true。
EnforceCountPerTurn bool
}
// DefaultCascadeImageValidationOptions 返回默认校验选项。
func DefaultCascadeImageValidationOptions() CascadeImageValidationOptions {
return CascadeImageValidationOptions{
EnforceByteSize: true,
EnforceCountPerTurn: true,
}
}
// ValidateCascadeImages 在发送前做确定性校验。
// 返回第一条校验失败的错误,上层据此生成 Anthropic 风格 400。
func ValidateCascadeImages(images []CascadeImage, opts CascadeImageValidationOptions) error {
if opts.EnforceCountPerTurn && len(images) > CascadeImageMaxPerTurn {
return fmt.Errorf("too many images: %d (max %d)", len(images), CascadeImageMaxPerTurn)
}
for i := range images {
img := &images[i]
if _, ok := cascadeImageAllowedMime[strings.ToLower(strings.TrimSpace(img.MimeType))]; !ok {
return fmt.Errorf("image[%d]: unsupported media_type %q", i, img.MimeType)
}
trimmed := strings.TrimSpace(img.Base64Data)
if trimmed == "" {
return fmt.Errorf("image[%d]: data must not be empty", i)
}
decoded, err := base64.StdEncoding.DecodeString(trimmed)
if err != nil {
// 尝试 RawStdEncoding 作为兜底(部分客户端省略 padding
if decoded2, err2 := base64.RawStdEncoding.DecodeString(trimmed); err2 == nil {
decoded = decoded2
} else {
return fmt.Errorf("image[%d]: invalid base64 data", i)
}
}
if len(decoded) == 0 {
return fmt.Errorf("image[%d]: decoded data is empty", i)
}
if opts.EnforceByteSize && len(decoded) > CascadeImageMaxBytes {
return fmt.Errorf("image[%d]: decoded size %d exceeds 1MB limit", i, len(decoded))
}
// 顺手把 digest/byteLen 算好,后续指纹阶段直接用
if img.SHA256 == "" || img.ByteLen == 0 {
sum := sha256.Sum256(decoded)
img.SHA256 = hex.EncodeToString(sum[:])
img.ByteLen = len(decoded)
}
// 归一化 MIME避免大小写差异
img.MimeType = strings.ToLower(strings.TrimSpace(img.MimeType))
}
return nil
}
// BuildImageDigests 把 CascadeImage 转成只含摘要的 ImageDigest 切片。
// 前提:调用者已经通过 ValidateCascadeImages 或 ComputeImageDigest 确保 SHA256/ByteLen 已填。
func BuildImageDigests(images []CascadeImage) []ImageDigest {
if len(images) == 0 {
return nil
}
out := make([]ImageDigest, len(images))
for i, img := range images {
out[i] = ImageDigest{
MimeType: img.MimeType,
SHA256: img.SHA256,
ByteLen: img.ByteLen,
Caption: img.Caption,
}
}
return out
}
// ComputeImageDigest 按给定 base64 字符串计算 digest 字段(不做限额校验)。
// 给只需要摘要不需要发送场景使用(例如历史 replay 构造)。
func ComputeImageDigest(img *CascadeImage) error {
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(img.Base64Data))
if err != nil {
decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(img.Base64Data))
if err != nil {
return fmt.Errorf("invalid base64")
}
}
sum := sha256.Sum256(decoded)
img.SHA256 = hex.EncodeToString(sum[:])
img.ByteLen = len(decoded)
img.MimeType = strings.ToLower(strings.TrimSpace(img.MimeType))
return nil
}
// encodeCodeiumImage 对单张 CodeiumImage 消息进行 proto wire 编码:
// message CodeiumImage { string base64Data = 1; string mimeType = 2; string caption = 3; }
// 返回值是 message body 原始字节(不含外层 field tag/length
func encodeCodeiumImage(img CascadeImage) []byte {
var body []byte
// field 1base64 字符串原样发Windsurf 客户端也是原样放,不做二次编码)
body = append(body, encodeStringField(1, img.Base64Data)...)
body = append(body, encodeStringField(2, img.MimeType)...)
if img.Caption != "" {
body = append(body, encodeStringField(3, img.Caption)...)
}
return body
}
// appendSendUserCascadeImages 把一组图像追加到 SendUserCascadeMessageRequest.imagesfield 6, repeated
// repeated message 字段按"每条独立的 field 6 segment"编码,与 Windsurf 客户端保持一致。
func appendSendUserCascadeImages(body []byte, images []CascadeImage) []byte {
for _, img := range images {
body = append(body, encodeBytesField(6, encodeCodeiumImage(img))...)
}
return body
}

View File

@ -0,0 +1,115 @@
package windsurf
import (
"encoding/base64"
"testing"
)
func TestValidateCascadeImages_AllowedMime(t *testing.T) {
png := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfake"))
tests := []struct {
mime string
wantErr bool
}{
{"image/png", false},
{"image/jpeg", false},
{"image/gif", false},
{"image/svg+xml", false},
{"image/webp", true},
{"text/plain", true},
}
for _, tt := range tests {
imgs := []CascadeImage{{MimeType: tt.mime, Base64Data: png}}
err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions())
if (err != nil) != tt.wantErr {
t.Errorf("mime=%s err=%v wantErr=%v", tt.mime, err, tt.wantErr)
}
}
}
func TestValidateCascadeImages_CountLimit(t *testing.T) {
png := base64.StdEncoding.EncodeToString([]byte("\x89PNG"))
imgs := make([]CascadeImage, 6)
for i := range imgs {
imgs[i] = CascadeImage{MimeType: "image/png", Base64Data: png}
}
err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions())
if err == nil {
t.Fatalf("expected count limit error")
}
}
func TestValidateCascadeImages_InvalidBase64(t *testing.T) {
imgs := []CascadeImage{{MimeType: "image/png", Base64Data: "!!!not-base64"}}
err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions())
if err == nil {
t.Fatalf("expected invalid base64 error")
}
}
func TestValidateCascadeImages_EmptyData(t *testing.T) {
imgs := []CascadeImage{{MimeType: "image/png", Base64Data: ""}}
err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions())
if err == nil {
t.Fatalf("expected empty data error")
}
}
func TestValidateCascadeImages_ByteLimit(t *testing.T) {
big := make([]byte, CascadeImageMaxBytes+1)
imgs := []CascadeImage{{MimeType: "image/png", Base64Data: base64.StdEncoding.EncodeToString(big)}}
err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions())
if err == nil {
t.Fatalf("expected size limit error")
}
}
func TestValidateCascadeImages_PopulatesDigest(t *testing.T) {
png := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfake"))
imgs := []CascadeImage{{MimeType: "image/PNG", Base64Data: png}}
if err := ValidateCascadeImages(imgs, DefaultCascadeImageValidationOptions()); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if imgs[0].SHA256 == "" {
t.Errorf("sha256 should be populated")
}
if imgs[0].ByteLen == 0 {
t.Errorf("byteLen should be populated")
}
if imgs[0].MimeType != "image/png" {
t.Errorf("mime should be normalized to lowercase, got %q", imgs[0].MimeType)
}
}
func TestBuildImageDigests(t *testing.T) {
imgs := []CascadeImage{
{MimeType: "image/png", SHA256: "abc", ByteLen: 10, Caption: "x", Base64Data: "ignored"},
{MimeType: "image/jpeg", SHA256: "def", ByteLen: 20},
}
out := BuildImageDigests(imgs)
if len(out) != 2 {
t.Fatalf("expected 2 digests, got %d", len(out))
}
if out[0].MimeType != "image/png" || out[0].SHA256 != "abc" || out[0].ByteLen != 10 || out[0].Caption != "x" {
t.Errorf("digest[0] mismatch: %+v", out[0])
}
// ImageDigest 不应包含 base64 字段
// (这个通过结构体字段检查,而非运行时 — 此处只是确认编译通过)
}
func TestComputeImageDigest(t *testing.T) {
img := CascadeImage{MimeType: "IMAGE/PNG", Base64Data: base64.StdEncoding.EncodeToString([]byte("hello"))}
if err := ComputeImageDigest(&img); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if img.ByteLen != 5 {
t.Errorf("byteLen=%d want 5", img.ByteLen)
}
// sha256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
if img.SHA256 != "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" {
t.Errorf("sha256 mismatch: %s", img.SHA256)
}
if img.MimeType != "image/png" {
t.Errorf("mime not normalized: %s", img.MimeType)
}
}

View File

@ -0,0 +1,68 @@
package windsurf
import (
"bytes"
"encoding/hex"
"testing"
)
// 对比官方规划里给出的 known-good hex vector
// base64Data="QQ==", mimeType="image/png", caption="a"
// 内部 body: 0a 04 51 51 3d 3d 12 09 69 6d 61 67 65 2f 70 6e 67 1a 01 61
func TestEncodeCodeiumImage_KnownVector(t *testing.T) {
img := CascadeImage{
Base64Data: "QQ==",
MimeType: "image/png",
Caption: "a",
}
got := encodeCodeiumImage(img)
want, _ := hex.DecodeString("0a045151" + "3d3d" + "120969" + "6d6167652f706e67" + "1a0161")
if !bytes.Equal(got, want) {
t.Errorf("encoded mismatch:\n got=%x\nwant=%x", got, want)
}
}
func TestEncodeCodeiumImage_OmitsCaptionWhenEmpty(t *testing.T) {
img := CascadeImage{
Base64Data: "QQ==",
MimeType: "image/png",
}
got := encodeCodeiumImage(img)
// 不含 field 3 tag (0x1a)
if bytes.Contains(got, []byte{0x1a}) {
t.Errorf("should not contain field 3 tag when caption empty: %x", got)
}
}
func TestAppendSendUserCascadeImages_WrapsField6(t *testing.T) {
imgs := []CascadeImage{
{Base64Data: "QQ==", MimeType: "image/png", Caption: "a"},
}
body := appendSendUserCascadeImages(nil, imgs)
// 外层field 6 tag (0x32) + varint 长度 + 内层 body
// 内层 body 长度 = 6 + 11 + 3 = 20 (0x14)
want, _ := hex.DecodeString("3214" + "0a045151" + "3d3d" + "120969" + "6d6167652f706e67" + "1a0161")
if !bytes.Equal(body, want) {
t.Errorf("wrapped field 6 segment mismatch:\n got=%x\nwant=%x", body, want)
}
}
func TestAppendSendUserCascadeImages_MultipleImagesIndependentSegments(t *testing.T) {
imgs := []CascadeImage{
{Base64Data: "QQ==", MimeType: "image/png"},
{Base64Data: "Qg==", MimeType: "image/jpeg"},
}
body := appendSendUserCascadeImages(nil, imgs)
// 应该有两次 field 6 tag (0x32)
count := bytes.Count(body, []byte{0x32})
if count != 2 {
t.Errorf("expected 2 field-6 segments, got %d in %x", count, body)
}
}
func TestAppendSendUserCascadeImages_NilInput(t *testing.T) {
body := appendSendUserCascadeImages(nil, nil)
if len(body) != 0 {
t.Errorf("nil input should produce empty bytes, got %x", body)
}
}

View File

@ -7,6 +7,7 @@ package windsurf
import (
"encoding/hex"
"os"
)
// ── Constants ──────────────────────────────────────────────────────────────
@ -18,12 +19,36 @@ const (
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)"
// DefaultRuntimeOS / DefaultHardwareArch are what we advertise in
// protocol metadata when the WINDSURF_METADATA_OS /
// WINDSURF_METADATA_ARCH env vars are unset. They match the Linux LS
// binary we spawn by default, so the upstream never sees a mismatch
// between advertised OS and the actual LS behavior.
DefaultRuntimeOS = "linux"
DefaultHardwareArch = "x86_64"
)
// RuntimeOS returns the OS string sent in protocol metadata, consulting
// WINDSURF_METADATA_OS on each call so tests can flip it with t.Setenv.
func RuntimeOS() string {
if v := os.Getenv("WINDSURF_METADATA_OS"); v != "" {
return v
}
return DefaultRuntimeOS
}
// HardwareArch returns the hardware string sent in protocol metadata,
// consulting WINDSURF_METADATA_ARCH on each call.
func HardwareArch() string {
if v := os.Getenv("WINDSURF_METADATA_ARCH"); v != "" {
return v
}
return DefaultHardwareArch
}
// ── Protobuf wire encoding ─────────────────────────────────────────────────
func writeVarint(value uint64) []byte {

View File

@ -11,6 +11,10 @@ type LSConnector interface {
Acquire(ctx context.Context, proxyURL string) (*LSLease, error)
Health(ctx context.Context) error
Status() *LSConnectorStatus
// Shutdown releases any resources owned by the connector. Connectors
// that only dial external endpoints implement this as a no-op; the
// embedded mode terminates its spawned LS processes here.
Shutdown()
}
type LSLease struct {
@ -68,6 +72,10 @@ func (d *DockerConnector) Status() *LSConnectorStatus {
}
}
// Shutdown is a no-op: DockerConnector only dials a remote endpoint and
// owns no long-lived goroutines or child processes.
func (d *DockerConnector) Shutdown() {}
type EmbeddedConnector struct {
pool *LSPool
}
@ -114,6 +122,16 @@ func (e *EmbeddedConnector) Status() *LSConnectorStatus {
}
}
// Shutdown terminates every LS process in the pool. Must be called on
// application teardown; otherwise child processes leak until the OS
// reaps them at exit.
func (e *EmbeddedConnector) Shutdown() {
if e == nil || e.pool == nil {
return
}
e.pool.Shutdown()
}
type ExternalConnector struct {
baseURL string
port int
@ -156,3 +174,6 @@ func (x *ExternalConnector) Status() *LSConnectorStatus {
Endpoint: x.baseURL,
}
}
// Shutdown is a no-op: ExternalConnector only dials a remote endpoint.
func (x *ExternalConnector) Shutdown() {}

View File

@ -173,13 +173,34 @@ func stableTurns(messages []ChatMessage) []ChatMessage {
}
func hashFingerprint(modelKey, apiKey string, turns []ChatMessage) string {
type canonicalImage struct {
MimeType string `json:"mime_type"`
SHA256 string `json:"sha256"`
ByteLen int `json:"byte_len"`
Caption string `json:"caption,omitempty"`
}
type canonical struct {
Role string `json:"role"`
Content string `json:"content"`
Role string `json:"role"`
Content string `json:"content"`
Images []canonicalImage `json:"images,omitempty"`
}
cans := make([]canonical, len(turns))
for i, t := range turns {
cans[i] = canonical{Role: t.Role, Content: t.Content}
c := canonical{Role: t.Role, Content: t.Content}
// 指纹只使用 ImageDigests永不使用 base64。
// 若 ImageDigests 为空则 canonical.Images 也省略(保持向后兼容:无图 hash 与旧版本一致)。
if len(t.ImageDigests) > 0 {
c.Images = make([]canonicalImage, len(t.ImageDigests))
for j, d := range t.ImageDigests {
c.Images[j] = canonicalImage{
MimeType: d.MimeType,
SHA256: d.SHA256,
ByteLen: d.ByteLen,
Caption: d.Caption,
}
}
}
cans[i] = c
}
data, _ := json.Marshal(cans)
h := sha256.Sum256([]byte(fmt.Sprintf("%s\x00\x00%s\x00\x00%s", modelKey, apiKey, data)))

View File

@ -0,0 +1,68 @@
package windsurf
import "testing"
func TestFingerprintBefore_BackwardCompatibleForTextOnly(t *testing.T) {
msgs := []ChatMessage{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "hi"},
{Role: "user", Content: "again"},
}
// 无图场景 hash 必须与 ImageDigests=nil 时一致(向后兼容)
fp := FingerprintBefore(msgs, "claude-opus-4-7", "key-A")
if fp == "" {
t.Fatal("unexpected empty fingerprint")
}
}
func TestFingerprintBefore_DifferentImagesDiffer(t *testing.T) {
base := []ChatMessage{
{Role: "user", Content: "describe", ImageDigests: []ImageDigest{{MimeType: "image/png", SHA256: "aaa", ByteLen: 100}}},
{Role: "assistant", Content: "ok"},
{Role: "user", Content: "next"},
}
diffImg := []ChatMessage{
{Role: "user", Content: "describe", ImageDigests: []ImageDigest{{MimeType: "image/png", SHA256: "bbb", ByteLen: 100}}},
{Role: "assistant", Content: "ok"},
{Role: "user", Content: "next"},
}
fp1 := FingerprintBefore(base, "claude-opus-4-7", "key-A")
fp2 := FingerprintBefore(diffImg, "claude-opus-4-7", "key-A")
if fp1 == fp2 {
t.Errorf("expected different fingerprints when image sha256 differs")
}
}
func TestFingerprintBefore_SameImageDifferentCaptionDiffers(t *testing.T) {
a := []ChatMessage{
{Role: "user", Content: "x", ImageDigests: []ImageDigest{{MimeType: "image/png", SHA256: "z", ByteLen: 10, Caption: "cat"}}},
{Role: "assistant", Content: "ok"},
{Role: "user", Content: "y"},
}
b := []ChatMessage{
{Role: "user", Content: "x", ImageDigests: []ImageDigest{{MimeType: "image/png", SHA256: "z", ByteLen: 10, Caption: "dog"}}},
{Role: "assistant", Content: "ok"},
{Role: "user", Content: "y"},
}
fp1 := FingerprintBefore(a, "claude-opus-4-7", "key-A")
fp2 := FingerprintBefore(b, "claude-opus-4-7", "key-A")
if fp1 == fp2 {
t.Errorf("expected different fingerprints when caption differs")
}
}
func TestFingerprintBefore_NoImagesSameAsAbsent(t *testing.T) {
a := []ChatMessage{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "hi"},
{Role: "user", Content: "again"},
}
b := []ChatMessage{
{Role: "user", Content: "hello", Images: nil, ImageDigests: nil},
{Role: "assistant", Content: "hi"},
{Role: "user", Content: "again"},
}
if FingerprintBefore(a, "m", "k") != FingerprintBefore(b, "m", "k") {
t.Errorf("no-image variants should hash identically (backward compat)")
}
}

View File

@ -0,0 +1,97 @@
package windsurf
import (
"os"
"path/filepath"
"runtime"
)
// dataDirStat reports whether a data directory path exists and is writable.
type dataDirStat struct {
Exists bool
Writable bool
}
// dataDirStatFn is replaced in tests to avoid touching the filesystem.
var dataDirStatFn = defaultDataDirStat
// userConfigDirFn is replaced in tests.
var userConfigDirFn = defaultUserConfigDir
func defaultDataDirStat(path string) dataDirStat {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return dataDirStat{}
}
// Probe writability by creating a sentinel file. This catches root-owned
// directories on Linux that are readable but not user-writable.
tmp := filepath.Join(path, ".sub2api-writable-check")
f, err := os.Create(tmp)
if err != nil {
return dataDirStat{Exists: true}
}
_ = f.Close()
_ = os.Remove(tmp)
return dataDirStat{Exists: true, Writable: true}
}
func defaultUserConfigDir() string {
if dir, err := os.UserConfigDir(); err == nil {
return dir
}
return ""
}
// LegacyLinuxDataDir is the original pre-cross-platform data directory.
// Preserved to keep existing Linux deployments functional after upgrade.
const LegacyLinuxDataDir = "/opt/windsurf/data"
// resolveDataDir picks the base directory under which each LS instance's
// per-key subdirectory will live.
//
// Priority:
// 1. cfg.DataDir — explicit caller wins
// 2. WINDSURF_DATA_DIR env var
// 3. /opt/windsurf/data if it exists AND is writable (Linux backward compat)
// 4. os.UserConfigDir()/windsurf/data (cross-platform default)
// 5. os.TempDir()/windsurf-data (last-resort fallback)
func resolveDataDir(cfg LSPoolConfig) string {
return resolveDataDirFor(cfg.DataDir, os.Getenv("WINDSURF_DATA_DIR"), runtime.GOOS)
}
func resolveDataDirFor(cfgDir, envDir, goos string) string {
if cfgDir != "" {
return cfgDir
}
if envDir != "" {
return envDir
}
if goos == "linux" {
if stat := dataDirStatFn(LegacyLinuxDataDir); stat.Exists && stat.Writable {
return LegacyLinuxDataDir
}
}
if cfgDir := userConfigDirFn(); cfgDir != "" {
return filepath.Join(cfgDir, "windsurf", "data")
}
return filepath.Join(os.TempDir(), "windsurf-data")
}
// instanceHomeDir returns the per-instance HOME sandbox path. The LS binary
// writes telemetry and caches under $HOME (Linux/macOS) or %USERPROFILE%
// (Windows), so isolating it per instance prevents one instance from leaking
// state into another or into the invoking user's real home directory.
func instanceHomeDir(instanceDataDir string) string {
return filepath.Join(instanceDataDir, "home")
}
// homeEnvForPlatform returns the set of environment assignments needed to
// direct the LS process at the given home directory. On Windows we also set
// USERPROFILE because most Windows programs check that first.
func homeEnvForPlatform(homeDir, goos string) []string {
envs := []string{"HOME=" + homeDir}
if goos == "windows" {
envs = append(envs, "USERPROFILE="+homeDir)
}
return envs
}

View File

@ -0,0 +1,119 @@
package windsurf
import (
"path/filepath"
"reflect"
"strings"
"testing"
)
func stubDataDirStat(t *testing.T, present map[string]dataDirStat) {
t.Helper()
orig := dataDirStatFn
dataDirStatFn = func(path string) dataDirStat {
return present[path]
}
t.Cleanup(func() { dataDirStatFn = orig })
}
func stubUserConfigDir(t *testing.T, dir string) {
t.Helper()
orig := userConfigDirFn
userConfigDirFn = func() string { return dir }
t.Cleanup(func() { userConfigDirFn = orig })
}
func TestResolveDataDir_ExplicitCfgWins(t *testing.T) {
stubUserConfigDir(t, "/home/test/.config")
got := resolveDataDirFor("/explicit/path", "", "linux")
if got != "/explicit/path" {
t.Errorf("explicit cfg should win, got %q", got)
}
}
func TestResolveDataDir_EnvWinsOverDefaults(t *testing.T) {
stubUserConfigDir(t, "/home/test/.config")
got := resolveDataDirFor("", "/env/path", "linux")
if got != "/env/path" {
t.Errorf("env var should win over defaults, got %q", got)
}
}
func TestResolveDataDir_LinuxLegacyKeptWhenWritable(t *testing.T) {
stubDataDirStat(t, map[string]dataDirStat{
LegacyLinuxDataDir: {Exists: true, Writable: true},
})
stubUserConfigDir(t, "/home/test/.config")
got := resolveDataDirFor("", "", "linux")
if got != LegacyLinuxDataDir {
t.Errorf("linux with writable /opt/windsurf/data should keep legacy path, got %q", got)
}
}
func TestResolveDataDir_LinuxLegacyNotWritableFallsBack(t *testing.T) {
stubDataDirStat(t, map[string]dataDirStat{
LegacyLinuxDataDir: {Exists: true, Writable: false}, // root-owned
})
stubUserConfigDir(t, "/home/test/.config")
got := resolveDataDirFor("", "", "linux")
want := filepath.Join("/home/test/.config", "windsurf", "data")
if got != want {
t.Errorf("unwritable legacy should fall back to UserConfigDir, got %q want %q", got, want)
}
}
func TestResolveDataDir_DarwinIgnoresLinuxLegacy(t *testing.T) {
stubDataDirStat(t, map[string]dataDirStat{
LegacyLinuxDataDir: {Exists: true, Writable: true},
})
stubUserConfigDir(t, "/Users/test/Library/Application Support")
got := resolveDataDirFor("", "", "darwin")
want := filepath.Join("/Users/test/Library/Application Support", "windsurf", "data")
if got != want {
t.Errorf("darwin should ignore /opt/windsurf/data, got %q want %q", got, want)
}
}
func TestResolveDataDir_WindowsUsesUserConfigDir(t *testing.T) {
stubUserConfigDir(t, `C:\Users\test\AppData\Roaming`)
got := resolveDataDirFor("", "", "windows")
want := filepath.Join(`C:\Users\test\AppData\Roaming`, "windsurf", "data")
if got != want {
t.Errorf("windows should use UserConfigDir, got %q want %q", got, want)
}
}
func TestResolveDataDir_EmptyUserConfigFallsBackToTemp(t *testing.T) {
stubUserConfigDir(t, "")
got := resolveDataDirFor("", "", "linux")
if !strings.Contains(got, "windsurf-data") {
t.Errorf("fallback should contain windsurf-data, got %q", got)
}
}
func TestInstanceHomeDir(t *testing.T) {
got := instanceHomeDir("/var/lib/windsurf/key1")
want := filepath.Join("/var/lib/windsurf/key1", "home")
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestHomeEnvForPlatform(t *testing.T) {
tests := []struct {
goos string
want []string
}{
{"linux", []string{"HOME=/sandbox"}},
{"darwin", []string{"HOME=/sandbox"}},
{"windows", []string{"HOME=/sandbox", "USERPROFILE=/sandbox"}},
}
for _, tt := range tests {
t.Run(tt.goos, func(t *testing.T) {
got := homeEnvForPlatform("/sandbox", tt.goos)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("goos=%s: got %v, want %v", tt.goos, got, tt.want)
}
})
}
}

View File

@ -0,0 +1,162 @@
package windsurf
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// ErrBinaryNotFound is returned when no Windsurf LS binary can be located
// via any configured source (env, explicit config, or platform candidates).
var ErrBinaryNotFound = fmt.Errorf("windsurf: language server binary not found")
// binaryStatFn reports whether the given path exists and is executable for
// the current platform. It is a package variable so tests can replace it
// with a map-backed implementation that does not touch the filesystem.
var binaryStatFn = defaultBinaryStat
// userHomeFn returns the user's home directory. Replaced in tests.
var userHomeFn = defaultUserHome
func defaultBinaryStat(path string) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return false
}
if runtime.GOOS == "windows" {
// Windows ignores the Unix execute bit — rely on the .exe suffix.
return strings.HasSuffix(strings.ToLower(path), ".exe")
}
return info.Mode()&0o111 != 0
}
func defaultUserHome() string {
if dir, err := os.UserHomeDir(); err == nil {
return dir
}
return ""
}
// DiscoverBinary resolves the Windsurf LS binary path for the current
// platform. Resolution order:
//
// 1. LS_BINARY_PATH environment variable (explicit override — user intent
// wins even if the path doesn't exist, so we can surface a clear error)
// 2. cfg.Binary (explicit config override)
// 3. Platform-specific candidate list (official install locations)
//
// Returns ErrBinaryNotFound when none of the sources yield an executable
// file; the error message directs the user to LS_BINARY_PATH or ls_mode=docker.
func DiscoverBinary(cfg LSPoolConfig) (string, error) {
return discoverBinaryFor(DetectPlatform(), os.Getenv("LS_BINARY_PATH"), cfg.Binary)
}
func discoverBinaryFor(p Platform, envPath, cfgPath string) (string, error) {
if envPath != "" {
return validateBinaryPath(envPath, p, "LS_BINARY_PATH")
}
if cfgPath != "" {
return validateBinaryPath(cfgPath, p, "cfg.Binary")
}
candidates, err := platformCandidates(p)
if err != nil {
return "", err
}
for _, path := range candidates {
if binaryStatFn(path) {
return path, nil
}
}
return "", fmt.Errorf("%w for %s; searched %d paths (%s); set LS_BINARY_PATH or use ls_mode=docker",
ErrBinaryNotFound, p, len(candidates), strings.Join(candidates, ", "))
}
func validateBinaryPath(path string, p Platform, source string) (string, error) {
if binaryStatFn(path) {
return path, nil
}
hint := "file does not exist or is not executable"
if p.OS == "windows" && !strings.HasSuffix(strings.ToLower(path), ".exe") {
hint = "Windows LS binaries must end in .exe"
} else if p.OS != "windows" {
hint += " (try chmod +x)"
}
return "", fmt.Errorf("%w: %s=%q — %s; set LS_BINARY_PATH to a valid path or use ls_mode=docker",
ErrBinaryNotFound, source, path, hint)
}
// platformCandidates returns the ordered list of paths where the official
// Windsurf LS binary may be installed on the given platform. Paths are
// ordered by preference — the first existing+executable path wins.
func platformCandidates(p Platform) ([]string, error) {
filename, err := BinaryFilename(p)
if err != nil {
return nil, err
}
switch p.OS {
case "darwin":
return darwinCandidates(filename), nil
case "linux":
return linuxCandidates(filename), nil
case "windows":
return windowsCandidates(filename), nil
}
// BinaryFilename would have errored first, so this is defensive.
return nil, fmt.Errorf("%w: %s (no candidate list)", ErrUnsupportedPlatform, p)
}
func darwinCandidates(filename string) []string {
const bundleSubpath = "Contents/Resources/app/extensions/windsurf/bin"
candidates := []string{
filepath.Join("/Applications/Windsurf.app", bundleSubpath, filename),
}
if home := userHomeFn(); home != "" {
candidates = append(candidates,
filepath.Join(home, "Applications/Windsurf.app", bundleSubpath, filename),
)
}
// Legacy sub2api install (pre-cross-platform).
candidates = append(candidates, filepath.Join("/opt/windsurf", filename))
return candidates
}
func linuxCandidates(filename string) []string {
candidates := []string{
// Legacy sub2api install (pre-cross-platform) — matches existing deployments.
filepath.Join("/opt/windsurf", filename),
// Official Debian/RPM install locations.
filepath.Join("/usr/share/windsurf/resources/app/extensions/windsurf/bin", filename),
filepath.Join("/usr/lib/windsurf/resources/app/extensions/windsurf/bin", filename),
}
// User-local install (Flatpak/AppImage unpacked).
if home := userHomeFn(); home != "" {
candidates = append(candidates,
filepath.Join(home, ".local/share/windsurf/resources/app/extensions/windsurf/bin", filename),
)
}
return candidates
}
func windowsCandidates(filename string) []string {
// Split the install subpath into its components so filepath.Join produces
// a platform-native path on whichever OS this runs (Windows '\\', Unix '/').
// This matters for tests running on non-Windows builders.
installSubpath := []string{"resources", "app", "extensions", "windsurf", "bin", filename}
var candidates []string
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
candidates = append(candidates,
filepath.Join(append([]string{localAppData, "Programs", "Windsurf"}, installSubpath...)...),
)
}
if programFiles := os.Getenv("PROGRAMFILES"); programFiles != "" {
candidates = append(candidates,
filepath.Join(append([]string{programFiles, "Windsurf"}, installSubpath...)...),
)
}
return candidates
}

View File

@ -0,0 +1,179 @@
package windsurf
import (
"errors"
"path/filepath"
"strings"
"testing"
)
// stubStatFn replaces binaryStatFn for the duration of a test, restoring the
// original on cleanup.
func stubStatFn(t *testing.T, present map[string]bool) {
t.Helper()
orig := binaryStatFn
binaryStatFn = func(path string) bool {
return present[path]
}
t.Cleanup(func() { binaryStatFn = orig })
}
func stubUserHome(t *testing.T, home string) {
t.Helper()
orig := userHomeFn
userHomeFn = func() string { return home }
t.Cleanup(func() { userHomeFn = orig })
}
func TestDiscoverBinary_EnvOverrideWins(t *testing.T) {
stubStatFn(t, map[string]bool{
"/tmp/my-ls": true,
"/opt/windsurf/language_server_linux_x64": true, // should not be picked
})
got, err := discoverBinaryFor(Platform{"linux", "amd64"}, "/tmp/my-ls", "/opt/windsurf/language_server_linux_x64")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "/tmp/my-ls" {
t.Errorf("env override should win, got %q", got)
}
}
func TestDiscoverBinary_EnvMissingReturnsError(t *testing.T) {
stubStatFn(t, map[string]bool{}) // nothing exists
_, err := discoverBinaryFor(Platform{"linux", "amd64"}, "/does/not/exist", "")
if err == nil {
t.Fatal("expected error for missing env path")
}
if !errors.Is(err, ErrBinaryNotFound) {
t.Errorf("expected ErrBinaryNotFound, got %v", err)
}
if !strings.Contains(err.Error(), "LS_BINARY_PATH") {
t.Errorf("error should mention LS_BINARY_PATH, got %v", err)
}
if !strings.Contains(err.Error(), "docker") {
t.Errorf("error should point at docker, got %v", err)
}
}
func TestDiscoverBinary_CfgBinaryUsedWhenEnvEmpty(t *testing.T) {
stubStatFn(t, map[string]bool{"/custom/ls": true})
got, err := discoverBinaryFor(Platform{"linux", "amd64"}, "", "/custom/ls")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "/custom/ls" {
t.Errorf("cfg path should be used, got %q", got)
}
}
func TestDiscoverBinary_FallsBackToCandidates(t *testing.T) {
stubStatFn(t, map[string]bool{
"/opt/windsurf/language_server_linux_x64": true,
})
got, err := discoverBinaryFor(Platform{"linux", "amd64"}, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "/opt/windsurf/language_server_linux_x64" {
t.Errorf("expected /opt/windsurf/language_server_linux_x64, got %q", got)
}
}
func TestDiscoverBinary_DarwinPicksBundleOverLegacy(t *testing.T) {
bundle := "/Applications/Windsurf.app/Contents/Resources/app/extensions/windsurf/bin/language_server_macos_arm"
legacy := "/opt/windsurf/language_server_macos_arm"
stubStatFn(t, map[string]bool{
bundle: true,
legacy: true,
})
stubUserHome(t, "/Users/test")
got, err := discoverBinaryFor(Platform{"darwin", "arm64"}, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != bundle {
t.Errorf("bundle path should win over legacy, got %q", got)
}
}
func TestDiscoverBinary_WindowsPicksLocalAppData(t *testing.T) {
t.Setenv("LOCALAPPDATA", `C:\Users\test\AppData\Local`)
t.Setenv("PROGRAMFILES", `C:\Program Files`)
// Construct the expected path the same way windowsCandidates() does so
// the test is portable across builders (macOS/Linux Go use '/', Windows
// uses '\\'). The Windows-native separator is verified by the Windows CI
// job in backend-ci.yml.
localPath := filepath.Join(`C:\Users\test\AppData\Local`, "Programs", "Windsurf",
"resources", "app", "extensions", "windsurf", "bin",
"language_server_windows_x64.exe")
stubStatFn(t, map[string]bool{localPath: true})
got, err := discoverBinaryFor(Platform{"windows", "amd64"}, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != localPath {
t.Errorf("expected LOCALAPPDATA path, got %q", got)
}
}
func TestDiscoverBinary_AllMissReturnsUsefulError(t *testing.T) {
stubStatFn(t, map[string]bool{})
stubUserHome(t, "/Users/test")
_, err := discoverBinaryFor(Platform{"darwin", "arm64"}, "", "")
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrBinaryNotFound) {
t.Errorf("expected ErrBinaryNotFound, got %v", err)
}
for _, want := range []string{"darwin/arm64", "LS_BINARY_PATH", "docker", "/Applications/Windsurf.app"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error missing %q: %v", want, err)
}
}
}
func TestDiscoverBinary_UnsupportedPlatformPropagates(t *testing.T) {
_, err := discoverBinaryFor(Platform{"freebsd", "amd64"}, "", "")
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrUnsupportedPlatform) {
t.Errorf("expected ErrUnsupportedPlatform, got %v", err)
}
}
func TestDiscoverBinary_WindowsNonExeRejected(t *testing.T) {
// Even if env stat returns true, validateBinaryPath uses the real binaryStatFn,
// which for Windows requires .exe suffix. Test via direct stat behavior.
stubStatFn(t, map[string]bool{"C:\\custom\\ls": false}) // stat returns false
_, err := discoverBinaryFor(Platform{"windows", "amd64"}, "C:\\custom\\ls", "")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), ".exe") {
t.Errorf("windows error should hint at .exe, got %v", err)
}
}
func TestPlatformCandidates_Linux(t *testing.T) {
stubUserHome(t, "/home/test")
got, err := platformCandidates(Platform{"linux", "amd64"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) < 3 {
t.Errorf("expected at least 3 linux candidates, got %d: %v", len(got), got)
}
if got[0] != "/opt/windsurf/language_server_linux_x64" {
t.Errorf("legacy path should be first for backward-compat, got %q", got[0])
}
}
func TestPlatformCandidates_UnsupportedPropagates(t *testing.T) {
_, err := platformCandidates(Platform{"plan9", "amd64"})
if err == nil || !errors.Is(err, ErrUnsupportedPlatform) {
t.Errorf("expected ErrUnsupportedPlatform, got %v", err)
}
}

View File

@ -19,6 +19,12 @@ const (
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
// Images 当前消息携带的图片(通常只有 user role 才非空)。
// 仅用于发送/replay。CascadeImage.Base64Data 不应出现在持久化/日志/指纹中。
Images []CascadeImage `json:"images,omitempty"`
// ImageDigests 仅摘要视图(含 sha256 / mime / byte_len / caption不含 base64
// 供 conversation pool 指纹与日志使用。
ImageDigests []ImageDigest `json:"image_digests,omitempty"`
}
type LegacyChatDelta struct {

View File

@ -15,8 +15,10 @@ package windsurf
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"log/slog"
@ -28,6 +30,9 @@ import (
"time"
"golang.org/x/net/http2"
"google.golang.org/protobuf/proto"
pb "github.com/Wei-Shaw/sub2api/internal/gen/language_server_pb"
)
const (
@ -38,8 +43,18 @@ const (
SendUserCascadeMessageRPC = "/exa.language_server_pb.LanguageServerService/SendUserCascadeMessage"
GetCascadeTrajectoryStepsRPC = "/exa.language_server_pb.LanguageServerService/GetCascadeTrajectorySteps"
GetCascadeTrajectoryStatusRPC = "/exa.language_server_pb.LanguageServerService/GetCascadeTrajectory"
GetCascadeModelConfigsRPC = "/exa.language_server_pb.LanguageServerService/GetCascadeModelConfigs"
)
// cascadeModelCapsCacheEntry 是单个 API key 下模型能力的缓存条目。
type cascadeModelCapsCacheEntry struct {
SupportsImages map[string]bool
FetchedAt time.Time
}
// cascadeModelCapsTTL 能力缓存 TTL5 分钟)。
const cascadeModelCapsTTL = 5 * time.Minute
// LocalLSClient talks to the local Windsurf LanguageServerService via h2c (plain HTTP/2 over TCP).
type LocalLSClient struct {
BaseURL string
@ -51,6 +66,10 @@ type LocalLSClient struct {
// server-side repository context and relies on caller-provided tool results.
TrackedWorkspace string
mu sync.Mutex
// 模型能力缓存per-API-key hash供 Cascade 图像 gate 使用。
modelCapsMu sync.Mutex
modelCapsCache map[string]cascadeModelCapsCacheEntry
}
// NewLocalLSClient builds a client for the local LS at the given port.
@ -159,7 +178,19 @@ func (l *LocalLSClient) StartCascade(ctx context.Context, token string) (string,
// SendUserCascadeMessage sends a message into an existing cascade session.
// Returns the (possibly new) cascadeID — it changes if panel-state retry triggers a new StartCascade.
// toolPreamble, if non-empty, is injected into the tool_calling_section override.
func (l *LocalLSClient) SendUserCascadeMessage(ctx context.Context, token, cascadeID, text, modelUID, toolPreamble string, modelEnumHint int) (string, error) {
// SendUserCascadeMessage sends a user chat message to Cascade.
// Returns the (possibly new) cascadeID — it changes if panel-state retry triggers a new StartCascade.
// toolPreamble, if non-empty, is injected into the tool_calling_section override.
// images可选作为 SendUserCascadeMessageRequest.images (field 6) 追加到 proto wire。
//
// allowRecreate 控制 panel-state-not-found 时是否内部静默重建 cascade
// - trueForceWarmup + StartCascade + 用 SAME text 再发一次。调用方须保证
// text 已包含完整历史(否则新 cascade 无状态 + text 无历史 = 上下文丢失)。
// - false直接返回错误让调用方重建含完整历史的 text 后再调。
//
// 经验值StreamCascadeChat 内当 reuseCascadeID 为空(本地 StartCascade 的流程,
// text 已是 full-history时传 truereuse 场景text 可能仅含最后一条消息)传 false。
func (l *LocalLSClient) SendUserCascadeMessage(ctx context.Context, token, cascadeID, text, modelUID, toolPreamble string, modelEnumHint int, images []CascadeImage, allowRecreate bool) (string, error) {
modelEnum := resolveModelEnum(modelUID)
if modelEnum == 0 && modelEnumHint > 0 {
modelEnum = modelEnumHint
@ -170,22 +201,29 @@ func (l *LocalLSClient) SendUserCascadeMessage(ctx context.Context, token, casca
body = append(body, encodeBytesField(2, encodeStringField(1, text))...)
body = append(body, encodeBytesField(3, buildMetadata(token, l.SessionID))...)
body = append(body, encodeBytesField(5, buildCascadeConfig(modelUID, modelEnum, toolPreamble))...)
// field 6: repeated CodeiumImage images逆向自 Windsurf.app chat-client
body = appendSendUserCascadeImages(body, images)
return l.grpcUnary(ctx, SendUserCascadeMessageRPC, body)
}
if err := doSend(cascadeID); err != nil {
if isPanelStateNotFound(err) {
_ = l.ForceWarmupCascade(ctx, token)
newCascadeID, startErr := l.StartCascade(ctx, token)
if startErr != nil {
return "", startErr
}
if err := doSend(newCascadeID); err != nil {
return "", err
}
return newCascadeID, nil
if !isPanelStateNotFound(err) {
return "", err
}
return "", err
if !allowRecreate {
// reuse 场景:不要静默用 last-message-only 的 text 去灌新 cascade。
// 返回错误,让 chatCascade 用 full-history text 重建整个调用。
return "", err
}
_ = l.ForceWarmupCascade(ctx, token)
newCascadeID, startErr := l.StartCascade(ctx, token)
if startErr != nil {
return "", startErr
}
if err := doSend(newCascadeID); err != nil {
return "", err
}
return newCascadeID, nil
}
return cascadeID, nil
}
@ -200,9 +238,9 @@ func buildMetadata(token, sessionID string) []byte {
meta = append(meta, encodeStringField(2, ExtensionVersion)...) // extension_version
meta = append(meta, encodeStringField(3, token)...) // api_key
meta = append(meta, encodeStringField(4, "en")...) // locale
meta = append(meta, encodeStringField(5, RuntimeOS)...) // os
meta = append(meta, encodeStringField(5, RuntimeOS())...) // os
meta = append(meta, encodeStringField(7, IDEVersion)...) // ide_version
meta = append(meta, encodeStringField(8, HardwareArch)...) // hardware
meta = append(meta, encodeStringField(8, HardwareArch())...) // hardware
meta = append(meta, encodeVarintField(9, uint64(time.Now().UnixMilli()))...) // request_id
meta = append(meta, encodeStringField(10, sessionID)...) // session_id
meta = append(meta, encodeStringField(12, AppName)...) // extension_name
@ -372,7 +410,8 @@ func (e *CascadeModelError) Error() string { return e.Msg }
// StreamCascadeChat performs the full Cascade chat flow and returns accumulated text + thinking.
// Includes cold/warm stall detection, step error handling, and final sweep (aligned with JS v1.9).
// If reuseCascadeID is non-empty, skips StartCascade and reuses the existing cascade session.
func (l *LocalLSClient) StreamCascadeChat(ctx context.Context, token, modelUID, userText, toolPreamble, reuseCascadeID string, modelEnumHint int) (*CascadeChatResult, error) {
// images 作为当前 user turn 的图像 sidecar 传递给 SendUserCascadeMessageproto field 6
func (l *LocalLSClient) StreamCascadeChat(ctx context.Context, token, modelUID, userText, toolPreamble, reuseCascadeID string, modelEnumHint int, images []CascadeImage) (*CascadeChatResult, error) {
if err := l.WarmupCascade(ctx, token); err != nil {
return nil, fmt.Errorf("warmup: %w", err)
}
@ -400,7 +439,12 @@ func (l *LocalLSClient) StreamCascadeChat(ctx context.Context, token, modelUID,
}
}
cascadeID, err = l.SendUserCascadeMessage(ctx, token, cascadeID, userText, modelUID, toolPreamble, modelEnumHint)
// allowRecreate=true 仅对本流程内 StartCascade 出来的全新 cascade 安全:
// 此时 userText 已是 full-history内部遇到 panel-not-found 可静默重建再发。
// reuse 场景caller 传入 reuseCascadeID下 userText 可能只含最后一条消息,
// 静默重建会把空状态 cascade 当成有历史的 resume 用 → 上下文丢失,所以禁止。
allowRecreate := reuseCascadeID == ""
cascadeID, err = l.SendUserCascadeMessage(ctx, token, cascadeID, userText, modelUID, toolPreamble, modelEnumHint, images, allowRecreate)
if err != nil {
return nil, fmt.Errorf("SendUserCascadeMessage: %w", err)
}
@ -1241,3 +1285,70 @@ func hasNonPrintable(s string) bool {
}
return false
}
// GetCascadeModelConfigs 查询 LS 的 GetCascadeModelConfigs RPC
// 返回 model_name -> supports_images 的映射。模型名按小写归一化。
func (l *LocalLSClient) GetCascadeModelConfigs(ctx context.Context, token string) (map[string]bool, error) {
// 请求 body只需 metadata即 field 1 encode(Metadata)
// 参考 package.json 提到的 proto这里用 metadata-only encoding 与其他 RPC 一致。
body := encodeBytesField(1, buildMetadata(token, l.SessionID))
raw, err := l.grpcUnaryRaw(ctx, GetCascadeModelConfigsRPC, body)
if err != nil {
return nil, fmt.Errorf("get_cascade_model_configs: %w", err)
}
var resp pb.GetCascadeModelConfigsResponse
if err := proto.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("unmarshal: %w", err)
}
out := make(map[string]bool, len(resp.GetModels()))
for _, m := range resp.GetModels() {
out[strings.ToLower(strings.TrimSpace(m.GetName()))] = m.GetSupportsImages()
}
return out, nil
}
// ModelSupportsImages 带缓存的图像能力查询。
// fail-openRPC 失败且无缓存时返回 (false, false, nil),由上层决定策略。
// 返回值:(found, supportsImages, error)
func (l *LocalLSClient) ModelSupportsImages(ctx context.Context, token, modelName string) (bool, bool, error) {
key := apiKeyHash(token)
l.modelCapsMu.Lock()
if l.modelCapsCache == nil {
l.modelCapsCache = make(map[string]cascadeModelCapsCacheEntry)
}
entry, ok := l.modelCapsCache[key]
fresh := ok && time.Since(entry.FetchedAt) < cascadeModelCapsTTL
l.modelCapsMu.Unlock()
if fresh {
v, found := entry.SupportsImages[strings.ToLower(strings.TrimSpace(modelName))]
return found, v, nil
}
// 拉新:失败时保留 stale
caps, err := l.GetCascadeModelConfigs(ctx, token)
if err != nil {
// stale fallback
if ok {
v, found := entry.SupportsImages[strings.ToLower(strings.TrimSpace(modelName))]
return found, v, nil
}
return false, false, err
}
l.modelCapsMu.Lock()
l.modelCapsCache[key] = cascadeModelCapsCacheEntry{
SupportsImages: caps,
FetchedAt: time.Now(),
}
l.modelCapsMu.Unlock()
v, found := caps[strings.ToLower(strings.TrimSpace(modelName))]
return found, v, nil
}
func apiKeyHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:8]) // 16 hex chars 足够区分
}

View File

@ -1,15 +1,19 @@
package windsurf
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"io"
"log/slog"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"sync/atomic"
@ -36,8 +40,13 @@ type LSPoolConfig struct {
func (c *LSPoolConfig) defaults() {
if c.Binary == "" {
c.Binary = os.Getenv("LS_BINARY_PATH")
if c.Binary == "" {
// Try env override first, then platform-aware discovery, then legacy
// Linux default so existing /opt/windsurf deployments keep booting.
// Real errors (missing binary, wrong platform) still surface later
// at spawn time with more context than we could give here.
if found, err := DiscoverBinary(*c); err == nil {
c.Binary = found
} else {
c.Binary = DefaultLSBinary
}
}
@ -54,7 +63,7 @@ func (c *LSPoolConfig) defaults() {
}
}
if c.DataDir == "" {
c.DataDir = "/opt/windsurf/data"
c.DataDir = resolveDataDir(*c)
}
}
@ -188,13 +197,7 @@ func (p *LSPool) stopEntry(e *LSEntry) {
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
}
terminateProcess(e.Cmd.Process, e.done)
}
type LSStatus struct {
@ -302,6 +305,13 @@ func (p *LSPool) spawnLS(ctx context.Context, key, proxyURL string) (*LSEntry, e
if err := os.MkdirAll(filepath.Join(dataDir, "db"), 0o755); err != nil {
return nil, fmt.Errorf("mkdirAll %s/db: %w", dataDir, err)
}
// Per-instance sandboxed HOME so the LS binary's telemetry/cache writes
// stay inside dataDir instead of leaking into the invoker's real home or
// /root. Required on macOS/Windows where /root does not exist.
homeDir := instanceHomeDir(dataDir)
if err := os.MkdirAll(homeDir, 0o755); err != nil {
return nil, fmt.Errorf("mkdirAll home %s: %w", homeDir, err)
}
args := []string{
fmt.Sprintf("--api_server_url=%s", p.config.APIServerURL),
@ -318,7 +328,10 @@ func (p *LSPool) spawnLS(ctx context.Context, key, proxyURL string) (*LSEntry, e
// 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")
cmd.Env = append(os.Environ(), homeEnvForPlatform(homeDir, runtime.GOOS)...)
// Run with cwd = binary directory so the LS can find helper binaries
// (e.g. `fd`) shipped alongside it in the official install layout.
cmd.Dir = filepath.Dir(p.config.Binary)
if proxyURL != "" {
cmd.Env = append(cmd.Env,
"HTTPS_PROXY="+proxyURL,
@ -328,15 +341,29 @@ func (p *LSPool) spawnLS(ctx context.Context, key, proxyURL string) (*LSEntry, e
)
}
cmd.Stdout = nil
cmd.Stderr = nil
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("ls stdout pipe %s: %w", key, err)
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("ls stderr pipe %s: %w", key, err)
}
p.log("Starting LS instance key=%s port=%d proxy=%s", key, port, redactProxyURL(proxyURL))
attachProcessGroup(cmd)
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("spawn LS %s: %w", key, err)
return nil, wrapSpawnError(key, p.config.Binary, err)
}
pid := 0
if cmd.Process != nil {
pid = cmd.Process.Pid
}
go scanLSOutput(stdoutPipe, key, pid, "stdout")
go scanLSOutput(stderrPipe, key, pid, "stderr")
entry := &LSEntry{
Cmd: cmd,
Port: port,
@ -386,3 +413,47 @@ func (p *LSPool) monitorProcess(key string, entry *LSEntry) {
}
p.mu.Unlock()
}
// scanLSOutput forwards each line from the LS process's stdout/stderr to slog.
// The goroutine exits when the pipe is closed (i.e. when the LS process exits
// and the kernel closes the write end).
func scanLSOutput(r io.Reader, key string, pid int, stream string) {
sc := bufio.NewScanner(r)
// Allow up to 1 MiB per line to handle verbose panic stacks.
sc.Buffer(make([]byte, 4096), 1<<20)
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
if line == "" {
continue
}
slog.Info("windsurf_ls_output",
"key", key,
"pid", pid,
"stream", stream,
"line", line,
)
}
if err := sc.Err(); err != nil && err != io.EOF {
slog.Debug("windsurf_ls_output_scan_error",
"key", key,
"pid", pid,
"stream", stream,
"error", err,
)
}
}
// wrapSpawnError annotates a cmd.Start() failure with platform-specific
// troubleshooting guidance so users don't have to guess why the LS binary
// refused to launch. The original error is preserved via %w so callers can
// still errors.Is/As against it.
func wrapSpawnError(key, binary string, err error) error {
base := fmt.Errorf("spawn LS %s (%s): %w", key, binary, err)
switch runtime.GOOS {
case "darwin":
return fmt.Errorf("%w — if macOS Gatekeeper blocked this, run: xattr -d com.apple.quarantine %s (or reinstall Windsurf from the official app)", base, binary)
case "windows":
return fmt.Errorf("%w — if Windows Defender/SmartScreen blocked this, verify the binary is not quarantined and has execute permissions", base)
}
return base
}

View File

@ -0,0 +1,60 @@
package windsurf
import (
"bytes"
"log/slog"
"strings"
"sync"
"testing"
"time"
)
// TestScanLSOutputEmitsLines checks that scanLSOutput forwards each non-empty
// line (with \r stripped) to slog and exits on EOF without leaking goroutines.
func TestScanLSOutputEmitsLines(t *testing.T) {
prev := slog.Default()
defer slog.SetDefault(prev)
var buf bytes.Buffer
var mu sync.Mutex
slog.SetDefault(slog.New(slog.NewTextHandler(&lockedWriter{b: &buf, mu: &mu}, &slog.HandlerOptions{Level: slog.LevelDebug})))
input := "line one\r\nline two\n\nline three\n"
done := make(chan struct{})
go func() {
scanLSOutput(strings.NewReader(input), "test-key", 4242, "stdout")
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("scanLSOutput did not return within 2s after EOF")
}
mu.Lock()
out := buf.String()
mu.Unlock()
for _, want := range []string{"line one", "line two", "line three", "test-key", "stdout", "pid=4242"} {
if !strings.Contains(out, want) {
t.Errorf("expected log output to contain %q, got:\n%s", want, out)
}
}
if strings.Count(out, "windsurf_ls_output") != 3 {
t.Errorf("expected 3 log entries (empty line skipped), got:\n%s", out)
}
}
// lockedWriter serializes slog handler writes so the test can read the buffer
// safely from the test goroutine.
type lockedWriter struct {
b *bytes.Buffer
mu *sync.Mutex
}
func (w *lockedWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.b.Write(p)
}

View File

@ -0,0 +1,39 @@
//go:build !windows
package windsurf
import (
"os"
"os/exec"
"syscall"
"time"
)
// attachProcessGroup configures the child to run in its own process group so
// we can signal the entire tree on shutdown. Windows has no Unix-style
// process groups; see lspool_stop_windows.go for the no-op.
func attachProcessGroup(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
// terminateProcess asks the LS process (and its children) to exit
// gracefully, then kills them if they don't exit within 5 seconds.
func terminateProcess(p *os.Process, done <-chan struct{}) {
pgid, err := syscall.Getpgid(p.Pid)
if err != nil {
// Process may have already exited; fall back to signalling
// the original PID directly.
pgid = p.Pid
}
// Negative pid here means "send to the whole process group" per kill(2).
_ = syscall.Kill(-pgid, syscall.SIGINT)
select {
case <-done:
case <-time.After(5 * time.Second):
_ = syscall.Kill(-pgid, syscall.SIGKILL)
<-done
}
}

View File

@ -0,0 +1,20 @@
//go:build windows
package windsurf
import (
"os"
"os/exec"
)
// attachProcessGroup is a no-op on Windows; we rely on Process.Kill() which
// maps to TerminateProcess and does not need a process-group hint.
func attachProcessGroup(cmd *exec.Cmd) {}
// terminateProcess kills the LS process immediately. os.Interrupt is a
// no-op on Windows (os/exec returns an error for it), so there is no
// graceful phase — go straight to TerminateProcess via Process.Kill().
func terminateProcess(p *os.Process, done <-chan struct{}) {
_ = p.Kill()
<-done
}

View File

@ -0,0 +1,56 @@
package windsurf
import (
"bytes"
"testing"
)
func TestRuntimeOS_DefaultIsLinux(t *testing.T) {
// t.Setenv unsets after the test, and a nil env var makes RuntimeOS()
// fall back to DefaultRuntimeOS. Setting to "" has the same effect.
t.Setenv("WINDSURF_METADATA_OS", "")
if got := RuntimeOS(); got != "linux" {
t.Errorf("default RuntimeOS = %q, want %q", got, "linux")
}
}
func TestRuntimeOS_EnvOverride(t *testing.T) {
t.Setenv("WINDSURF_METADATA_OS", "darwin")
if got := RuntimeOS(); got != "darwin" {
t.Errorf("env-overridden RuntimeOS = %q, want %q", got, "darwin")
}
}
func TestHardwareArch_DefaultIsX86_64(t *testing.T) {
t.Setenv("WINDSURF_METADATA_ARCH", "")
if got := HardwareArch(); got != "x86_64" {
t.Errorf("default HardwareArch = %q, want %q", got, "x86_64")
}
}
func TestHardwareArch_EnvOverride(t *testing.T) {
t.Setenv("WINDSURF_METADATA_ARCH", "arm64")
if got := HardwareArch(); got != "arm64" {
t.Errorf("env-overridden HardwareArch = %q, want %q", got, "arm64")
}
}
// TestBuildMetadata_UsesOverriddenValues confirms that buildMetadata reads
// RuntimeOS() / HardwareArch() at call time, not at package init, so
// switching the env per-test actually flows through to the wire format.
func TestBuildMetadata_UsesOverriddenValues(t *testing.T) {
t.Setenv("WINDSURF_METADATA_OS", "darwin")
t.Setenv("WINDSURF_METADATA_ARCH", "arm64")
meta := buildMetadata("test-token", "test-session-id")
if !bytes.Contains(meta, []byte("darwin")) {
t.Errorf("expected meta to contain overridden OS %q, got %q", "darwin", meta)
}
if !bytes.Contains(meta, []byte("arm64")) {
t.Errorf("expected meta to contain overridden arch %q, got %q", "arm64", meta)
}
if bytes.Contains(meta, []byte("linux")) || bytes.Contains(meta, []byte("x86_64")) {
t.Errorf("meta should not contain default values when env is set, got %q", meta)
}
}

View File

@ -0,0 +1,81 @@
package windsurf
import (
"fmt"
"os/exec"
"runtime"
"strings"
)
// Platform identifies a (OS, architecture) pair that Windsurf may support.
// OS values match runtime.GOOS ("linux", "darwin", "windows"), and Arch
// values match runtime.GOARCH ("amd64", "arm64").
type Platform struct {
OS string
Arch string
}
// String renders the platform as "os/arch" (e.g. "darwin/arm64").
func (p Platform) String() string { return p.OS + "/" + p.Arch }
// procTranslatedProbe reports whether the current process is running under
// Rosetta 2 translation (an amd64 Go binary on Apple Silicon). It is mocked
// by tests.
var procTranslatedProbe = defaultProcTranslatedProbe
// defaultProcTranslatedProbe runs `sysctl sysctl.proc_translated` on darwin
// and returns true when the value is "1". On non-darwin platforms it always
// returns false. The sysctl key is only defined on darwin, so we skip the
// exec on every other OS to avoid spurious errors.
func defaultProcTranslatedProbe() bool {
if runtime.GOOS != "darwin" {
return false
}
out, err := exec.Command("sysctl", "-n", "sysctl.proc_translated").Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "1"
}
// DetectPlatform returns the platform for which we should load the LS binary.
// It accounts for Rosetta translation: an amd64 Go binary running on Apple
// Silicon should load the arm64 LS, not the x64 one, because the native
// Mac arm64 binary performs better and the x64 build may not exist.
func DetectPlatform() Platform {
return detectPlatformFor(runtime.GOOS, runtime.GOARCH, procTranslatedProbe())
}
func detectPlatformFor(goos, goarch string, rosetta bool) Platform {
p := Platform{OS: goos, Arch: goarch}
if p.OS == "darwin" && p.Arch == "amd64" && rosetta {
p.Arch = "arm64"
}
return p
}
// ErrUnsupportedPlatform is returned when no Windsurf LS binary exists for
// the current platform. Callers should fall back to ls_mode=docker.
var ErrUnsupportedPlatform = fmt.Errorf("windsurf: no language server binary for this platform")
// BinaryFilename returns the official Windsurf LS binary filename for the
// given platform, matching the mapping used by the Windsurf VS Code
// extension (extracted from extension.js PlatformArch enum).
func BinaryFilename(p Platform) (string, error) {
switch {
case p.OS == "linux" && p.Arch == "amd64":
return "language_server_linux_x64", nil
case p.OS == "linux" && p.Arch == "arm64":
return "language_server_linux_arm", nil
case p.OS == "darwin" && p.Arch == "arm64":
return "language_server_macos_arm", nil
case p.OS == "darwin" && p.Arch == "amd64":
return "language_server_macos_x64", nil
case p.OS == "windows" && p.Arch == "amd64":
return "language_server_windows_x64.exe", nil
case p.OS == "windows" && p.Arch == "arm64":
return "language_server_windows_arm.exe", nil
default:
return "", fmt.Errorf("%w: %s — use ls_mode=docker to run the Linux LS in a container", ErrUnsupportedPlatform, p)
}
}

View File

@ -0,0 +1,94 @@
package windsurf
import (
"errors"
"strings"
"testing"
)
func TestBinaryFilename(t *testing.T) {
tests := []struct {
name string
p Platform
want string
wantErr bool
}{
{"linux amd64", Platform{"linux", "amd64"}, "language_server_linux_x64", false},
{"linux arm64", Platform{"linux", "arm64"}, "language_server_linux_arm", false},
{"darwin arm64", Platform{"darwin", "arm64"}, "language_server_macos_arm", false},
{"darwin amd64 (intel mac)", Platform{"darwin", "amd64"}, "language_server_macos_x64", false},
{"windows amd64", Platform{"windows", "amd64"}, "language_server_windows_x64.exe", false},
{"windows arm64", Platform{"windows", "arm64"}, "language_server_windows_arm.exe", false},
{"freebsd", Platform{"freebsd", "amd64"}, "", true},
{"linux 386", Platform{"linux", "386"}, "", true},
{"empty", Platform{}, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := BinaryFilename(tt.p)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil (value %q)", got)
}
if !errors.Is(err, ErrUnsupportedPlatform) {
t.Fatalf("expected ErrUnsupportedPlatform in chain, got %v", err)
}
// Error message should point the user toward Docker.
if !strings.Contains(err.Error(), "docker") {
t.Errorf("unsupported-platform error should mention docker, got: %v", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("BinaryFilename(%v) = %q, want %q", tt.p, got, tt.want)
}
})
}
}
func TestDetectPlatformFor(t *testing.T) {
tests := []struct {
name string
goos string
goarch string
rosetta bool
wantOS string
wantArch string
}{
{"linux amd64 unchanged", "linux", "amd64", false, "linux", "amd64"},
{"windows amd64 unchanged", "windows", "amd64", false, "windows", "amd64"},
{"darwin arm64 unchanged", "darwin", "arm64", false, "darwin", "arm64"},
{"darwin amd64 no rosetta stays amd64", "darwin", "amd64", false, "darwin", "amd64"},
{"darwin amd64 under rosetta promotes to arm64", "darwin", "amd64", true, "darwin", "arm64"},
{"linux amd64 rosetta flag ignored (not darwin)", "linux", "amd64", true, "linux", "amd64"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectPlatformFor(tt.goos, tt.goarch, tt.rosetta)
if got.OS != tt.wantOS || got.Arch != tt.wantArch {
t.Errorf("detectPlatformFor(%q,%q,%v) = %v, want {OS:%q Arch:%q}",
tt.goos, tt.goarch, tt.rosetta, got, tt.wantOS, tt.wantArch)
}
})
}
}
func TestPlatformString(t *testing.T) {
if got := (Platform{OS: "darwin", Arch: "arm64"}).String(); got != "darwin/arm64" {
t.Errorf("Platform.String() = %q, want %q", got, "darwin/arm64")
}
}
func TestProcTranslatedProbeNonDarwin(t *testing.T) {
// The default probe must always return false on non-darwin platforms
// without calling sysctl. We can only validate this runtime behavior on
// the builder we're on — for non-darwin builders the probe should be
// false even if sysctl were somehow present.
//
// For darwin builders, we accept either result; we only assert that the
// probe does not panic and returns a bool.
_ = defaultProcTranslatedProbe()
}

View File

@ -0,0 +1,85 @@
package windsurf
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// Simulates a Windsurf LS that rejects SendUserCascadeMessage with
// "panel state not found". Verifies that allowRecreate=false bubbles the
// error up (so chatCascade can rebuild full-history text) while
// allowRecreate=true still triggers the internal recreate path.
func TestSendUserCascadeMessage_AllowRecreateGatesSilentRetry(t *testing.T) {
tests := []struct {
name string
allowRecreate bool
wantErr bool
// When allowRecreate=true the client tries ForceWarmup + StartCascade.
// When allowRecreate=false we expect no such attempts.
wantStartCascadeCalled bool
}{
{"disabled bubbles error", false, true, false},
{"enabled attempts recreate", true, true, true}, // test LS keeps rejecting so overall errors, but StartCascade must be attempted
}
for _, tt := range tests {
t.Run(tt.name, func(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()
// Every RPC returns panel-not-found so we can detect whether
// StartCascade was attempted after the initial failure.
w.Header().Set("Content-Type", "application/grpc")
w.Header().Set("grpc-status", "5")
w.Header().Set("grpc-message", "panel state not found for session abc")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewLocalLSClient(42099, "csrf")
client.BaseURL = server.URL
client.HTTP = server.Client()
// Pre-mark as warmed so the first SendUserCascadeMessage doesn't trigger
// warmup on its own path — keeps the test focused.
client.Warmed = true
_, err := client.SendUserCascadeMessage(
context.Background(),
"token",
"existing-cascade-id",
"hello",
"claude-sonnet-4",
"",
0,
nil,
tt.allowRecreate,
)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr)
}
mu.Lock()
defer mu.Unlock()
startCascadeCalled := false
for _, p := range paths {
if strings.HasSuffix(p, "/StartCascade") {
startCascadeCalled = true
break
}
}
if startCascadeCalled != tt.wantStartCascadeCalled {
t.Fatalf("StartCascade called = %v, want %v (paths: %v)",
startCascadeCalled, tt.wantStartCascadeCalled, paths)
}
})
}
}

View File

@ -0,0 +1,48 @@
package windsurf
import (
"errors"
"runtime"
"strings"
"testing"
)
func TestWrapSpawnError_PreservesOriginalError(t *testing.T) {
orig := errors.New("exec: no such file")
wrapped := wrapSpawnError("test-key", "/opt/ls", orig)
if !errors.Is(wrapped, orig) {
t.Errorf("wrapped error should unwrap to original, got %v", wrapped)
}
if !strings.Contains(wrapped.Error(), "test-key") {
t.Errorf("wrapped error should mention the instance key, got %v", wrapped)
}
if !strings.Contains(wrapped.Error(), "/opt/ls") {
t.Errorf("wrapped error should mention the binary path, got %v", wrapped)
}
}
// Platform-specific hint assertions: these only run on their native OS.
// Cross-platform CI (Phase 0.2 matrix) exercises each branch natively.
func TestWrapSpawnError_PlatformHint(t *testing.T) {
err := wrapSpawnError("k", "/tmp/ls", errors.New("operation not permitted"))
msg := err.Error()
switch runtime.GOOS {
case "darwin":
for _, want := range []string{"Gatekeeper", "xattr", "com.apple.quarantine"} {
if !strings.Contains(msg, want) {
t.Errorf("darwin hint missing %q: %v", want, err)
}
}
case "windows":
for _, want := range []string{"Defender", "quarantined"} {
if !strings.Contains(msg, want) {
t.Errorf("windows hint missing %q: %v", want, err)
}
}
default:
// Linux and other Unix: plain error, no extra hint.
if strings.Contains(msg, "Gatekeeper") || strings.Contains(msg, "Defender") {
t.Errorf("non-mac/non-windows error should not contain platform-specific hints, got %v", err)
}
}
}

View File

@ -312,12 +312,15 @@ type AnthropicMessage struct {
Content json.RawMessage `json:"content"`
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
// Images 当前消息携带的图像块(仅 user/tool role 有效)。
Images []CascadeImage `json:"images,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
// - 保留 AnthropicMessage.Images 到输出的 ChatMessage.Imagestool role 时挂到 user turn
func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool) []ChatMessage {
var out []ChatMessage
@ -327,6 +330,7 @@ func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool
out = append(out, ChatMessage{
Role: "user",
Content: fmt.Sprintf("<tool_response>\n%s\n</tool_response>", content),
Images: m.Images, // tool_result 里的图抬到 user turn
})
continue
}
@ -353,9 +357,11 @@ func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool
})
parts = append(parts, "<tool_call>"+string(callJSON)+"</tool_call>")
}
// assistant turn 通常不带图,但为了健壮性仍保留(若上游真传了)
out = append(out, ChatMessage{
Role: "assistant",
Content: strings.Join(parts, "\n"),
Images: m.Images,
})
continue
}
@ -363,6 +369,7 @@ func NormalizeMessagesForCascade(messages []AnthropicMessage, tools []OpenAITool
out = append(out, ChatMessage{
Role: m.Role,
Content: extractRawContentText(m.Content),
Images: m.Images,
})
}

View File

@ -54,6 +54,7 @@ const (
defaultGeminiTextTestPrompt = "hi"
defaultGeminiImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
defaultOpenAIImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
defaultClaudeTestPrompt = "hi"
)
// isOpenAIImageModel checks if the model is an OpenAI image generation model (e.g. gpt-image-2).
@ -126,11 +127,15 @@ func generateSessionString() (string, error) {
}
// createTestPayload creates a Claude Code style test request payload
func createTestPayload(modelID string) (map[string]any, error) {
func createTestPayload(modelID string, prompt string) (map[string]any, error) {
sessionID, err := generateSessionString()
if err != nil {
return nil, err
}
text := strings.TrimSpace(prompt)
if text == "" {
text = defaultClaudeTestPrompt
}
return map[string]any{
"model": modelID,
@ -140,7 +145,7 @@ func createTestPayload(modelID string) (map[string]any, error) {
"content": []map[string]any{
{
"type": "text",
"text": "hi",
"text": text,
"cache_control": map[string]string{
"type": "ephemeral",
},
@ -195,11 +200,11 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
return s.testWindsurfAccountConnection(c, account, modelID)
}
return s.testClaudeAccountConnection(c, account, modelID)
return s.testClaudeAccountConnection(c, account, modelID, prompt)
}
// testClaudeAccountConnection tests an Anthropic Claude account's connection
func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account *Account, modelID string) error {
func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
ctx := c.Request.Context()
// Determine the model to use
@ -215,7 +220,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
// Bedrock accounts use a separate test path
if account.IsBedrock() {
return s.testBedrockAccountConnection(c, ctx, account, testModelID)
return s.testBedrockAccountConnection(c, ctx, account, testModelID, prompt)
}
// Determine authentication method and API URL
@ -260,7 +265,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
c.Writer.Flush()
// Create Claude Code style payload (same for all account types)
payload, err := createTestPayload(testModelID)
payload, err := createTestPayload(testModelID, prompt)
if err != nil {
return s.sendErrorAndEnd(c, "Failed to create test payload")
}
@ -285,10 +290,10 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
// Set authentication header
if useBearer {
req.Header.Set("anthropic-beta", claude.DefaultBetaHeader)
req.Header.Set("anthropic-beta", claude.GetOAuthBetaHeader(testModelID))
req.Header.Set("Authorization", "Bearer "+authToken)
} else {
req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader)
req.Header.Set("anthropic-beta", claude.GetAPIKeyBetaHeader(testModelID))
req.Header.Set("x-api-key", authToken)
}
@ -321,7 +326,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
}
// testBedrockAccountConnection tests a Bedrock (SigV4 or API Key) account using non-streaming invoke
func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string) error {
func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string, prompt string) error {
region := bedrockRuntimeRegion(account)
resolvedModelID, ok := ResolveBedrockModelID(account, testModelID)
if !ok {
@ -337,6 +342,10 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
c.Writer.Flush()
// Create a minimal Bedrock-compatible payload (no stream, no cache_control)
bedrockText := strings.TrimSpace(prompt)
if bedrockText == "" {
bedrockText = defaultClaudeTestPrompt
}
bedrockPayload := map[string]any{
"anthropic_version": "bedrock-2023-05-31",
"messages": []map[string]any{
@ -345,7 +354,7 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
"content": []map[string]any{
{
"type": "text",
"text": "hi",
"text": bedrockText,
},
},
},
@ -501,7 +510,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
c.Writer.Flush()
// Create OpenAI Responses API payload
payload := createOpenAITestPayload(testModelID, isOAuth)
payload := createOpenAITestPayload(testModelID, isOAuth, prompt)
payloadBytes, _ := json.Marshal(payload)
// Send test_start event
@ -636,7 +645,7 @@ func (s *AccountTestService) routeAntigravityTest(c *gin.Context, account *Accou
if strings.HasPrefix(modelID, "gemini-") {
return s.testGeminiAccountConnection(c, account, modelID, prompt)
}
return s.testClaudeAccountConnection(c, account, modelID)
return s.testClaudeAccountConnection(c, account, modelID, prompt)
}
return s.testAntigravityAccountConnection(c, account, modelID)
}
@ -955,7 +964,11 @@ func (s *AccountTestService) processGeminiStream(c *gin.Context, body io.Reader)
}
// createOpenAITestPayload creates a test payload for OpenAI Responses API
func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any {
func createOpenAITestPayload(modelID string, isOAuth bool, prompt string) map[string]any {
openaiText := strings.TrimSpace(prompt)
if openaiText == "" {
openaiText = defaultClaudeTestPrompt
}
payload := map[string]any{
"model": modelID,
"input": []map[string]any{
@ -964,7 +977,7 @@ func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any {
"content": []map[string]any{
{
"type": "input_text",
"text": "hi",
"text": openaiText,
},
},
},

View File

@ -599,7 +599,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
return nil, fmt.Errorf("no access token available")
}
modelID := openaipkg.DefaultTestModel
payload := createOpenAITestPayload(modelID, true)
payload := createOpenAITestPayload(modelID, true, "")
payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal openai probe payload: %w", err)

View File

@ -39,6 +39,9 @@ type WindsurfChatRequest struct {
Tools []windsurf.OpenAITool
ToolChoice interface{}
ToolPreamble string // computed by handler, passed through to Cascade
// Images 当前 user turn 的 sidecar 图像Cascade proto 的 SendUserCascadeMessageRequest.images field 6
// 内容必须已通过 ValidateCascadeImages或等价校验
Images []windsurf.CascadeImage
}
type WindsurfChatResponse struct {
@ -80,11 +83,11 @@ func (s *WindsurfChatService) Chat(ctx context.Context, req *WindsurfChatRequest
var resp *WindsurfChatResponse
switch mode {
case "cascade":
resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint)
resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint, req.Images)
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)
resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint, req.Images)
}
if err != nil {
@ -111,7 +114,39 @@ func (s *WindsurfChatService) resolveMode(meta *windsurf.ModelMeta) string {
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) {
var modelIdentityTemplates = map[string]string{
"anthropic": "You are %s, a large language model created by Anthropic. You are helpful, harmless, and honest. When asked about your identity or which model you are, you MUST respond that you are %s, made by Anthropic.",
"openai": "You are %s, a large language model created by OpenAI. When asked about your identity, you MUST respond that you are %s, made by OpenAI.",
"google": "You are %s, a large language model created by Google. When asked about your identity, you MUST respond that you are %s, made by Google.",
"deepseek": "You are %s, a large language model created by DeepSeek. When asked about your identity, you MUST respond that you are %s, made by DeepSeek.",
"xai": "You are %s, a large language model created by xAI. When asked about your identity, you MUST respond that you are %s, made by xAI.",
}
func injectModelIdentity(messages []windsurf.ChatMessage, meta *windsurf.ModelMeta, modelKey string) []windsurf.ChatMessage {
if meta == nil || meta.Provider == "" {
return messages
}
for _, m := range messages {
if m.Role == "system" {
return messages
}
}
tmpl, ok := modelIdentityTemplates[meta.Provider]
if !ok {
return messages
}
displayName := modelKey
if meta.Name != "" {
displayName = meta.Name
}
identity := windsurf.ChatMessage{
Role: "system",
Content: fmt.Sprintf(tmpl, displayName, displayName),
}
return append([]windsurf.ChatMessage{identity}, messages...)
}
func (s *WindsurfChatService) chatCascade(ctx context.Context, client *windsurf.LocalLSClient, apiKey string, meta *windsurf.ModelMeta, messages []windsurf.ChatMessage, toolPreamble string, modelKey string, lsEndpoint string, images []windsurf.CascadeImage) (*WindsurfChatResponse, error) {
modelUID := ""
modelEnumHint := 0
if meta != nil {
@ -119,12 +154,36 @@ func (s *WindsurfChatService) chatCascade(ctx context.Context, client *windsurf.
modelEnumHint = meta.EnumValue
}
// ── Model identity prompt injection ──
// When the client doesn't provide its own system prompt, prepend one so
// the model identifies itself as the requested model rather than leaking
// the underlying Windsurf/Cascade backend identity.
// Skip when the client already has a system message (Claude Code / Cline)
// to avoid triggering Cascade anti-injection on reasoning models.
messages = injectModelIdentity(messages, meta, modelKey)
// 图像能力 gate仅在请求含图时检查。
// 策略fail-open on RPC error显式 supports_images=false 时拒绝(返回 CascadeModelError 触发 failover
if len(images) > 0 {
found, ok, err := client.ModelSupportsImages(ctx, apiKey, modelUID)
if err != nil {
slog.Warn("windsurf_cascade_caps_fetch_failed", "model", modelUID, "error", err)
// fail-open
} else if found && !ok {
return nil, fmt.Errorf("model %q does not support image inputs in Windsurf Cascade", modelUID)
}
}
fpBefore := windsurf.FingerprintBefore(messages, modelKey, apiKey)
// failover 切号后禁止复用 cascadecascade_id 属于上一个账号的 LS
// 在当前账号上一定会触发 "panel state not found" 浪费一次请求。
// 同时切号场景下需要提升历史预算——新账号完全没有服务端上下文,
// 必须把完整聊天记录塞进文本里。
skipReuse := false
switchover := false
if switches, ok := AccountSwitchCountFromContext(ctx); ok && switches > 0 {
skipReuse = true
switchover = true
}
var entry *windsurf.ConversationEntry
if !skipReuse {
@ -138,13 +197,14 @@ func (s *WindsurfChatService) chatCascade(ctx context.Context, client *windsurf.
slog.Info("windsurf_cascade_reuse_hit", "cascade_id", reuseCascadeID[:8], "model", modelKey)
}
userText := buildCascadeText(messages, modelUID, isResume)
userText := buildCascadeText(messages, modelUID, isResume, switchover)
result, err := client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, reuseCascadeID, modelEnumHint)
result, err := client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, reuseCascadeID, modelEnumHint, images)
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, "", modelEnumHint)
// panel-state-not-found 恢复:新 cascade 没有服务端历史,必须发完整聊天记录。
userText = buildCascadeText(messages, modelUID, false, true)
result, err = client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, "", modelEnumHint, images)
}
if err != nil {
return nil, err
@ -189,12 +249,19 @@ func (s *WindsurfChatService) chatLegacy(ctx context.Context, client *windsurf.L
}
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."
cascadeMaxHistoryBytes = 200_000
cascade1MHistoryBytes = 900_000
// cascadeSwitchoverHistoryBytes 是切号 / panel-state-not-found 恢复场景下的
// "尽量塞进完整历史" 预算。目标是让新账号拿到尽可能完整的对话上下文。
// 3.5MB 留了 500KB 给 proto 其它字段metadata/config/images避开 gRPC 4MB 默认上限。
cascadeSwitchoverHistoryBytes = 3_500_000
cascadeMultiTurnPreamble = "The following is a multi-turn conversation. You MUST remember and use all information from prior turns."
)
func cascadeHistoryBudget(modelUID string) int {
func cascadeHistoryBudget(modelUID string, switchover bool) int {
if switchover {
return cascadeSwitchoverHistoryBytes
}
if strings.Contains(strings.ToLower(modelUID), "1m") {
return cascade1MHistoryBytes
}
@ -205,7 +272,11 @@ func cascadeHistoryBudget(modelUID string) int {
// 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 {
//
// switchover=true 提升历史预算到 cascadeSwitchoverHistoryBytes~3.5MB
// 用于切号 / panel-state-not-found 恢复场景——新账号/新 cascade 没有服务端历史,
// 必须把完整聊天记录塞进文本里。isResume=true 时该参数被忽略resume 只发最后一条)。
func buildCascadeText(messages []windsurf.ChatMessage, modelUID string, isResume, switchover bool) string {
var systemParts []string
var convo []windsurf.ChatMessage
@ -241,11 +312,12 @@ func buildCascadeText(messages []windsurf.ChatMessage, modelUID string, isResume
}
// Multi-turn: build history with budget trimming
maxBytes := cascadeHistoryBudget(modelUID)
maxBytes := cascadeHistoryBudget(modelUID, switchover)
historyBytes := len(sysText)
// Walk backward from second-to-last, collecting turns that fit
var lines []string
droppedTurns := 0
for i := len(convo) - 2; i >= 0; i-- {
m := convo[i]
tag := "human"
@ -254,16 +326,26 @@ func buildCascadeText(messages []windsurf.ChatMessage, modelUID string, isResume
}
line := fmt.Sprintf("<%s>\n%s\n</%s>", tag, m.Content, tag)
if historyBytes+len(line) > maxBytes && len(lines) > 0 {
droppedTurns = i + 1
slog.Info("windsurf_cascade_history_trimmed",
"turn", i,
"total_turns", len(convo),
"kept_kb", historyBytes/1024,
"dropped_turns", droppedTurns,
"switchover", switchover,
)
break
}
lines = append([]string{line}, lines...)
historyBytes += len(line)
}
if switchover && droppedTurns == 0 {
slog.Info("windsurf_cascade_switchover_history",
"total_turns", len(convo),
"kept_kb", historyBytes/1024,
"dropped_turns", 0,
)
}
latest := convo[len(convo)-1]
text := cascadeMultiTurnPreamble + "\n\n" +

View File

@ -0,0 +1,167 @@
package service
import (
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
)
// Test that the switchover flag expands the history budget to ~3.5MB and preserves
// all turns for a large multi-turn conversation that would otherwise be trimmed
// under the normal 200KB budget. This guards the core fix: after a Windsurf
// account switch, the new account must receive the full chat history.
func TestBuildCascadeText_SwitchoverKeepsFullHistory(t *testing.T) {
// Build a ~1.5MB multi-turn history: 30 turns of ~50KB each (alternating
// user/assistant). Exceeds the normal 200KB cap; well within the 3.5MB cap.
const perTurnBytes = 50 * 1024
const turns = 30
bulk := strings.Repeat("x", perTurnBytes)
var messages []windsurf.ChatMessage
messages = append(messages, windsurf.ChatMessage{Role: "system", Content: "sys"})
for i := 0; i < turns; i++ {
role := "user"
if i%2 == 1 {
role = "assistant"
}
messages = append(messages, windsurf.ChatMessage{Role: role, Content: bulk})
}
// Latest user message (the one actually being answered).
messages = append(messages, windsurf.ChatMessage{Role: "user", Content: "final question"})
normalText := buildCascadeText(messages, "claude-sonnet-4", false, false)
switchoverText := buildCascadeText(messages, "claude-sonnet-4", false, true)
if len(normalText) >= len(switchoverText) {
t.Fatalf("switchover text (%d bytes) must be larger than normal (%d bytes)",
len(switchoverText), len(normalText))
}
if len(normalText) > cascadeMaxHistoryBytes+perTurnBytes {
t.Fatalf("normal text (%d bytes) must fit near %d budget", len(normalText), cascadeMaxHistoryBytes)
}
if len(switchoverText) < perTurnBytes*turns {
t.Fatalf("switchover text (%d bytes) dropped turns; expected >= %d (all %d turns kept)",
len(switchoverText), perTurnBytes*turns, turns)
}
if len(switchoverText) > cascadeSwitchoverHistoryBytes+perTurnBytes {
t.Fatalf("switchover text (%d bytes) exceeded budget %d", len(switchoverText), cascadeSwitchoverHistoryBytes)
}
// Final user message must always be preserved (it's the question being asked).
if !strings.Contains(switchoverText, "final question") {
t.Fatal("switchover text must include the final user message")
}
if !strings.Contains(normalText, "final question") {
t.Fatal("normal text must include the final user message")
}
}
// Resume mode ignores switchover — only the last user message is sent because
// Cascade server already has the history for the reused cascade_id.
func TestBuildCascadeText_ResumeIgnoresSwitchover(t *testing.T) {
messages := []windsurf.ChatMessage{
{Role: "user", Content: "first"},
{Role: "assistant", Content: "reply"},
{Role: "user", Content: "second question"},
}
got := buildCascadeText(messages, "claude-sonnet-4", true, true)
if got != "second question" {
t.Fatalf("resume=true must return only last user message, got %q", got)
}
}
func TestInjectModelIdentity(t *testing.T) {
tests := []struct {
name string
messages []windsurf.ChatMessage
meta *windsurf.ModelMeta
modelKey string
wantInjected bool
}{
{
name: "anthropic model without system",
messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}},
meta: &windsurf.ModelMeta{Name: "claude-sonnet-4.6", Provider: "anthropic"},
modelKey: "claude-sonnet-4.6",
wantInjected: true,
},
{
name: "client already has system — skip injection",
messages: []windsurf.ChatMessage{
{Role: "system", Content: "You are a helpful assistant"},
{Role: "user", Content: "hi"},
},
meta: &windsurf.ModelMeta{Name: "claude-sonnet-4.6", Provider: "anthropic"},
modelKey: "claude-sonnet-4.6",
wantInjected: false,
},
{
name: "nil meta — skip injection",
messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}},
meta: nil,
modelKey: "unknown",
wantInjected: false,
},
{
name: "unknown provider — skip injection",
messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}},
meta: &windsurf.ModelMeta{Name: "some-model", Provider: "unknownvendor"},
modelKey: "some-model",
wantInjected: false,
},
{
name: "openai model without system",
messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}},
meta: &windsurf.ModelMeta{Name: "gpt-4o", Provider: "openai"},
modelKey: "gpt-4o",
wantInjected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := injectModelIdentity(tt.messages, tt.meta, tt.modelKey)
if tt.wantInjected {
if len(result) != len(tt.messages)+1 {
t.Fatalf("expected injection (len %d → %d), got len %d",
len(tt.messages), len(tt.messages)+1, len(result))
}
if result[0].Role != "system" {
t.Fatalf("injected message role = %q, want system", result[0].Role)
}
displayName := tt.meta.Name
if !strings.Contains(result[0].Content, displayName) {
t.Fatalf("injected content should contain model name %q, got %q",
displayName, result[0].Content)
}
} else {
if len(result) != len(tt.messages) {
t.Fatalf("expected no injection (len %d), got len %d",
len(tt.messages), len(result))
}
}
})
}
}
func TestCascadeHistoryBudget(t *testing.T) {
tests := []struct {
name string
modelUID string
switchover bool
want int
}{
{"normal model normal budget", "claude-sonnet-4", false, cascadeMaxHistoryBytes},
{"1m model normal budget", "claude-sonnet-4-1m", false, cascade1MHistoryBytes},
{"normal model switchover", "claude-sonnet-4", true, cascadeSwitchoverHistoryBytes},
{"1m model switchover", "claude-sonnet-4-1m", true, cascadeSwitchoverHistoryBytes},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := cascadeHistoryBudget(tt.modelUID, tt.switchover); got != tt.want {
t.Errorf("cascadeHistoryBudget(%q, %v) = %d, want %d",
tt.modelUID, tt.switchover, got, tt.want)
}
})
}
}

View File

@ -81,14 +81,15 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
})
}
for _, m := range req.Messages {
for mi, m := range req.Messages {
contentBlocks := windsurfParseContentBlocks(m.Content)
var toolResultMsgs []windsurf.AnthropicMessage
var toolUseMsgs []windsurf.OpenAIToolCall
var textParts []string
var turnImages []windsurf.CascadeImage
for _, block := range contentBlocks {
for bi, block := range contentBlocks {
switch block.Type {
case "tool_result":
hasToolHistory = true
@ -102,6 +103,13 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
Content: contentJSON,
ToolCallID: block.ToolUseID,
})
// tool_result 内部可能含 image 块;按规划策略,把它们提取出来归到当前 turn images
if extractedImgs, err := windsurfExtractImagesFromRaw(block.Content, fmt.Sprintf("messages[%d].content[%d].content", mi, bi)); err != nil {
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return nil, err
} else {
turnImages = append(turnImages, extractedImgs...)
}
case "tool_use":
hasToolHistory = true
inputJSON, _ := json.Marshal(block.Input)
@ -117,6 +125,21 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
textParts = append(textParts, block.Text)
case "thinking":
// skip
case "image":
if block.Source == nil {
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error",
fmt.Sprintf("messages[%d].content[%d].source is required for image blocks", mi, bi))
return nil, fmt.Errorf("image block missing source")
}
if !strings.EqualFold(strings.TrimSpace(block.Source.Type), "base64") {
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error",
fmt.Sprintf("messages[%d].content[%d].source.type must be \"base64\"", mi, bi))
return nil, fmt.Errorf("unsupported image source type")
}
turnImages = append(turnImages, windsurf.CascadeImage{
MimeType: block.Source.MediaType,
Base64Data: block.Source.Data,
})
default:
if block.Text != "" {
textParts = append(textParts, block.Text)
@ -130,8 +153,13 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
Role: m.Role,
Content: contentJSON,
ToolCalls: toolUseMsgs,
Images: turnImages,
})
} else if len(toolResultMsgs) > 0 {
// tool_result 消息:图片挂到第一条 tool_result 上(保持对应关系大致正确)
if len(turnImages) > 0 && len(toolResultMsgs) > 0 {
toolResultMsgs[0].Images = turnImages
}
for _, tr := range toolResultMsgs {
anthropicMsgs = append(anthropicMsgs, tr)
}
@ -141,6 +169,7 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
anthropicMsgs = append(anthropicMsgs, windsurf.AnthropicMessage{
Role: m.Role,
Content: contentJSON,
Images: turnImages,
})
}
}
@ -165,10 +194,38 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
chatMessages = append(chatMessages, windsurf.ChatMessage{
Role: m.Role,
Content: text,
Images: m.Images,
})
}
}
// 提取"当前 user turn"的图像作为 sidecar发给 Cascade 的 images 字段。
// 策略:最后一个 role=="user" 的 message 的 Images。
var currentTurnImages []windsurf.CascadeImage
for i := len(chatMessages) - 1; i >= 0; i-- {
if chatMessages[i].Role == "user" && len(chatMessages[i].Images) > 0 {
currentTurnImages = chatMessages[i].Images
break
}
}
// 本地确定性校验fail-fast 返回 Anthropic 风格 400
if len(currentTurnImages) > 0 {
if err := windsurf.ValidateCascadeImages(currentTurnImages, windsurf.DefaultCascadeImageValidationOptions()); err != nil {
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return nil, err
}
// 同步把 digests 写回 chat messages便于后续指纹/日志
for i := range chatMessages {
if len(chatMessages[i].Images) > 0 {
chatMessages[i].ImageDigests = windsurf.BuildImageDigests(chatMessages[i].Images)
}
}
reqLog.Info("windsurf_gateway.images",
zap.Int("current_turn_images", len(currentTurnImages)),
)
}
chatReq := &WindsurfChatRequest{
AccountID: account.ID,
Model: req.Model,
@ -176,6 +233,7 @@ func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, ac
Stream: req.Stream,
Tools: openAITools,
ToolPreamble: toolPreamble,
Images: currentTurnImages,
}
upstreamStart := time.Now()
@ -551,13 +609,22 @@ type windsurfRequestTool struct {
// ---- 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"`
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"`
// Source 来自 Anthropic image block{type:"base64", media_type:"image/png", data:"..."}
Source *windsurfContentImageSource `json:"source,omitempty"`
}
// windsurfContentImageSource 对应 Anthropic image content block 的 source 字段。
type windsurfContentImageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
}
func windsurfParseContentBlocks(raw json.RawMessage) []windsurfContentBlock {
@ -727,6 +794,41 @@ func windsurfExtractContentTextFromRaw(raw json.RawMessage) string {
return string(raw)
}
// windsurfExtractImagesFromRaw 从 tool_result 的 content 字段里提取 image 块,
// 返回可直接送进 Cascade 的 CascadeImage未校验
// pathLabel 用于生成错误消息Anthropic 风格 error message
func windsurfExtractImagesFromRaw(raw json.RawMessage, pathLabel string) ([]windsurf.CascadeImage, error) {
if len(raw) == 0 {
return nil, nil
}
// 纯字符串 content 没有图
var s string
if json.Unmarshal(raw, &s) == nil {
return nil, nil
}
var blocks []windsurfContentBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
return nil, nil // 不是 block 数组,按纯文本处理,没图
}
var out []windsurf.CascadeImage
for i, b := range blocks {
if b.Type != "image" {
continue
}
if b.Source == nil {
return nil, fmt.Errorf("%s[%d].source is required for image blocks", pathLabel, i)
}
if !strings.EqualFold(strings.TrimSpace(b.Source.Type), "base64") {
return nil, fmt.Errorf("%s[%d].source.type must be \"base64\"", pathLabel, i)
}
out = append(out, windsurf.CascadeImage{
MimeType: b.Source.MediaType,
Base64Data: b.Source.Data,
})
}
return out, nil
}
func windsurfLogger(c *gin.Context, component string, fields ...zap.Field) *zap.Logger {
l := logger.L().With(zap.String("component", component))
if c != nil {

View File

@ -88,6 +88,15 @@ func (s *WindsurfLSService) Status() *windsurf.LSConnectorStatus {
return s.connector.Status()
}
// Stop terminates LS resources owned by this service (embedded LS processes,
// docker discovery goroutines). Safe to call on a nil receiver.
func (s *WindsurfLSService) Stop() {
if s == nil || s.connector == nil {
return
}
s.connector.Shutdown()
}
type WindsurfAuthService struct {
cfg config.WindsurfConfig
authClient *windsurf.AuthClient

View File

@ -109,14 +109,10 @@
</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"
>
<select v-model="commonOpts.group_id" class="input">
<option :value="null">{{ t('admin.accounts.ungroupedGroup') }}</option>
<option v-for="g in groups" :key="g.id" :value="g.id">
{{ g.name }}
{{ formatGroupName(g.name) }}
</option>
</select>
</div>
@ -210,7 +206,7 @@ const batchText = ref('')
const commonOpts = reactive({
proxy_id: null as number | null,
group_ids: [] as number[],
group_id: null as number | null,
concurrency: 1,
probe_after: true
})
@ -218,7 +214,18 @@ const commonOpts = reactive({
const batchResults = ref<WindsurfBatchLoginResult[]>([])
const batchSuccessCount = computed(() => batchResults.value.filter(r => r.success).length)
function formatGroupName(name: string) {
return name === 'default' ? t('admin.accounts.defaultGroup') : name
}
function selectedGroupIds() {
return commonOpts.group_id === null ? undefined : [commonOpts.group_id]
}
function handleClose() {
if (batchResults.value.length > 0) {
emit('created')
}
emit('close')
singleForm.email = ''
singleForm.password = ''
@ -235,7 +242,6 @@ async function handleSubmit() {
} else {
await handleBatchLogin()
}
emit('created')
} catch (e: any) {
appStore.showError(e?.response?.data?.message || e?.message || t('admin.windsurf.loginFailed'))
} finally {
@ -249,13 +255,14 @@ async function handleSingleLogin() {
password: singleForm.password,
name: singleForm.name || singleForm.email,
proxy_id: commonOpts.proxy_id,
group_ids: commonOpts.group_ids.length > 0 ? commonOpts.group_ids : undefined,
group_ids: selectedGroupIds(),
concurrency: commonOpts.concurrency,
probe_after: commonOpts.probe_after
})
appStore.showSuccess(
`${t('admin.windsurf.loginSuccess')}${resp.email} (${resp.tier})`
)
emit('created')
handleClose()
}
@ -265,12 +272,15 @@ async function handleBatchLogin() {
.map(l => l.trim())
.filter(l => l.length > 0 && l.includes('----'))
if (items.length === 0) return
if (items.length === 0) {
appStore.showError(t('admin.windsurf.batchItemsRequired'))
return
}
const resp = await adminAPI.windsurf.batchLogin({
items,
proxy_id: commonOpts.proxy_id,
group_ids: commonOpts.group_ids.length > 0 ? commonOpts.group_ids : undefined,
group_ids: selectedGroupIds(),
concurrency: commonOpts.concurrency,
probe_after: commonOpts.probe_after
})

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import en from '../locales/en'
import zh from '../locales/zh'
describe('windsurf locale messages', () => {
it('escapes email placeholders so vue-i18n does not parse them as linked messages', () => {
expect(zh.admin.windsurf.batchItemsPlaceholder).toContain("user1{'@'}example.com----password1")
expect(zh.admin.windsurf.batchItemsPlaceholder).not.toContain('user1@example.com')
expect(zh.admin.windsurf.batchItemsRequired).toContain('email----password')
expect(zh.admin.accounts.groups).toBe('分组')
expect(zh.admin.accounts.defaultGroup).toBe('默认分组')
expect(en.admin.windsurf.batchItemsPlaceholder).toContain("user1{'@'}example.com----password1")
expect(en.admin.windsurf.batchItemsPlaceholder).not.toContain('user1@example.com')
expect(en.admin.windsurf.batchItemsRequired).toContain('email----password')
expect(en.admin.accounts.groups).toBe('Groups')
expect(en.admin.accounts.defaultGroup).toBe('Default Group')
})
})

View File

@ -2513,6 +2513,8 @@ export default {
allTypes: 'All Types',
allStatus: 'All Status',
allGroups: 'All Groups',
groups: 'Groups',
defaultGroup: 'Default Group',
ungroupedGroup: 'Ungrouped',
oauthType: 'OAuth',
setupToken: 'Setup Token',
@ -3379,6 +3381,21 @@ export default {
imageTestMode: 'Mode: Image generation test',
imagePreview: 'Generated images:',
imageReceived: 'Received test image #{count}',
customPromptLabel: 'Custom prompt (optional)',
customPromptPlaceholder: 'Leave empty to use the default prompt, or enter custom content here',
customPromptHint: 'Customize the test message to avoid fixed prompts being flagged as test traffic.',
geminiImagePromptLabel: 'Image prompt',
geminiImagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.',
geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
geminiImageTestHint: 'When a Gemini image model is selected, this test sends a real image-generation request and previews the returned image below.',
geminiImageTestMode: 'Mode: Gemini image generation test',
geminiImagePreview: 'Generated images:',
geminiImageReceived: 'Received test image #{count}',
soraUpstreamBaseUrlHint: 'Upstream Sora service URL (another Sub2API instance or compatible API)',
soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).',
soraTestTarget: 'Target: Sora account capability',
soraTestMode: 'Mode: Connectivity + Capability checks',
soraTestingFlow: 'Running Sora connectivity and capability checks...',
// Stats Modal
viewStats: 'View Stats',
usageStatistics: 'Usage Statistics',
@ -5646,7 +5663,8 @@ export default {
password: 'Password',
batchItems: 'Batch Accounts',
batchItemsHint: 'One per line, format: email----password',
batchItemsPlaceholder: 'user1@example.com----password1\nuser2@example.com----password2',
batchItemsPlaceholder: "user1{'@'}example.com----password1\nuser2{'@'}example.com----password2",
batchItemsRequired: 'Enter at least one valid account line in the format: email----password',
probeAfterLogin: 'Probe after login',
loginSuccess: 'Windsurf login successful',
loginFailed: 'Windsurf login failed',

View File

@ -2592,6 +2592,8 @@ export default {
allTypes: '全部类型',
allStatus: '全部状态',
allGroups: '全部分组',
groups: '分组',
defaultGroup: '默认分组',
ungroupedGroup: '未分配分组',
oauthType: 'OAuth',
// Schedulable toggle
@ -3507,6 +3509,21 @@ export default {
imageTestMode: '模式:生图测试',
imagePreview: '生成结果:',
imageReceived: '已收到第 {count} 张测试图片',
customPromptLabel: '自定义提示词(可选)',
customPromptPlaceholder: '留空则使用默认提示词,填写后将发送自定义内容',
customPromptHint: '自定义测试内容,避免使用固定提示词被识别为测试流量。',
geminiImagePromptLabel: '生图提示词',
geminiImagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
geminiImageTestHint: '选择 Gemini 图片模型后,这里会直接发起生图测试,并在下方展示返回图片。',
geminiImageTestMode: '模式Gemini 生图测试',
geminiImagePreview: '生成结果:',
geminiImageReceived: '已收到第 {count} 张测试图片',
soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 Sub2API 实例或兼容 API',
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
soraTestTarget: '检测目标Sora 账号能力',
soraTestMode: '模式:连通性 + 能力探测',
soraTestingFlow: '执行 Sora 连通性与能力检测...',
// Stats Modal
viewStats: '查看统计',
usageStatistics: '使用统计',
@ -5807,7 +5824,8 @@ export default {
password: '密码',
batchItems: '批量账号',
batchItemsHint: '每行一个格式email----password',
batchItemsPlaceholder: 'user1@example.com----password1\nuser2@example.com----password2',
batchItemsPlaceholder: "user1{'@'}example.com----password1\nuser2{'@'}example.com----password2",
batchItemsRequired: '请输入至少一行有效账号格式email----password',
probeAfterLogin: '登录后自动探测',
loginSuccess: 'Windsurf 登录成功',
loginFailed: 'Windsurf 登录失败',