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