diff --git a/README.md b/README.md index 49a1263..d0dcf4b 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ curl http://localhost:8080/v1/chat/completions \ ## Thinking Mode -Append a suffix (default `-thinking`) to the model name, e.g. `claude-sonnet-4.5-thinking`. Configure output format in the admin panel under Settings - Thinking Mode. +Append a suffix (default `-thinking`) to the model name, e.g. `claude-sonnet-4.5-thinking`. Claude-compatible requests that include a top-level `thinking` config such as `{"type":"enabled","budget_tokens":2048}` or `{"type":"adaptive"}` also enable thinking mode automatically. Configure output format in the admin panel under Settings - Thinking Mode. ## Environment Variables diff --git a/README_CN.md b/README_CN.md index b6b79d2..542b2f3 100644 --- a/README_CN.md +++ b/README_CN.md @@ -72,7 +72,7 @@ curl http://localhost:8080/v1/chat/completions \ ## 思考模式 -在模型名后加后缀(默认 `-thinking`)即可启用,例如 `claude-sonnet-4.5-thinking`。输出格式可在管理面板「设置 - Thinking 模式」中配置。 +在模型名后加后缀(默认 `-thinking`)即可启用,例如 `claude-sonnet-4.5-thinking`。Claude 兼容请求如果带有顶层 `thinking` 配置,例如 `{"type":"enabled","budget_tokens":2048}` 或 `{"type":"adaptive"}`,也会自动启用 thinking 模式。输出格式可在管理面板「设置 - Thinking 模式」中配置。 ## 环境变量 diff --git a/proxy/handler.go b/proxy/handler.go index a7fb592..2c42be9 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -66,6 +66,9 @@ func validateClaudeRequestShape(req *ClaudeRequest) string { if len(req.Messages) == 0 { return "messages must not be empty" } + if msg := validateClaudeThinkingConfig(req.Thinking, req.MaxTokens); msg != "" { + return msg + } hasUserContext := false lastRole := "" @@ -94,6 +97,75 @@ func validateClaudeRequestShape(req *ClaudeRequest) string { return "" } +func validateClaudeThinkingConfig(thinking *ClaudeThinkingConfig, maxTokens int) string { + if thinking == nil { + return "" + } + + kind := strings.ToLower(strings.TrimSpace(thinking.Type)) + switch kind { + case "enabled": + if maxTokens == 0 { + return "thinking.type enabled cannot be used with max_tokens=0" + } + if thinking.BudgetTokens <= 0 { + return "thinking.budget_tokens is required when thinking.type is enabled" + } + if thinking.BudgetTokens < 1024 { + return "thinking.budget_tokens must be at least 1024" + } + if maxTokens > 0 && thinking.BudgetTokens >= maxTokens { + return "thinking.budget_tokens must be less than max_tokens" + } + case "adaptive": + if thinking.BudgetTokens != 0 { + return "thinking.budget_tokens is not supported when thinking.type is adaptive" + } + case "disabled": + if thinking.BudgetTokens != 0 { + return "thinking.budget_tokens is not supported when thinking.type is disabled" + } + default: + return "thinking.type must be one of: enabled, adaptive, disabled" + } + + display := strings.ToLower(strings.TrimSpace(thinking.Display)) + if display != "" && display != "summarized" && display != "omitted" { + return "thinking.display must be one of: summarized, omitted" + } + if kind == "disabled" && display != "" { + return "thinking.display is not supported when thinking.type is disabled" + } + + return "" +} + +type claudeThinkingResponseOptions struct { + Format string + OmitDisplay bool +} + +func resolveClaudeThinkingResponseOptions(thinking *ClaudeThinkingConfig, defaultFormat string) claudeThinkingResponseOptions { + opts := claudeThinkingResponseOptions{Format: defaultFormat} + if opts.Format == "" { + opts.Format = "thinking" + } + if thinking == nil { + return opts + } + + display := strings.ToLower(strings.TrimSpace(thinking.Display)) + switch display { + case "summarized": + opts.Format = "thinking" + case "omitted": + opts.Format = "thinking" + opts.OmitDisplay = true + } + + return opts +} + func validateOpenAIRequestShape(req *OpenAIRequest) string { if len(req.Messages) == 0 { return "messages must not be empty" @@ -569,8 +641,17 @@ func (h *Handler) handleCountTokens(w http.ResponseWriter, r *http.Request) { h.sendClaudeError(w, 400, "invalid_request_error", "Invalid JSON") return } + if msg := validateClaudeThinkingConfig(req.Thinking, req.MaxTokens); msg != "" { + h.sendClaudeError(w, 400, "invalid_request_error", msg) + return + } - estimatedTokens := estimateClaudeRequestInputTokens(&req) + thinkingCfg := config.GetThinkingConfig() + actualModel, thinking := resolveClaudeThinkingMode(req.Model, req.Thinking, thinkingCfg.Suffix) + req.Model = actualModel + effectiveReq := cloneClaudeRequestForThinking(&req, thinking) + + estimatedTokens := estimateClaudeRequestInputTokens(effectiveReq) if estimatedTokens < 1 { estimatedTokens = 1 } @@ -622,10 +703,12 @@ func (h *Handler) handleClaudeMessagesInternal(w http.ResponseWriter, r *http.Re // 解析模型和 thinking 模式 thinkingCfg := config.GetThinkingConfig() - actualModel, thinking := ParseModelAndThinking(req.Model, thinkingCfg.Suffix) + actualModel, thinking := resolveClaudeThinkingMode(req.Model, req.Thinking, thinkingCfg.Suffix) req.Model = actualModel - estimatedInputTokens := estimateClaudeRequestInputTokens(&req) - cacheProfile := h.promptCache.BuildClaudeProfile(&req, estimatedInputTokens) + effectiveReq := cloneClaudeRequestForThinking(&req, thinking) + thinkingResponseOpts := resolveClaudeThinkingResponseOptions(req.Thinking, thinkingCfg.ClaudeFormat) + estimatedInputTokens := estimateClaudeRequestInputTokens(effectiveReq) + cacheProfile := h.promptCache.BuildClaudeProfile(effectiveReq, estimatedInputTokens) cacheUsage := h.promptCache.Compute(account.ID, cacheProfile) // 转换请求 @@ -633,14 +716,14 @@ func (h *Handler) handleClaudeMessagesInternal(w http.ResponseWriter, r *http.Re // Stream or non-stream if req.Stream { - h.handleClaudeStream(w, account, kiroPayload, req.Model, thinking, estimatedInputTokens, cacheUsage, cacheProfile) + h.handleClaudeStream(w, account, kiroPayload, req.Model, thinking, thinkingResponseOpts, estimatedInputTokens, cacheUsage, cacheProfile) } else { - h.handleClaudeNonStream(w, account, kiroPayload, req.Model, thinking, estimatedInputTokens, cacheUsage, cacheProfile) + h.handleClaudeNonStream(w, account, kiroPayload, req.Model, thinking, thinkingResponseOpts, estimatedInputTokens, cacheUsage, cacheProfile) } } // handleClaudeStream Claude 流式响应 -func (h *Handler) handleClaudeStream(w http.ResponseWriter, account *config.Account, payload *KiroPayload, model string, thinking bool, estimatedInputTokens int, cacheUsage promptCacheUsage, cacheProfile *promptCacheProfile) { +func (h *Handler) handleClaudeStream(w http.ResponseWriter, account *config.Account, payload *KiroPayload, model string, thinking bool, thinkingOpts claudeThinkingResponseOptions, estimatedInputTokens int, cacheUsage promptCacheUsage, cacheProfile *promptCacheProfile) { w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") @@ -652,7 +735,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, account *config.Acco } // 获取 thinking 输出格式配置 - thinkingFormat := config.GetThinkingConfig().ClaudeFormat + thinkingFormat := thinkingOpts.Format msgID := "msg_" + uuid.New().String() var inputTokens, outputTokens int @@ -769,6 +852,19 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, account *config.Acco "delta": map[string]string{"type": "text_delta", "text": text}, }) default: + if thinkingOpts.OmitDisplay { + if thinkingState == 1 { + startContentBlock("thinking") + return + } + if thinkingState == 3 { + if activeBlockType != "thinking" { + startContentBlock("thinking") + } + closeActiveBlock() + } + return + } if thinkingState == 3 && text == "" { if activeBlockType == "thinking" { closeActiveBlock() @@ -1103,7 +1199,7 @@ func (h *Handler) recordFailure() { } // handleClaudeNonStream Claude 非流式响应 -func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, account *config.Account, payload *KiroPayload, model string, thinking bool, estimatedInputTokens int, cacheUsage promptCacheUsage, cacheProfile *promptCacheProfile) { +func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, account *config.Account, payload *KiroPayload, model string, thinking bool, thinkingOpts claudeThinkingResponseOptions, estimatedInputTokens int, cacheUsage promptCacheUsage, cacheProfile *promptCacheProfile) { var content string var thinkingContent string var toolUses []KiroToolUse @@ -1146,13 +1242,14 @@ func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, account *config.A } // 合并 thinking 内容(如果有 reasoningContentEvent 的内容) - thinkingFormat := config.GetThinkingConfig().ClaudeFormat + thinkingFormat := thinkingOpts.Format finalContent, extractedReasoning := extractThinkingFromContent(content) - if thinking && thinkingContent == "" && extractedReasoning != "" { - thinkingContent = extractedReasoning + rawThinkingContent := thinkingContent + if thinking && rawThinkingContent == "" && extractedReasoning != "" { + rawThinkingContent = extractedReasoning } if !thinking { - thinkingContent = "" + rawThinkingContent = "" } if realInputTokens > 0 { @@ -1160,26 +1257,32 @@ func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, account *config.A } else if inputTokens <= 0 { inputTokens = estimatedInputTokens } - outputTokens = estimateClaudeOutputTokens(finalContent, thinkingContent, toolUses) + outputTokens = estimateClaudeOutputTokens(finalContent, rawThinkingContent, toolUses) h.recordSuccess(inputTokens, outputTokens, credits) h.pool.RecordSuccess(account.ID) h.pool.UpdateStats(account.ID, inputTokens+outputTokens, credits) h.promptCache.Update(account.ID, cacheProfile) - if thinking && thinkingContent != "" { + responseThinkingContent := rawThinkingContent + includeEmptyThinkingBlock := thinking && thinkingOpts.OmitDisplay && rawThinkingContent != "" + if includeEmptyThinkingBlock { + responseThinkingContent = "" + } + + if thinking && responseThinkingContent != "" { switch thinkingFormat { case "think": - finalContent = "" + thinkingContent + "" + finalContent - thinkingContent = "" + finalContent = "" + responseThinkingContent + "" + finalContent + responseThinkingContent = "" case "reasoning_content": - finalContent = thinkingContent + finalContent // Claude 格式不支持 reasoning_content,直接拼接 - thinkingContent = "" + finalContent = responseThinkingContent + finalContent // Claude 格式不支持 reasoning_content,直接拼接 + responseThinkingContent = "" default: } } - resp := KiroToClaudeResponse(finalContent, thinkingContent, toolUses, inputTokens, outputTokens, model) + resp := KiroToClaudeResponse(finalContent, responseThinkingContent, includeEmptyThinkingBlock, toolUses, inputTokens, outputTokens, model) resp.Usage.InputTokens = billedClaudeInputTokens(inputTokens, cacheUsage) resp.Usage.CacheCreationInputTokens = cacheUsage.CacheCreationInputTokens resp.Usage.CacheReadInputTokens = cacheUsage.CacheReadInputTokens diff --git a/proxy/handler_test.go b/proxy/handler_test.go index 672092a..e905bf1 100644 --- a/proxy/handler_test.go +++ b/proxy/handler_test.go @@ -1,8 +1,6 @@ package proxy -import ( - "testing" -) +import "testing" func TestThinkingSourceReasoningFirst(t *testing.T) { var source thinkingStreamSource @@ -101,6 +99,240 @@ func TestValidateClaudeRequestShapeRejectsAssistantPrefill(t *testing.T) { } } +func TestResolveClaudeThinkingModeHonorsRequestThinking(t *testing.T) { + tests := []struct { + name string + model string + thinking *ClaudeThinkingConfig + wantModel string + wantThinking bool + }{ + { + name: "adaptive request enables thinking", + model: "claude-sonnet-4.6", + thinking: &ClaudeThinkingConfig{Type: "adaptive"}, + wantModel: "claude-sonnet-4.6", + wantThinking: true, + }, + { + name: "enabled request enables thinking", + model: "claude-opus-4.5", + thinking: &ClaudeThinkingConfig{Type: "enabled", BudgetTokens: 2048}, + wantModel: "claude-opus-4.5", + wantThinking: true, + }, + { + name: "disabled request keeps thinking off", + model: "claude-opus-4.7", + thinking: &ClaudeThinkingConfig{Type: "disabled"}, + wantModel: "claude-opus-4.7", + wantThinking: false, + }, + { + name: "suffix remains supported when thinking is disabled", + model: "claude-sonnet-4.5-thinking", + thinking: &ClaudeThinkingConfig{Type: "disabled"}, + wantModel: "claude-sonnet-4.5", + wantThinking: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotModel, gotThinking := resolveClaudeThinkingMode(tc.model, tc.thinking, "-thinking") + if gotModel != tc.wantModel { + t.Fatalf("expected model %q, got %q", tc.wantModel, gotModel) + } + if gotThinking != tc.wantThinking { + t.Fatalf("expected thinking=%v, got %v", tc.wantThinking, gotThinking) + } + }) + } +} + +func TestCloneClaudeRequestForThinkingInjectsPromptWithoutMutatingOriginal(t *testing.T) { + req := &ClaudeRequest{ + Model: "claude-sonnet-4.6", + System: "Follow the user instructions.", + } + + cloned := cloneClaudeRequestForThinking(req, true) + blocks, ok := cloned.System.([]interface{}) + if !ok { + t.Fatalf("expected cloned system prompt to be structured blocks, got %T", cloned.System) + } + if len(blocks) != 2 { + t.Fatalf("expected 2 system blocks after prepend, got %d", len(blocks)) + } + gotPrompt := extractSystemPrompt(cloned.System) + expected := ThinkingModePrompt + "\n\nFollow the user instructions." + if gotPrompt != expected { + t.Fatalf("expected injected system prompt %q, got %q", expected, gotPrompt) + } + if original, ok := req.System.(string); !ok || original != "Follow the user instructions." { + t.Fatalf("expected original request system prompt to stay unchanged, got %#v", req.System) + } +} + +func TestCloneClaudeRequestForThinkingPreservesStructuredSystemBlocks(t *testing.T) { + req := &ClaudeRequest{ + Model: "claude-sonnet-4.6", + System: []interface{}{ + map[string]interface{}{ + "type": "text", + "text": "cached system", + "cache_control": map[string]interface{}{ + "type": "ephemeral", + "ttl": "5m", + }, + }, + }, + } + + cloned := cloneClaudeRequestForThinking(req, true) + blocks, ok := cloned.System.([]interface{}) + if !ok { + t.Fatalf("expected structured system blocks, got %T", cloned.System) + } + if len(blocks) != 2 { + t.Fatalf("expected 2 system blocks after prepend, got %d", len(blocks)) + } + first, ok := blocks[0].(map[string]interface{}) + if !ok || first["text"] != ThinkingModePrompt+"\n" { + t.Fatalf("expected first block to be thinking prompt, got %#v", blocks[0]) + } + second, ok := blocks[1].(map[string]interface{}) + if !ok { + t.Fatalf("expected original system block to remain a map, got %T", blocks[1]) + } + cacheControl, ok := second["cache_control"].(map[string]interface{}) + if !ok || cacheControl["type"] != "ephemeral" { + t.Fatalf("expected original cache_control to be preserved, got %#v", second["cache_control"]) + } +} + +func TestThinkingPromptAffectsClaudeTokenEstimate(t *testing.T) { + req := &ClaudeRequest{ + Model: "claude-sonnet-4.6", + Messages: []ClaudeMessage{{Role: "user", Content: "hello"}}, + } + + baseTokens := estimateClaudeRequestInputTokens(req) + thinkingTokens := estimateClaudeRequestInputTokens(cloneClaudeRequestForThinking(req, true)) + + if thinkingTokens <= baseTokens { + t.Fatalf("expected thinking tokens (%d) to exceed base tokens (%d)", thinkingTokens, baseTokens) + } +} + +func TestValidateClaudeThinkingConfig(t *testing.T) { + tests := []struct { + name string + thinking *ClaudeThinkingConfig + maxTokens int + expectError bool + }{ + { + name: "adaptive is valid", + thinking: &ClaudeThinkingConfig{Type: "adaptive"}, + maxTokens: 4096, + expectError: false, + }, + { + name: "enabled requires budget", + thinking: &ClaudeThinkingConfig{Type: "enabled"}, + maxTokens: 4096, + expectError: true, + }, + { + name: "enabled requires at least 1024 budget tokens", + thinking: &ClaudeThinkingConfig{Type: "enabled", BudgetTokens: 512}, + maxTokens: 4096, + expectError: true, + }, + { + name: "enabled rejects max tokens zero", + thinking: &ClaudeThinkingConfig{Type: "enabled", BudgetTokens: 2048}, + maxTokens: 0, + expectError: true, + }, + { + name: "enabled budget must stay below max tokens", + thinking: &ClaudeThinkingConfig{Type: "enabled", BudgetTokens: 4096}, + maxTokens: 4096, + expectError: true, + }, + { + name: "disabled rejects display", + thinking: &ClaudeThinkingConfig{Type: "disabled", Display: "summarized"}, + maxTokens: 4096, + expectError: true, + }, + { + name: "missing type is rejected", + thinking: &ClaudeThinkingConfig{}, + maxTokens: 4096, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + errMsg := validateClaudeThinkingConfig(tc.thinking, tc.maxTokens) + if tc.expectError && errMsg == "" { + t.Fatalf("expected validation error") + } + if !tc.expectError && errMsg != "" { + t.Fatalf("expected thinking config to be valid, got %q", errMsg) + } + }) + } +} + +func TestResolveClaudeThinkingResponseOptions(t *testing.T) { + tests := []struct { + name string + thinking *ClaudeThinkingConfig + defaultFmt string + wantFmt string + wantOmit bool + }{ + { + name: "default config is preserved when display unset", + thinking: &ClaudeThinkingConfig{Type: "enabled", BudgetTokens: 2048}, + defaultFmt: "think", + wantFmt: "think", + wantOmit: false, + }, + { + name: "summarized forces official thinking blocks", + thinking: &ClaudeThinkingConfig{Type: "adaptive", Display: "summarized"}, + defaultFmt: "reasoning_content", + wantFmt: "thinking", + wantOmit: false, + }, + { + name: "omitted forces official thinking blocks and hides content", + thinking: &ClaudeThinkingConfig{Type: "adaptive", Display: "omitted"}, + defaultFmt: "think", + wantFmt: "thinking", + wantOmit: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + opts := resolveClaudeThinkingResponseOptions(tc.thinking, tc.defaultFmt) + if opts.Format != tc.wantFmt { + t.Fatalf("expected format %q, got %q", tc.wantFmt, opts.Format) + } + if opts.OmitDisplay != tc.wantOmit { + t.Fatalf("expected omitDisplay=%v, got %v", tc.wantOmit, opts.OmitDisplay) + } + }) + } +} + func TestMergeUniqueModelsPreservesUnionAcrossAccounts(t *testing.T) { base := []ModelInfo{ {ModelId: "claude-sonnet-4.5", InputTypes: []string{"TEXT"}}, diff --git a/proxy/translator.go b/proxy/translator.go index 500b74e..38b562e 100644 --- a/proxy/translator.go +++ b/proxy/translator.go @@ -76,6 +76,19 @@ func ParseModelAndThinking(model string, thinkingSuffix string) (string, bool) { return model, thinking } +func resolveClaudeThinkingMode(model string, thinkingCfg *ClaudeThinkingConfig, thinkingSuffix string) (string, bool) { + actualModel, suffixThinking := ParseModelAndThinking(model, thinkingSuffix) + return actualModel, suffixThinking || isClaudeThinkingRequested(thinkingCfg) +} + +func isClaudeThinkingRequested(thinkingCfg *ClaudeThinkingConfig) bool { + if thinkingCfg == nil { + return false + } + kind := strings.ToLower(strings.TrimSpace(thinkingCfg.Type)) + return kind == "enabled" || kind == "adaptive" +} + func MapModel(model string) string { mapped, _ := ParseModelAndThinking(model, "-thinking") return mapped @@ -84,15 +97,22 @@ func MapModel(model string) string { // ==================== Claude API 类型 ==================== type ClaudeRequest struct { - Model string `json:"model"` - Messages []ClaudeMessage `json:"messages"` - MaxTokens int `json:"max_tokens"` - Temperature float64 `json:"temperature,omitempty"` - TopP float64 `json:"top_p,omitempty"` - Stream bool `json:"stream,omitempty"` - System interface{} `json:"system,omitempty"` // string or []SystemBlock - Tools []ClaudeTool `json:"tools,omitempty"` - ToolChoice interface{} `json:"tool_choice,omitempty"` + Model string `json:"model"` + Messages []ClaudeMessage `json:"messages"` + MaxTokens int `json:"max_tokens"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + Stream bool `json:"stream,omitempty"` + System interface{} `json:"system,omitempty"` // string or []SystemBlock + Thinking *ClaudeThinkingConfig `json:"thinking,omitempty"` + Tools []ClaudeTool `json:"tools,omitempty"` + ToolChoice interface{} `json:"tool_choice,omitempty"` +} + +type ClaudeThinkingConfig struct { + Type string `json:"type,omitempty"` + BudgetTokens int `json:"budget_tokens,omitempty"` + Display string `json:"display,omitempty"` } type ClaudeMessage struct { @@ -104,6 +124,7 @@ type ClaudeContentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` Thinking string `json:"thinking,omitempty"` + Signature string `json:"signature,omitempty"` ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Input interface{} `json:"input,omitempty"` @@ -157,12 +178,7 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { origin := "AI_EDITOR" // 提取系统提示 - systemPrompt := extractSystemPrompt(req.System) - - // 如果启用 thinking 模式,注入 thinking 提示 - if thinking { - systemPrompt = ThinkingModePrompt + "\n\n" + systemPrompt - } + systemPrompt := buildClaudeSystemPrompt(req.System, thinking) // 构建历史消息 history := make([]KiroHistoryMessage, 0) @@ -263,6 +279,88 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { return payload } +func buildClaudeSystemPrompt(system interface{}, thinking bool) string { + systemPrompt := extractSystemPrompt(system) + if !thinking { + return systemPrompt + } + if systemPrompt == "" { + return ThinkingModePrompt + } + return ThinkingModePrompt + "\n\n" + systemPrompt +} + +func cloneClaudeRequestForThinking(req *ClaudeRequest, thinking bool) *ClaudeRequest { + if req == nil { + return nil + } + + cloned := *req + if thinking { + cloned.System = prependThinkingSystem(req.System) + } + return &cloned +} + +func prependThinkingSystem(system interface{}) interface{} { + thinkingText := ThinkingModePrompt + if hasClaudeSystemContent(system) { + thinkingText += "\n" + } + thinkingBlock := map[string]interface{}{ + "type": "text", + "text": thinkingText, + } + + switch v := system.(type) { + case nil: + return []interface{}{thinkingBlock} + case string: + if v == "" { + return []interface{}{thinkingBlock} + } + return []interface{}{ + thinkingBlock, + map[string]interface{}{ + "type": "text", + "text": v, + }, + } + case []interface{}: + blocks := make([]interface{}, 0, len(v)+1) + blocks = append(blocks, thinkingBlock) + blocks = append(blocks, v...) + return blocks + case []string: + blocks := make([]interface{}, 0, len(v)+1) + blocks = append(blocks, thinkingBlock) + for _, block := range v { + blocks = append(blocks, map[string]interface{}{ + "type": "text", + "text": block, + }) + } + return blocks + default: + return []interface{}{thinkingBlock} + } +} + +func hasClaudeSystemContent(system interface{}) bool { + switch v := system.(type) { + case nil: + return false + case string: + return v != "" + case []interface{}: + return len(v) > 0 + case []string: + return len(v) > 0 + default: + return true + } +} + func extractSystemPrompt(system interface{}) string { if system == nil { return "" @@ -459,10 +557,10 @@ func shortenToolName(name string) string { // ==================== Kiro -> Claude 转换 ==================== -func KiroToClaudeResponse(content, thinkingContent string, toolUses []KiroToolUse, inputTokens, outputTokens int, model string) *ClaudeResponse { +func KiroToClaudeResponse(content, thinkingContent string, includeEmptyThinkingBlock bool, toolUses []KiroToolUse, inputTokens, outputTokens int, model string) *ClaudeResponse { blocks := make([]ClaudeContentBlock, 0) - if thinkingContent != "" { + if thinkingContent != "" || includeEmptyThinkingBlock { blocks = append(blocks, ClaudeContentBlock{ Type: "thinking", Thinking: thinkingContent, diff --git a/proxy/translator_test.go b/proxy/translator_test.go index 7c5dc43..e0f276f 100644 --- a/proxy/translator_test.go +++ b/proxy/translator_test.go @@ -233,6 +233,23 @@ func TestClaudeToKiroDropsLeadingAssistantHistory(t *testing.T) { } } +func TestKiroToClaudeResponseCanEmitEmptyThinkingBlock(t *testing.T) { + resp := KiroToClaudeResponse("final answer", "", true, nil, 10, 20, "claude-sonnet-4.6") + + if len(resp.Content) != 2 { + t.Fatalf("expected empty thinking block plus text block, got %d blocks", len(resp.Content)) + } + if resp.Content[0].Type != "thinking" { + t.Fatalf("expected first block to be thinking, got %#v", resp.Content[0]) + } + if resp.Content[0].Thinking != "" { + t.Fatalf("expected omitted thinking block to have empty content, got %#v", resp.Content[0].Thinking) + } + if resp.Content[1].Type != "text" || resp.Content[1].Text != "final answer" { + t.Fatalf("expected text block to be preserved, got %#v", resp.Content[1]) + } +} + func TestToolResultsContinuationIncludesInstructionPrefix(t *testing.T) { req := &OpenAIRequest{ Model: "claude-sonnet-4.5",