856 lines
26 KiB
Go
856 lines
26 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"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,
|
||
}
|
||
}
|
||
|
||
func (s *WindsurfGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte, _ bool) (*ForwardResult, error) {
|
||
startTime := time.Now()
|
||
reqLog := windsurfLogger(c, "windsurf_gateway.forward",
|
||
zap.Int64("account_id", account.ID),
|
||
)
|
||
|
||
var req windsurfMessagesRequest
|
||
if err := json.Unmarshal(body, &req); err != nil {
|
||
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Invalid request body")
|
||
return nil, fmt.Errorf("unmarshal request: %w", err)
|
||
}
|
||
normalizeWindsurfRequest(&req)
|
||
if strings.TrimSpace(req.Model) == "" {
|
||
s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Missing model")
|
||
return nil, fmt.Errorf("missing model")
|
||
}
|
||
|
||
reqLog = reqLog.With(zap.String("model", req.Model), zap.Bool("stream", req.Stream), zap.Int("tools_count", len(req.Tools)))
|
||
|
||
// Convert Anthropic tools to OpenAI format
|
||
var openAITools []windsurf.OpenAITool
|
||
for _, t := range req.Tools {
|
||
openAITools = append(openAITools, windsurf.OpenAITool{
|
||
Type: "function",
|
||
Function: windsurf.OpenAIFunction{
|
||
Name: t.Name,
|
||
Description: t.Description,
|
||
Parameters: t.InputSchema,
|
||
},
|
||
})
|
||
}
|
||
|
||
hasTools := len(openAITools) > 0
|
||
|
||
// Convert Anthropic messages to intermediate form
|
||
var anthropicMsgs []windsurf.AnthropicMessage
|
||
hasToolHistory := false
|
||
|
||
if len(req.System) > 0 {
|
||
anthropicMsgs = append(anthropicMsgs, windsurf.AnthropicMessage{
|
||
Role: "system",
|
||
Content: req.System,
|
||
})
|
||
}
|
||
|
||
for 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.BuildToolPreambleForProto(openAITools, req.ToolChoice)
|
||
chatMessages = windsurf.NormalizeMessagesForCascade(anthropicMsgs, []windsurf.OpenAITool{})
|
||
reqLog.Info("windsurf_gateway.tool_emulation",
|
||
zap.Int("tools_count", len(openAITools)),
|
||
zap.Int("preamble_len", len(toolPreamble)),
|
||
zap.Int("messages_count", len(chatMessages)),
|
||
zap.Bool("has_tool_history", hasToolHistory),
|
||
)
|
||
} else {
|
||
for _, m := range anthropicMsgs {
|
||
text := windsurfExtractContentText(json.RawMessage(m.Content))
|
||
chatMessages = append(chatMessages, windsurf.ChatMessage{
|
||
Role: m.Role,
|
||
Content: text,
|
||
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,
|
||
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"`
|
||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||
MaxTokens int `json:"max_tokens"`
|
||
}
|
||
|
||
type windsurfRequestMessage struct {
|
||
Role string `json:"role"`
|
||
Content json.RawMessage `json:"content"`
|
||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||
}
|
||
|
||
type windsurfRequestTool struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
InputSchema json.RawMessage `json:"input_schema"`
|
||
}
|
||
|
||
// ---- Helper functions (prefixed to avoid collision with windsurf_gateway_handler.go) ----
|
||
|
||
type windsurfContentBlock struct {
|
||
Type string `json:"type"`
|
||
Text string `json:"text,omitempty"`
|
||
ID string `json:"id,omitempty"`
|
||
Name string `json:"name,omitempty"`
|
||
Input interface{} `json:"input,omitempty"`
|
||
ToolUseID string `json:"tool_use_id,omitempty"`
|
||
Content json.RawMessage `json:"content,omitempty"`
|
||
// 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) {
|
||
if req == nil {
|
||
return
|
||
}
|
||
|
||
req.Tools = normalizeWindsurfRequestTools(req.Tools)
|
||
req.ToolChoice = normalizeWindsurfToolChoice(req.ToolChoice)
|
||
for i := range req.Messages {
|
||
req.Messages[i].Content = normalizeWindsurfMessageContent(req.Messages[i].Content)
|
||
}
|
||
}
|
||
|
||
func normalizeWindsurfRequestTools(tools []windsurfRequestTool) []windsurfRequestTool {
|
||
if len(tools) == 0 {
|
||
return nil
|
||
}
|
||
|
||
out := make([]windsurfRequestTool, 0, len(tools))
|
||
seen := make(map[string]int, len(tools))
|
||
for _, tool := range tools {
|
||
tool.Name = windsurf.NormalizeToolName(tool.Name)
|
||
key := strings.ToLower(strings.TrimSpace(tool.Name))
|
||
if key == "" {
|
||
continue
|
||
}
|
||
if idx, ok := seen[key]; ok {
|
||
if out[idx].Description == "" {
|
||
out[idx].Description = tool.Description
|
||
}
|
||
if len(out[idx].InputSchema) == 0 {
|
||
out[idx].InputSchema = tool.InputSchema
|
||
}
|
||
continue
|
||
}
|
||
seen[key] = len(out)
|
||
out = append(out, tool)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func normalizeWindsurfToolChoice(toolChoice interface{}) interface{} {
|
||
switch tc := toolChoice.(type) {
|
||
case map[string]interface{}:
|
||
normalized := make(map[string]interface{}, len(tc))
|
||
for key, value := range tc {
|
||
normalized[key] = value
|
||
}
|
||
if name, ok := normalized["name"].(string); ok {
|
||
normalized["name"] = windsurf.NormalizeToolName(name)
|
||
}
|
||
if fn, ok := normalized["function"].(map[string]interface{}); ok {
|
||
nextFn := make(map[string]interface{}, len(fn))
|
||
for key, value := range fn {
|
||
nextFn[key] = value
|
||
}
|
||
if name, ok := nextFn["name"].(string); ok {
|
||
nextFn["name"] = windsurf.NormalizeToolName(name)
|
||
}
|
||
normalized["function"] = nextFn
|
||
}
|
||
return normalized
|
||
default:
|
||
return toolChoice
|
||
}
|
||
}
|
||
|
||
func normalizeWindsurfMessageContent(raw json.RawMessage) json.RawMessage {
|
||
if len(raw) == 0 {
|
||
return raw
|
||
}
|
||
|
||
var text string
|
||
if json.Unmarshal(raw, &text) == nil {
|
||
return raw
|
||
}
|
||
|
||
var blocks []windsurfContentBlock
|
||
if json.Unmarshal(raw, &blocks) != nil {
|
||
return raw
|
||
}
|
||
|
||
changed := false
|
||
for i := range blocks {
|
||
if blocks[i].Type == "tool_use" {
|
||
normalized := windsurf.NormalizeToolName(blocks[i].Name)
|
||
if normalized != blocks[i].Name {
|
||
blocks[i].Name = normalized
|
||
changed = true
|
||
}
|
||
}
|
||
}
|
||
if !changed {
|
||
return raw
|
||
}
|
||
|
||
updated, err := json.Marshal(blocks)
|
||
if err != nil {
|
||
return raw
|
||
}
|
||
return updated
|
||
}
|
||
|
||
func windsurfExtractContentText(raw json.RawMessage) string {
|
||
var s string
|
||
if json.Unmarshal(raw, &s) == nil {
|
||
return s
|
||
}
|
||
var blocks []struct {
|
||
Type string `json:"type"`
|
||
Text string `json:"text"`
|
||
}
|
||
if json.Unmarshal(raw, &blocks) == nil {
|
||
var out string
|
||
for _, b := range blocks {
|
||
if b.Type == "text" {
|
||
out += b.Text
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
return string(raw)
|
||
}
|
||
|
||
func windsurfExtractContentTextFromRaw(raw json.RawMessage) string {
|
||
if len(raw) == 0 {
|
||
return ""
|
||
}
|
||
var s string
|
||
if json.Unmarshal(raw, &s) == nil {
|
||
return s
|
||
}
|
||
var blocks []struct {
|
||
Type string `json:"type"`
|
||
Text string `json:"text"`
|
||
}
|
||
if json.Unmarshal(raw, &blocks) == nil {
|
||
textOnly := len(blocks) > 0
|
||
var parts []string
|
||
for _, b := range blocks {
|
||
if b.Type != "text" {
|
||
textOnly = false
|
||
break
|
||
}
|
||
parts = append(parts, b.Text)
|
||
}
|
||
if textOnly {
|
||
return strings.Join(parts, "\n")
|
||
}
|
||
}
|
||
return string(raw)
|
||
}
|
||
|
||
// 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)
|
||
}
|