Files
sub2api/backend/internal/pkg/kiro/translator.go
T
2026-04-30 14:02:05 +08:00

2731 lines
73 KiB
Go

package kiro
import (
"bufio"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/google/uuid"
"github.com/tidwall/gjson"
)
const (
kiroMaxToolDescLen = 10237
kiroMaxToolNameLen = 63
thinkingStartTag = "<thinking>"
thinkingEndTag = "</thinking>"
embeddedToolCallPrefix = "[Called "
minFrameSize = 16
maxEventMsgSize = 10 << 20
writeToolDescriptionSuffix = "IMPORTANT: If the content to write exceeds 150 lines, write only the first 50 lines with this tool, then append the remaining content using Edit calls in chunks of no more than 50 lines. Use a unique placeholder if needed. Do not write the whole file in one call."
editToolDescriptionSuffix = "IMPORTANT: If new content exceeds 50 lines, split it into multiple Edit calls, replacing or appending no more than 50 lines per call. If appending, use a unique placeholder and remove it in the final chunk."
systemChunkedWritePolicy = "When Write or Edit tools include chunking limits, comply silently and complete the operation through multiple tool calls when needed."
)
var (
trailingCommaPattern = regexp.MustCompile(`,\s*([}\]])`)
requiredToolFields = map[string][][]string{
"write": {{"file_path", "path"}, {"content"}},
"write_to_file": {{"path"}, {"content"}},
"fswrite": {{"path"}, {"content"}},
"create_file": {{"path"}, {"content"}},
"edit_file": {{"path"}},
"apply_diff": {{"path"}, {"diff"}},
"str_replace_editor": {{"path"}, {"old_str"}, {"new_str"}},
"bash": {{"cmd", "command"}},
"execute": {{"command"}},
"run_command": {{"command"}},
}
)
type Usage struct {
InputTokens int
OutputTokens int
TotalTokens int
CacheReadInputTokens int
}
type StreamResult struct {
Usage Usage
StopReason string
FirstDeltaDur *time.Duration
}
type ParseResult struct {
ResponseBody []byte
Usage Usage
StopReason string
}
type KiroRequestContext struct {
ToolNameMap map[string]string
ThinkingEnabled bool
}
type KiroBuildResult struct {
Payload []byte
Context KiroRequestContext
}
type KiroPayload struct {
ConversationState KiroConversationState `json:"conversationState"`
ProfileArn string `json:"profileArn,omitempty"`
InferenceConfig *KiroInferenceConfig `json:"inferenceConfig,omitempty"`
}
type KiroInferenceConfig struct {
MaxTokens int `json:"maxTokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"topP,omitempty"`
}
type thinkingDirective struct {
Mode string
BudgetTokens int
Effort string
}
type KiroConversationState struct {
AgentContinuationID string `json:"agentContinuationId,omitempty"`
AgentTaskType string `json:"agentTaskType,omitempty"`
ChatTriggerType string `json:"chatTriggerType"`
ConversationID string `json:"conversationId"`
CurrentMessage KiroCurrentMessage `json:"currentMessage"`
History []KiroHistoryMessage `json:"history,omitempty"`
}
type KiroCurrentMessage struct {
UserInputMessage KiroUserInputMessage `json:"userInputMessage"`
}
type KiroHistoryMessage struct {
UserInputMessage *KiroUserInputMessage `json:"userInputMessage,omitempty"`
AssistantResponseMessage *KiroAssistantResponseMessage `json:"assistantResponseMessage,omitempty"`
}
type KiroImage struct {
Format string `json:"format"`
Source KiroImageSource `json:"source"`
}
type KiroImageSource struct {
Bytes string `json:"bytes"`
}
type KiroUserInputMessage struct {
Content string `json:"content"`
ModelID string `json:"modelId"`
Origin string `json:"origin"`
Images []KiroImage `json:"images,omitempty"`
UserInputMessageContext *KiroUserInputMessageContext `json:"userInputMessageContext,omitempty"`
}
type KiroUserInputMessageContext struct {
ToolResults []KiroToolResult `json:"toolResults,omitempty"`
Tools []KiroToolWrapper `json:"tools,omitempty"`
}
type KiroToolResult struct {
Content []KiroTextContent `json:"content"`
Status string `json:"status"`
ToolUseID string `json:"toolUseId"`
}
type KiroTextContent struct {
Text string `json:"text"`
}
type KiroToolWrapper struct {
ToolSpecification KiroToolSpecification `json:"toolSpecification"`
}
type KiroToolSpecification struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema KiroInputSchema `json:"inputSchema"`
}
type KiroInputSchema struct {
JSON interface{} `json:"json"`
}
type KiroAssistantResponseMessage struct {
Content string `json:"content"`
ToolUses []KiroToolUse `json:"toolUses,omitempty"`
}
type KiroToolUse struct {
ToolUseID string `json:"toolUseId"`
Name string `json:"name"`
Input map[string]interface{} `json:"input"`
IsTruncated bool `json:"-"`
TruncatedRaw string `json:"-"`
}
type toolUseState struct {
ToolUseID string
Name string
InputBuffer strings.Builder
}
type eventStreamMessage struct {
EventType string
Payload []byte
}
func MapModel(model string) string {
switch strings.TrimSpace(strings.ToLower(model)) {
case "claude-opus-4-6", "claude-opus-4-6-thinking", "claude-opus-4.6":
return "claude-opus-4.6"
case "claude-sonnet-4-6", "claude-sonnet-4-6-thinking", "claude-sonnet-4.6":
return "claude-sonnet-4.6"
case "claude-opus-4-5-20251101", "claude-opus-4-5-20251101-thinking", "claude-opus-4.5":
return "claude-opus-4.5"
case "claude-sonnet-4-5-20250929", "claude-sonnet-4-5-20250929-thinking", "claude-sonnet-4.5":
return "claude-sonnet-4.5"
case "claude-haiku-4-5-20251001", "claude-haiku-4-5-20251001-thinking", "claude-haiku-4.5":
return "claude-haiku-4.5"
default:
return ""
}
}
func normalizeModelAlias(model string) string {
base := strings.TrimSpace(strings.ToLower(model))
for {
next := strings.TrimSuffix(base, "-thinking")
if next == base {
return next
}
base = next
}
}
func BuildKiroPayload(claudeBody []byte, modelID, profileArn, origin string, headers http.Header) ([]byte, error) {
result, err := BuildKiroPayloadWithContext(claudeBody, modelID, profileArn, origin, headers)
if err != nil {
return nil, err
}
return result.Payload, nil
}
func BuildKiroPayloadWithContext(claudeBody []byte, modelID, profileArn, origin string, headers http.Header) (*KiroBuildResult, error) {
const kiroMaxOutputTokens = 32000
requestCtx := KiroRequestContext{ToolNameMap: map[string]string{}}
var maxTokens int64
if mt := gjson.GetBytes(claudeBody, "max_tokens"); mt.Exists() {
maxTokens = mt.Int()
if maxTokens == -1 {
maxTokens = kiroMaxOutputTokens
}
}
var temperature float64
var hasTemperature bool
if temp := gjson.GetBytes(claudeBody, "temperature"); temp.Exists() {
temperature = temp.Float()
hasTemperature = true
}
var topP float64
var hasTopP bool
if tp := gjson.GetBytes(claudeBody, "top_p"); tp.Exists() {
topP = tp.Float()
hasTopP = true
}
messages := gjson.GetBytes(claudeBody, "messages")
thinking := deriveThinkingDirective(claudeBody, headers)
requestCtx.ThinkingEnabled = thinking != nil
toolChoiceHint := extractClaudeToolChoiceHint(claudeBody, &requestCtx)
systemPrompt := buildInjectedSystemPrompt(extractSystemPrompt(claudeBody), thinking, toolChoiceHint)
history, currentUserMsg, currentToolResults := processMessages(messages, modelID, normalizeOrigin(origin), &requestCtx)
history = prependSystemHistory(history, systemPrompt, modelID, normalizeOrigin(origin))
var tools gjson.Result
if !isToolChoiceNone(claudeBody) {
tools = gjson.GetBytes(claudeBody, "tools")
}
kiroTools := convertClaudeToolsToKiro(tools, &requestCtx)
currentToolResults, orphanedToolUseIDs := validateToolPairing(history, currentToolResults)
removeOrphanedToolUses(history, orphanedToolUseIDs)
kiroTools = appendMissingPlaceholderTools(kiroTools, collectHistoryToolNames(history))
if currentUserMsg != nil {
currentUserMsg.Content = buildFinalContent(currentUserMsg.Content, currentToolResults)
currentToolResults = deduplicateToolResults(currentToolResults)
if len(kiroTools) > 0 || len(currentToolResults) > 0 {
currentUserMsg.UserInputMessageContext = &KiroUserInputMessageContext{
Tools: kiroTools,
ToolResults: currentToolResults,
}
}
}
var currentMessage KiroCurrentMessage
if currentUserMsg != nil {
currentMessage = KiroCurrentMessage{UserInputMessage: *currentUserMsg}
} else {
currentMessage = KiroCurrentMessage{UserInputMessage: KiroUserInputMessage{
Content: buildFinalContent("", nil),
ModelID: modelID,
Origin: normalizeOrigin(origin),
}}
}
var inferenceConfig *KiroInferenceConfig
if maxTokens > 0 || hasTemperature || hasTopP {
inferenceConfig = &KiroInferenceConfig{}
if maxTokens > 0 {
inferenceConfig.MaxTokens = int(maxTokens)
}
if hasTemperature {
inferenceConfig.Temperature = temperature
}
if hasTopP {
inferenceConfig.TopP = topP
}
}
conversationID := extractMetadataFromMessages(messages, "conversationId")
continuationID := extractMetadataFromMessages(messages, "continuationId")
if conversationID == "" {
conversationID = uuid.NewString()
}
payload := KiroPayload{
ConversationState: KiroConversationState{
AgentTaskType: "vibe",
ChatTriggerType: "MANUAL",
ConversationID: conversationID,
CurrentMessage: currentMessage,
History: history,
},
ProfileArn: profileArn,
InferenceConfig: inferenceConfig,
}
if continuationID != "" {
payload.ConversationState.AgentContinuationID = continuationID
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return &KiroBuildResult{Payload: payloadBytes, Context: requestCtx}, nil
}
func ParseNonStreamingEventStream(body io.Reader, model string) (*ParseResult, error) {
return ParseNonStreamingEventStreamWithContext(body, model, KiroRequestContext{})
}
func ParseNonStreamingEventStreamWithContext(body io.Reader, model string, requestCtx KiroRequestContext) (*ParseResult, error) {
content, toolUses, usage, stopReason, err := parseEventStream(body)
if err != nil {
return nil, err
}
return &ParseResult{
ResponseBody: buildClaudeResponse(content, toolUses, model, usage, stopReason, requestCtx),
Usage: usage,
StopReason: stopReason,
}, nil
}
func StreamEventStreamAsAnthropic(ctx context.Context, body io.Reader, w io.Writer, model string, inputTokens int) (*StreamResult, error) {
return StreamEventStreamAsAnthropicWithContext(ctx, body, w, model, inputTokens, KiroRequestContext{})
}
func StreamEventStreamAsAnthropicWithContext(ctx context.Context, body io.Reader, w io.Writer, model string, inputTokens int, requestCtx KiroRequestContext) (*StreamResult, error) {
reader := bufio.NewReader(body)
start := time.Now()
var firstDelta *time.Duration
usage := Usage{InputTokens: inputTokens}
contentBlockIndex := -1
thinkingBlockIndex := -1
messageStartSent := false
textBlockOpen := false
thinkingBlockOpen := false
processedIDs := make(map[string]bool)
emittedToolContents := make(map[string]bool)
streamingToolBlockIndices := make(map[string]int)
streamingToolStarted := make(map[string]bool)
streamingToolStopped := make(map[string]bool)
currentStreamingToolID := ""
pendingAssistantText := ""
pendingLeadingWhitespace := ""
stopReason := ""
thinkingBuffer := ""
inThinkingBlock := false
stripThinkingLeadingNewline := false
sawNonThinkingBlock := false
writeEvent := func(event string, data any) error {
payload, err := json.Marshal(data)
if err != nil {
return err
}
_, err = io.WriteString(w, "event: "+event+"\ndata: "+string(payload)+"\n\n")
return err
}
ensureMessageStart := func() error {
if messageStartSent {
return nil
}
if err := writeEvent("message_start", map[string]any{
"type": "message_start",
"message": map[string]any{
"id": "msg_" + uuid.NewString()[:24],
"type": "message",
"role": "assistant",
"content": []any{},
"model": model,
"stop_reason": nil,
"stop_sequence": nil,
"usage": map[string]any{
"input_tokens": usage.InputTokens,
"output_tokens": 0,
},
},
}); err != nil {
return err
}
messageStartSent = true
return nil
}
closeText := func() error {
if !textBlockOpen {
return nil
}
textBlockOpen = false
return writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": contentBlockIndex})
}
closeThinking := func() error {
if !thinkingBlockOpen {
return nil
}
thinkingBlockOpen = false
return writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": thinkingBlockIndex})
}
closeStreamingTool := func(toolUseID string) error {
if toolUseID == "" || !streamingToolStarted[toolUseID] || streamingToolStopped[toolUseID] {
return nil
}
streamingToolStopped[toolUseID] = true
if currentStreamingToolID == toolUseID {
currentStreamingToolID = ""
}
return writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": streamingToolBlockIndices[toolUseID]})
}
closeOpenStreamingTool := func() error {
return closeStreamingTool(currentStreamingToolID)
}
startStreamingToolUse := func(toolUseID, name string) error {
if toolUseID == "" || name == "" || streamingToolStopped[toolUseID] {
return nil
}
sawNonThinkingBlock = true
if currentStreamingToolID != "" && currentStreamingToolID != toolUseID {
if err := closeOpenStreamingTool(); err != nil {
return err
}
}
if stopReason == "" {
stopReason = "tool_use"
}
if err := ensureMessageStart(); err != nil {
return err
}
if firstDelta == nil {
delta := time.Since(start)
firstDelta = &delta
}
if err := closeThinking(); err != nil {
return err
}
if err := closeText(); err != nil {
return err
}
blockIndex, ok := streamingToolBlockIndices[toolUseID]
if !ok {
contentBlockIndex++
blockIndex = contentBlockIndex
streamingToolBlockIndices[toolUseID] = blockIndex
}
currentStreamingToolID = toolUseID
if streamingToolStarted[toolUseID] {
return nil
}
streamingToolStarted[toolUseID] = true
return writeEvent("content_block_start", map[string]any{
"type": "content_block_start",
"index": blockIndex,
"content_block": map[string]any{
"type": "tool_use",
"id": toolUseID,
"name": restoreResponseToolName(name, requestCtx),
"input": map[string]any{},
},
})
}
emitStreamingToolInput := func(toolUseID, name, fragment string) error {
if fragment == "" {
return nil
}
if err := startStreamingToolUse(toolUseID, name); err != nil {
return err
}
if toolUseID == "" || !streamingToolStarted[toolUseID] || streamingToolStopped[toolUseID] {
return nil
}
return writeEvent("content_block_delta", map[string]any{
"type": "content_block_delta",
"index": streamingToolBlockIndices[toolUseID],
"delta": map[string]any{
"type": "input_json_delta",
"partial_json": fragment,
},
})
}
processStreamingToolUseEvent := func(event map[string]interface{}) error {
tu := nestedEvent(event, "toolUseEvent")
toolUseID := getString(tu, "toolUseId")
name := getString(tu, "name")
if err := startStreamingToolUse(toolUseID, name); err != nil {
return err
}
if inputRaw, ok := tu["input"]; ok {
switch v := inputRaw.(type) {
case string:
if err := emitStreamingToolInput(toolUseID, name, v); err != nil {
return err
}
case map[string]interface{}:
encoded, err := json.Marshal(v)
if err != nil {
return err
}
if err := emitStreamingToolInput(toolUseID, name, string(encoded)); err != nil {
return err
}
}
}
isStop, _ := tu["stop"].(bool)
if isStop {
processedIDs[toolUseID] = true
if stopReason == "" {
stopReason = "tool_use"
}
return closeStreamingTool(toolUseID)
}
return nil
}
emitTextDelta := func(text string, allowWhitespace bool) error {
if text == "" || (!allowWhitespace && strings.TrimSpace(text) == "") {
return nil
}
if err := closeOpenStreamingTool(); err != nil {
return err
}
if !textBlockOpen && !allowWhitespace {
if pendingLeadingWhitespace != "" {
text = strings.TrimLeftFunc(pendingLeadingWhitespace+text, unicode.IsSpace)
pendingLeadingWhitespace = ""
if text == "" {
return nil
}
}
}
if err := ensureMessageStart(); err != nil {
return err
}
sawNonThinkingBlock = true
if firstDelta == nil {
delta := time.Since(start)
firstDelta = &delta
}
if err := closeThinking(); err != nil {
return err
}
if !textBlockOpen {
contentBlockIndex++
textBlockOpen = true
if err := writeEvent("content_block_start", map[string]any{
"type": "content_block_start",
"index": contentBlockIndex,
"content_block": map[string]any{
"type": "text",
"text": "",
},
}); err != nil {
return err
}
}
return writeEvent("content_block_delta", map[string]any{
"type": "content_block_delta",
"index": contentBlockIndex,
"delta": map[string]any{
"type": "text_delta",
"text": text,
},
})
}
emitToolUse := func(tool KiroToolUse) error {
if !shouldEmitToolUse(tool, emittedToolContents) {
return nil
}
sawNonThinkingBlock = true
if err := closeOpenStreamingTool(); err != nil {
return err
}
if err := ensureMessageStart(); err != nil {
return err
}
if err := closeText(); err != nil {
return err
}
if err := closeThinking(); err != nil {
return err
}
contentBlockIndex++
if err := writeEvent("content_block_start", map[string]any{
"type": "content_block_start",
"index": contentBlockIndex,
"content_block": map[string]any{
"type": "tool_use",
"id": tool.ToolUseID,
"name": restoreResponseToolName(tool.Name, requestCtx),
"input": map[string]any{},
},
}); err != nil {
return err
}
inputJSON, _ := json.Marshal(tool.Input)
if err := writeEvent("content_block_delta", map[string]any{
"type": "content_block_delta",
"index": contentBlockIndex,
"delta": map[string]any{
"type": "input_json_delta",
"partial_json": string(inputJSON),
},
}); err != nil {
return err
}
return writeEvent("content_block_stop", map[string]any{"type": "content_block_stop", "index": contentBlockIndex})
}
flushPendingAssistantText := func() error {
text, embeddedTools, pending := drainEmbeddedToolText(pendingAssistantText)
pendingAssistantText = pending
if err := emitTextDelta(text, false); err != nil {
return err
}
for _, tool := range embeddedTools {
if err := emitToolUse(tool); err != nil {
return err
}
}
return nil
}
emitPlainAssistantText := func(text string) error {
if text == "" {
return nil
}
pendingAssistantText += text
return flushPendingAssistantText()
}
startThinkingBlock := func() error {
if err := closeOpenStreamingTool(); err != nil {
return err
}
if err := closeText(); err != nil {
return err
}
if err := ensureMessageStart(); err != nil {
return err
}
if firstDelta == nil {
delta := time.Since(start)
firstDelta = &delta
}
if thinkingBlockOpen {
return nil
}
contentBlockIndex++
thinkingBlockIndex = contentBlockIndex
thinkingBlockOpen = true
return writeEvent("content_block_start", map[string]any{
"type": "content_block_start",
"index": thinkingBlockIndex,
"content_block": map[string]any{
"type": "thinking",
"thinking": "",
},
})
}
emitThinkingDelta := func(text string) error {
if !thinkingBlockOpen {
if err := startThinkingBlock(); err != nil {
return err
}
}
return writeEvent("content_block_delta", map[string]any{
"type": "content_block_delta",
"index": thinkingBlockIndex,
"delta": map[string]any{
"type": "thinking_delta",
"thinking": text,
},
})
}
finishThinkingBlock := func() error {
if err := emitThinkingDelta(""); err != nil {
return err
}
return closeThinking()
}
processThinkingTaggedText := func(text string) error {
if text == "" {
return nil
}
thinkingBuffer += text
for {
if !inThinkingBlock {
startPos := findRealThinkingStartTag(thinkingBuffer, 0)
if startPos != -1 {
before := thinkingBuffer[:startPos]
if strings.TrimSpace(before) != "" {
if err := emitPlainAssistantText(before); err != nil {
return err
}
}
inThinkingBlock = true
stripThinkingLeadingNewline = true
thinkingBuffer = thinkingBuffer[startPos+len(thinkingStartTag):]
if err := startThinkingBlock(); err != nil {
return err
}
continue
}
safeLen := safeThinkingStreamFlushLen(thinkingBuffer, len(thinkingStartTag))
if safeLen > 0 {
safeText := thinkingBuffer[:safeLen]
if strings.TrimSpace(safeText) != "" {
if err := emitPlainAssistantText(safeText); err != nil {
return err
}
thinkingBuffer = thinkingBuffer[safeLen:]
}
}
break
}
if stripThinkingLeadingNewline {
if strings.HasPrefix(thinkingBuffer, "\n") {
thinkingBuffer = thinkingBuffer[1:]
stripThinkingLeadingNewline = false
} else if thinkingBuffer != "" {
stripThinkingLeadingNewline = false
}
}
endPos := findStreamThinkingEndTagStrict(thinkingBuffer, 0)
if endPos != -1 {
if thinkingText := thinkingBuffer[:endPos]; thinkingText != "" {
if err := emitThinkingDelta(thinkingText); err != nil {
return err
}
}
inThinkingBlock = false
if err := finishThinkingBlock(); err != nil {
return err
}
thinkingBuffer = thinkingBuffer[endPos+len(thinkingEndTag)+len("\n\n"):]
continue
}
safeLen := safeThinkingStreamFlushLen(thinkingBuffer, len(thinkingEndTag)+len("\n\n"))
if safeLen > 0 {
if err := emitThinkingDelta(thinkingBuffer[:safeLen]); err != nil {
return err
}
thinkingBuffer = thinkingBuffer[safeLen:]
}
break
}
return nil
}
flushThinkingAtBoundary := func() error {
if !requestCtx.ThinkingEnabled || thinkingBuffer == "" {
return nil
}
if inThinkingBlock {
endPos := findStreamThinkingEndTagAtBufferEnd(thinkingBuffer, 0)
if endPos != -1 {
if thinkingText := thinkingBuffer[:endPos]; thinkingText != "" {
if err := emitThinkingDelta(thinkingText); err != nil {
return err
}
}
afterPos := endPos + len(thinkingEndTag)
remaining := strings.TrimLeftFunc(thinkingBuffer[afterPos:], unicode.IsSpace)
thinkingBuffer = ""
inThinkingBlock = false
if err := finishThinkingBlock(); err != nil {
return err
}
return emitPlainAssistantText(remaining)
}
if err := emitThinkingDelta(thinkingBuffer); err != nil {
return err
}
thinkingBuffer = ""
inThinkingBlock = false
return finishThinkingBlock()
}
remaining := thinkingBuffer
thinkingBuffer = ""
return emitPlainAssistantText(remaining)
}
flushThinkingAtEOF := func() error {
if !requestCtx.ThinkingEnabled {
return nil
}
return flushThinkingAtBoundary()
}
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
msg, err := readEventStreamMessage(reader)
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if msg == nil || len(msg.Payload) == 0 {
continue
}
var event map[string]interface{}
if err := json.Unmarshal(msg.Payload, &event); err != nil {
continue
}
if sr := readStopReason(event); sr != "" {
stopReason = sr
}
switch msg.EventType {
case "assistantResponseEvent":
assistant := nestedEvent(event, "assistantResponseEvent")
if sr := readStopReason(assistant); sr != "" {
stopReason = sr
}
text := getString(assistant, "content")
if text == "" {
text = getString(event, "content")
}
if text != "" {
if requestCtx.ThinkingEnabled {
if err := processThinkingTaggedText(text); err != nil {
return nil, err
}
} else {
pendingAssistantText += text
if err := flushPendingAssistantText(); err != nil {
return nil, err
}
}
}
for _, tool := range readToolUses(assistant, event) {
if processedIDs[tool.ToolUseID] {
continue
}
processedIDs[tool.ToolUseID] = true
if err := flushThinkingAtBoundary(); err != nil {
return nil, err
}
if err := emitToolUse(tool); err != nil {
return nil, err
}
}
case "reasoningContentEvent":
reasoning := nestedEvent(event, "reasoningContentEvent")
text := getString(reasoning, "text")
if text == "" {
text = getString(event, "text")
}
if text == "" {
continue
}
if requestCtx.ThinkingEnabled {
wrapped := thinkingStartTag + text + thinkingEndTag + "\n\n"
if err := processThinkingTaggedText(wrapped); err != nil {
return nil, err
}
}
case "toolUseEvent":
if err := flushThinkingAtBoundary(); err != nil {
return nil, err
}
if err := processStreamingToolUseEvent(event); err != nil {
return nil, err
}
case "messageMetadataEvent", "metadataEvent", "supplementaryWebLinksEvent", "usageEvent", "messageStopEvent", "message_stop":
updateUsageFromEvent(&usage, msg.EventType, event)
default:
updateUsageFromEvent(&usage, msg.EventType, event)
}
}
if err := closeOpenStreamingTool(); err != nil {
return nil, err
}
if err := flushThinkingAtEOF(); err != nil {
return nil, err
}
if err := flushPendingAssistantText(); err != nil {
return nil, err
}
if requestCtx.ThinkingEnabled && thinkingBlockIndex != -1 && !sawNonThinkingBlock {
stopReason = "max_tokens"
if err := emitTextDelta(" ", true); err != nil {
return nil, err
}
}
if err := closeText(); err != nil {
return nil, err
}
if err := closeThinking(); err != nil {
return nil, err
}
if usage.TotalTokens == 0 {
usage.TotalTokens = usage.InputTokens + usage.OutputTokens
}
if stopReason == "" {
if len(emittedToolContents) > 0 {
stopReason = "tool_use"
} else {
stopReason = "end_turn"
}
}
if err := ensureMessageStart(); err != nil {
return nil, err
}
if err := writeEvent("message_delta", map[string]any{
"type": "message_delta",
"delta": map[string]any{
"stop_reason": stopReason,
"stop_sequence": nil,
},
"usage": map[string]any{
"input_tokens": usage.InputTokens,
"output_tokens": usage.OutputTokens,
"cache_read_input_tokens": usage.CacheReadInputTokens,
"cache_creation_input_tokens": 0,
},
}); err != nil {
return nil, err
}
if err := writeEvent("message_stop", map[string]any{"type": "message_stop"}); err != nil {
return nil, err
}
return &StreamResult{
Usage: usage,
StopReason: stopReason,
FirstDeltaDur: firstDelta,
}, nil
}
func extractSystemPrompt(claudeBody []byte) string {
systemField := gjson.GetBytes(claudeBody, "system")
if systemField.IsArray() {
var sb strings.Builder
for _, block := range systemField.Array() {
if block.Get("type").String() == "text" {
_, _ = sb.WriteString(block.Get("text").String())
} else if block.Type == gjson.String {
_, _ = sb.WriteString(block.String())
}
}
return sb.String()
}
return systemField.String()
}
func isThinkingEnabledWithHeaders(body []byte, headers http.Header) bool {
return deriveThinkingDirective(body, headers) != nil
}
func deriveThinkingDirective(body []byte, headers http.Header) *thinkingDirective {
if override := thinkingDirectiveFromModel(gjson.GetBytes(body, "model").String()); override != nil {
return override
}
switch thinkingType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())); thinkingType {
case "adaptive":
effort := strings.TrimSpace(gjson.GetBytes(body, "output_config.effort").String())
if effort == "" {
effort = "high"
}
budget := int(gjson.GetBytes(body, "thinking.budget_tokens").Int())
if budget <= 0 {
budget = 20000
}
return &thinkingDirective{Mode: "adaptive", BudgetTokens: budget, Effort: effort}
case "enabled":
budget := int(gjson.GetBytes(body, "thinking.budget_tokens").Int())
if budget <= 0 {
budget = 16000
}
return &thinkingDirective{Mode: "enabled", BudgetTokens: budget}
}
if headers != nil {
if beta := headers.Get("Anthropic-Beta"); strings.Contains(beta, "interleaved-thinking") {
return &thinkingDirective{Mode: "enabled", BudgetTokens: 16000}
}
}
if effort := gjson.GetBytes(body, "reasoning_effort").String(); effort != "" && effort != "none" {
return &thinkingDirective{Mode: "enabled", BudgetTokens: 16000}
}
model := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "model").String()))
if strings.Contains(model, "-reason") {
return &thinkingDirective{Mode: "enabled", BudgetTokens: 16000}
}
return nil
}
func thinkingDirectiveFromModel(model string) *thinkingDirective {
model = strings.ToLower(strings.TrimSpace(model))
if !strings.Contains(model, "thinking") {
return nil
}
switch normalizeModelAlias(model) {
case "claude-opus-4-6", "claude-opus-4.6":
return &thinkingDirective{
Mode: "adaptive",
BudgetTokens: 20000,
Effort: "high",
}
default:
return &thinkingDirective{
Mode: "enabled",
BudgetTokens: 20000,
}
}
}
func buildInjectedSystemPrompt(systemPrompt string, thinking *thinkingDirective, toolChoiceHint string) string {
systemPrompt = strings.TrimSpace(systemPrompt)
timestampContext := fmt.Sprintf("[Context: Current time is %s]", time.Now().Format("2006-01-02 15:04:05 MST"))
if systemPrompt == "" {
systemPrompt = timestampContext
} else {
systemPrompt = timestampContext + "\n\n" + systemPrompt
}
if toolChoiceHint != "" {
if systemPrompt != "" {
systemPrompt += "\n"
}
systemPrompt += toolChoiceHint
}
if !strings.Contains(systemPrompt, systemChunkedWritePolicy) {
systemPrompt += "\n" + systemChunkedWritePolicy
}
if thinking != nil {
switch thinking.Mode {
case "adaptive":
effort := strings.TrimSpace(thinking.Effort)
if effort == "" {
effort = "high"
}
thinkingPrefix := "<thinking_mode>adaptive</thinking_mode>\n<thinking_effort>" + effort + "</thinking_effort>"
return thinkingPrefix + "\n\n" + systemPrompt
default:
budget := thinking.BudgetTokens
if budget <= 0 {
budget = 16000
}
thinkingPrefix := "<thinking_mode>enabled</thinking_mode>\n<max_thinking_length>" + strconv.Itoa(budget) + "</max_thinking_length>"
return thinkingPrefix + "\n\n" + systemPrompt
}
}
return systemPrompt
}
func extractClaudeToolChoiceHint(claudeBody []byte, requestCtx *KiroRequestContext) string {
toolChoice := gjson.GetBytes(claudeBody, "tool_choice")
if !toolChoice.Exists() {
return ""
}
if toolChoice.Type == gjson.String {
switch strings.ToLower(strings.TrimSpace(toolChoice.String())) {
case "none":
return "[INSTRUCTION: Do not use any tools. Respond with text only.]"
case "auto", "":
return ""
}
}
switch strings.ToLower(strings.TrimSpace(toolChoice.Get("type").String())) {
case "any":
return "[INSTRUCTION: You MUST use at least one of the available tools to respond. Do not respond with text only - always make a tool call.]"
case "tool":
toolName := mapKiroToolName(toolChoice.Get("name").String(), requestCtx)
if toolName != "" {
return fmt.Sprintf("[INSTRUCTION: You MUST use the tool named '%s' to respond. Do not use any other tool or respond with text only.]", toolName)
}
case "none":
return "[INSTRUCTION: Do not use any tools. Respond with text only.]"
}
return ""
}
func isToolChoiceNone(claudeBody []byte) bool {
toolChoice := gjson.GetBytes(claudeBody, "tool_choice")
if !toolChoice.Exists() {
return false
}
if toolChoice.Type == gjson.String {
return strings.EqualFold(strings.TrimSpace(toolChoice.String()), "none")
}
return strings.EqualFold(strings.TrimSpace(toolChoice.Get("type").String()), "none")
}
func kiroToolNameAlias(name string) string {
return mapKiroToolName(name, nil)
}
func prependSystemHistory(history []KiroHistoryMessage, systemPrompt, modelID, origin string) []KiroHistoryMessage {
systemPrompt = strings.TrimSpace(systemPrompt)
if systemPrompt == "" {
return history
}
prefix := []KiroHistoryMessage{
{
UserInputMessage: &KiroUserInputMessage{
Content: systemPrompt,
ModelID: modelID,
Origin: origin,
},
},
{
AssistantResponseMessage: &KiroAssistantResponseMessage{
Content: "I will follow these instructions.",
},
},
}
return append(prefix, history...)
}
func normalizeOrigin(origin string) string {
switch origin {
case "KIRO_CLI", "AMAZON_Q":
return "CLI"
case "KIRO_AI_EDITOR", "KIRO_IDE", "":
return "AI_EDITOR"
default:
return origin
}
}
func extractMetadataFromMessages(messages gjson.Result, key string) string {
arr := messages.Array()
for i := len(arr) - 1; i >= 0; i-- {
if val := arr[i].Get("additional_kwargs." + key); val.Exists() && val.String() != "" {
return val.String()
}
}
return ""
}
func convertClaudeToolsToKiro(tools gjson.Result, requestCtx *KiroRequestContext) []KiroToolWrapper {
if !tools.IsArray() {
return nil
}
var out []KiroToolWrapper
for _, tool := range tools.Array() {
originalName := tool.Get("name").String()
if strings.TrimSpace(originalName) == "" {
originalName = tool.Get("type").String()
}
isWebSearch := strings.TrimSpace(originalName) == "web_search"
name := mapKiroToolName(originalName, requestCtx)
description := strings.TrimSpace(tool.Get("description").String())
if isWebSearch {
if cached := GetCachedWebSearchDescription(); cached != "" {
description = cached
} else {
description = remoteWebSearchDescription
}
}
if description == "" {
description = "Tool: " + name
}
description = appendChunkedToolDescription(originalName, description)
description = truncateKiroToolDescription(description)
inputSchema := normalizeKiroJSONSchema(tool.Get("input_schema").Value())
out = append(out, KiroToolWrapper{
ToolSpecification: KiroToolSpecification{
Name: name,
Description: description,
InputSchema: KiroInputSchema{JSON: inputSchema},
},
})
}
return out
}
func appendChunkedToolDescription(name, description string) string {
suffix := chunkedToolDescriptionSuffix(name)
if suffix == "" {
return description
}
if strings.Contains(description, suffix) {
description = strings.Replace(description, suffix, "", 1)
}
if strings.TrimSpace(description) == "" {
return suffix
}
base := strings.TrimRight(description, "\n")
joined := base + "\n" + suffix
if len(joined) <= kiroMaxToolDescLen {
return joined
}
const truncationMarker = "... (description truncated)"
baseLimit := kiroMaxToolDescLen - len(suffix) - 1 - len(truncationMarker)
if baseLimit <= 0 {
return truncateKiroToolDescription(joined)
}
return truncateUTF8(base, baseLimit) + truncationMarker + "\n" + suffix
}
func chunkedToolDescriptionSuffix(name string) string {
switch strings.ToLower(strings.TrimSpace(name)) {
case "write", "write_to_file", "fswrite", "create_file":
return writeToolDescriptionSuffix
case "edit", "edit_file", "str_replace_editor", "apply_diff":
return editToolDescriptionSuffix
default:
return ""
}
}
func truncateKiroToolDescription(description string) string {
if len(description) <= kiroMaxToolDescLen {
return description
}
return truncateUTF8(description, kiroMaxToolDescLen-30) + "... (description truncated)"
}
func truncateUTF8(s string, limit int) string {
if limit <= 0 {
return ""
}
if len(s) <= limit {
return s
}
for limit > 0 && !utf8.RuneStart(s[limit]) {
limit--
}
return s[:limit]
}
func shortenToolNameIfNeeded(name string) string {
name = strings.TrimSpace(name)
if len(name) <= kiroMaxToolNameLen {
return name
}
sum := sha256.Sum256([]byte(name))
suffix := fmt.Sprintf("%x", sum[:])[:8]
prefixLen := kiroMaxToolNameLen - 1 - len(suffix)
prefix := name
if len(prefix) > prefixLen {
prefix = prefix[:prefixLen]
for len(prefix) > 0 && !utf8.ValidString(prefix) {
prefix = prefix[:len(prefix)-1]
}
}
return prefix + "_" + suffix
}
func mapKiroToolName(name string, requestCtx *KiroRequestContext) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
if name == "web_search" {
return "remote_web_search"
}
short := shortenToolNameIfNeeded(name)
if short != name && requestCtx != nil {
if requestCtx.ToolNameMap == nil {
requestCtx.ToolNameMap = make(map[string]string)
}
requestCtx.ToolNameMap[short] = name
}
return short
}
func normalizeKiroJSONSchema(schema any) any {
return normalizeKiroJSONSchemaValue(schema, true)
}
func normalizeKiroJSONSchemaValue(schema any, enforceObjectKeywords bool) any {
obj, ok := schema.(map[string]interface{})
if !ok || obj == nil {
return defaultKiroJSONSchema()
}
normalized := make(map[string]interface{}, len(obj)+4)
for key, value := range obj {
normalized[key] = normalizeSchemaChild(key, value)
}
if typ, ok := normalized["type"].(string); !ok || strings.TrimSpace(typ) == "" {
normalized["type"] = "object"
}
typ, _ := normalized["type"].(string)
needsObjectKeywords := enforceObjectKeywords ||
strings.TrimSpace(typ) == "object" ||
hasSchemaKey(normalized, "properties") ||
hasSchemaKey(normalized, "required") ||
hasSchemaKey(normalized, "additionalProperties")
if needsObjectKeywords {
properties, ok := normalized["properties"].(map[string]interface{})
if !ok || properties == nil {
normalized["properties"] = map[string]interface{}{}
} else {
for key, value := range properties {
properties[key] = normalizeKiroJSONSchemaValue(value, false)
}
normalized["properties"] = properties
}
normalized["required"] = normalizeSchemaRequired(normalized["required"])
switch additional := normalized["additionalProperties"].(type) {
case bool:
case map[string]interface{}:
normalized["additionalProperties"] = normalizeKiroJSONSchemaValue(additional, false)
default:
normalized["additionalProperties"] = true
}
}
return normalized
}
func hasSchemaKey(schema map[string]interface{}, key string) bool {
_, ok := schema[key]
return ok
}
func defaultKiroJSONSchema() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
"required": []interface{}{},
"additionalProperties": true,
}
}
func normalizeSchemaRequired(value interface{}) []interface{} {
arr, ok := value.([]interface{})
if !ok {
return []interface{}{}
}
out := make([]interface{}, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
func normalizeSchemaChild(key string, value interface{}) interface{} {
switch key {
case "items", "not":
if obj, ok := value.(map[string]interface{}); ok {
return normalizeKiroJSONSchemaValue(obj, false)
}
if arr, ok := value.([]interface{}); ok {
out := make([]interface{}, 0, len(arr))
for _, item := range arr {
out = append(out, normalizeKiroJSONSchemaValue(item, false))
}
return out
}
case "oneOf", "anyOf", "allOf":
if arr, ok := value.([]interface{}); ok {
out := make([]interface{}, 0, len(arr))
for _, item := range arr {
out = append(out, normalizeKiroJSONSchemaValue(item, false))
}
return out
}
}
return value
}
func processMessages(messages gjson.Result, modelID, origin string, requestCtx *KiroRequestContext) ([]KiroHistoryMessage, *KiroUserInputMessage, []KiroToolResult) {
messagesArray := mergeAdjacentMessages(messages.Array())
if len(messagesArray) > 0 && messagesArray[0].Get("role").String() == "assistant" {
messagesArray = append([]gjson.Result{gjson.Parse(`{"role":"user","content":"."}`)}, messagesArray...)
}
var history []KiroHistoryMessage
var currentUserMsg *KiroUserInputMessage
var currentToolResults []KiroToolResult
for i, msg := range messagesArray {
role := msg.Get("role").String()
last := i == len(messagesArray)-1
switch role {
case "user":
userMsg, toolResults := buildUserMessageStruct(msg, modelID, origin)
if strings.TrimSpace(userMsg.Content) == "" {
if len(toolResults) > 0 {
userMsg.Content = "Tool results provided."
} else {
userMsg.Content = "Continue"
}
}
if last {
currentUserMsg = &userMsg
currentToolResults = toolResults
} else {
if len(toolResults) > 0 {
userMsg.UserInputMessageContext = &KiroUserInputMessageContext{ToolResults: toolResults}
}
history = append(history, KiroHistoryMessage{UserInputMessage: &userMsg})
}
case "assistant":
assistantMsg := buildAssistantMessageStruct(msg, requestCtx)
if last {
history = append(history, KiroHistoryMessage{AssistantResponseMessage: &assistantMsg})
currentUserMsg = &KiroUserInputMessage{
Content: "Continue",
ModelID: modelID,
Origin: origin,
}
} else {
history = append(history, KiroHistoryMessage{AssistantResponseMessage: &assistantMsg})
}
}
}
return history, currentUserMsg, currentToolResults
}
func validateToolPairing(history []KiroHistoryMessage, currentToolResults []KiroToolResult) ([]KiroToolResult, map[string]bool) {
allToolUseIDs := make(map[string]bool)
pairedToolUseIDs := make(map[string]bool)
for _, h := range history {
if h.AssistantResponseMessage != nil {
for _, tu := range h.AssistantResponseMessage.ToolUses {
allToolUseIDs[tu.ToolUseID] = true
}
}
if h.UserInputMessage != nil && h.UserInputMessage.UserInputMessageContext != nil {
for _, tr := range h.UserInputMessage.UserInputMessageContext.ToolResults {
pairedToolUseIDs[tr.ToolUseID] = true
}
}
}
filtered := currentToolResults[:0]
for _, tr := range currentToolResults {
if allToolUseIDs[tr.ToolUseID] && !pairedToolUseIDs[tr.ToolUseID] {
filtered = append(filtered, tr)
pairedToolUseIDs[tr.ToolUseID] = true
}
}
orphaned := make(map[string]bool)
for toolUseID := range allToolUseIDs {
if !pairedToolUseIDs[toolUseID] {
orphaned[toolUseID] = true
}
}
return filtered, orphaned
}
func removeOrphanedToolUses(history []KiroHistoryMessage, orphaned map[string]bool) {
if len(orphaned) == 0 {
return
}
for i := range history {
msg := history[i].AssistantResponseMessage
if msg == nil || len(msg.ToolUses) == 0 {
continue
}
filtered := msg.ToolUses[:0]
for _, toolUse := range msg.ToolUses {
if !orphaned[toolUse.ToolUseID] {
filtered = append(filtered, toolUse)
}
}
msg.ToolUses = filtered
}
}
func collectHistoryToolNames(history []KiroHistoryMessage) []string {
seen := make(map[string]bool)
var names []string
for _, h := range history {
if h.AssistantResponseMessage == nil {
continue
}
for _, tu := range h.AssistantResponseMessage.ToolUses {
name := strings.TrimSpace(tu.Name)
if name == "" {
continue
}
key := strings.ToLower(name)
if seen[key] {
continue
}
seen[key] = true
names = append(names, name)
}
}
return names
}
func appendMissingPlaceholderTools(tools []KiroToolWrapper, historyToolNames []string) []KiroToolWrapper {
if len(historyToolNames) == 0 {
return tools
}
seen := make(map[string]bool)
for _, tool := range tools {
seen[strings.ToLower(strings.TrimSpace(tool.ToolSpecification.Name))] = true
}
for _, name := range historyToolNames {
key := strings.ToLower(strings.TrimSpace(name))
if key == "" || seen[key] {
continue
}
seen[key] = true
tools = append(tools, KiroToolWrapper{
ToolSpecification: KiroToolSpecification{
Name: name,
Description: "Tool used in conversation history",
InputSchema: KiroInputSchema{JSON: normalizeKiroJSONSchema(nil)},
},
})
}
return tools
}
func buildFinalContent(content string, toolResults []KiroToolResult) string {
if strings.TrimSpace(content) == "" {
if len(toolResults) > 0 {
return "Tool results provided."
}
return "Continue"
}
return content
}
func deduplicateToolResults(toolResults []KiroToolResult) []KiroToolResult {
seen := make(map[string]bool)
out := make([]KiroToolResult, 0, len(toolResults))
for _, tr := range toolResults {
if seen[tr.ToolUseID] {
continue
}
seen[tr.ToolUseID] = true
out = append(out, tr)
}
return out
}
func buildUserMessageStruct(msg gjson.Result, modelID, origin string) (KiroUserInputMessage, []KiroToolResult) {
content := msg.Get("content")
var contentBuilder strings.Builder
var toolResults []KiroToolResult
var images []KiroImage
seenToolUseIDs := make(map[string]bool)
if content.IsArray() {
for _, part := range content.Array() {
switch part.Get("type").String() {
case "text":
_, _ = contentBuilder.WriteString(part.Get("text").String())
case "image":
mediaType := part.Get("source.media_type").String()
data := part.Get("source.data").String()
format := ""
if idx := strings.LastIndex(mediaType, "/"); idx != -1 {
format = mediaType[idx+1:]
}
if format != "" && data != "" {
images = append(images, KiroImage{
Format: format,
Source: KiroImageSource{Bytes: data},
})
}
case "tool_result":
toolUseID := part.Get("tool_use_id").String()
if toolUseID == "" || seenToolUseIDs[toolUseID] {
continue
}
seenToolUseIDs[toolUseID] = true
status := "success"
if part.Get("is_error").Bool() {
status = "error"
}
textContents := []KiroTextContent{{Text: "Tool use was cancelled by the user"}}
resultContent := part.Get("content")
if resultContent.IsArray() {
textContents = textContents[:0]
for _, item := range resultContent.Array() {
if item.Get("type").String() == "text" {
textContents = append(textContents, KiroTextContent{Text: item.Get("text").String()})
} else if item.Type == gjson.String {
textContents = append(textContents, KiroTextContent{Text: item.String()})
}
}
} else if resultContent.Type == gjson.String {
textContents = []KiroTextContent{{Text: resultContent.String()}}
}
toolResults = append(toolResults, KiroToolResult{
ToolUseID: toolUseID,
Content: textContents,
Status: status,
})
}
}
} else {
_, _ = contentBuilder.WriteString(content.String())
}
userMsg := KiroUserInputMessage{
Content: contentBuilder.String(),
ModelID: modelID,
Origin: origin,
}
if len(images) > 0 {
userMsg.Images = images
}
return userMsg, toolResults
}
func buildAssistantMessageStruct(msg gjson.Result, requestCtx *KiroRequestContext) KiroAssistantResponseMessage {
content := msg.Get("content")
var contentBuilder strings.Builder
var toolUses []KiroToolUse
if content.IsArray() {
for _, part := range content.Array() {
switch part.Get("type").String() {
case "text":
_, _ = contentBuilder.WriteString(part.Get("text").String())
case "tool_use":
toolName := mapKiroToolName(part.Get("name").String(), requestCtx)
input := map[string]interface{}{}
toolInput := part.Get("input")
if toolInput.IsObject() {
toolInput.ForEach(func(key, value gjson.Result) bool {
input[key.String()] = value.Value()
return true
})
}
toolUses = append(toolUses, KiroToolUse{
ToolUseID: part.Get("id").String(),
Name: toolName,
Input: input,
})
}
}
} else {
_, _ = contentBuilder.WriteString(content.String())
}
finalContent := contentBuilder.String()
if strings.TrimSpace(finalContent) == "" {
finalContent = " "
}
return KiroAssistantResponseMessage{
Content: finalContent,
ToolUses: toolUses,
}
}
func mergeAdjacentMessages(messages []gjson.Result) []gjson.Result {
if len(messages) <= 1 {
return messages
}
var merged []gjson.Result
for _, msg := range messages {
if len(merged) == 0 {
merged = append(merged, msg)
continue
}
lastMsg := merged[len(merged)-1]
role := msg.Get("role").String()
lastRole := lastMsg.Get("role").String()
if role == "tool" || lastRole == "tool" || role != lastRole {
merged = append(merged, msg)
continue
}
mergedMsg := map[string]interface{}{
"role": role,
"content": json.RawMessage(mergeMessageContent(lastMsg, msg)),
}
encoded, _ := json.Marshal(mergedMsg)
merged[len(merged)-1] = gjson.ParseBytes(encoded)
}
return merged
}
func mergeMessageContent(msg1, msg2 gjson.Result) string {
var blocks1, blocks2 []map[string]interface{}
content1 := msg1.Get("content")
content2 := msg2.Get("content")
if content1.IsArray() {
for _, block := range content1.Array() {
blocks1 = append(blocks1, blockToMap(block))
}
} else if content1.Type == gjson.String {
blocks1 = append(blocks1, map[string]interface{}{"type": "text", "text": content1.String()})
}
if content2.IsArray() {
for _, block := range content2.Array() {
blocks2 = append(blocks2, blockToMap(block))
}
} else if content2.Type == gjson.String {
blocks2 = append(blocks2, map[string]interface{}{"type": "text", "text": content2.String()})
}
if len(blocks1) > 0 && len(blocks2) > 0 && blocks1[len(blocks1)-1]["type"] == "text" && blocks2[0]["type"] == "text" {
leftText, leftOK := blocks1[len(blocks1)-1]["text"].(string)
rightText, rightOK := blocks2[0]["text"].(string)
if leftOK && rightOK {
blocks1[len(blocks1)-1]["text"] = leftText + "\n\n" + rightText
blocks2 = blocks2[1:]
}
}
allBlocks := append(blocks1, blocks2...)
result, _ := json.Marshal(allBlocks)
return string(result)
}
func blockToMap(block gjson.Result) map[string]interface{} {
result := make(map[string]interface{})
block.ForEach(func(key, value gjson.Result) bool {
if value.IsObject() {
result[key.String()] = blockToMap(value)
} else if value.IsArray() {
var arr []interface{}
for _, item := range value.Array() {
if item.IsObject() {
arr = append(arr, blockToMap(item))
} else {
arr = append(arr, item.Value())
}
}
result[key.String()] = arr
} else {
result[key.String()] = value.Value()
}
return true
})
return result
}
func parseEventStream(body io.Reader) (string, []KiroToolUse, Usage, string, error) {
reader := bufio.NewReader(body)
var content strings.Builder
var toolUses []KiroToolUse
var usage Usage
stopReason := ""
processedIDs := make(map[string]bool)
var currentTool *toolUseState
for {
msg, err := readEventStreamMessage(reader)
if err == io.EOF {
break
}
if err != nil {
return "", nil, usage, stopReason, err
}
if msg == nil || len(msg.Payload) == 0 {
continue
}
var event map[string]interface{}
if err := json.Unmarshal(msg.Payload, &event); err != nil {
continue
}
if sr := readStopReason(event); sr != "" {
stopReason = sr
}
switch msg.EventType {
case "assistantResponseEvent":
assistant := nestedEvent(event, "assistantResponseEvent")
if text := getString(assistant, "content"); text != "" {
_, _ = content.WriteString(text)
} else if text := getString(event, "content"); text != "" {
_, _ = content.WriteString(text)
}
if sr := readStopReason(assistant); sr != "" {
stopReason = sr
}
for _, tool := range readToolUses(assistant, event) {
if processedIDs[tool.ToolUseID] {
continue
}
processedIDs[tool.ToolUseID] = true
toolUses = append(toolUses, tool)
}
case "toolUseEvent":
completed, next := processToolUseEvent(event, currentTool, processedIDs)
currentTool = next
toolUses = append(toolUses, completed...)
case "reasoningContentEvent":
reasoning := nestedEvent(event, "reasoningContentEvent")
text := getString(reasoning, "text")
if text == "" {
text = getString(event, "text")
}
if text != "" {
_, _ = content.WriteString(thinkingStartTag)
_, _ = content.WriteString(text)
_, _ = content.WriteString(thinkingEndTag)
}
default:
updateUsageFromEvent(&usage, msg.EventType, event)
}
}
if currentTool != nil && currentTool.ToolUseID != "" && !processedIDs[currentTool.ToolUseID] {
completed, _ := processToolUseEvent(map[string]interface{}{
"toolUseEvent": map[string]interface{}{
"toolUseId": currentTool.ToolUseID,
"name": currentTool.Name,
"stop": true,
"input": currentTool.InputBuffer.String(),
},
}, currentTool, processedIDs)
toolUses = append(toolUses, completed...)
}
cleanText, embeddedToolUses, _ := drainEmbeddedToolText(content.String())
toolUses = append(toolUses, embeddedToolUses...)
toolUses = deduplicateToolUses(toolUses)
if usage.TotalTokens == 0 {
usage.TotalTokens = usage.InputTokens + usage.OutputTokens
}
if stopReason == "" {
if hasUsableToolUses(toolUses) {
stopReason = "tool_use"
} else {
stopReason = "end_turn"
}
}
return cleanText, toolUses, usage, stopReason, nil
}
func buildClaudeResponse(content string, toolUses []KiroToolUse, model string, usage Usage, stopReason string, requestCtx KiroRequestContext) []byte {
var blocks []map[string]interface{}
blocks = append(blocks, extractThinkingBlocks(content)...)
usableTools := 0
for _, tool := range toolUses {
if tool.IsTruncated {
continue
}
usableTools++
blocks = append(blocks, map[string]interface{}{
"type": "tool_use",
"id": tool.ToolUseID,
"name": restoreResponseToolName(tool.Name, requestCtx),
"input": tool.Input,
})
}
pureThinking := hasThinkingBlocksOnly(blocks) && usableTools == 0
if pureThinking {
blocks = append(blocks, map[string]interface{}{"type": "text", "text": ""})
stopReason = "max_tokens"
}
if len(blocks) == 0 {
blocks = append(blocks, map[string]interface{}{"type": "text", "text": ""})
}
if stopReason == "" {
if usableTools > 0 {
stopReason = "tool_use"
} else {
stopReason = "end_turn"
}
}
response := map[string]interface{}{
"id": "msg_" + uuid.NewString()[:24],
"type": "message",
"role": "assistant",
"model": model,
"content": blocks,
"stop_reason": stopReason,
"usage": map[string]interface{}{
"input_tokens": usage.InputTokens,
"output_tokens": usage.OutputTokens,
"cache_read_input_tokens": usage.CacheReadInputTokens,
},
}
result, _ := json.Marshal(response)
return result
}
func restoreResponseToolName(name string, requestCtx KiroRequestContext) string {
name = strings.TrimSpace(name)
if requestCtx.ToolNameMap == nil {
return name
}
if original := strings.TrimSpace(requestCtx.ToolNameMap[name]); original != "" {
return original
}
return name
}
func hasThinkingBlocksOnly(blocks []map[string]interface{}) bool {
if len(blocks) == 0 {
return false
}
hasThinking := false
for _, block := range blocks {
blockType, _ := block["type"].(string)
switch blockType {
case "thinking":
hasThinking = true
case "text":
return false
default:
return false
}
}
return hasThinking
}
func extractThinkingBlocks(content string) []map[string]interface{} {
if content == "" {
return nil
}
if findRealThinkingStartTag(content, 0) == -1 {
return []map[string]interface{}{{"type": "text", "text": content}}
}
var blocks []map[string]interface{}
pos := 0
for pos < len(content) {
start := findRealThinkingStartTag(content, pos)
if start == -1 {
if text := content[pos:]; strings.TrimSpace(text) != "" {
blocks = append(blocks, map[string]interface{}{"type": "text", "text": text})
}
break
}
end := findRealThinkingEndTag(content, start+len(thinkingStartTag))
if end == -1 {
if text := content[pos:]; strings.TrimSpace(text) != "" {
blocks = append(blocks, map[string]interface{}{"type": "text", "text": text})
}
break
}
if text := content[pos:start]; strings.TrimSpace(text) != "" {
blocks = append(blocks, map[string]interface{}{"type": "text", "text": text})
}
thinking := strings.TrimPrefix(content[start+len(thinkingStartTag):end], "\n")
if strings.TrimSpace(thinking) != "" {
blocks = append(blocks, map[string]interface{}{
"type": "thinking",
"thinking": thinking,
"signature": thinkingSignature(thinking),
})
}
pos = end + len(thinkingEndTag)
if strings.HasPrefix(content[pos:], "\n\n") {
pos += len("\n\n")
}
}
if len(blocks) == 0 {
blocks = append(blocks, map[string]interface{}{"type": "text", "text": ""})
}
return blocks
}
func findRealThinkingStartTag(content string, from int) int {
return findRealThinkingTag(content, thinkingStartTag, from, false)
}
func findRealThinkingEndTag(content string, from int) int {
searchFrom := from
for {
pos := findRealThinkingTag(content, thinkingEndTag, searchFrom, true)
if pos == -1 {
return -1
}
after := pos + len(thinkingEndTag)
if strings.HasPrefix(content[after:], "\n\n") || strings.TrimSpace(content[after:]) == "" {
return pos
}
searchFrom = pos + 1
}
}
func findStreamThinkingEndTagStrict(content string, from int) int {
searchFrom := from
for {
pos := findRealThinkingTag(content, thinkingEndTag, searchFrom, true)
if pos == -1 {
return -1
}
after := pos + len(thinkingEndTag)
if strings.HasPrefix(content[after:], "\n\n") {
return pos
}
searchFrom = pos + 1
}
}
func findStreamThinkingEndTagAtBufferEnd(content string, from int) int {
searchFrom := from
for {
pos := findRealThinkingTag(content, thinkingEndTag, searchFrom, true)
if pos == -1 {
return -1
}
after := pos + len(thinkingEndTag)
if strings.TrimSpace(content[after:]) == "" {
return pos
}
searchFrom = pos + 1
}
}
func safeThinkingStreamFlushLen(content string, keepBytes int) int {
if keepBytes <= 0 || len(content) <= keepBytes {
return 0
}
pos := len(content) - keepBytes
for pos > 0 && !utf8.ValidString(content[:pos]) {
pos--
}
for pos > 0 && !utf8.RuneStart(content[pos]) {
pos--
}
return pos
}
func findRealThinkingTag(content, tag string, from int, allowEndBoundary bool) int {
if from < 0 {
from = 0
}
searchFrom := from
for searchFrom < len(content) {
rel := strings.Index(content[searchFrom:], tag)
if rel == -1 {
return -1
}
pos := searchFrom + rel
after := pos + len(tag)
if !isThinkingTagQuoted(content, pos, after) &&
!isInsideMarkdownFence(content, pos) &&
!isLineBlockQuote(content, pos) &&
(!allowEndBoundary || after <= len(content)) {
return pos
}
searchFrom = pos + 1
}
return -1
}
func isThinkingTagQuoted(content string, start, after int) bool {
if start > 0 && isThinkingQuoteChar(content[start-1]) {
return true
}
return after < len(content) && isThinkingQuoteChar(content[after])
}
func isThinkingQuoteChar(ch byte) bool {
switch ch {
case '`', '"', '\'', '\\':
return true
default:
return false
}
}
func isInsideMarkdownFence(content string, pos int) bool {
inFence := false
lineStart := 0
for lineStart < pos {
lineEnd := strings.IndexByte(content[lineStart:], '\n')
if lineEnd == -1 {
lineEnd = len(content)
} else {
lineEnd += lineStart
}
line := strings.TrimSpace(content[lineStart:lineEnd])
if strings.HasPrefix(line, "```") || strings.HasPrefix(line, "~~~") {
inFence = !inFence
}
lineStart = lineEnd + 1
}
return inFence
}
func isLineBlockQuote(content string, pos int) bool {
lineStart := strings.LastIndexByte(content[:pos], '\n') + 1
return strings.HasPrefix(strings.TrimLeftFunc(content[lineStart:pos], unicode.IsSpace), ">")
}
func thinkingSignature(content string) string {
if content == "" {
return ""
}
sum := sha256.Sum256([]byte(content))
return base64.StdEncoding.EncodeToString(sum[:])
}
func readEventStreamMessage(reader *bufio.Reader) (*eventStreamMessage, error) {
prelude := make([]byte, 12)
_, err := io.ReadFull(reader, prelude)
if err != nil {
return nil, err
}
totalLength := binary.BigEndian.Uint32(prelude[0:4])
headersLength := binary.BigEndian.Uint32(prelude[4:8])
if totalLength < minFrameSize || totalLength > maxEventMsgSize {
return nil, fmt.Errorf("invalid kiro eventstream frame length: %d", totalLength)
}
if headersLength > totalLength-16 {
return nil, fmt.Errorf("invalid kiro eventstream headers length: %d", headersLength)
}
remaining := make([]byte, totalLength-12)
if _, err := io.ReadFull(reader, remaining); err != nil {
return nil, err
}
eventType := extractEventType(remaining[:headersLength])
payloadStart := headersLength
payloadEnd := uint32(len(remaining)) - 4
if payloadStart >= payloadEnd {
return &eventStreamMessage{EventType: eventType}, nil
}
return &eventStreamMessage{
EventType: eventType,
Payload: remaining[payloadStart:payloadEnd],
}, nil
}
func extractEventType(headers []byte) string {
offset := 0
for offset < len(headers) {
nameLen := int(headers[offset])
offset++
if offset+nameLen > len(headers) {
break
}
name := string(headers[offset : offset+nameLen])
offset += nameLen
if offset >= len(headers) {
break
}
valueType := headers[offset]
offset++
if valueType == 7 {
if offset+2 > len(headers) {
break
}
valueLen := int(binary.BigEndian.Uint16(headers[offset : offset+2]))
offset += 2
if offset+valueLen > len(headers) {
break
}
value := string(headers[offset : offset+valueLen])
offset += valueLen
if name == ":event-type" {
return value
}
continue
}
next, ok := skipHeaderValue(headers, offset, valueType)
if !ok {
break
}
offset = next
}
return ""
}
func skipHeaderValue(headers []byte, offset int, valueType byte) (int, bool) {
switch valueType {
case 0, 1:
return offset, true
case 2:
if offset+1 > len(headers) {
return offset, false
}
return offset + 1, true
case 3:
if offset+2 > len(headers) {
return offset, false
}
return offset + 2, true
case 4:
if offset+4 > len(headers) {
return offset, false
}
return offset + 4, true
case 5, 8:
if offset+8 > len(headers) {
return offset, false
}
return offset + 8, true
case 6:
if offset+2 > len(headers) {
return offset, false
}
length := int(binary.BigEndian.Uint16(headers[offset : offset+2]))
offset += 2
if offset+length > len(headers) {
return offset, false
}
return offset + length, true
case 9:
if offset+16 > len(headers) {
return offset, false
}
return offset + 16, true
default:
return offset, false
}
}
func processToolUseEvent(event map[string]interface{}, currentTool *toolUseState, processedIDs map[string]bool) ([]KiroToolUse, *toolUseState) {
tu := nestedEvent(event, "toolUseEvent")
toolUseID := getString(tu, "toolUseId")
name := getString(tu, "name")
isStop, _ := tu["stop"].(bool)
var inputFragment string
var inputMap map[string]interface{}
if inputRaw, ok := tu["input"]; ok {
switch v := inputRaw.(type) {
case string:
inputFragment = v
case map[string]interface{}:
inputMap = v
}
}
if toolUseID != "" && name != "" {
if currentTool == nil || currentTool.ToolUseID != toolUseID {
if processedIDs[toolUseID] {
return nil, currentTool
}
currentTool = &toolUseState{ToolUseID: toolUseID, Name: name}
}
}
if currentTool != nil && inputFragment != "" {
_, _ = currentTool.InputBuffer.WriteString(inputFragment)
}
if currentTool != nil && inputMap != nil {
currentTool.InputBuffer.Reset()
encoded, _ := json.Marshal(inputMap)
_, _ = currentTool.InputBuffer.Write(encoded)
}
if !isStop || currentTool == nil {
return nil, currentTool
}
processedIDs[currentTool.ToolUseID] = true
return []KiroToolUse{finalizeRawToolUse(currentTool.ToolUseID, currentTool.Name, currentTool.InputBuffer.String())}, nil
}
func repairJSON(input string) string {
str := strings.TrimSpace(input)
if str == "" {
return "{}"
}
var parsed interface{}
if err := json.Unmarshal([]byte(str), &parsed); err == nil {
return str
}
str = escapeControlCharsInStrings(str)
str = trailingCommaPattern.ReplaceAllString(str, "$1")
openBraces, openBrackets, inString := jsonBalance(str)
if inString {
str += `"`
openBraces, openBrackets, _ = jsonBalance(str)
}
if openBraces > 0 {
str += strings.Repeat("}", openBraces)
}
if openBrackets > 0 {
str += strings.Repeat("]", openBrackets)
}
if err := json.Unmarshal([]byte(str), &parsed); err != nil {
return strings.TrimSpace(input)
}
return str
}
func escapeControlCharsInStrings(input string) string {
var out strings.Builder
inString := false
escape := false
for i := 0; i < len(input); i++ {
ch := input[i]
if escape {
_ = out.WriteByte(ch)
escape = false
continue
}
if ch == '\\' {
_ = out.WriteByte(ch)
escape = true
continue
}
if ch == '"' {
inString = !inString
_ = out.WriteByte(ch)
continue
}
if inString {
switch ch {
case '\n':
_, _ = out.WriteString("\\n")
continue
case '\r':
_, _ = out.WriteString("\\r")
continue
case '\t':
_, _ = out.WriteString("\\t")
continue
}
}
_ = out.WriteByte(ch)
}
return out.String()
}
func jsonBalance(input string) (openBraces int, openBrackets int, inString bool) {
escape := false
for i := 0; i < len(input); i++ {
ch := input[i]
if escape {
escape = false
continue
}
if ch == '\\' {
escape = true
continue
}
if ch == '"' {
inString = !inString
continue
}
if inString {
continue
}
switch ch {
case '{':
openBraces++
case '}':
openBraces--
case '[':
openBrackets++
case ']':
openBrackets--
}
}
return openBraces, openBrackets, inString
}
func finalizeRawToolUse(toolUseID, name, rawInput string) KiroToolUse {
tool := KiroToolUse{
ToolUseID: toolUseID,
Name: normalizeResponseToolName(name),
Input: map[string]interface{}{},
}
rawInput = strings.TrimSpace(rawInput)
tool.TruncatedRaw = rawInput
repaired := repairJSON(rawInput)
if strings.TrimSpace(repaired) != "" {
_ = json.Unmarshal([]byte(repaired), &tool.Input)
}
tool.IsTruncated = isTruncatedToolUse(tool.Name, rawInput, tool.Input)
return tool
}
func finalizeStructuredToolUse(toolUseID, name string, input map[string]interface{}) KiroToolUse {
if input == nil {
input = map[string]interface{}{}
}
tool := KiroToolUse{
ToolUseID: toolUseID,
Name: normalizeResponseToolName(name),
Input: input,
}
tool.IsTruncated = hasMissingRequiredFields(tool.Name, tool.Input)
return tool
}
func normalizeResponseToolName(name string) string {
name = strings.TrimSpace(name)
if name == "web_search" {
return "remote_web_search"
}
return name
}
func shouldEmitToolUse(tool KiroToolUse, emittedToolContents map[string]bool) bool {
if tool.IsTruncated {
return false
}
key := toolUseContentKey(tool)
if key == "" {
return false
}
if emittedToolContents[key] {
return false
}
emittedToolContents[key] = true
return true
}
func hasUsableToolUses(toolUses []KiroToolUse) bool {
for _, tool := range toolUses {
if !tool.IsTruncated {
return true
}
}
return false
}
func deduplicateToolUses(toolUses []KiroToolUse) []KiroToolUse {
seenIDs := make(map[string]bool)
seenContent := make(map[string]bool)
out := make([]KiroToolUse, 0, len(toolUses))
for _, tool := range toolUses {
if tool.ToolUseID != "" {
if seenIDs[tool.ToolUseID] {
continue
}
seenIDs[tool.ToolUseID] = true
}
key := toolUseContentKey(tool)
if key != "" && seenContent[key] {
continue
}
if key != "" {
seenContent[key] = true
}
out = append(out, tool)
}
return out
}
func toolUseContentKey(tool KiroToolUse) string {
name := strings.TrimSpace(tool.Name)
if name == "" {
return ""
}
inputJSON, _ := json.Marshal(tool.Input)
return name + ":" + string(inputJSON)
}
func drainEmbeddedToolText(text string) (cleanText string, toolUses []KiroToolUse, pending string) {
complete, pending := splitCompleteEmbeddedToolText(text)
if strings.TrimSpace(complete) == "" {
return "", nil, pending
}
cleanText, toolUses = parseEmbeddedToolCalls(complete)
return cleanText, deduplicateToolUses(toolUses), pending
}
func splitCompleteEmbeddedToolText(text string) (complete string, pending string) {
searchFrom := 0
for {
idx := strings.Index(text[searchFrom:], embeddedToolCallPrefix)
if idx == -1 {
return text, ""
}
idx += searchFrom
_, _, end, ok := parseEmbeddedToolCallAt(text, idx)
if !ok {
return text[:idx], text[idx:]
}
searchFrom = end
}
}
func parseEmbeddedToolCalls(text string) (string, []KiroToolUse) {
if !strings.Contains(text, embeddedToolCallPrefix) {
return text, nil
}
var (
builder strings.Builder
toolUses []KiroToolUse
index int
)
for index < len(text) {
start := strings.Index(text[index:], embeddedToolCallPrefix)
if start == -1 {
builder.WriteString(text[index:])
break
}
start += index
builder.WriteString(text[index:start])
tool, _, end, ok := parseEmbeddedToolCallAt(text, start)
if !ok {
builder.WriteString(text[start:])
break
}
toolUses = append(toolUses, tool)
index = end
}
return builder.String(), toolUses
}
func parseEmbeddedToolCallAt(text string, start int) (KiroToolUse, int, int, bool) {
if start < 0 || start >= len(text) || !strings.HasPrefix(text[start:], embeddedToolCallPrefix) {
return KiroToolUse{}, 0, 0, false
}
pos := start + len(embeddedToolCallPrefix)
argsMarker := " with args:"
argsIndex := strings.Index(text[pos:], argsMarker)
if argsIndex == -1 {
return KiroToolUse{}, 0, 0, false
}
argsIndex += pos
toolName := strings.TrimSpace(text[pos:argsIndex])
if toolName == "" {
return KiroToolUse{}, 0, 0, false
}
jsonStart := argsIndex + len(argsMarker)
for jsonStart < len(text) && (text[jsonStart] == ' ' || text[jsonStart] == '\t' || text[jsonStart] == '\n') {
jsonStart++
}
if jsonStart >= len(text) || text[jsonStart] != '{' {
return KiroToolUse{}, 0, 0, false
}
jsonEnd := findMatchingJSONBracket(text, jsonStart)
if jsonEnd == -1 {
return KiroToolUse{}, 0, 0, false
}
end := jsonEnd + 1
for end < len(text) && text[end] != ']' {
end++
}
if end >= len(text) {
return KiroToolUse{}, 0, 0, false
}
rawJSON := text[jsonStart : jsonEnd+1]
tool := finalizeRawToolUse("toolu_"+GenerateToolUseID(), toolName, rawJSON)
return tool, start, end + 1, true
}
func findMatchingJSONBracket(text string, start int) int {
depth := 0
inString := false
escape := false
for i := start; i < len(text); i++ {
ch := text[i]
if escape {
escape = false
continue
}
if ch == '\\' {
escape = true
continue
}
if ch == '"' {
inString = !inString
continue
}
if inString {
continue
}
switch ch {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return i
}
}
}
return -1
}
func isTruncatedToolUse(name, rawInput string, input map[string]interface{}) bool {
rawInput = strings.TrimSpace(rawInput)
if rawInput == "" {
return hasToolRequirements(name)
}
if looksLikeTruncatedJSON(rawInput) {
return true
}
return hasMissingRequiredFields(name, input)
}
func looksLikeTruncatedJSON(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" || raw[0] != '{' {
return false
}
openBraces, openBrackets, inString := jsonBalance(raw)
if openBraces > 0 || openBrackets > 0 || inString {
return true
}
last := raw[len(raw)-1]
return last == ':' || last == ','
}
func hasToolRequirements(name string) bool {
_, ok := requiredToolFields[strings.ToLower(strings.TrimSpace(name))]
return ok
}
func hasMissingRequiredFields(name string, input map[string]interface{}) bool {
groups, ok := requiredToolFields[strings.ToLower(strings.TrimSpace(name))]
if !ok {
return false
}
for _, group := range groups {
matched := false
for _, candidate := range group {
if _, exists := input[candidate]; exists {
matched = true
break
}
}
if !matched {
return true
}
}
return false
}
func updateUsageFromEvent(usage *Usage, eventType string, event map[string]interface{}) {
if usage == nil {
return
}
meta := nestedEvent(event, eventType)
if len(meta) == 0 {
meta = event
}
if tokenUsage, ok := meta["tokenUsage"].(map[string]interface{}); ok {
if value, ok := toInt(tokenUsage["uncachedInputTokens"]); ok {
usage.InputTokens = value
}
if value, ok := toInt(tokenUsage["outputTokens"]); ok {
usage.OutputTokens = value
}
if value, ok := toInt(tokenUsage["totalTokens"]); ok {
usage.TotalTokens = value
}
if value, ok := toInt(tokenUsage["cacheReadInputTokens"]); ok {
usage.CacheReadInputTokens = value
if usage.InputTokens == 0 {
usage.InputTokens = value
} else {
usage.InputTokens += value
}
}
}
if value, ok := toInt(event["inputTokens"]); ok && value > 0 {
usage.InputTokens = value
}
if value, ok := toInt(event["outputTokens"]); ok && value > 0 {
usage.OutputTokens = value
}
if value, ok := toInt(event["totalTokens"]); ok && value > 0 {
usage.TotalTokens = value
}
if value, ok := toInt(meta["inputTokens"]); ok && value > 0 {
usage.InputTokens = value
}
if value, ok := toInt(meta["outputTokens"]); ok && value > 0 {
usage.OutputTokens = value
}
if value, ok := toInt(meta["totalTokens"]); ok && value > 0 {
usage.TotalTokens = value
}
}
func readToolUses(primary, fallback map[string]interface{}) []KiroToolUse {
var raw []interface{}
if value, ok := primary["toolUses"].([]interface{}); ok {
raw = value
} else if value, ok := fallback["toolUses"].([]interface{}); ok {
raw = value
}
if len(raw) == 0 {
return nil
}
out := make([]KiroToolUse, 0, len(raw))
for _, item := range raw {
tool, ok := item.(map[string]interface{})
if !ok {
continue
}
input := map[string]interface{}{}
if value, ok := tool["input"].(map[string]interface{}); ok {
input = value
}
out = append(out, finalizeStructuredToolUse(getString(tool, "toolUseId"), getString(tool, "name"), input))
}
return out
}
func nestedEvent(event map[string]interface{}, key string) map[string]interface{} {
if nested, ok := event[key].(map[string]interface{}); ok {
return nested
}
return event
}
func getString(m map[string]interface{}, key string) string {
if value, ok := m[key].(string); ok {
return value
}
return ""
}
func readStopReason(m map[string]interface{}) string {
if stop := getString(m, "stop_reason"); stop != "" {
return stop
}
return getString(m, "stopReason")
}
func toInt(value interface{}) (int, bool) {
switch v := value.(type) {
case float64:
return int(v), true
case int:
return v, true
case int64:
return int(v), true
case json.Number:
n, err := v.Int64()
return int(n), err == nil
default:
return 0, false
}
}