From 793795e7d80b2f40aa17bca080e50760e73ac1bf Mon Sep 17 00:00:00 2001 From: root Date: Tue, 14 Jul 2026 22:58:03 +0700 Subject: [PATCH 1/4] fix(proxy): allow idc accounts to probe fallback regions --- proxy/kiro_api.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/proxy/kiro_api.go b/proxy/kiro_api.go index ce79e66a..a8f78bab 100644 --- a/proxy/kiro_api.go +++ b/proxy/kiro_api.go @@ -145,7 +145,8 @@ func shouldProbeFallbackRegions(account *config.Account) bool { if strings.TrimSpace(account.Region) == "" { return true } - return strings.EqualFold(strings.TrimSpace(account.AuthMethod), "external_idp") + method := strings.ToLower(strings.TrimSpace(account.AuthMethod)) + return method == "external_idp" || method == "idc" } // GetUsageLimits 获取账户使用量和订阅信息 From bdbdd7f32d868724ae111b6606b091dfa1a9405c Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 22:25:31 +0700 Subject: [PATCH 2/4] feat: add native reasoning and env-gated diagnostics --- diagnostics/flags.go | 58 ++++ proxy/handler.go | 255 +++++++++++++++-- proxy/kiro.go | 176 +++++++++--- proxy/kiro_api.go | 1 + proxy/reasoning.go | 550 +++++++++++++++++++++++++++++++++++++ proxy/reasoning_test.go | 176 ++++++++++++ proxy/responses_handler.go | 52 +++- proxy/responses_types.go | 9 + proxy/translator.go | 122 +++++++- 9 files changed, 1338 insertions(+), 61 deletions(-) create mode 100644 diagnostics/flags.go create mode 100644 proxy/reasoning.go create mode 100644 proxy/reasoning_test.go diff --git a/diagnostics/flags.go b/diagnostics/flags.go new file mode 100644 index 00000000..b55b95dc --- /dev/null +++ b/diagnostics/flags.go @@ -0,0 +1,58 @@ +package diagnostics + +import ( + "os" + "strings" +) + +var ( + masterEnabled = envBool("KIRO_DIAGNOSTICS") + + streamEnabled = masterEnabled || + envBool("KIRO_DIAG_STREAM") + + payloadEnabled = masterEnabled || + envBool("KIRO_DIAG_PAYLOAD") + + reasoningEnabled = masterEnabled || + envBool("KIRO_DIAG_REASONING") + + chunksEnabled = envBool("KIRO_DIAG_CHUNKS") +) + +func envBool(name string) bool { + value, exists := os.LookupEnv(name) + if !exists { + return false + } + + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on", "enabled": + return true + default: + return false + } +} + +func Stream() bool { + return streamEnabled +} + +func Payload() bool { + return payloadEnabled +} + +func Reasoning() bool { + return reasoningEnabled +} + +func Chunks() bool { + return chunksEnabled +} + +func Any() bool { + return streamEnabled || + payloadEnabled || + reasoningEnabled || + chunksEnabled +} diff --git a/proxy/handler.go b/proxy/handler.go index 2d269f6a..e13e4c28 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -9,6 +9,7 @@ import ( "kiro-go/config" "kiro-go/logger" "kiro-go/pool" + "kiro-go/diagnostics" "net/http" "path/filepath" "strings" @@ -781,10 +782,51 @@ func mergeModelInfo(base ModelInfo, extra ModelInfo) ModelInfo { if base.TokenLimits == nil { base.TokenLimits = extra.TokenLimits } + if len(base.AdditionalModelRequestFieldsSchema) == 0 && + len(extra.AdditionalModelRequestFieldsSchema) > 0 { + base.AdditionalModelRequestFieldsSchema = + append( + json.RawMessage(nil), + extra.AdditionalModelRequestFieldsSchema..., + ) + } base.InputTypes = mergeStringLists(base.InputTypes, extra.InputTypes) return base } +func (h *Handler) reasoningCapabilityForModel( + modelID string, +) ModelReasoningCapability { + normalizedID := strings.ToLower( + strings.TrimSpace(MapModel(modelID)), + ) + + h.modelsCacheMu.RLock() + models := append([]ModelInfo(nil), h.cachedModels...) + h.modelsCacheMu.RUnlock() + + if len(models) == 0 { + h.refreshModelsCache() + + h.modelsCacheMu.RLock() + models = append([]ModelInfo(nil), h.cachedModels...) + h.modelsCacheMu.RUnlock() + } + + for _, model := range models { + currentID := strings.ToLower( + strings.TrimSpace(model.ModelId), + ) + if currentID == normalizedID { + return ParseModelReasoningCapability(model) + } + } + + return ModelReasoningCapability{ + ModelID: modelID, + } +} + func mergeStringLists(base []string, extra []string) []string { if len(extra) == 0 { return base @@ -834,9 +876,39 @@ func (h *Handler) handleCountTokens(w http.ResponseWriter, r *http.Request) { } thinkingCfg := config.GetThinkingConfig() - actualModel, thinking := resolveClaudeThinkingMode(req.Model, req.Thinking, thinkingCfg.Suffix) - req.Model = actualModel - effectiveReq := cloneClaudeRequestForThinking(&req, thinking) + actualModel, thinkingRequested := + resolveClaudeThinkingMode( + req.Model, + req.Thinking, + thinkingCfg.Suffix, + ) + + req.Model = actualModel + + capability := h.reasoningCapabilityForModel(actualModel) + + additionalFields, nativeRequested, buildErr := + BuildClaudeAdditionalModelRequestFields( + &req, + capability, + ) + if buildErr != nil { + h.sendClaudeError( + w, + 400, + "invalid_request_error", + buildErr.Error(), + ) + return + } + + thinkingRequested = thinkingRequested || nativeRequested + + useLegacyThinkingPrompt := + thinkingRequested && + len(additionalFields) == 0 + + effectiveReq := cloneClaudeRequestForThinking(&req, useLegacyThinkingPrompt,) estimatedTokens := estimateClaudeRequestInputTokens(effectiveReq) if estimatedTokens < 1 { @@ -852,6 +924,38 @@ func (h *Handler) handleClaudeMessages(w http.ResponseWriter, r *http.Request) { h.handleClaudeMessagesInternal(w, r) } +func claudeThinkingType(req *ClaudeRequest) string { + if req == nil || req.Thinking == nil { + return "" + } + + return strings.TrimSpace(req.Thinking.Type) +} + +func claudeThinkingBudget(req *ClaudeRequest) int { + if req == nil || req.Thinking == nil { + return 0 + } + + return req.Thinking.BudgetTokens +} + +func claudeReasoningEffort(req *ClaudeRequest) string { + if req == nil { + return "" + } + + if req.OutputConfig != nil { + if effort := strings.TrimSpace( + req.OutputConfig.Effort, + ); effort != "" { + return effort + } + } + + return strings.TrimSpace(req.ReasoningEffort) +} + func (h *Handler) handleClaudeMessagesInternal(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method Not Allowed", 405) @@ -875,17 +979,87 @@ func (h *Handler) handleClaudeMessagesInternal(w http.ResponseWriter, r *http.Re return } - // 解析模型和 thinking 模式 thinkingCfg := config.GetThinkingConfig() - actualModel, thinking := resolveClaudeThinkingMode(req.Model, req.Thinking, thinkingCfg.Suffix) - req.Model = actualModel - effectiveReq := cloneClaudeRequestForThinking(&req, thinking) - thinkingResponseOpts := resolveClaudeThinkingResponseOptions(req.Thinking, thinkingCfg.ClaudeFormat) - estimatedInputTokens := estimateClaudeRequestInputTokens(effectiveReq) - cacheProfile := h.promptCache.BuildClaudeProfile(effectiveReq, estimatedInputTokens) - - // 转换请求 - kiroPayload := ClaudeToKiro(&req, thinking) + requestedModel := req.Model + actualModel, legacyOrClientThinking := + resolveClaudeThinkingMode( + req.Model, + req.Thinking, + thinkingCfg.Suffix, + ) + + req.Model = actualModel + + capability := h.reasoningCapabilityForModel(actualModel) + + additionalFields, nativeRequested, buildErr := + BuildClaudeAdditionalModelRequestFields( + &req, + capability, + ) + if buildErr != nil { + h.sendClaudeError( + w, + 400, + "invalid_request_error", + buildErr.Error(), + ) + return + } + + thinking := legacyOrClientThinking || nativeRequested + + if diagnostics.Reasoning() { + logger.Infof( + "[ClaudeThinking] requested=%t type=%q budgetTokens=%d effort=%q requestedModel=%q actualModel=%q", + thinking, + claudeThinkingType(&req), + claudeThinkingBudget(&req), + claudeReasoningEffort(&req), + requestedModel, + actualModel, + ) + logger.Infof( + "[KiroReasoning] model=%s requested=%t schemaPath=%s supportsThinking=%t thinkingTypes=%v supportedEfforts=%v fields=%s", + actualModel, + nativeRequested, + capability.EffortPath, + capability.SupportsThinking, + capability.ThinkingTypes, + capability.Efforts, + reasoningFieldsJSON(additionalFields), + ) + } + + useLegacyThinkingPrompt := + thinking && len(additionalFields) == 0 + + effectiveReq := cloneClaudeRequestForThinking( + &req, + useLegacyThinkingPrompt, + ) + + thinkingResponseOpts := + resolveClaudeThinkingResponseOptions( + req.Thinking, + thinkingCfg.ClaudeFormat, + ) + + estimatedInputTokens := + estimateClaudeRequestInputTokens(effectiveReq) + + cacheProfile := h.promptCache.BuildClaudeProfile( + effectiveReq, + estimatedInputTokens, + ) + + kiroPayload := ClaudeToKiro( + &req, + useLegacyThinkingPrompt, + ) + + kiroPayload.AdditionalModelRequestFields = + additionalFields // Stream or non-stream apiKeyID := apiKeyIDFromContext(r.Context()) @@ -1322,6 +1496,20 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload if len(toolUses) > 0 { stopReason = "tool_use" } + + if diagnostics.Stream() { + logger.Infof( + "[ClaudeStream] completed model=%s account=%s stopReason=%s toolUses=%d outputChars=%d inputTokens=%d outputTokens=%d durationMs=%d", + model, + account.Email, + stopReason, + len(toolUses), + len([]rune(outputContent)), + inputTokens, + outputTokens, + time.Since(reqStart).Milliseconds(), + ) + } ensureMessageStart() h.sendSSE(w, flusher, "message_delta", map[string]interface{}{ @@ -1671,11 +1859,44 @@ func (h *Handler) handleOpenAIChat(w http.ResponseWriter, r *http.Request) { // 解析模型和 thinking 模式 thinkingCfg := config.GetThinkingConfig() - actualModel, thinking := ParseModelAndThinking(req.Model, thinkingCfg.Suffix) - req.Model = actualModel - estimatedInputTokens := estimateOpenAIRequestInputTokens(&req) - kiroPayload := OpenAIToKiro(&req, thinking) + actualModel, suffixThinking := + ParseModelAndThinking( + req.Model, + thinkingCfg.Suffix, + ) + + req.Model = actualModel + + capability := h.reasoningCapabilityForModel(actualModel) + + additionalFields, nativeRequested, buildErr := + BuildOpenAIAdditionalModelRequestFields( + &req, + capability, + ) + if buildErr != nil { + h.sendOpenAIError( + w, + 400, + "invalid_request_error", + buildErr.Error(), + ) + return + } + + thinking := suffixThinking || nativeRequested + + useLegacyThinkingPrompt := thinking && len(additionalFields) == 0 + + estimatedInputTokens := estimateOpenAIRequestInputTokens(&req) + + kiroPayload := OpenAIToKiro( + &req, + useLegacyThinkingPrompt, + ) + + kiroPayload.AdditionalModelRequestFields = additionalFields apiKeyID := apiKeyIDFromContext(r.Context()) if req.Stream { diff --git a/proxy/kiro.go b/proxy/kiro.go index b3ecf567..9805b210 100644 --- a/proxy/kiro.go +++ b/proxy/kiro.go @@ -10,6 +10,7 @@ import ( "io" "kiro-go/config" "kiro-go/logger" + "kiro-go/diagnostics" "net/http" "net/url" "regexp" @@ -72,7 +73,7 @@ func GetClientForProxy(proxyURL string) *http.Client { return cached.(*http.Client) } client := &http.Client{ - Timeout: 5 * time.Minute, + Timeout: 15 * time.Minute, Transport: buildKiroTransport(proxyURL), } proxyClientCache.Store(proxyURL, client) @@ -130,7 +131,7 @@ func buildKiroTransport(proxyURL string) *http.Transport { // InitKiroHttpClient initializes (or reinitializes) the HTTP clients used for Kiro API requests. func InitKiroHttpClient(proxyURL string) { client := &http.Client{ - Timeout: 5 * time.Minute, + Timeout: 15 * time.Minute, Transport: buildKiroTransport(proxyURL), } kiroHttpStore.Store(client) @@ -158,6 +159,10 @@ type KiroPayload struct { } `json:"conversationState"` ProfileArn string `json:"profileArn,omitempty"` InferenceConfig *InferenceConfig `json:"inferenceConfig,omitempty"` + + // Native model-specific request configuration discovered from + // ListAvailableModels.additionalModelRequestFieldsSchema. + AdditionalModelRequestFields map[string]interface{} `json:"additionalModelRequestFields,omitempty"` // ToolNameMap maps sanitized tool names (sent to Kiro) back to the // original names supplied by the client. Used to restore original names @@ -366,6 +371,12 @@ func CallKiroAPI(account *config.Account, payload *KiroPayload, callback *KiroSt // Target the profile's data-plane region; endpoint URLs are declared for us-east-1. epURL := regionalizeURLForProfile(ep.URL, account, payload.ProfileArn) + + if diagnostics.Reasoning() && len(payload.AdditionalModelRequestFields) > 0 { + logger.Infof("[KiroOutboundReasoning] endpoint=%s fields=%s", ep.Name, reasoningFieldsJSON( + payload.AdditionalModelRequestFields,), + ) + } reqBody, _ := json.Marshal(payload) req, err := http.NewRequest("POST", epURL, bytes.NewReader(reqBody)) @@ -422,7 +433,17 @@ func CallKiroAPI(account *config.Account, payload *KiroPayload, callback *KiroSt // parseAndStream defers resp.Body.Close(), so a panic in a streaming // callback (OnText/OnToolUse/parseEventStream) still returns the upstream // TCP connection to the transport pool instead of leaking it. - return parseAndStream(resp.Body, callback) + //return parseAndStream(resp.Body, callback) + if err := parseAndStream(resp.Body, callback); err != nil { + logger.Warnf( + "[KiroAPI] Endpoint %s stream failed: %v", + ep.Name, + err, + ) + return err + } + + return nil } if lastErr != nil { @@ -455,6 +476,17 @@ const maxEventStreamMessageBytes = 16 * 1024 * 1024 // allocating gigabytes. var errEventStreamFrameTooLarge = errors.New("event-stream: frame totalLength exceeds maximum") +func diagnosticPreview(value string) string { + const maxRunes = 512 + + runes := []rune(value) + if len(runes) <= maxRunes { + return value + } + + return string(runes[:maxRunes]) + "…[truncated]" +} + // parseEventStream decodes an AWS binary Event Stream response body. func parseEventStream(body io.Reader, callback *KiroStreamCallback) error { if callback == nil { @@ -467,14 +499,36 @@ func parseEventStream(body io.Reader, callback *KiroStreamCallback) error { var currentToolUse *toolUseState var lastAssistantContent string var lastReasoningContent string + var eventCount int //add + var assistantEventCount int //add + var reasoningEventCount int //add + var toolEventCount int //add + var lastEventType string //add + var assistantChars int //add + var reasoningChars int //add for { // Prelude: 12 bytes (total_len + headers_len + crc) prelude := make([]byte, 12) _, err := io.ReadFull(body, prelude) if err == io.EOF { - break - } + if diagnostics.Stream() { + logger.Infof( + "[KiroStream] EOF events=%d last=%q assistantEvents=%d reasoningEvents=%d reasoningChars=%d toolEvents=%d assistantChars=%d inputTokens=%d outputTokens=%d credits=%.3f", + eventCount, + lastEventType, + assistantEventCount, + reasoningEventCount, + reasoningChars, + toolEventCount, + assistantChars, + inputTokens, + outputTokens, + totalCredits, + ) + } + break + } if err != nil { return err } @@ -509,6 +563,11 @@ func parseEventStream(body io.Reader, callback *KiroStreamCallback) error { } eventType := extractEventType(msgBuf[0:headersLength]) + if diagnostics.Stream() { + eventCount++ + lastEventType = eventType + } + payloadBytes := msgBuf[headersLength : len(msgBuf)-4] if len(payloadBytes) == 0 { continue @@ -523,33 +582,86 @@ func parseEventStream(body io.Reader, callback *KiroStreamCallback) error { // Dispatch by event type. switch eventType { - case "assistantResponseEvent": - if content, ok := event["content"].(string); ok && content != "" { - normalized := normalizeChunk(content, &lastAssistantContent) - if normalized != "" && callback.OnText != nil { - callback.OnText(normalized, false) - } - } - case "reasoningContentEvent": - if text, ok := event["text"].(string); ok && text != "" { - normalized := normalizeChunk(text, &lastReasoningContent) - if normalized != "" && callback.OnText != nil { - callback.OnText(normalized, true) - } - } - case "toolUseEvent": - currentToolUse = handleToolUseEvent(event, currentToolUse, callback) - case "meteringEvent": - if usage, ok := event["usage"].(float64); ok { - totalCredits += usage - } - case "contextUsageEvent": - if pct, ok := event["contextUsagePercentage"].(float64); ok { - if callback.OnContextUsage != nil { - callback.OnContextUsage(pct) - } - } - } + case "assistantResponseEvent": + if diagnostics.Stream() { + assistantEventCount++ + } + + if content, ok := event["content"].(string); ok && content != "" { + if diagnostics.Stream() { + assistantChars += len([]rune(content)) + } + + previous := lastAssistantContent + + normalized := normalizeChunk(content, &lastAssistantContent) + + if diagnostics.Chunks() { + logger.Infof( + "[StreamChunk] type=assistant previous=%q raw=%q normalized=%q", + diagnosticPreview(previous), + diagnosticPreview(content), + diagnosticPreview(normalized), + ) + } + + if normalized != "" && callback.OnText != nil { + callback.OnText(normalized, false) + } + } + + case "reasoningContentEvent": + if diagnostics.Stream() { + reasoningEventCount++ + } + + if text, ok := event["text"].(string); ok && text != "" { + previous := lastReasoningContent + normalized := normalizeChunk(text, &lastReasoningContent) + + if diagnostics.Stream() { + reasoningChars += len([]rune(normalized)) + } + + if diagnostics.Chunks() { + logger.Infof( + "[StreamChunk] type=reasoning previous=%q raw=%q normalized=%q", + diagnosticPreview(previous), + diagnosticPreview(text), + diagnosticPreview(normalized), + ) + } + + if normalized != "" && callback.OnText != nil { + callback.OnText(normalized, true) + } + } + + case "toolUseEvent": + if diagnostics.Stream() { + toolEventCount++ + } + currentToolUse = handleToolUseEvent(event, currentToolUse, callback) + + case "meteringEvent": + if usage, ok := event["usage"].(float64); ok { + totalCredits += usage + } + + case "contextUsageEvent": + if pct, ok := event["contextUsagePercentage"].(float64); ok { + if callback.OnContextUsage != nil { + callback.OnContextUsage(pct) + } + } + + default: + logger.Debugf( + "[KiroStream] unhandled event type=%q payload=%v", + eventType, + event, + ) + } } if currentToolUse != nil { diff --git a/proxy/kiro_api.go b/proxy/kiro_api.go index a8f78bab..1a6b12e4 100644 --- a/proxy/kiro_api.go +++ b/proxy/kiro_api.go @@ -861,4 +861,5 @@ type ModelInfo struct { MaxInputTokens int `json:"maxInputTokens"` MaxOutputTokens int `json:"maxOutputTokens"` } `json:"tokenLimits"` + AdditionalModelRequestFieldsSchema json.RawMessage `json:"additionalModelRequestFieldsSchema,omitempty"` } diff --git a/proxy/reasoning.go b/proxy/reasoning.go new file mode 100644 index 00000000..7dd6a1f0 --- /dev/null +++ b/proxy/reasoning.go @@ -0,0 +1,550 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "strings" +) + +type ReasoningSchemaPath string + +const ( + ReasoningSchemaNone ReasoningSchemaPath = "" + ReasoningSchemaOutputConfig ReasoningSchemaPath = "output_config" + ReasoningSchemaReasoning ReasoningSchemaPath = "reasoning" +) + +type ModelReasoningCapability struct { + ModelID string + + SupportsThinking bool + ThinkingTypes []string + SupportsDisplay bool + SupportsBudgetTokens bool + EffortPath ReasoningSchemaPath + Efforts []string + SchemaParseError string +} + +func (c ModelReasoningCapability) SupportsNativeReasoning() bool { + return c.SupportsThinking || c.EffortPath != ReasoningSchemaNone +} + +func ParseModelReasoningCapability(model ModelInfo) ModelReasoningCapability { + capability := ModelReasoningCapability{ + ModelID: model.ModelId, + } + + root, err := decodeModelRequestSchema( + model.AdditionalModelRequestFieldsSchema, + ) + if err != nil { + capability.SchemaParseError = err.Error() + return capability + } + if root == nil { + return capability + } + + root = unwrapAdditionalFieldsSchema(root) + properties := schemaProperties(root) + if properties == nil { + return capability + } + + if thinkingSchema := schemaObject(properties["thinking"]); thinkingSchema != nil { + capability.SupportsThinking = true + + thinkingProps := schemaProperties(thinkingSchema) + capability.ThinkingTypes = enumStrings( + schemaObject(thinkingProps["type"]), + ) + capability.SupportsDisplay = + thinkingProps != nil && thinkingProps["display"] != nil + capability.SupportsBudgetTokens = + thinkingProps != nil && thinkingProps["budget_tokens"] != nil + } + + if outputConfigSchema := schemaObject(properties["output_config"]); outputConfigSchema != nil { + outputProps := schemaProperties(outputConfigSchema) + efforts := enumStrings(schemaObject(outputProps["effort"])) + if len(efforts) > 0 { + capability.EffortPath = ReasoningSchemaOutputConfig + capability.Efforts = normalizeEffortList(efforts) + return capability + } + } + + if reasoningSchema := schemaObject(properties["reasoning"]); reasoningSchema != nil { + reasoningProps := schemaProperties(reasoningSchema) + efforts := enumStrings(schemaObject(reasoningProps["effort"])) + if len(efforts) > 0 { + capability.EffortPath = ReasoningSchemaReasoning + capability.Efforts = normalizeEffortList(efforts) + } + } + + return capability +} + +func decodeModelRequestSchema(raw json.RawMessage) (map[string]interface{}, error) { + data := bytes.TrimSpace(raw) + if len(data) == 0 || bytes.Equal(data, []byte("null")) { + return nil, nil + } + + for depth := 0; depth < 3; depth++ { + if len(data) == 0 { + return nil, nil + } + + if data[0] == '"' { + var encoded string + if err := json.Unmarshal(data, &encoded); err != nil { + return nil, fmt.Errorf("decode schema string: %w", err) + } + data = []byte(encoded) + continue + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("decode schema object: %w", err) + } + return result, nil + } + + return nil, fmt.Errorf("schema nesting is too deep") +} + + +func unwrapAdditionalFieldsSchema( + root map[string]interface{}, +) map[string]interface{} { + for _, key := range []string{"schema", "jsonSchema"} { + if nested := schemaObject(root[key]); nested != nil { + root = nested + } + } + + properties := schemaProperties(root) + if properties == nil { + return root + } + + if nested := schemaObject(properties["additionalModelRequestFields"]); nested != nil { + return nested + } + + return root +} + +func schemaProperties( + schema map[string]interface{}, +) map[string]interface{} { + if schema == nil { + return nil + } + + properties, _ := schema["properties"].(map[string]interface{}) + return properties +} + +func schemaObject(value interface{}) map[string]interface{} { + object, _ := value.(map[string]interface{}) + return object +} + +func enumStrings(schema map[string]interface{}) []string { + if schema == nil { + return nil + } + + if constant, ok := schema["const"].(string); ok { + return []string{constant} + } + + rawValues, ok := schema["enum"].([]interface{}) + if !ok { + return nil + } + + values := make([]string, 0, len(rawValues)) + for _, rawValue := range rawValues { + value, ok := rawValue.(string) + if !ok { + continue + } + + value = strings.TrimSpace(value) + if value != "" { + values = append(values, value) + } + } + + return values +} + +var canonicalEffortOrder = []string{ + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +} + +func canonicalEffort(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + + switch value { + case "extra_high", "extra-high": + return "xhigh" + default: + return value + } +} + +func normalizeEffortList(values []string) []string { + byCanonical := make(map[string]string) + + for _, value := range values { + canonical := canonicalEffort(value) + if canonical == "" { + continue + } + if _, exists := byCanonical[canonical]; !exists { + byCanonical[canonical] = value + } + } + + ordered := make([]string, 0, len(byCanonical)) + + for _, canonical := range canonicalEffortOrder { + if original, ok := byCanonical[canonical]; ok { + ordered = append(ordered, original) + delete(byCanonical, canonical) + } + } + + for _, original := range byCanonical { + ordered = append(ordered, original) + } + + return ordered +} + +type reasoningIntent struct { + Enabled bool + Disabled bool + ThinkingType string + Display string + Effort string + BudgetTokens int + MaxTokens int +} + +func BuildClaudeAdditionalModelRequestFields( + req *ClaudeRequest, + capability ModelReasoningCapability, +) ( + fields map[string]interface{}, + requested bool, + err error, +) { + if req == nil { + return nil, false, nil + } + + intent := reasoningIntent{ + MaxTokens: req.MaxTokens, + } + + if req.Thinking != nil { + intent.ThinkingType = strings.ToLower( + strings.TrimSpace(req.Thinking.Type), + ) + intent.Display = strings.ToLower( + strings.TrimSpace(req.Thinking.Display), + ) + intent.BudgetTokens = req.Thinking.BudgetTokens + + switch intent.ThinkingType { + case "enabled", "adaptive": + intent.Enabled = true + case "disabled", "none": + intent.Disabled = true + } + } + + if req.OutputConfig != nil { + intent.Effort = req.OutputConfig.Effort + } + if strings.TrimSpace(intent.Effort) == "" { + intent.Effort = req.ReasoningEffort + } + if strings.TrimSpace(intent.Effort) != "" { + intent.Enabled = true + } + + return buildAdditionalModelRequestFields(intent, capability) +} + +func BuildOpenAIAdditionalModelRequestFields( + req *OpenAIRequest, + capability ModelReasoningCapability, +) ( + fields map[string]interface{}, + requested bool, + err error, +) { + if req == nil { + return nil, false, nil + } + + intent := reasoningIntent{ + MaxTokens: req.MaxTokens, + } + + if req.Thinking != nil { + intent.ThinkingType = strings.ToLower( + strings.TrimSpace(req.Thinking.Type), + ) + intent.Display = strings.ToLower( + strings.TrimSpace(req.Thinking.Display), + ) + intent.BudgetTokens = req.Thinking.BudgetTokens + + switch intent.ThinkingType { + case "enabled", "adaptive": + intent.Enabled = true + case "disabled", "none": + intent.Disabled = true + } + } + + if req.OutputConfig != nil { + intent.Effort = req.OutputConfig.Effort + } + if strings.TrimSpace(intent.Effort) == "" && req.Reasoning != nil { + intent.Effort = req.Reasoning.Effort + } + if strings.TrimSpace(intent.Effort) == "" { + intent.Effort = req.ReasoningEffort + } + if strings.TrimSpace(intent.Effort) != "" { + intent.Enabled = true + } + + return buildAdditionalModelRequestFields(intent, capability) +} + +func buildAdditionalModelRequestFields( + intent reasoningIntent, + capability ModelReasoningCapability, +) ( + fields map[string]interface{}, + requested bool, + err error, +) { + if intent.Disabled { + if strings.TrimSpace(intent.Effort) != "" { + return nil, false, fmt.Errorf( + "thinking is disabled but reasoning effort was also provided", + ) + } + return nil, false, nil + } + + requested = intent.Enabled + if !requested { + return nil, false, nil + } + + if !capability.SupportsNativeReasoning() { + return nil, true, nil + } + + fields = make(map[string]interface{}) + + if capability.SupportsThinking { + thinking := make(map[string]interface{}) + + thinkingType := selectThinkingType( + intent.ThinkingType, + capability.ThinkingTypes, + ) + if thinkingType != "" { + thinking["type"] = thinkingType + } + + if capability.SupportsDisplay && intent.Display != "" { + switch intent.Display { + case "summarized", "omitted": + thinking["display"] = intent.Display + default: + return nil, true, fmt.Errorf( + "unsupported thinking.display %q", + intent.Display, + ) + } + } + + if capability.SupportsBudgetTokens && + intent.BudgetTokens > 0 { + thinking["budget_tokens"] = intent.BudgetTokens + } + + if len(thinking) > 0 { + fields["thinking"] = thinking + } + } + + effort := strings.TrimSpace(intent.Effort) + + if effort == "" && + intent.BudgetTokens > 0 && + len(capability.Efforts) > 0 { + effort = effortFromBudget( + intent.BudgetTokens, + intent.MaxTokens, + capability.Efforts, + ) + } + + if effort != "" { + supportedEffort, ok := matchSupportedEffort( + effort, + capability.Efforts, + ) + if !ok { + return nil, true, fmt.Errorf( + "model %s does not support reasoning effort %q; supported: %s", + capability.ModelID, + effort, + strings.Join(capability.Efforts, ", "), + ) + } + + switch capability.EffortPath { + case ReasoningSchemaOutputConfig: + fields["output_config"] = map[string]interface{}{ + "effort": supportedEffort, + } + + case ReasoningSchemaReasoning: + fields["reasoning"] = map[string]interface{}{ + "effort": supportedEffort, + } + + default: + return nil, true, fmt.Errorf( + "model %s does not expose a configurable effort field", + capability.ModelID, + ) + } + } + + if len(fields) == 0 { + return nil, true, nil + } + + return fields, true, nil +} + +func selectThinkingType( + requested string, + supported []string, +) string { + requested = strings.ToLower(strings.TrimSpace(requested)) + + if len(supported) == 0 { + if requested == "enabled" || requested == "adaptive" { + return requested + } + return "adaptive" + } + + for _, supportedType := range supported { + if strings.EqualFold(supportedType, requested) { + return supportedType + } + } + + for _, preferred := range []string{"adaptive", "enabled"} { + for _, supportedType := range supported { + if strings.EqualFold(supportedType, preferred) { + return supportedType + } + } + } + + return supported[0] +} + +func matchSupportedEffort( + requested string, + supported []string, +) (string, bool) { + requestedCanonical := canonicalEffort(requested) + + for _, supportedEffort := range supported { + if canonicalEffort(supportedEffort) == requestedCanonical { + return supportedEffort, true + } + } + + return "", false +} + +func effortFromBudget( + budgetTokens int, + maxTokens int, + supported []string, +) string { + if len(supported) == 0 { + return "" + } + + if maxTokens <= 0 || budgetTokens <= 0 { + return supported[len(supported)/2] + } + + ratio := float64(budgetTokens) / float64(maxTokens) + if ratio < 0 { + ratio = 0 + } + if ratio > 1 { + ratio = 1 + } + + index := int(math.Ceil(ratio*float64(len(supported)))) - 1 + if index < 0 { + index = 0 + } + if index >= len(supported) { + index = len(supported) - 1 + } + + return supported[index] +} + +func reasoningFieldsJSON( + fields map[string]interface{}, +) string { + if len(fields) == 0 { + return "{}" + } + + data, err := json.Marshal(fields) + if err != nil { + return fmt.Sprintf( + `{"marshalError":%q}`, + err.Error(), + ) + } + + return string(data) +} \ No newline at end of file diff --git a/proxy/reasoning_test.go b/proxy/reasoning_test.go new file mode 100644 index 00000000..96b80f97 --- /dev/null +++ b/proxy/reasoning_test.go @@ -0,0 +1,176 @@ +package proxy + +import ( + "encoding/json" + "testing" +) + +func TestParseOutputConfigReasoningSchema(t *testing.T) { + model := ModelInfo{ + ModelId: "test-model", + AdditionalModelRequestFieldsSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "thinking": { + "type": "object", + "properties": { + "type": { + "enum": ["adaptive"] + }, + "display": { + "enum": ["summarized", "omitted"] + } + } + }, + "output_config": { + "type": "object", + "properties": { + "effort": { + "enum": ["low", "medium", "high", "xhigh", "max"] + } + } + } + } + }`), + } + + capability := + ParseModelReasoningCapability(model) + + if !capability.SupportsThinking { + t.Fatal("expected thinking support") + } + if capability.EffortPath != ReasoningSchemaOutputConfig { + t.Fatalf( + "unexpected effort path: %s", + capability.EffortPath, + ) + } + if len(capability.Efforts) != 5 { + t.Fatalf( + "unexpected efforts: %#v", + capability.Efforts, + ) + } +} + +func TestParseReasoningPathSchema(t *testing.T) { + model := ModelInfo{ + ModelId: "test-model", + AdditionalModelRequestFieldsSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "reasoning": { + "type": "object", + "properties": { + "effort": { + "enum": ["low", "medium", "high"] + } + } + } + } + }`), + } + + capability := + ParseModelReasoningCapability(model) + + if capability.EffortPath != ReasoningSchemaReasoning { + t.Fatalf( + "unexpected effort path: %s", + capability.EffortPath, + ) + } +} + +func TestBuildClaudeNativeFields(t *testing.T) { + req := &ClaudeRequest{ + Model: "test-model", + MaxTokens: 16000, + Thinking: &ClaudeThinkingConfig{ + Type: "adaptive", + Display: "summarized", + }, + OutputConfig: &ClaudeOutputConfig{ + Effort: "xhigh", + }, + } + + capability := ModelReasoningCapability{ + ModelID: "test-model", + SupportsThinking: true, + ThinkingTypes: []string{"adaptive"}, + SupportsDisplay: true, + EffortPath: ReasoningSchemaOutputConfig, + Efforts: []string{ + "low", + "medium", + "high", + "xhigh", + "max", + }, + } + + fields, requested, err := + BuildClaudeAdditionalModelRequestFields( + req, + capability, + ) + + if err != nil { + t.Fatal(err) + } + if !requested { + t.Fatal("expected reasoning request") + } + + outputConfig, ok := + fields["output_config"].(map[string]interface{}) + if !ok { + t.Fatalf( + "missing output_config: %#v", + fields, + ) + } + + if outputConfig["effort"] != "xhigh" { + t.Fatalf( + "unexpected effort: %#v", + outputConfig, + ) + } +} + +func TestRejectUnsupportedEffort(t *testing.T) { + req := &ClaudeRequest{ + Model: "test-model", + Thinking: &ClaudeThinkingConfig{ + Type: "adaptive", + }, + OutputConfig: &ClaudeOutputConfig{ + Effort: "max", + }, + } + + capability := ModelReasoningCapability{ + ModelID: "test-model", + SupportsThinking: true, + ThinkingTypes: []string{"adaptive"}, + EffortPath: ReasoningSchemaOutputConfig, + Efforts: []string{ + "low", + "medium", + "high", + }, + } + + _, _, err := + BuildClaudeAdditionalModelRequestFields( + req, + capability, + ) + + if err == nil { + t.Fatal("expected unsupported effort error") + } +} \ No newline at end of file diff --git a/proxy/responses_handler.go b/proxy/responses_handler.go index e73f3211..f6fc106e 100644 --- a/proxy/responses_handler.go +++ b/proxy/responses_handler.go @@ -91,10 +91,12 @@ func (h *Handler) handleOpenAIResponses(w http.ResponseWriter, r *http.Request) } openaiReq := &OpenAIRequest{ - Model: req.Model, - Messages: finalMessages, - Stream: req.Stream, - Tools: req.Tools, + Model: req.Model, + Messages: finalMessages, + Stream: req.Stream, + Tools: req.Tools, + Thinking: req.Thinking, + OutputConfig: req.OutputConfig, } if req.Temperature != nil { openaiReq.Temperature = *req.Temperature @@ -102,13 +104,45 @@ func (h *Handler) handleOpenAIResponses(w http.ResponseWriter, r *http.Request) if req.MaxOutputTokens != nil { openaiReq.MaxTokens = *req.MaxOutputTokens } + if req.Reasoning != nil { + openaiReq.Reasoning = &OpenAIReasoningConfig{ + Effort: req.Reasoning.Effort, + } + } thinkingCfg := config.GetThinkingConfig() - actualModel, thinking := ParseModelAndThinking(req.Model, thinkingCfg.Suffix) - openaiReq.Model = actualModel - - estimatedInputTokens := estimateOpenAIRequestInputTokens(openaiReq) - kiroPayload := OpenAIToKiro(openaiReq, thinking) + actualModel, suffixThinking := ParseModelAndThinking(req.Model, thinkingCfg.Suffix,) + + openaiReq.Model = actualModel + + capability := h.reasoningCapabilityForModel(actualModel) + + additionalFields, nativeRequested, buildErr := + BuildOpenAIAdditionalModelRequestFields( + openaiReq, + capability, + ) + if buildErr != nil { + h.sendOpenAIError( + w, + 400, + "invalid_request_error", + buildErr.Error(), + ) + return + } + + thinking := suffixThinking || nativeRequested + + useLegacyThinkingPrompt := + thinking && len(additionalFields) == 0 + + estimatedInputTokens := + estimateOpenAIRequestInputTokens(openaiReq) + + kiroPayload := OpenAIToKiro(openaiReq, useLegacyThinkingPrompt,) + + kiroPayload.AdditionalModelRequestFields = additionalFields apiKeyID := apiKeyIDFromContext(r.Context()) respID := generateResponseID() diff --git a/proxy/responses_types.go b/proxy/responses_types.go index c36413a0..de826caf 100644 --- a/proxy/responses_types.go +++ b/proxy/responses_types.go @@ -13,6 +13,11 @@ type ResponsesRequest struct { Store *bool `json:"store,omitempty"` Temperature *float64 `json:"temperature,omitempty"` MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + // OpenAI Responses API reasoning shape. + Reasoning *ResponsesReasoningConfig `json:"reasoning,omitempty"` + // Optional compatibility shapes. + Thinking *ClaudeThinkingConfig `json:"thinking,omitempty"` + OutputConfig *ClaudeOutputConfig `json:"output_config,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } @@ -60,3 +65,7 @@ type ResponsesError struct { Code string `json:"code,omitempty"` Message string `json:"message"` } + +type ResponsesReasoningConfig struct { + Effort string `json:"effort,omitempty"` +} \ No newline at end of file diff --git a/proxy/translator.go b/proxy/translator.go index 30ca2840..66e1608b 100644 --- a/proxy/translator.go +++ b/proxy/translator.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "kiro-go/config" + "kiro-go/logger" + "kiro-go/diagnostics" "regexp" "strings" "time" @@ -42,6 +44,21 @@ var claudeVersionPattern = regexp.MustCompile(`claude-(opus|sonnet|haiku)-(\d+)- const ThinkingModePrompt = `enabled 200000` +const agentToolExecutionPrompt = `You are operating as the model backend for an autonomous coding agent. + +When the current request requires reading files, searching code, editing or creating files, running commands, tests, or validation, invoke the appropriate provided tool immediately. + +Reasoning, planning, or describing a tool action does not count as executing it. +Do not merely announce, describe, promise, or plan the next action. +Do not end the turn while required tool actions or verification remain. +Continue using tools until the requested work is complete. +Only then provide a concise final summary.` + +const agentToolTurnReminder = `[Agent execution requirement] +If this request requires a tool, invoke the appropriate tool now. +Thinking about, describing, or announcing the tool action is not sufficient. +Continue until the requested action and verification are complete.` + const minimalFallbackUserContent = "." const toolResultsContinuationPrefix = "Tool results:" const toolResultImagePlaceholder = "[Tool returned an image; the image is attached to this message.]" @@ -112,6 +129,20 @@ func isClaudeThinkingRequested(thinkingCfg *ClaudeThinkingConfig) bool { return kind == "enabled" || kind == "adaptive" } +func claudeToolChoiceIsNone(choice interface{}) bool { + switch value := choice.(type) { + case string: + return strings.EqualFold(strings.TrimSpace(value), "none") + + case map[string]interface{}: + kind, _ := value["type"].(string) + return strings.EqualFold(strings.TrimSpace(kind), "none") + + default: + return false + } +} + func MapModel(model string) string { mapped, _ := ParseModelAndThinking(model, "-thinking") return mapped @@ -128,10 +159,19 @@ type ClaudeRequest struct { Stream bool `json:"stream,omitempty"` System interface{} `json:"system,omitempty"` // string or []SystemBlock Thinking *ClaudeThinkingConfig `json:"thinking,omitempty"` + // Anthropic Messages API native effort shape. + OutputConfig *ClaudeOutputConfig `json:"output_config,omitempty"` + // Compatibility for clients that send OpenAI-style top-level effort + // even though they call /v1/messages. + ReasoningEffort string `json:"reasoning_effort,omitempty"` Tools []ClaudeTool `json:"tools,omitempty"` ToolChoice interface{} `json:"tool_choice,omitempty"` } +type ClaudeOutputConfig struct { + Effort string `json:"effort,omitempty"` +} + type ClaudeThinkingConfig struct { Type string `json:"type,omitempty"` BudgetTokens int `json:"budget_tokens,omitempty"` @@ -202,6 +242,13 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { // 提取系统提示 systemPrompt := buildClaudeSystemPrompt(req.System, thinking) + if len(req.Tools) > 0 && !claudeToolChoiceIsNone(req.ToolChoice) { + if systemPrompt != "" { + systemPrompt += "\n\n" + } + + systemPrompt += agentToolExecutionPrompt + } // 构建历史消息 history := make([]KiroHistoryMessage, 0) @@ -308,6 +355,21 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { finalContent = finalContent + "\n\n" + continuation } } + + // Reinforce tool execution on fresh user turns. + // + // Do not add this to tool-result continuations because those already belong + // to an active tool loop. + if len(req.Tools) > 0 && + len(currentToolResults) == 0 && + !claudeToolChoiceIsNone(req.ToolChoice) { + + if finalContent != "" { + finalContent += "\n\n" + } + + finalContent += agentToolTurnReminder + } // 转换工具 kiroTools, toolNameMap := convertClaudeTools(req.Tools) @@ -996,6 +1058,15 @@ type OpenAIRequest struct { TopP float64 `json:"top_p,omitempty"` Stream bool `json:"stream,omitempty"` Tools []OpenAITool `json:"tools,omitempty"` + // Common compatibility shapes. + Thinking *ClaudeThinkingConfig `json:"thinking,omitempty"` + OutputConfig *ClaudeOutputConfig `json:"output_config,omitempty"` + Reasoning *OpenAIReasoningConfig `json:"reasoning,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` +} + +type OpenAIReasoningConfig struct { + Effort string `json:"effort,omitempty"` } type OpenAIMessage struct { @@ -1648,9 +1719,34 @@ func truncatePayloadToLimit(payload *KiroPayload, hasPriming bool) { if payload == nil { return } - if payloadByteSize(payload) <= maxPayloadBytes { - return - } + + beforeBytes := payloadByteSize(payload) + beforeHistory := len(payload.ConversationState.History) + beforeCurrentChars := len([]rune(payload.ConversationState.CurrentMessage.UserInputMessage.Content)) + ctx := payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext + + toolCount := 0 + toolResultCount := 0 + + if ctx != nil { + toolCount = len(ctx.Tools) + toolResultCount = len(ctx.ToolResults) + } + + if beforeBytes <= maxPayloadBytes { + if diagnostics.Payload() { + logger.Infof( + "[Payload] model=%s bytes=%d history=%d tools=%d toolResults=%d currentChars=%d truncated=false", + currentMessageModelID(payload), + beforeBytes, + beforeHistory, + toolCount, + toolResultCount, + beforeCurrentChars, + ) + } + return + } history := payload.ConversationState.History primingCount := 0 @@ -1712,6 +1808,26 @@ func truncatePayloadToLimit(payload *KiroPayload, hasPriming bool) { if payloadByteSize(payload) > maxPayloadBytes { truncateCurrentMessage(payload) } + + afterBytes := payloadByteSize(payload) + afterHistory := len(payload.ConversationState.History) + afterCurrentChars := len([]rune(payload.ConversationState.CurrentMessage.UserInputMessage.Content)) + + if diagnostics.Payload() { + logger.Infof( + "[Payload] model=%s bytes=%d->%d history=%d->%d tools=%d toolResults=%d currentChars=%d->%d truncated=true", + currentMessageModelID(payload), + beforeBytes, + afterBytes, + beforeHistory, + afterHistory, + toolCount, + toolResultCount, + beforeCurrentChars, + afterCurrentChars, + ) + } + } // historyEntryByteSize returns the serialized size of a single history entry, From 5562b0bd646babc02bfabddc64d78807293c592c Mon Sep 17 00:00:00 2001 From: trankien84 Date: Wed, 22 Jul 2026 17:18:42 +0700 Subject: [PATCH 3/4] docs: document diagnostic environment variables --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 892c9529..c477d06c 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,17 @@ The setting takes effect immediately without restarting. |----------|-------------|---------| | `CONFIG_PATH` | Config file path | `data/config.json` | | `ADMIN_PASSWORD` | Admin panel password (overrides config) | - | +| `KIRO_DIAGNOSTICS` | Enables stream, payload, and reasoning diagnostics | `false` | +| `KIRO_DIAG_STREAM` | Logs aggregate stream metrics | `false` | +| `KIRO_DIAG_PAYLOAD` | Logs payload size and truncation metrics | `false` | +| `KIRO_DIAG_REASONING` | Logs reasoning capabilities and forwarded fields | `false` | +| `KIRO_DIAG_CHUNKS` | Logs raw and normalized stream chunks; may contain sensitive content | `false` | + +Diagnostic logging is disabled by default. + +Accepted enabled values: `1`, `true`, `yes`, `on`, `enabled`. + +`KIRO_DIAGNOSTICS` does not enable `KIRO_DIAG_CHUNKS`. ## Contributing From 99258f0bc369cfc29442b2736fa9a4d0b7559b20 Mon Sep 17 00:00:00 2001 From: trankien84 Date: Wed, 22 Jul 2026 18:58:09 +0700 Subject: [PATCH 4/4] fix: preserve Claude Code thinking compatibility Normalize Claude thinking.type=enabled to a model-supported adaptive type before schema validation. Keep strict validation for unsupported thinking types, effort levels, display values, and budget fields, and add regression coverage for Claude and OpenAI-compatible reasoning requests. --- proxy/handler.go | 103 ++------ proxy/kiro.go | 6 +- proxy/reasoning.go | 93 ++++++- proxy/reasoning_test.go | 525 +++++++++++++++++++++++++++++++++++++++- proxy/translator.go | 9 +- 5 files changed, 634 insertions(+), 102 deletions(-) diff --git a/proxy/handler.go b/proxy/handler.go index ceb494b8..6abb3315 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -897,9 +897,7 @@ func mergeModelInfo(base ModelInfo, extra ModelInfo) ModelInfo { } // Read reasoning capabilities from the cached model schema. -func (h *Handler) reasoningCapabilityForModel( - modelID string, -) ModelReasoningCapability { +func (h *Handler) reasoningCapabilityForModel(modelID string) ModelReasoningCapability { normalizedID := strings.ToLower( strings.TrimSpace(MapModel(modelID)), ) @@ -925,8 +923,7 @@ func (h *Handler) reasoningCapabilityForModel( } capability := ParseModelReasoningCapability(model) - if diagnostics.Reasoning() && - capability.SchemaParseError != "" { + if diagnostics.Reasoning() && capability.SchemaParseError != "" { logger.Warnf( "[KiroReasoning] model=%s schema parse failed: %s", modelID, @@ -991,37 +988,20 @@ func (h *Handler) handleCountTokens(w http.ResponseWriter, r *http.Request) { } thinkingCfg := config.GetThinkingConfig() - actualModel, thinkingRequested := - resolveClaudeThinkingMode( - req.Model, - req.Thinking, - thinkingCfg.Suffix, - ) - + actualModel, thinkingRequested := resolveClaudeThinkingMode(req.Model, req.Thinking, thinkingCfg.Suffix) req.Model = actualModel capability := h.reasoningCapabilityForModel(actualModel) - additionalFields, nativeRequested, buildErr := - BuildClaudeAdditionalModelRequestFields( - &req, - capability, - ) + additionalFields, nativeRequested, buildErr := BuildClaudeAdditionalModelRequestFields(&req, capability) if buildErr != nil { - h.sendClaudeError( - w, - 400, - "invalid_request_error", - buildErr.Error(), - ) + h.sendClaudeError(w, 400, "invalid_request_error", buildErr.Error()) return } thinkingRequested = thinkingRequested || nativeRequested - useLegacyThinkingPrompt := - thinkingRequested && - len(additionalFields) == 0 + useLegacyThinkingPrompt := thinkingRequested && len(additionalFields) == 0 effectiveReq := cloneClaudeRequestForThinking(&req, useLegacyThinkingPrompt) @@ -1061,9 +1041,7 @@ func claudeReasoningEffort(req *ClaudeRequest) string { } if req.OutputConfig != nil { - if effort := strings.TrimSpace( - req.OutputConfig.Effort, - ); effort != "" { + if effort := strings.TrimSpace(req.OutputConfig.Effort); effort != "" { return effort } } @@ -1103,18 +1081,9 @@ func (h *Handler) handleClaudeMessagesInternal(w http.ResponseWriter, r *http.Re capability := h.reasoningCapabilityForModel(actualModel) // Build only reasoning fields supported by this model. - additionalFields, nativeRequested, buildErr := - BuildClaudeAdditionalModelRequestFields( - &req, - capability, - ) + additionalFields, nativeRequested, buildErr := BuildClaudeAdditionalModelRequestFields(&req, capability) if buildErr != nil { - h.sendClaudeError( - w, - 400, - "invalid_request_error", - buildErr.Error(), - ) + h.sendClaudeError(w, 400, "invalid_request_error", buildErr.Error()) return } @@ -1143,35 +1112,19 @@ func (h *Handler) handleClaudeMessagesInternal(w http.ResponseWriter, r *http.Re ) } - useLegacyThinkingPrompt := - thinking && len(additionalFields) == 0 + useLegacyThinkingPrompt := thinking && len(additionalFields) == 0 - effectiveReq := cloneClaudeRequestForThinking( - &req, - useLegacyThinkingPrompt, - ) + effectiveReq := cloneClaudeRequestForThinking(&req, useLegacyThinkingPrompt) - thinkingResponseOpts := - resolveClaudeThinkingResponseOptions( - req.Thinking, - thinkingCfg.ClaudeFormat, - ) + thinkingResponseOpts := resolveClaudeThinkingResponseOptions(req.Thinking, thinkingCfg.ClaudeFormat) - estimatedInputTokens := - estimateClaudeRequestInputTokens(effectiveReq) + estimatedInputTokens := estimateClaudeRequestInputTokens(effectiveReq) - cacheProfile := h.promptCache.BuildClaudeProfile( - effectiveReq, - estimatedInputTokens, - ) + cacheProfile := h.promptCache.BuildClaudeProfile(effectiveReq, estimatedInputTokens) - kiroPayload := ClaudeToKiro( - &req, - useLegacyThinkingPrompt, - ) + kiroPayload := ClaudeToKiro(&req, useLegacyThinkingPrompt) - kiroPayload.AdditionalModelRequestFields = - additionalFields + kiroPayload.AdditionalModelRequestFields = additionalFields // Stream or non-stream apiKeyID := apiKeyIDFromContext(r.Context()) @@ -2056,28 +2009,15 @@ func (h *Handler) handleOpenAIChat(w http.ResponseWriter, r *http.Request) { // 解析模型和 thinking 模式 thinkingCfg := config.GetThinkingConfig() - actualModel, suffixThinking := - ParseModelAndThinking( - req.Model, - thinkingCfg.Suffix, - ) + actualModel, suffixThinking := ParseModelAndThinking(req.Model, thinkingCfg.Suffix) req.Model = actualModel capability := h.reasoningCapabilityForModel(actualModel) - additionalFields, nativeRequested, buildErr := - BuildOpenAIAdditionalModelRequestFields( - &req, - capability, - ) + additionalFields, nativeRequested, buildErr := BuildOpenAIAdditionalModelRequestFields(&req, capability) if buildErr != nil { - h.sendOpenAIError( - w, - 400, - "invalid_request_error", - buildErr.Error(), - ) + h.sendOpenAIError(w, 400, "invalid_request_error", buildErr.Error()) return } @@ -2087,10 +2027,7 @@ func (h *Handler) handleOpenAIChat(w http.ResponseWriter, r *http.Request) { estimatedInputTokens := estimateOpenAIRequestInputTokens(&req) - kiroPayload := OpenAIToKiro( - &req, - useLegacyThinkingPrompt, - ) + kiroPayload := OpenAIToKiro(&req, useLegacyThinkingPrompt) kiroPayload.AdditionalModelRequestFields = additionalFields diff --git a/proxy/kiro.go b/proxy/kiro.go index 1f9337ae..858b83de 100644 --- a/proxy/kiro.go +++ b/proxy/kiro.go @@ -438,11 +438,7 @@ func CallKiroAPI(account *config.Account, payload *KiroPayload, callback *KiroSt // callback (OnText/OnToolUse/parseEventStream) still returns the upstream // TCP connection to the transport pool instead of leaking it. if err := parseAndStream(resp.Body, callback); err != nil { - logger.Warnf( - "[KiroAPI] Endpoint %s stream failed: %v", - ep.Name, - err, - ) + logger.Warnf("[KiroAPI] Endpoint %s stream failed: %v", ep.Name, err) return err } diff --git a/proxy/reasoning.go b/proxy/reasoning.go index da8e4796..d9fc3a5b 100644 --- a/proxy/reasoning.go +++ b/proxy/reasoning.go @@ -268,12 +268,8 @@ func BuildClaudeAdditionalModelRequestFields( intent := reasoningIntent{} if req.Thinking != nil { - intent.ThinkingType = strings.ToLower( - strings.TrimSpace(req.Thinking.Type), - ) - intent.Display = strings.ToLower( - strings.TrimSpace(req.Thinking.Display), - ) + intent.ThinkingType = normalizeClaudeThinkingType(req.Thinking.Type, capability.ThinkingTypes) + intent.Display = strings.ToLower(strings.TrimSpace(req.Thinking.Display)) intent.BudgetTokens = req.Thinking.BudgetTokens switch intent.ThinkingType { @@ -337,7 +333,20 @@ func BuildOpenAIAdditionalModelRequestFields( if strings.TrimSpace(intent.Effort) == "" { intent.Effort = req.ReasoningEffort } - if strings.TrimSpace(intent.Effort) != "" { + + effort := canonicalEffort(intent.Effort) + switch effort { + case "": + // Client did not request an effort level. + + case "none": + // OpenAI's "none" means reasoning should be disabled. + intent.Disabled = true + intent.Enabled = false + intent.Effort = "" + + default: + intent.Effort = effort intent.Enabled = true } @@ -359,6 +368,20 @@ func buildAdditionalModelRequestFields( "thinking is disabled but reasoning effort was also provided", ) } + + if capability.SupportsThinking { + if disabledType, ok := matchThinkingType( + "disabled", + capability.ThinkingTypes, + ); ok { + return map[string]interface{}{ + "thinking": map[string]interface{}{ + "type": disabledType, + }, + }, true, nil + } + } + return nil, false, nil } @@ -522,6 +545,62 @@ func selectThinkingType(requested string, supported []string) (string, bool) { return "", false } +func matchThinkingType( + requested string, + supported []string, +) (string, bool) { + requested = strings.TrimSpace(requested) + + for _, value := range supported { + if strings.EqualFold( + strings.TrimSpace(value), + requested, + ) { + return value, true + } + } + + return "", false +} + +func normalizeClaudeThinkingType( + requested string, + supported []string, +) string { + requested = strings.ToLower( + strings.TrimSpace(requested), + ) + + switch requested { + case "enabled": + // Keep enabled when the model natively supports it. + if value, ok := matchThinkingType( + "enabled", + supported, + ); ok { + return value + } + + // Claude Code's enabled maps to Kiro adaptive thinking. + if value, ok := matchThinkingType( + "adaptive", + supported, + ); ok { + return value + } + + case "none": + if value, ok := matchThinkingType( + "disabled", + supported, + ); ok { + return value + } + } + + return requested +} + func matchSupportedEffort( requested string, supported []string, diff --git a/proxy/reasoning_test.go b/proxy/reasoning_test.go index d6f75bb7..f17c6764 100644 --- a/proxy/reasoning_test.go +++ b/proxy/reasoning_test.go @@ -101,27 +101,75 @@ func TestRejectUnsupportedThinkingType( req := &ClaudeRequest{ Model: "test-model", Thinking: &ClaudeThinkingConfig{ - Type: "enabled", - BudgetTokens: 4096, + Type: "forced", }, } capability := ModelReasoningCapability{ ModelID: "test-model", SupportsThinking: true, - ThinkingTypes: []string{"adaptive"}, + ThinkingTypes: []string{ + "adaptive", + "disabled", + }, } - _, _, err := + _, requested, err := BuildClaudeAdditionalModelRequestFields( req, capability, ) if err == nil { - t.Fatal( - "expected unsupported thinking type error", + t.Fatal("expected unsupported thinking type error") + } + + if !requested { + t.Fatal("request should be recognized as a reasoning request") + } +} + +func TestClaudeEnabledThinkingMapsToAdaptive( + t *testing.T, +) { + req := &ClaudeRequest{ + Model: "claude-opus-4.8", + Thinking: &ClaudeThinkingConfig{ + Type: "enabled", + BudgetTokens: 10000, + }, + } + + capability := ModelReasoningCapability{ + ModelID: "claude-opus-4.8", + SupportsThinking: true, + ThinkingTypes: []string{ + "adaptive", + "disabled", + }, + } + + fields, requested, err := + BuildClaudeAdditionalModelRequestFields( + req, + capability, ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !requested { + t.Fatal("expected reasoning to be requested") + } + + thinking, ok := fields["thinking"].(map[string]interface{}) + if !ok { + t.Fatalf("missing thinking fields: %#v", fields) + } + + if thinking["type"] != "adaptive" { + t.Fatalf("thinking.type = %#v, want adaptive", thinking["type"]) } } @@ -245,3 +293,468 @@ func TestRejectUnsupportedEffort(t *testing.T) { t.Fatal("expected unsupported effort error") } } + +func TestOpenAIEnabledThinkingMapsToAdaptive(t *testing.T) { + req := &OpenAIRequest{ + Model: "claude-opus-4.8", + Thinking: &ClaudeThinkingConfig{ + Type: "enabled", + }, + } + + capability := ModelReasoningCapability{ + ModelID: "claude-opus-4.8", + SupportsThinking: true, + ThinkingTypes: []string{ + "adaptive", + "disabled", + }, + } + + fields, requested, err := + BuildOpenAIAdditionalModelRequestFields( + req, + capability, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !requested { + t.Fatal("expected reasoning to be requested") + } + + thinking := requireReasoningMap( + t, + fields, + "thinking", + ) + + if got := thinking["type"]; got != "adaptive" { + t.Fatalf( + "thinking.type = %#v, want adaptive", + got, + ) + } +} + +func TestBudgetTokensForwardedWhenSchemaSupportsIt( + t *testing.T, +) { + req := &ClaudeRequest{ + Model: "test-model", + Thinking: &ClaudeThinkingConfig{ + Type: "adaptive", + BudgetTokens: 8192, + }, + } + + capability := ModelReasoningCapability{ + ModelID: "test-model", + SupportsThinking: true, + ThinkingTypes: []string{"adaptive"}, + SupportsBudgetTokens: true, + } + + fields, requested, err := + BuildClaudeAdditionalModelRequestFields( + req, + capability, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !requested { + t.Fatal("expected reasoning to be requested") + } + + thinking := requireReasoningMap( + t, + fields, + "thinking", + ) + + if got := thinking["budget_tokens"]; got != 8192 { + t.Fatalf( + "thinking.budget_tokens = %#v, want 8192", + got, + ) + } +} + +func TestBudgetTokensOmittedWhenSchemaDoesNotSupportIt( + t *testing.T, +) { + req := &ClaudeRequest{ + Model: "test-model", + Thinking: &ClaudeThinkingConfig{ + Type: "adaptive", + BudgetTokens: 8192, + }, + } + + capability := ModelReasoningCapability{ + ModelID: "test-model", + SupportsThinking: true, + ThinkingTypes: []string{"adaptive"}, + SupportsBudgetTokens: false, + } + + fields, requested, err := BuildClaudeAdditionalModelRequestFields(req, capability) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !requested { + t.Fatal("expected reasoning to be requested") + } + + thinking := requireReasoningMap( + t, + fields, + "thinking", + ) + + if _, exists := thinking["budget_tokens"]; exists { + t.Fatalf( + "budget_tokens should be omitted: %#v", + thinking, + ) + } + + if got := thinking["type"]; got != "adaptive" { + t.Fatalf( + "thinking.type = %#v, want adaptive", + got, + ) + } +} + +func TestExplicitEffortAndBudgetTokensFollowSchemaIndependently( + t *testing.T, +) { + req := &OpenAIRequest{ + Model: "test-model", + Thinking: &ClaudeThinkingConfig{ + Type: "adaptive", + BudgetTokens: 4096, + }, + OutputConfig: &ClaudeOutputConfig{ + Effort: "high", + }, + } + + capability := ModelReasoningCapability{ + ModelID: "test-model", + SupportsThinking: true, + ThinkingTypes: []string{"adaptive"}, + SupportsBudgetTokens: true, + EffortPath: ReasoningSchemaOutputConfig, + Efforts: []string{ + "low", + "medium", + "high", + }, + } + + fields, requested, err := + BuildOpenAIAdditionalModelRequestFields( + req, + capability, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !requested { + t.Fatal("expected reasoning to be requested") + } + + thinking := requireReasoningMap( + t, + fields, + "thinking", + ) + + if got := thinking["budget_tokens"]; got != 4096 { + t.Fatalf( + "thinking.budget_tokens = %#v, want 4096", + got, + ) + } + + outputConfig := requireReasoningMap( + t, + fields, + "output_config", + ) + + if got := outputConfig["effort"]; got != "high" { + t.Fatalf( + "output_config.effort = %#v, want high", + got, + ) + } +} + +func TestOpenAIReasoningEffortPrecedence(t *testing.T) { + tests := []struct { + name string + req *OpenAIRequest + want string + }{ + { + name: "output_config takes precedence", + req: &OpenAIRequest{ + OutputConfig: &ClaudeOutputConfig{ + Effort: "high", + }, + Reasoning: &OpenAIReasoningConfig{ + Effort: "medium", + }, + ReasoningEffort: "low", + }, + want: "high", + }, + { + name: "reasoning object takes precedence over legacy field", + req: &OpenAIRequest{ + Reasoning: &OpenAIReasoningConfig{ + Effort: "medium", + }, + ReasoningEffort: "low", + }, + want: "medium", + }, + { + name: "legacy reasoning_effort remains supported", + req: &OpenAIRequest{ + ReasoningEffort: "low", + }, + want: "low", + }, + } + + capability := ModelReasoningCapability{ + ModelID: "test-model", + SupportsThinking: true, + ThinkingTypes: []string{"adaptive"}, + EffortPath: ReasoningSchemaOutputConfig, + Efforts: []string{ + "low", + "medium", + "high", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fields, requested, err := + BuildOpenAIAdditionalModelRequestFields( + test.req, + capability, + ) + + if err != nil { + t.Fatalf( + "unexpected error: %v", + err, + ) + } + + if !requested { + t.Fatal( + "expected reasoning to be requested", + ) + } + + outputConfig := requireReasoningMap( + t, + fields, + "output_config", + ) + + if got := outputConfig["effort"]; got != test.want { + + t.Fatalf( + "effort = %#v, want %q", + got, + test.want, + ) + } + }) + } +} + +func TestOpenAIReasoningEffortNoneDisablesReasoning( + t *testing.T, +) { + req := &OpenAIRequest{ + Model: "test-model", + Reasoning: &OpenAIReasoningConfig{ + Effort: "none", + }, + } + + capability := ModelReasoningCapability{ + ModelID: "test-model", + SupportsThinking: true, + ThinkingTypes: []string{ + "adaptive", + "disabled", + }, + EffortPath: ReasoningSchemaOutputConfig, + Efforts: []string{ + "low", + "medium", + "high", + }, + } + + fields, requested, err := + BuildOpenAIAdditionalModelRequestFields( + req, + capability, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if requested { + t.Fatal( + "reasoning should not be requested for effort=none", + ) + } + + if len(fields) != 0 { + t.Fatalf( + "expected no native reasoning fields, got %#v", + fields, + ) + } +} + +func TestUnsupportedOpenAIEffortIsRejected( + t *testing.T, +) { + req := &OpenAIRequest{ + Model: "test-model", + Reasoning: &OpenAIReasoningConfig{ + Effort: "minimal", + }, + } + + capability := ModelReasoningCapability{ + ModelID: "test-model", + SupportsThinking: true, + ThinkingTypes: []string{"adaptive"}, + EffortPath: ReasoningSchemaOutputConfig, + Efforts: []string{ + "low", + "medium", + "high", + }, + } + + _, requested, err := + BuildOpenAIAdditionalModelRequestFields( + req, + capability, + ) + + if err == nil { + t.Fatal( + "expected unsupported effort error", + ) + } + + if !requested { + t.Fatal( + "request should be recognized as a reasoning request", + ) + } +} + +func TestOpenAINoReasoningFieldsKeepsDefaultBehavior( + t *testing.T, +) { + req := &OpenAIRequest{ + Model: "test-model", + } + + capability := ModelReasoningCapability{ + ModelID: "test-model", + SupportsThinking: true, + ThinkingTypes: []string{"adaptive"}, + EffortPath: ReasoningSchemaOutputConfig, + Efforts: []string{ + "low", + "medium", + "high", + }, + } + + fields, requested, err := + BuildOpenAIAdditionalModelRequestFields( + req, + capability, + ) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if requested { + t.Fatal( + "reasoning should not be requested", + ) + } + + if len(fields) != 0 { + t.Fatalf( + "expected no reasoning fields, got %#v", + fields, + ) + } +} + +func requireReasoningMap( + t *testing.T, + fields map[string]interface{}, + key string, +) map[string]interface{} { + t.Helper() + + if fields == nil { + t.Fatalf( + "fields is nil; expected %q", + key, + ) + } + + value, exists := fields[key] + if !exists { + t.Fatalf( + "missing %q in fields: %#v", + key, + fields, + ) + } + + result, ok := value.(map[string]interface{}) + if !ok { + t.Fatalf( + "%q has type %T, want map[string]interface{}", + key, + value, + ) + } + + return result +} diff --git a/proxy/translator.go b/proxy/translator.go index 7cc2bfb0..2d531709 100644 --- a/proxy/translator.go +++ b/proxy/translator.go @@ -293,8 +293,15 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { // 提取系统提示 systemPrompt := buildClaudeSystemPrompt(req.System, thinking) + toolSteeringInjected := len(req.Tools) > 0 && !claudeToolChoiceIsNone(req.ToolChoice) + + if diagnostics.Reasoning() { + logger.Infof( + "[ToolSteering] tools=%d toolChoice=%v injected=%t", len(req.Tools), req.ToolChoice, toolSteeringInjected) + } + // Reinforce tool execution for active agent turns. - if len(req.Tools) > 0 && !claudeToolChoiceIsNone(req.ToolChoice) { + if toolSteeringInjected { if systemPrompt != "" { systemPrompt += "\n\n" }