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 diff --git a/diagnostics/flags.go b/diagnostics/flags.go new file mode 100644 index 00000000..94275780 --- /dev/null +++ b/diagnostics/flags.go @@ -0,0 +1,60 @@ +package diagnostics + +import ( + "os" + "strings" +) + +// Diagnostic flags are read once at startup. +// Raw chunk logs require a separate opt-in. +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 3e26f0a6..6abb3315 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -7,6 +7,7 @@ import ( "io" "kiro-go/auth" "kiro-go/config" + "kiro-go/diagnostics" "kiro-go/logger" "kiro-go/pool" "net/http" @@ -883,10 +884,61 @@ 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 } +// Read reasoning capabilities from the cached model schema. +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 { + continue + } + capability := ParseModelReasoningCapability(model) + + if diagnostics.Reasoning() && capability.SchemaParseError != "" { + logger.Warnf( + "[KiroReasoning] model=%s schema parse failed: %s", + modelID, + capability.SchemaParseError, + ) + } + + return capability + } + + return ModelReasoningCapability{ + ModelID: modelID, + } +} + func mergeStringLists(base []string, extra []string) []string { if len(extra) == 0 { return base @@ -936,9 +988,22 @@ func (h *Handler) handleCountTokens(w http.ResponseWriter, r *http.Request) { } thinkingCfg := config.GetThinkingConfig() - actualModel, thinking := resolveClaudeThinkingMode(req.Model, req.Thinking, thinkingCfg.Suffix) + actualModel, thinkingRequested := resolveClaudeThinkingMode(req.Model, req.Thinking, thinkingCfg.Suffix) req.Model = actualModel - effectiveReq := cloneClaudeRequestForThinking(&req, thinking) + + 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 { @@ -954,6 +1019,36 @@ 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) @@ -977,17 +1072,59 @@ func (h *Handler) handleClaudeMessagesInternal(w http.ResponseWriter, r *http.Re return } - // 解析模型和 thinking 模式 thinkingCfg := config.GetThinkingConfig() - actualModel, thinking := resolveClaudeThinkingMode(req.Model, req.Thinking, thinkingCfg.Suffix) + requestedModel := req.Model + actualModel, legacyOrClientThinking := resolveClaudeThinkingMode(req.Model, req.Thinking, thinkingCfg.Suffix) + req.Model = actualModel - effectiveReq := cloneClaudeRequestForThinking(&req, thinking) + + capability := h.reasoningCapabilityForModel(actualModel) + + // Build only reasoning fields supported by this model. + 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 thinkingDisplays=%v supportedEfforts=%v fields=%s", + actualModel, + nativeRequested, + capability.EffortPath, + capability.SupportsThinking, + capability.ThinkingTypes, + capability.ThinkingDisplays, + 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, thinking) + kiroPayload := ClaudeToKiro(&req, useLegacyThinkingPrompt) + + kiroPayload.AdditionalModelRequestFields = additionalFields // Stream or non-stream apiKeyID := apiKeyIDFromContext(r.Context()) @@ -1472,6 +1609,20 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload 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{}{ "type": "message_delta", @@ -1857,11 +2008,28 @@ func (h *Handler) handleOpenAIChat(w http.ResponseWriter, r *http.Request) { // 解析模型和 thinking 模式 thinkingCfg := config.GetThinkingConfig() - actualModel, thinking := 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) + 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, thinking) + kiroPayload := OpenAIToKiro(&req, useLegacyThinkingPrompt) + + kiroPayload.AdditionalModelRequestFields = additionalFields apiKeyID := apiKeyIDFromContext(r.Context()) // forwarded marks a request that already passed through one Kiro-Go pool, so a diff --git a/proxy/kiro.go b/proxy/kiro.go index b3ecf567..858b83de 100644 --- a/proxy/kiro.go +++ b/proxy/kiro.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "kiro-go/config" + "kiro-go/diagnostics" "kiro-go/logger" "net/http" "net/url" @@ -55,6 +56,9 @@ var kiroEndpoints = []kiroEndpoint{ var kiroHttpStore atomic.Pointer[http.Client] var kiroRestHttpStore atomic.Pointer[http.Client] +// Allow long-running agent streams to finish. +const kiroStreamingTimeout = 15 * time.Minute + // proxyClientCache caches http.Client instances keyed by proxy URL for per-account proxy support. var proxyClientCache sync.Map @@ -72,7 +76,7 @@ func GetClientForProxy(proxyURL string) *http.Client { return cached.(*http.Client) } client := &http.Client{ - Timeout: 5 * time.Minute, + Timeout: kiroStreamingTimeout, Transport: buildKiroTransport(proxyURL), } proxyClientCache.Store(proxyURL, client) @@ -130,7 +134,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: kiroStreamingTimeout, Transport: buildKiroTransport(proxyURL), } kiroHttpStore.Store(client) @@ -159,6 +163,10 @@ type KiroPayload struct { 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 // in tool_use responses so the client can match them to its tool registry. @@ -367,6 +375,13 @@ 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) + // Log reasoning fields without exposing the full payload. + 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)) if err != nil { @@ -422,7 +437,12 @@ 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) + 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 +475,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,12 +498,35 @@ func parseEventStream(body io.Reader, callback *KiroStreamCallback) error { var currentToolUse *toolUseState var lastAssistantContent string var lastReasoningContent string + var eventCount int + var assistantEventCount int + var reasoningEventCount int + var toolEventCount int + var lastEventType string + var assistantChars int + var reasoningChars int for { // Prelude: 12 bytes (total_len + headers_len + crc) prelude := make([]byte, 12) _, err := io.ReadFull(body, prelude) if err == io.EOF { + // Collect stream metrics only when diagnostics are enabled. + 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 { @@ -509,6 +563,12 @@ func parseEventStream(body io.Reader, callback *KiroStreamCallback) error { } eventType := extractEventType(msgBuf[0:headersLength]) + // Collect stream metrics only when diagnostics are enabled. + if diagnostics.Stream() { + eventCount++ + lastEventType = eventType + } + payloadBytes := msgBuf[headersLength : len(msgBuf)-4] if len(payloadBytes) == 0 { continue @@ -524,31 +584,86 @@ func parseEventStream(body io.Reader, callback *KiroStreamCallback) error { // Dispatch by event type. switch eventType { case "assistantResponseEvent": + // if diagnostics.Stream() { + // assistantEventCount++ + // } + if content, ok := event["content"].(string); ok && content != "" { + + previous := lastAssistantContent + normalized := normalizeChunk(content, &lastAssistantContent) + + if diagnostics.Stream() && normalized != "" { + assistantEventCount++ + assistantChars += len([]rune(normalized)) + } + + 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() && normalized != "" { + reasoningEventCount++ + 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", + eventType, + ) } } diff --git a/proxy/kiro_api.go b/proxy/kiro_api.go index d992a4fd..c4de0c47 100644 --- a/proxy/kiro_api.go +++ b/proxy/kiro_api.go @@ -145,7 +145,9 @@ func shouldProbeFallbackRegions(account *config.Account) bool { if strings.TrimSpace(account.Region) == "" { return true } - return strings.EqualFold(strings.TrimSpace(account.AuthMethod), "external_idp") + // IDC and external IdP login regions may differ from the Kiro profile region. + method := strings.ToLower(strings.TrimSpace(account.AuthMethod)) + return method == "external_idp" || method == "idc" } // GetUsageLimits 获取账户使用量和订阅信息 @@ -860,4 +862,6 @@ type ModelInfo struct { MaxInputTokens int `json:"maxInputTokens"` MaxOutputTokens int `json:"maxOutputTokens"` } `json:"tokenLimits"` + // Model-specific request schema returned by Kiro. + AdditionalModelRequestFieldsSchema json.RawMessage `json:"additionalModelRequestFieldsSchema,omitempty"` } diff --git a/proxy/kiro_test.go b/proxy/kiro_test.go index 36760153..233e5736 100644 --- a/proxy/kiro_test.go +++ b/proxy/kiro_test.go @@ -193,15 +193,19 @@ func TestBuildKiroTransportFallsBackToEnvironmentProxy(t *testing.T) { assertProxyURL(t, got, "http://env-proxy.local:2323") } -func TestInitKiroHttpClientKeepsShortRestTimeout(t *testing.T) { +func TestInitKiroHttpClientKeepsSeparateRestTimeout(t *testing.T) { InitKiroHttpClient("") t.Cleanup(func() { InitKiroHttpClient("") }) streamClient := kiroHttpStore.Load() restClient := kiroRestHttpStore.Load() - if streamClient.Timeout != 5*time.Minute { - t.Fatalf("expected streaming timeout to be 5m, got %s", streamClient.Timeout) + if streamClient.Timeout != kiroStreamingTimeout { + t.Fatalf( + "expected streaming timeout to be %s, got %s", + kiroStreamingTimeout, + streamClient.Timeout, + ) } if restClient.Timeout != 30*time.Second { t.Fatalf("expected REST timeout to stay 30s, got %s", restClient.Timeout) diff --git a/proxy/reasoning.go b/proxy/reasoning.go new file mode 100644 index 00000000..d9fc3a5b --- /dev/null +++ b/proxy/reasoning.go @@ -0,0 +1,650 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "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 + ThinkingDisplays []string + SupportsBudgetTokens bool + EffortPath ReasoningSchemaPath + Efforts []string + SchemaParseError string +} + +func (c ModelReasoningCapability) SupportsNativeReasoning() bool { + return c.SupportsThinking || c.EffortPath != ReasoningSchemaNone +} + +// Parse thinking and effort support from the model schema. +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"]), + ) + displaySchema := schemaObject(thinkingProps["display"]) + + capability.SupportsDisplay = displaySchema != nil + capability.ThinkingDisplays = enumStrings(displaySchema) + 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 + } +} + +// Normalize aliases while preserving schema-provided values. +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) + } + } + + unknown := make([]string, 0, len(byCanonical)) + + for _, original := range byCanonical { + unknown = append(unknown, original) + } + sort.Strings(unknown) + ordered = append(ordered, unknown...) + + return ordered +} + +type reasoningIntent struct { + Enabled bool + Disabled bool + ThinkingType string + Display string + Effort string + BudgetTokens int +} + +func BuildClaudeAdditionalModelRequestFields( + req *ClaudeRequest, + capability ModelReasoningCapability, +) ( + fields map[string]interface{}, + requested bool, + err error, +) { + if req == nil { + return nil, false, nil + } + + intent := reasoningIntent{} + + if req.Thinking != nil { + 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 { + 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{} + + 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 + } + + 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 + } + + return buildAdditionalModelRequestFields(intent, capability) +} + +// Build only fields advertised by the model schema. +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", + ) + } + + 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 + } + + 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, ok := selectThinkingType( + intent.ThinkingType, + capability.ThinkingTypes, + ) + + if !ok { + return nil, true, fmt.Errorf( + "model %s does not support thinking.type %q; supported: %s", + capability.ModelID, + intent.ThinkingType, + strings.Join( + capability.ThinkingTypes, + ", ", + ), + ) + } + + if thinkingType != "" { + thinking["type"] = thinkingType + } + + if intent.Display != "" { + if !capability.SupportsDisplay { + return nil, true, fmt.Errorf( + "model %s does not support thinking.display", + capability.ModelID, + ) + } + + display := intent.Display + + // If the schema defines an enum, only accept values present in the enum. + if len(capability.ThinkingDisplays) > 0 { + var ok bool + + display, ok = matchSupportedDisplay( + intent.Display, + capability.ThinkingDisplays, + ) + if !ok { + return nil, true, fmt.Errorf( + "model %s does not support thinking.display %q; supported: %s", + capability.ModelID, + intent.Display, + strings.Join( + capability.ThinkingDisplays, + ", ", + ), + ) + } + } + + // If the schema defines a field but no enum: + // forward the original value sent by the client. + thinking["display"] = 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 != "" { + 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, bool) { + requested = strings.TrimSpace(requested) + + if requested != "" { + for _, supportedType := range supported { + if strings.EqualFold( + supportedType, + requested, + ) { + return supportedType, true + } + } + + return "", false + } + + for _, preferred := range []string{ + "adaptive", + "enabled", + } { + for _, supportedType := range supported { + if strings.EqualFold( + supportedType, + preferred, + ) { + return supportedType, true + } + } + } + + if len(supported) > 0 { + return supported[0], true + } + + 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, +) (string, bool) { + requestedCanonical := canonicalEffort(requested) + + for _, supportedEffort := range supported { + if canonicalEffort(supportedEffort) == requestedCanonical { + return supportedEffort, true + } + } + + return "", false +} + +func matchSupportedDisplay( + requested string, + supported []string, +) (string, bool) { + requested = strings.TrimSpace(requested) + + for _, value := range supported { + if strings.EqualFold(value, requested) { + return value, true + } + } + + return "", false +} + +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) +} diff --git a/proxy/reasoning_test.go b/proxy/reasoning_test.go new file mode 100644 index 00000000..f17c6764 --- /dev/null +++ b/proxy/reasoning_test.go @@ -0,0 +1,760 @@ +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, + ) + } + if len(capability.ThinkingDisplays) != 2 { + t.Fatalf( + "unexpected thinking displays: %#v", + capability.ThinkingDisplays, + ) + } +} + +func TestRejectUnsupportedThinkingDisplay( + t *testing.T, +) { + req := &ClaudeRequest{ + Model: "test-model", + Thinking: &ClaudeThinkingConfig{ + Type: "adaptive", + Display: "full", + }, + } + + capability := ModelReasoningCapability{ + ModelID: "test-model", + SupportsThinking: true, + ThinkingTypes: []string{"adaptive"}, + SupportsDisplay: true, + ThinkingDisplays: []string{ + "summarized", + "omitted", + }, + } + + _, _, err := + BuildClaudeAdditionalModelRequestFields( + req, + capability, + ) + + if err == nil { + t.Fatal( + "expected unsupported display error", + ) + } +} + +func TestRejectUnsupportedThinkingType( + t *testing.T, +) { + req := &ClaudeRequest{ + Model: "test-model", + Thinking: &ClaudeThinkingConfig{ + Type: "forced", + }, + } + + capability := ModelReasoningCapability{ + ModelID: "test-model", + SupportsThinking: true, + ThinkingTypes: []string{ + "adaptive", + "disabled", + }, + } + + _, requested, err := + BuildClaudeAdditionalModelRequestFields( + req, + capability, + ) + + if err == nil { + 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"]) + } +} + +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") + } +} + +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/responses_handler.go b/proxy/responses_handler.go index 8513806f..fafc26ab 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,46 @@ 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) + actualModel, suffixThinking := ParseModelAndThinking(req.Model, thinkingCfg.Suffix) + openaiReq.Model = actualModel - estimatedInputTokens := estimateOpenAIRequestInputTokens(openaiReq) - kiroPayload := OpenAIToKiro(openaiReq, thinking) + capability := h.reasoningCapabilityForModel(actualModel) + + // Map Responses reasoning to the model's native schema. + 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..9474ed1f 100644 --- a/proxy/responses_types.go +++ b/proxy/responses_types.go @@ -3,17 +3,22 @@ package proxy import "encoding/json" type ResponsesRequest struct { - Model string `json:"model"` - Input json.RawMessage `json:"input"` - Instructions string `json:"instructions,omitempty"` - Stream bool `json:"stream,omitempty"` - Tools []OpenAITool `json:"tools,omitempty"` - ToolChoice json.RawMessage `json:"tool_choice,omitempty"` - PreviousResponseID string `json:"previous_response_id,omitempty"` - Store *bool `json:"store,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - MaxOutputTokens *int `json:"max_output_tokens,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` + Model string `json:"model"` + Input json.RawMessage `json:"input"` + Instructions string `json:"instructions,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools []OpenAITool `json:"tools,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + PreviousResponseID string `json:"previous_response_id,omitempty"` + 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"` } type ResponsesObject struct { @@ -60,3 +65,7 @@ type ResponsesError struct { Code string `json:"code,omitempty"` Message string `json:"message"` } + +type ResponsesReasoningConfig struct { + Effort string `json:"effort,omitempty"` +} diff --git a/proxy/translator.go b/proxy/translator.go index c41b09d4..2d531709 100644 --- a/proxy/translator.go +++ b/proxy/translator.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "kiro-go/config" + "kiro-go/diagnostics" + "kiro-go/logger" "regexp" "strings" "time" @@ -43,6 +45,22 @@ 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.` + +// Remind the model to execute pending tool work. +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.]" @@ -162,6 +180,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 @@ -178,8 +210,17 @@ type ClaudeRequest struct { 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"` + // 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 { @@ -252,6 +293,21 @@ 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 toolSteeringInjected { + if systemPrompt != "" { + systemPrompt += "\n\n" + } + + systemPrompt += agentToolExecutionPrompt + } // 构建历史消息 history := make([]KiroHistoryMessage, 0) @@ -359,6 +415,21 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { } } + // 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) @@ -1046,6 +1117,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 { @@ -1705,8 +1785,45 @@ func truncatePayloadToLimit(payload *KiroPayload, hasPriming bool) { return } - tokenLimit := maxPayloadTokens(currentMessageModelID(payload)) + modelID := currentMessageModelID(payload) + tokenLimit := maxPayloadTokens(modelID) + + // Measure payload changes without altering truncation logic. + var beforeBytes int + var beforeTokens int + var beforeHistory int + var beforeCurrentChars int + var beforeToolCount int + var beforeToolResultCount int + + if diagnostics.Payload() { + beforeBytes = payloadByteSize(payload) + beforeTokens = estimateKiroPayloadTokens(payload) + beforeHistory = len(payload.ConversationState.History) + beforeCurrentChars = len([]rune(payload.ConversationState.CurrentMessage.UserInputMessage.Content)) + + ctx := payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext + + if ctx != nil { + beforeToolCount = len(ctx.Tools) + beforeToolResultCount = len(ctx.ToolResults) + } + } + if payloadFits(payload, tokenLimit) { + if diagnostics.Payload() { + logger.Infof( + "[Payload] model=%s bytes=%d tokens=%d/%d history=%d tools=%d toolResults=%d currentChars=%d truncated=false", + modelID, + beforeBytes, + beforeTokens, + tokenLimit, + beforeHistory, + beforeToolCount, + beforeToolResultCount, + beforeCurrentChars, + ) + } return } @@ -1840,6 +1957,43 @@ func truncatePayloadToLimit(payload *KiroPayload, hasPriming bool) { // Safety net: any path that broke the pairing must not reach the wire. detachOrphanedToolResults(payload) + + if diagnostics.Payload() { + afterBytes := payloadByteSize(payload) + afterTokens := estimateKiroPayloadTokens(payload) + afterHistory := len(payload.ConversationState.History) + afterCurrentChars := len([]rune(payload.ConversationState.CurrentMessage.UserInputMessage.Content)) + + afterToolCount := 0 + afterToolResultCount := 0 + + afterCtx := payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext + + if afterCtx != nil { + afterToolCount = len(afterCtx.Tools) + afterToolResultCount = len( + afterCtx.ToolResults, + ) + } + + logger.Infof( + "[Payload] model=%s bytes=%d->%d tokens=%d->%d/%d history=%d->%d tools=%d->%d toolResults=%d->%d currentChars=%d->%d truncated=true", + modelID, + beforeBytes, + afterBytes, + beforeTokens, + afterTokens, + tokenLimit, + beforeHistory, + afterHistory, + beforeToolCount, + afterToolCount, + beforeToolResultCount, + afterToolResultCount, + beforeCurrentChars, + afterCurrentChars, + ) + } } // activeToolTurn returns the assistant turn whose structured toolUses are