package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
)
// cascadeReuseTTL 是 Windsurf Cascade 会话 ID 在 Redis 中的存活时长。
// 与 LS 端 cascade 老化窗口对齐:超过 30 分钟即使本地 cache 命中,
// LS 也大概率返回 panel-not-found,由 chatCascade 的回退路径兜底。
const cascadeReuseTTL = 30 * time.Minute
type WindsurfChatService struct {
cfg config.WindsurfConfig
lsService *WindsurfLSService
tokenProvider *WindsurfTokenProvider
cache GatewayCache
}
func NewWindsurfChatService(
cfg config.WindsurfConfig,
lsService *WindsurfLSService,
tokenProvider *WindsurfTokenProvider,
cache GatewayCache,
) *WindsurfChatService {
return &WindsurfChatService{
cfg: cfg,
lsService: lsService,
tokenProvider: tokenProvider,
cache: cache,
}
}
type WindsurfChatRequest struct {
AccountID int64
// GroupID 来自 apiKey.GroupID,用于 Cascade 复用 cache 隔离。0 表示不参与缓存。
GroupID int64
// SessionHash 与 sticky session 共用,标识同一对话流。空串表示不复用 cascade。
SessionHash string
Model string
Messages []windsurf.ChatMessage
Stream bool
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 {
Text string
Thinking string
Model string
Mode string
Usage *windsurf.StepUsage // server-reported; nil if unavailable
FirstTextAt time.Time // when first text appeared (zero if no text)
ToolCalls []windsurf.NativeToolCall
}
func (s *WindsurfChatService) Chat(ctx context.Context, req *WindsurfChatRequest) (*WindsurfChatResponse, error) {
token, err := s.tokenProvider.GetToken(ctx, req.AccountID)
if err != nil {
return nil, fmt.Errorf("get token: %w", err)
}
modelKey := windsurf.ResolveModel(req.Model)
meta := windsurf.GetModelInfo(modelKey)
mode := s.resolveMode(meta)
// Tool emulation requires cascade mode for proto section injection
if mode == "legacy" && req.ToolPreamble != "" {
mode = "cascade"
}
var lease *windsurf.LSLease
if token.LSBinding.ContainerID != "" || token.LSBinding.ContainerName != "" {
lease, err = s.lsService.AcquireByBinding(token.LSBinding)
} else {
lease, err = s.lsService.Acquire(ctx, token.ProxyURL)
}
if err != nil {
return nil, fmt.Errorf("acquire LS: %w", err)
}
defer lease.Release()
var resp *WindsurfChatResponse
switch mode {
case "cascade":
resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint, req.Images, req.AccountID, req.GroupID, req.SessionHash)
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, req.Images, req.AccountID, req.GroupID, req.SessionHash)
}
if err != nil {
if mode == "cascade" && s.cfg.Chat.AllowModeFallback && meta != nil && meta.EnumValue > 0 {
slog.Warn("windsurf_cascade_fallback_to_legacy", "model", modelKey, "error", err)
resp, err = s.chatLegacy(ctx, lease.Client, token.APIKey, meta, req.Messages, modelKey)
if err == nil {
resp.Mode = "legacy"
}
}
if err != nil {
return nil, fmt.Errorf("chat (%s): %w", mode, err)
}
}
return resp, nil
}
func (s *WindsurfChatService) resolveMode(meta *windsurf.ModelMeta) string {
configMode := s.cfg.Chat.DefaultMode
if configMode == "cascade" || configMode == "legacy" {
return configMode
}
return windsurf.GetChatMode(meta, int(s.cfg.Chat.LegacyEnumCutoff))
}
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,
accountID int64,
groupID int64,
sessionHash string,
) (*WindsurfChatResponse, error) {
modelUID := ""
modelEnumHint := 0
if meta != nil {
modelUID = meta.ModelUID
modelEnumHint = meta.EnumValue
}
messages = injectModelIdentity(messages, meta, modelKey)
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)
} else if found && !ok {
return nil, fmt.Errorf("model %q does not support image inputs in Windsurf Cascade", modelUID)
}
}
// reuse 路径:sessionHash 非空且 cache 命中即复用 cascade,
// userText 缩为"system + 最后一条 user"——cascade trajectory 已承载历史。
canReuse := sessionHash != "" && s.cache != nil && groupID != 0
cacheKey := ""
reuseID := ""
if canReuse {
cacheKey = buildCascadeCacheKey(groupID, accountID, modelUID, lsEndpoint, sessionHash, sysPromptHash(messages))
if id, err := s.cache.GetCascadeID(ctx, cacheKey); err == nil && id != "" {
reuseID = id
} else if err != nil {
slog.Warn("windsurf_cascade_cache_get_failed", "error", err)
}
}
var userText string
if reuseID != "" {
userText = buildCascadeTextForReuse(messages)
} else {
userText = buildCascadeText(messages, modelUID)
}
result, err := client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, reuseID, modelEnumHint, images)
// reuse 触发 panel-not-found:清缓存 + 用 full-history 重试一次。
if err != nil && reuseID != "" && isPanelNotFound(err) {
slog.Info("windsurf_cascade_reuse_invalidated", "cascade_id", reuseID, "reason", "panel_not_found")
if cacheKey != "" {
_ = s.cache.DeleteCascadeID(ctx, cacheKey)
}
userText = buildCascadeText(messages, modelUID)
result, err = client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, "", modelEnumHint, images)
}
if err != nil {
// 任何错误都失效缓存(保守策略,避免下次复用到坏 cascade)。
if canReuse && cacheKey != "" {
_ = s.cache.DeleteCascadeID(ctx, cacheKey)
}
return nil, err
}
// 成功:写回 cache。
if canReuse && cacheKey != "" && result.CascadeID != "" {
if setErr := s.cache.SetCascadeID(ctx, cacheKey, result.CascadeID, cascadeReuseTTL); setErr != nil {
slog.Warn("windsurf_cascade_cache_set_failed", "error", setErr)
}
}
return &WindsurfChatResponse{
Text: result.Text,
Thinking: result.Thinking,
Model: modelKey,
Mode: "cascade",
Usage: result.Usage,
FirstTextAt: result.FirstTextAt,
ToolCalls: result.ToolCalls,
}, nil
}
// buildCascadeCacheKey 构造 Cascade 复用 cache 的 key。
// 任一组件变化(账号、模型、LS 实例、会话、system prompt)都会自动 cache miss。
func buildCascadeCacheKey(groupID, accountID int64, modelUID, lsEndpoint, sessionHash, sysHash string) string {
h := sha256.New()
fmt.Fprintf(h, "%d|%d|%s|%s|%s|%s", groupID, accountID, modelUID, lsEndpoint, sessionHash, sysHash)
return hex.EncodeToString(h.Sum(nil))[:24]
}
// sysPromptHash 计算 system 消息内容的指纹。system 变化必须强制开新 cascade,
// 否则旧 cascade 内已建立的"角色/约束"语境会污染新对话。
func sysPromptHash(messages []windsurf.ChatMessage) string {
h := sha256.New()
for _, m := range messages {
if m.Role == "system" {
h.Write([]byte(m.Content))
h.Write([]byte{0})
}
}
return hex.EncodeToString(h.Sum(nil))[:16]
}
// isPanelNotFound 判定 LS 端"cascade panel state not found"错误。
// 与 windsurf.LocalLSClient 内部 panel-state-not-found 检测保持一致。
func isPanelNotFound(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
if strings.Contains(s, "panel state not found") {
return true
}
return strings.Contains(s, "not_found") && strings.Contains(s, "panel")
}
// buildCascadeTextForReuse 构造 reuse 模式下的 user turn 文本:
// 仅含 system instructions + 最新一条 user 消息。Cascade 内部已通过 trajectory 保留前序历史。
// 注意:cacheKey 已包含 sysPromptHash,system 变化会强制 cache miss → 走 buildCascadeText 全量路径。
func buildCascadeTextForReuse(messages []windsurf.ChatMessage) string {
var sysParts []string
var lastUser string
for _, m := range messages {
if m.Role == "system" {
sysParts = append(sysParts, m.Content)
}
}
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
lastUser = messages[i].Content
break
}
}
sys := strings.TrimSpace(strings.Join(sysParts, "\n"))
if sys != "" {
return "\n" + sys + "\n\n\n" + lastUser
}
return lastUser
}
func (s *WindsurfChatService) chatLegacy(ctx context.Context, client *windsurf.LocalLSClient, apiKey string, meta *windsurf.ModelMeta, messages []windsurf.ChatMessage, modelKey string) (*WindsurfChatResponse, error) {
modelEnum := 0
modelName := ""
if meta != nil {
modelEnum = meta.EnumValue
modelName = meta.Name
}
text, err := client.StreamLegacyChat(ctx, apiKey, messages, modelEnum, modelName)
if err != nil {
return nil, err
}
return &WindsurfChatResponse{
Text: text,
Model: modelKey,
Mode: "legacy",
}, nil
}
const (
cascadeMaxHistoryBytes = 200_000
cascade1MHistoryBytes = 900_000
cascadeMultiTurnPreamble = "The following is a multi-turn conversation. You MUST remember and use all information from prior turns."
)
func cascadeHistoryBudget(modelUID string) int {
if strings.Contains(strings.ToLower(modelUID), "1m") {
return cascade1MHistoryBytes
}
return cascadeMaxHistoryBytes
}
// buildCascadeText constructs the full text payload for SendUserCascadeMessage.
// System prompt is wrapped in , multi-turn history uses
// / tags with a budget cap to trim the oldest turns.
func buildCascadeText(messages []windsurf.ChatMessage, modelUID string) string {
var systemParts []string
var convo []windsurf.ChatMessage
for _, m := range messages {
if m.Role == "system" {
systemParts = append(systemParts, m.Content)
} else if m.Role == "user" || m.Role == "assistant" {
convo = append(convo, m)
}
}
if len(convo) == 0 {
return ""
}
sysText := strings.TrimSpace(strings.Join(systemParts, "\n"))
if sysText != "" {
sysText = "\n" + sysText + "\n"
}
// Single turn: system + last message
if len(convo) <= 1 {
text := convo[len(convo)-1].Content
if sysText != "" {
text = sysText + "\n\n" + text
}
return text
}
// Multi-turn: build history with budget trimming
maxBytes := cascadeHistoryBudget(modelUID)
historyBytes := len(sysText)
var lines []string
droppedTurns := 0
for i := len(convo) - 2; i >= 0; i-- {
m := convo[i]
tag := "human"
if m.Role == "assistant" {
tag = "assistant"
}
line := fmt.Sprintf("<%s>\n%s\n%s>", tag, m.Content, tag)
if historyBytes+len(line) > maxBytes && len(lines) > 0 {
droppedTurns = i + 1
slog.Info("windsurf_cascade_history_trimmed",
"turn", i,
"total_turns", len(convo),
"kept_kb", historyBytes/1024,
"dropped_turns", droppedTurns,
)
break
}
lines = append([]string{line}, lines...)
historyBytes += len(line)
}
latest := convo[len(convo)-1]
text := cascadeMultiTurnPreamble + "\n\n" +
strings.Join(lines, "\n\n") + "\n\n" +
"\n" + latest.Content + "\n"
if sysText != "" {
text = sysText + "\n\n" + text
}
return text
}