package service
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type WindsurfGatewayService struct {
chatService *WindsurfChatService
cfg config.WindsurfConfig
accountRepo AccountRepository
}
func NewWindsurfGatewayService(
chatService *WindsurfChatService,
cfg config.WindsurfConfig,
accountRepo AccountRepository,
) *WindsurfGatewayService {
return &WindsurfGatewayService{
chatService: chatService,
cfg: cfg,
accountRepo: accountRepo,
}
}
// Forward 处理 Windsurf 平台的 Anthropic-兼容请求。
// groupID 与 sessionHash 用于 Cascade 多轮复用:在同一 sticky session 上复用上游 LS cascade,
// 跳过 StartCascade 的额外 RPC,并避免每轮把 full-history 重灌进 trajectory。空值表示不复用。
func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte, _ bool, groupID int64, sessionHash string) (*ForwardResult, error) {
startTime := time.Now()
reqLog := windsurfLogger(c, "windsurf_gateway.forward",
zap.Int64("account_id", account.ID),
)
var req windsurfMessagesRequest
if err := json.Unmarshal(body, &req); err != nil {
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Invalid request body")
return nil, fmt.Errorf("unmarshal request: %w", err)
}
fillWindsurfWorkspaceContextFromHeaders(&req, c)
workspaceContext := normalizeWindsurfRequest(&req)
if strings.TrimSpace(req.Model) == "" {
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Missing model")
return nil, fmt.Errorf("missing model")
}
reqLog = reqLog.With(zap.String("model", req.Model), zap.Bool("stream", req.Stream), zap.Int("tools_count", len(req.Tools)))
// Convert Anthropic tools to OpenAI format
var openAITools []windsurf.OpenAITool
for _, t := range req.Tools {
openAITools = append(openAITools, windsurf.OpenAITool{
Type: "function",
Function: windsurf.OpenAIFunction{
Name: t.Name,
Description: t.Description,
Parameters: t.InputSchema,
},
})
}
hasTools := len(openAITools) > 0
// Convert Anthropic messages to intermediate form
var anthropicMsgs []windsurf.AnthropicMessage
hasToolHistory := false
if len(req.System) > 0 {
anthropicMsgs = append(anthropicMsgs, windsurf.AnthropicMessage{
Role: "system",
Content: req.System,
})
}
for 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 bi, block := range contentBlocks {
switch block.Type {
case "tool_result":
hasToolHistory = true
resultContent := ""
if block.Content != nil {
resultContent = windsurfExtractContentTextFromRaw(block.Content)
}
contentJSON, _ := json.Marshal(resultContent)
toolResultMsgs = append(toolResultMsgs, windsurf.AnthropicMessage{
Role: "tool",
Content: contentJSON,
ToolCallID: block.ToolUseID,
})
// 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)
toolUseMsgs = append(toolUseMsgs, windsurf.OpenAIToolCall{
ID: block.ID,
Type: "function",
Function: windsurf.OpenAIToolCallFunc{
Name: block.Name,
Arguments: string(inputJSON),
},
})
case "text":
textParts = append(textParts, block.Text)
case "thinking":
// skip
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)
}
}
}
if len(toolUseMsgs) > 0 {
contentJSON, _ := json.Marshal(strings.Join(textParts, "\n"))
anthropicMsgs = append(anthropicMsgs, windsurf.AnthropicMessage{
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)
}
} else {
text := windsurfExtractContentText(m.Content)
contentJSON, _ := json.Marshal(text)
anthropicMsgs = append(anthropicMsgs, windsurf.AnthropicMessage{
Role: m.Role,
Content: contentJSON,
Images: turnImages,
})
}
}
emulateTools := hasTools || hasToolHistory
var chatMessages []windsurf.ChatMessage
var toolPreamble string
if emulateTools {
toolPreamble = windsurf.BuildToolPreambleForProtoWithEnvironment(openAITools, req.ToolChoice, workspaceContext)
chatMessages = windsurf.NormalizeMessagesForCascade(anthropicMsgs, []windsurf.OpenAITool{})
reqLog.Info("windsurf_gateway.tool_emulation",
zap.Int("tools_count", len(openAITools)),
zap.Int("preamble_len", len(toolPreamble)),
zap.Int("messages_count", len(chatMessages)),
zap.Bool("has_tool_history", hasToolHistory),
)
} else {
for _, m := range anthropicMsgs {
text := windsurfExtractContentText(json.RawMessage(m.Content))
chatMessages = append(chatMessages, windsurf.ChatMessage{
Role: m.Role,
Content: text,
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,
GroupID: groupID,
SessionHash: sessionHash,
Model: req.Model,
Messages: chatMessages,
Stream: req.Stream,
Tools: openAITools,
ToolPreamble: toolPreamble,
Images: currentTurnImages,
}
upstreamStart := time.Now()
resp, err := s.chatService.Chat(ctx, chatReq)
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
if err != nil {
reqLog.Error("windsurf_gateway.chat_failed", zap.Error(err))
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: PlatformWindsurf,
AccountID: account.ID,
AccountName: account.Name,
Kind: "http_error",
Message: err.Error(),
})
// CascadeModelError → set model rate limit + trigger account failover
var modelErr *windsurf.CascadeModelError
if errors.As(err, &modelErr) {
modelKey := windsurf.ResolveModel(req.Model)
cooldown := 5 * time.Minute
if strings.Contains(modelErr.Msg, "stall") {
cooldown = 60 * time.Second
}
resetAt := time.Now().Add(cooldown)
if s.accountRepo != nil {
if rlErr := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, resetAt); rlErr != nil {
reqLog.Error("windsurf_gateway.set_model_rate_limit_failed", zap.Error(rlErr))
} else {
reqLog.Info("windsurf_gateway.model_rate_limited",
zap.String("model_key", modelKey),
zap.Duration("cooldown", cooldown),
)
}
}
setOpsUpstreamError(c, 502, modelErr.Msg, "")
return nil, &UpstreamFailoverError{
StatusCode: 502,
ResponseBody: []byte(modelErr.Msg),
}
}
setOpsUpstreamError(c, http.StatusBadGateway, "Upstream LS request failed", err.Error())
s.writeClaudeError(c, http.StatusBadGateway, "api_error", "Upstream LS request failed")
return nil, fmt.Errorf("chat: %w", err)
}
durationMs := time.Since(startTime).Milliseconds()
if !resp.FirstTextAt.IsZero() {
SetOpsLatencyMs(c, OpsTimeToFirstTokenMsKey, resp.FirstTextAt.Sub(startTime).Milliseconds())
}
msgID := generateAnthropicMessageID()
// Prefer native structured tool calls from trajectory steps;
// fallback to text-based parsing when none found.
var parsed windsurf.FeedResult
if len(resp.ToolCalls) > 0 {
parsed.Text = resp.Text
for _, tc := range resp.ToolCalls {
parsed.ToolCalls = append(parsed.ToolCalls, windsurf.ToolCall{
ID: tc.ID,
Name: tc.Name,
ArgumentsJSON: tc.ArgumentsJSON,
})
}
reqLog.Info("windsurf_gateway.native_tool_calls",
zap.Int("count", len(resp.ToolCalls)),
)
} else {
parsed = windsurf.ParseToolCallsFromText(resp.Text)
}
// Prefer server-reported usage; fallback to chars/4 estimate
var inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int
if resp.Usage != nil && (resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0) {
inputTokens = resp.Usage.InputTokens
outputTokens = resp.Usage.OutputTokens
cacheReadTokens = resp.Usage.CacheReadTokens
cacheWriteTokens = resp.Usage.CacheWriteTokens
} else {
inputTokens = windsurf.EstimateInputTokensFromMessages(chatMessages)
outputTokens = windsurf.EstimateTokens(len(parsed.Text) + len(resp.Thinking))
}
reqLog.Info("windsurf_gateway.completed",
zap.Int64("duration_ms", durationMs),
zap.String("upstream_model", resp.Model),
zap.Int("text_len", len(parsed.Text)),
zap.Int("thinking_len", len(resp.Thinking)),
zap.Int("tool_calls_count", len(parsed.ToolCalls)),
zap.Bool("native_tools", len(resp.ToolCalls) > 0),
zap.Int("input_tokens", inputTokens),
zap.Int("output_tokens", outputTokens),
)
if req.Stream {
s.streamAnthropicResponse(c, msgID, req.Model, resp, parsed, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens)
} else {
s.writeAnthropicResponse(c, msgID, req.Model, resp, parsed, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens)
}
upstreamModel := resp.Model
if upstreamModel == req.Model {
upstreamModel = ""
}
var firstTokenMs *int
if !resp.FirstTextAt.IsZero() {
ms := int(resp.FirstTextAt.Sub(startTime).Milliseconds())
firstTokenMs = &ms
}
return &ForwardResult{
RequestID: msgID,
Usage: ClaudeUsage{
InputTokens: inputTokens,
OutputTokens: outputTokens,
CacheReadInputTokens: cacheReadTokens,
CacheCreationInputTokens: cacheWriteTokens,
},
Model: req.Model,
UpstreamModel: upstreamModel,
Stream: req.Stream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
}, nil
}
func (s *WindsurfGatewayService) writeClaudeError(c *gin.Context, status int, errType, message string) {
c.JSON(status, gin.H{
"type": "error",
"error": gin.H{"type": errType, "message": message},
})
}
func (s *WindsurfGatewayService) writeAnthropicResponse(c *gin.Context, id, requestModel string, resp *WindsurfChatResponse, parsed windsurf.FeedResult, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int) {
var content []gin.H
if resp.Thinking != "" {
content = append(content, gin.H{"type": "thinking", "thinking": resp.Thinking})
}
if parsed.Text != "" {
content = append(content, gin.H{"type": "text", "text": parsed.Text})
}
for _, tc := range parsed.ToolCalls {
var input interface{}
if err := json.Unmarshal([]byte(tc.ArgumentsJSON), &input); err != nil {
input = map[string]interface{}{}
}
content = append(content, gin.H{
"type": "tool_use",
"id": tc.ID,
"name": tc.Name,
"input": input,
})
}
if len(content) == 0 {
content = append(content, gin.H{"type": "text", "text": ""})
}
stopReason := "end_turn"
if len(parsed.ToolCalls) > 0 {
stopReason = "tool_use"
}
// model 字段回写策略:
// 优先上游 resp.Model(Windsurf 返回的内部名如 "claude-opus-4-7-medium"),
// 这样 cctest.ai 等检测工具不会对照"标准 claude-opus-4-7"的严格指纹库,
// 而是走宽松匹配,真实后端是 Claude 就能过 LLM 指纹这一关。
// 仅在上游未回模型名时回退到用户请求模型。
model := resp.Model
if model == "" {
model = requestModel
}
c.JSON(http.StatusOK, gin.H{
"id": id,
"type": "message",
"role": "assistant",
"model": model,
"content": content,
"stop_reason": stopReason,
"stop_sequence": nil,
"usage": gin.H{
"input_tokens": inputTokens,
"cache_creation_input_tokens": cacheWriteTokens,
"cache_read_input_tokens": cacheReadTokens,
"output_tokens": outputTokens,
},
})
}
func (s *WindsurfGatewayService) streamAnthropicResponse(c *gin.Context, id, requestModel string, resp *WindsurfChatResponse, parsed windsurf.FeedResult, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
// 与 antigravity/gateway 保持一致,显式禁用 nginx/反代缓冲,防止 SSE 在代理侧被攒齐再转发
// 导致 Claude Code 等客户端长时间收不到任何帧而超时断开。
c.Header("X-Accel-Buffering", "no")
writeSSE := func(event string, data any) {
b, _ := json.Marshal(data)
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, b)
c.Writer.Flush()
}
stopReason := "end_turn"
if len(parsed.ToolCalls) > 0 {
stopReason = "tool_use"
}
// model 字段策略同 writeAnthropicResponse:优先上游名,回退到请求模型。
model := resp.Model
if model == "" {
model = requestModel
}
// message_start: 初始 usage 里 output_tokens 从 0 开始累加,stop_reason/stop_sequence
// 必须带 null 占位 —— 真 Anthropic API 在 message_start 里这两个字段就是 null。
writeSSE("message_start", gin.H{
"type": "message_start",
"message": gin.H{
"id": id,
"type": "message",
"role": "assistant",
"model": model,
"content": []any{},
"stop_reason": nil,
"stop_sequence": nil,
"usage": gin.H{
"input_tokens": inputTokens,
"cache_creation_input_tokens": cacheWriteTokens,
"cache_read_input_tokens": cacheReadTokens,
"output_tokens": 0,
},
},
})
// ping 事件:官方规范在第一个 content_block_start 之后发 ping。
// 这里用 pingEmitted 标志,确保只在第一个 content_block_start 发出后紧跟一个 ping。
pingEmitted := false
emitPingIfNeeded := func() {
if pingEmitted {
return
}
writeSSE("ping", gin.H{"type": "ping"})
pingEmitted = true
}
blockIndex := 0
// Thinking block (reasoning_content)
if resp.Thinking != "" {
writeSSE("content_block_start", gin.H{
"type": "content_block_start",
"index": blockIndex,
"content_block": gin.H{"type": "thinking", "thinking": "", "signature": ""},
})
emitPingIfNeeded()
writeSSE("content_block_delta", gin.H{
"type": "content_block_delta",
"index": blockIndex,
"delta": gin.H{"type": "thinking_delta", "thinking": resp.Thinking},
})
writeSSE("content_block_stop", gin.H{
"type": "content_block_stop",
"index": blockIndex,
})
blockIndex++
}
if parsed.Text != "" {
writeSSE("content_block_start", gin.H{
"type": "content_block_start",
"index": blockIndex,
"content_block": gin.H{"type": "text", "text": ""},
})
emitPingIfNeeded()
writeSSE("content_block_delta", gin.H{
"type": "content_block_delta",
"index": blockIndex,
"delta": gin.H{"type": "text_delta", "text": parsed.Text},
})
writeSSE("content_block_stop", gin.H{
"type": "content_block_stop",
"index": blockIndex,
})
blockIndex++
}
for _, tc := range parsed.ToolCalls {
writeSSE("content_block_start", gin.H{
"type": "content_block_start",
"index": blockIndex,
"content_block": gin.H{
"type": "tool_use",
"id": tc.ID,
"name": tc.Name,
"input": map[string]interface{}{},
},
})
emitPingIfNeeded()
// input_json_delta 按官方规范:先发空 partial_json,再把完整 JSON 作为一段或多段发出。
// 真 Claude 会 chunk 成多段,我们没有中间态,但先发 "" 再发整块这个序列能通过结构校验。
writeSSE("content_block_delta", gin.H{
"type": "content_block_delta",
"index": blockIndex,
"delta": gin.H{"type": "input_json_delta", "partial_json": ""},
})
writeSSE("content_block_delta", gin.H{
"type": "content_block_delta",
"index": blockIndex,
"delta": gin.H{"type": "input_json_delta", "partial_json": tc.ArgumentsJSON},
})
writeSSE("content_block_stop", gin.H{
"type": "content_block_stop",
"index": blockIndex,
})
blockIndex++
}
if blockIndex == 0 {
writeSSE("content_block_start", gin.H{
"type": "content_block_start",
"index": 0,
"content_block": gin.H{"type": "text", "text": ""},
})
emitPingIfNeeded()
writeSSE("content_block_stop", gin.H{
"type": "content_block_stop",
"index": 0,
})
}
// message_delta: 真 Anthropic 的 usage 这里会带 output_tokens 累加值,
// 以及 cache_creation/read/input_tokens 镜像(签名检测对这里比较敏感)。
writeSSE("message_delta", gin.H{
"type": "message_delta",
"delta": gin.H{"stop_reason": stopReason, "stop_sequence": nil},
"usage": gin.H{
"input_tokens": inputTokens,
"cache_creation_input_tokens": cacheWriteTokens,
"cache_read_input_tokens": cacheReadTokens,
"output_tokens": outputTokens,
},
})
writeSSE("message_stop", gin.H{
"type": "message_stop",
})
}
// ---- Request types ----
type windsurfMessagesRequest struct {
Model string `json:"model"`
Stream bool `json:"stream"`
System json.RawMessage `json:"system"`
Messages []windsurfRequestMessage `json:"messages"`
Tools []windsurfRequestTool `json:"tools,omitempty"`
MaxTokens int `json:"max_tokens"`
Metadata map[string]any `json:"metadata,omitempty"`
Workspace string `json:"workspace,omitempty"`
Worktree string `json:"worktree,omitempty"`
Project string `json:"project,omitempty"`
ProjectDir string `json:"project_dir,omitempty"`
ProjectPath string `json:"project_path,omitempty"`
Root string `json:"root,omitempty"`
RootPath string `json:"root_path,omitempty"`
CWD string `json:"cwd,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
}
type windsurfRequestMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type windsurfRequestTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
// ---- Helper functions (prefixed to avoid collision with windsurf_gateway_handler.go) ----
type windsurfContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input interface{} `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
// 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 {
if len(raw) == 0 {
return nil
}
var s string
if json.Unmarshal(raw, &s) == nil {
return []windsurfContentBlock{{Type: "text", Text: s}}
}
var blocks []windsurfContentBlock
if json.Unmarshal(raw, &blocks) == nil {
return blocks
}
return []windsurfContentBlock{{Type: "text", Text: string(raw)}}
}
func normalizeWindsurfRequest(req *windsurfMessagesRequest) string {
if req == nil {
return ""
}
workspaceContext := ""
if ctx := buildWindsurfWorkspaceContext(req); ctx != "" {
req.System = prependWindsurfSystemText(req.System, ctx)
workspaceContext = ctx
}
req.Tools = normalizeWindsurfRequestTools(req.Tools)
req.ToolChoice = normalizeWindsurfToolChoice(req.ToolChoice)
for i := range req.Messages {
req.Messages[i].Content = normalizeWindsurfMessageContent(req.Messages[i].Content)
}
return workspaceContext
}
func fillWindsurfWorkspaceContextFromHeaders(req *windsurfMessagesRequest, c *gin.Context) {
if req == nil || c == nil || c.Request == nil {
return
}
if strings.TrimSpace(req.CWD) == "" {
req.CWD = firstHeader(c,
"X-Codex-Cwd",
"X-Codex-Current-Working-Directory",
"X-Current-Working-Directory",
"X-Working-Directory",
"X-Cwd",
)
}
if firstWindsurfNonEmptyString(req.Workspace, req.Worktree, req.ProjectPath, req.ProjectDir, req.RootPath, req.Root, req.Project) == "" {
req.Workspace = firstHeader(c,
"X-Codex-Workspace",
"X-Codex-Workspace-Path",
"X-Workspace",
"X-Workspace-Path",
"X-Project-Path",
"X-Project-Root",
)
}
}
func firstHeader(c *gin.Context, names ...string) string {
for _, name := range names {
if value := strings.TrimSpace(c.GetHeader(name)); value != "" {
return value
}
}
return ""
}
func buildWindsurfWorkspaceContext(req *windsurfMessagesRequest) string {
if req == nil {
return ""
}
cwd := firstWindsurfNonEmptyString(
req.CWD,
metadataString(req.Metadata, "cwd"),
metadataString(req.Metadata, "current_working_directory"),
metadataString(req.Metadata, "working_directory"),
extractWindsurfCallerCWD(req),
)
workspace := firstWindsurfNonEmptyString(
req.Workspace,
req.Worktree,
req.ProjectPath,
req.ProjectDir,
req.RootPath,
req.Root,
req.Project,
metadataString(req.Metadata, "workspace"),
metadataString(req.Metadata, "workspace_path"),
metadataString(req.Metadata, "worktree"),
metadataString(req.Metadata, "project_path"),
metadataString(req.Metadata, "project_dir"),
metadataString(req.Metadata, "project_root"),
metadataString(req.Metadata, "root_path"),
metadataString(req.Metadata, "root"),
)
if cwd == "" && workspace == "" {
return ""
}
var lines []string
lines = append(lines, "")
if cwd != "" {
lines = append(lines, "Working directory: "+cwd)
}
if workspace != "" && workspace != cwd {
lines = append(lines, "Workspace: "+workspace)
}
lines = append(lines, "Relative file paths and search paths are resolved from the working directory/workspace above.")
lines = append(lines, "")
return strings.Join(lines, "\n")
}
var (
windsurfPathTailRe = `(?:[\\/~]|[A-Za-z]:[\\/])[^\s` + "`" + `'"<>\n.,;)]+`
windsurfCWDRe = regexp.MustCompile(`(?im)(?:^|\n)\s*(?:[-*]\s+)?(?:Primary\s+|Current\s+|Initial\s+|Default\s+|Active\s+|Project\s+|My\s+)?(?:Working\s+directory|cwd)\s*[:=]\s*` + "`?" + `(` + windsurfPathTailRe + `)` + "`?" + `|current\s+working\s+directory(?:\s+is)?\s*[:=]?\s*` + "`?" + `(` + windsurfPathTailRe + `)` + "`?" + `|\s*(` + windsurfPathTailRe + `)\s*`)
windsurfBareCWDRe = regexp.MustCompile(`^[\s,;:.,。、;: "'` + "`" + `(\[]*((?:[A-Za-z]:[\\/]|/[A-Za-z]|~[\\/])[A-Za-z0-9._\\/-]+)`)
windsurfBulletCWDRe = regexp.MustCompile(`(?m)^[\s]*[-*•]\s+` + "`?" + `((?:[A-Za-z]:[\\/]|/[A-Za-z]|~[\\/])[^\s` + "`" + `'"<>\n]+)` + "`?" + `\s*$`)
windsurfFilePathExtRe = regexp.MustCompile(`(?i)\.(?:js|mjs|cjs|ts|tsx|jsx|json|jsonc|md|mdx|py|pyc|go|rs|java|kt|swift|cpp|cc|cxx|c|h|hpp|html?|css|scss|sass|less|yaml|yml|toml|ini|cfg|conf|sh|bash|zsh|fish|ps1|bat|cmd|exe|dll|so|dylib|zip|tar|gz|bz2|xz|7z|rar|png|jpe?g|gif|webp|svg|ico|mp[34]|wav|flac|ogg|webm|mov|avi|mkv|pdf|docx?|xlsx?|pptx?|csv|tsv|sql|db|sqlite|log|lock|map|min\.js|min\.css)$`)
)
func extractWindsurfCallerCWD(req *windsurfMessagesRequest) string {
if req == nil {
return ""
}
if cwd := scanWindsurfMessagesForCWD(req.System, req.Messages); cwd != "" {
return cwd
}
if cwd := scanWindsurfUserHeadForBareCWD(req.Messages); cwd != "" {
return cwd
}
return scanWindsurfSystemForBulletCWD(req.System)
}
func scanWindsurfMessagesForCWD(system json.RawMessage, messages []windsurfRequestMessage) string {
for _, text := range windsurfSystemTexts(system) {
if cwd := extractWindsurfCWDFromText(text); cwd != "" {
return cwd
}
}
for _, msg := range messages {
for _, text := range windsurfMessageTexts(msg.Content) {
if cwd := extractWindsurfCWDFromText(text); cwd != "" {
return cwd
}
}
}
return ""
}
func extractWindsurfCWDFromText(text string) string {
for _, match := range windsurfCWDRe.FindAllStringSubmatch(text, -1) {
for i := 1; i < len(match); i++ {
if cwd := cleanWindsurfCWD(match[i]); cwd != "" {
return cwd
}
}
}
return ""
}
func scanWindsurfUserHeadForBareCWD(messages []windsurfRequestMessage) string {
for _, msg := range messages {
if msg.Role != "user" {
continue
}
for _, text := range windsurfMessageTexts(msg.Content) {
if cwd := matchWindsurfBareCWD(prefixRunes(text, 300)); cwd != "" {
return cwd
}
if !strings.Contains(strings.ToLower(text), "\s*`).ReplaceAllString(text, "")
if cwd := matchWindsurfBareCWD(prefixRunes(stripped, 500)); cwd != "" {
return cwd
}
}
break
}
return ""
}
func matchWindsurfBareCWD(text string) string {
match := windsurfBareCWDRe.FindStringSubmatch(text)
if len(match) < 2 {
return ""
}
return cleanWindsurfCWD(match[1])
}
func scanWindsurfSystemForBulletCWD(system json.RawMessage) string {
for _, text := range windsurfSystemTexts(system) {
for _, match := range windsurfBulletCWDRe.FindAllStringSubmatch(text, -1) {
if len(match) < 2 {
continue
}
if cwd := cleanWindsurfCWD(match[1]); cwd != "" {
return cwd
}
}
}
return ""
}
func cleanWindsurfCWD(value string) string {
value = strings.Trim(strings.TrimSpace(value), "`\"'")
if value == "" || value == "" || strings.ContainsAny(value, "\x00\r\n") {
return ""
}
if len(value) < 5 || windsurfFilePathExtRe.MatchString(value) {
return ""
}
return value
}
func windsurfSystemTexts(raw json.RawMessage) []string {
if len(raw) == 0 {
return nil
}
var s string
if json.Unmarshal(raw, &s) == nil {
return []string{s}
}
var blocks []windsurfContentBlock
if json.Unmarshal(raw, &blocks) == nil {
var out []string
for _, block := range blocks {
if block.Type == "text" && block.Text != "" {
out = append(out, block.Text)
}
}
return out
}
return []string{string(raw)}
}
func windsurfMessageTexts(raw json.RawMessage) []string {
if len(raw) == 0 {
return nil
}
var s string
if json.Unmarshal(raw, &s) == nil {
return []string{s}
}
var blocks []windsurfContentBlock
if json.Unmarshal(raw, &blocks) == nil {
var out []string
for _, block := range blocks {
if block.Type == "text" && block.Text != "" {
out = append(out, block.Text)
}
}
return out
}
return []string{string(raw)}
}
func prefixRunes(s string, limit int) string {
if limit <= 0 {
return ""
}
for i := range s {
if limit == 0 {
return s[:i]
}
limit--
}
return s
}
func metadataString(metadata map[string]any, key string) string {
if len(metadata) == 0 {
return ""
}
v, ok := metadata[key]
if !ok {
return ""
}
switch typed := v.(type) {
case string:
return strings.TrimSpace(typed)
case map[string]any:
for _, nestedKey := range []string{"cwd", "path", "root", "dir"} {
if s := metadataString(typed, nestedKey); s != "" {
return s
}
}
}
return ""
}
func firstWindsurfNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func prependWindsurfSystemText(raw json.RawMessage, text string) json.RawMessage {
text = strings.TrimSpace(text)
if text == "" {
return raw
}
block, err := json.Marshal(windsurfContentBlock{Type: "text", Text: text})
if err != nil {
return raw
}
if len(raw) == 0 || string(raw) == "null" {
out, err := json.Marshal([]json.RawMessage{block})
if err != nil {
return raw
}
return out
}
var systemText string
if json.Unmarshal(raw, &systemText) == nil {
orig, err := json.Marshal(windsurfContentBlock{Type: "text", Text: systemText})
if err != nil {
return raw
}
out, err := json.Marshal([]json.RawMessage{block, orig})
if err != nil {
return raw
}
return out
}
var items []json.RawMessage
if json.Unmarshal(raw, &items) == nil {
items = append([]json.RawMessage{block}, items...)
out, err := json.Marshal(items)
if err != nil {
return raw
}
return out
}
return raw
}
func normalizeWindsurfRequestTools(tools []windsurfRequestTool) []windsurfRequestTool {
if len(tools) == 0 {
return nil
}
out := make([]windsurfRequestTool, 0, len(tools))
seen := make(map[string]int, len(tools))
for _, tool := range tools {
tool.Name = windsurf.NormalizeToolName(tool.Name)
key := strings.ToLower(strings.TrimSpace(tool.Name))
if key == "" {
continue
}
if idx, ok := seen[key]; ok {
if out[idx].Description == "" {
out[idx].Description = tool.Description
}
if len(out[idx].InputSchema) == 0 {
out[idx].InputSchema = tool.InputSchema
}
continue
}
seen[key] = len(out)
out = append(out, tool)
}
return out
}
func normalizeWindsurfToolChoice(toolChoice interface{}) interface{} {
switch tc := toolChoice.(type) {
case map[string]interface{}:
normalized := make(map[string]interface{}, len(tc))
for key, value := range tc {
normalized[key] = value
}
if name, ok := normalized["name"].(string); ok {
normalized["name"] = windsurf.NormalizeToolName(name)
}
if fn, ok := normalized["function"].(map[string]interface{}); ok {
nextFn := make(map[string]interface{}, len(fn))
for key, value := range fn {
nextFn[key] = value
}
if name, ok := nextFn["name"].(string); ok {
nextFn["name"] = windsurf.NormalizeToolName(name)
}
normalized["function"] = nextFn
}
return normalized
default:
return toolChoice
}
}
func normalizeWindsurfMessageContent(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return raw
}
var text string
if json.Unmarshal(raw, &text) == nil {
return raw
}
var blocks []windsurfContentBlock
if json.Unmarshal(raw, &blocks) != nil {
return raw
}
changed := false
for i := range blocks {
if blocks[i].Type == "tool_use" {
normalized := windsurf.NormalizeToolName(blocks[i].Name)
if normalized != blocks[i].Name {
blocks[i].Name = normalized
changed = true
}
}
}
if !changed {
return raw
}
updated, err := json.Marshal(blocks)
if err != nil {
return raw
}
return updated
}
func windsurfExtractContentText(raw json.RawMessage) string {
var s string
if json.Unmarshal(raw, &s) == nil {
return windsurf.NormalizeUserVisibleMetaText(s)
}
var blocks []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if json.Unmarshal(raw, &blocks) == nil {
var out string
for _, b := range blocks {
if b.Type == "text" {
out += b.Text
}
}
return windsurf.NormalizeUserVisibleMetaText(out)
}
return windsurf.NormalizeUserVisibleMetaText(string(raw))
}
func windsurfExtractContentTextFromRaw(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
var blocks []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if json.Unmarshal(raw, &blocks) == nil {
textOnly := len(blocks) > 0
var parts []string
for _, b := range blocks {
if b.Type != "text" {
textOnly = false
break
}
parts = append(parts, b.Text)
}
if textOnly {
return strings.Join(parts, "\n")
}
}
return string(raw)
}
// 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 {
if reqID := c.GetHeader("X-Request-ID"); reqID != "" {
l = l.With(zap.String("request_id", reqID))
}
}
return l.With(fields...)
}
// generateAnthropicMessageID 生成符合 Anthropic API 签名格式的消息 ID:
// "msg_01" 前缀 + 22 位 base62 随机字符(总长 28 字符,与官方 msg_013Zva2CMHLNnXjNJJKqJ2EF 一致)。
// 签名校验类工具常按 prefix/length 校验,长度差一位就会挂。
func generateAnthropicMessageID() string {
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const suffixLen = 22
var buf [suffixLen]byte
_, _ = rand.Read(buf[:])
out := make([]byte, suffixLen)
for i, b := range buf {
out[i] = alphabet[int(b)%len(alphabet)]
}
return "msg_01" + string(out)
}