mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-08 06:43:41 +00:00
Merge branch 'main' into plus
This commit is contained in:
@@ -50,6 +50,10 @@ type ToolCallAccumulator struct {
|
||||
// Returns:
|
||||
// - []string: A slice of strings, each containing an OpenAI-compatible JSON response
|
||||
func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
|
||||
var localParam any
|
||||
if param == nil {
|
||||
param = &localParam
|
||||
}
|
||||
if *param == nil {
|
||||
*param = &ConvertAnthropicResponseToOpenAIParams{
|
||||
CreatedAt: 0,
|
||||
|
||||
@@ -33,4 +33,7 @@ import (
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/gemini"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/openai/chat-completions"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/antigravity/openai/responses"
|
||||
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/claude"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/openai"
|
||||
)
|
||||
|
||||
20
internal/translator/kiro/claude/init.go
Normal file
20
internal/translator/kiro/claude/init.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Package claude provides translation between Kiro and Claude formats.
|
||||
package claude
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
Claude,
|
||||
Kiro,
|
||||
ConvertClaudeRequestToKiro,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertKiroStreamToClaude,
|
||||
NonStream: ConvertKiroNonStreamToClaude,
|
||||
},
|
||||
)
|
||||
}
|
||||
21
internal/translator/kiro/claude/kiro_claude.go
Normal file
21
internal/translator/kiro/claude/kiro_claude.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Package claude provides translation between Kiro and Claude formats.
|
||||
// Since Kiro executor generates Claude-compatible SSE format internally (with event: prefix),
|
||||
// translations are pass-through for streaming, but responses need proper formatting.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// ConvertKiroStreamToClaude converts Kiro streaming response to Claude format.
|
||||
// Kiro executor already generates complete SSE format with "event:" prefix,
|
||||
// so this is a simple pass-through.
|
||||
func ConvertKiroStreamToClaude(ctx context.Context, model string, originalRequest, request, rawResponse []byte, param *any) []string {
|
||||
return []string{string(rawResponse)}
|
||||
}
|
||||
|
||||
// ConvertKiroNonStreamToClaude converts Kiro non-streaming response to Claude format.
|
||||
// The response is already in Claude format, so this is a pass-through.
|
||||
func ConvertKiroNonStreamToClaude(ctx context.Context, model string, originalRequest, request, rawResponse []byte, param *any) string {
|
||||
return string(rawResponse)
|
||||
}
|
||||
992
internal/translator/kiro/claude/kiro_claude_request.go
Normal file
992
internal/translator/kiro/claude/kiro_claude_request.go
Normal file
@@ -0,0 +1,992 @@
|
||||
// Package claude provides request translation functionality for Claude API to Kiro format.
|
||||
// It handles parsing and transforming Claude API requests into the Kiro/Amazon Q API format,
|
||||
// extracting model information, system instructions, message contents, and tool declarations.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
kirocommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/common"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// remoteWebSearchDescription is a minimal fallback for when dynamic fetch from MCP tools/list hasn't completed yet.
|
||||
const remoteWebSearchDescription = "WebSearch looks up information outside the model's training data. Supports multiple queries to gather comprehensive information."
|
||||
|
||||
// Kiro API request structs - field order determines JSON key order
|
||||
|
||||
// KiroPayload is the top-level request structure for Kiro API
|
||||
type KiroPayload struct {
|
||||
ConversationState KiroConversationState `json:"conversationState"`
|
||||
ProfileArn string `json:"profileArn,omitempty"`
|
||||
InferenceConfig *KiroInferenceConfig `json:"inferenceConfig,omitempty"`
|
||||
}
|
||||
|
||||
// KiroInferenceConfig contains inference parameters for the Kiro API.
|
||||
type KiroInferenceConfig struct {
|
||||
MaxTokens int `json:"maxTokens,omitempty"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
TopP float64 `json:"topP,omitempty"`
|
||||
}
|
||||
|
||||
// KiroConversationState holds the conversation context
|
||||
type KiroConversationState struct {
|
||||
AgentContinuationID string `json:"agentContinuationId,omitempty"`
|
||||
AgentTaskType string `json:"agentTaskType,omitempty"`
|
||||
ChatTriggerType string `json:"chatTriggerType"` // Required: "MANUAL"
|
||||
ConversationID string `json:"conversationId"`
|
||||
CurrentMessage KiroCurrentMessage `json:"currentMessage"`
|
||||
History []KiroHistoryMessage `json:"history,omitempty"`
|
||||
}
|
||||
|
||||
// KiroCurrentMessage wraps the current user message
|
||||
type KiroCurrentMessage struct {
|
||||
UserInputMessage KiroUserInputMessage `json:"userInputMessage"`
|
||||
}
|
||||
|
||||
// KiroHistoryMessage represents a message in the conversation history
|
||||
type KiroHistoryMessage struct {
|
||||
UserInputMessage *KiroUserInputMessage `json:"userInputMessage,omitempty"`
|
||||
AssistantResponseMessage *KiroAssistantResponseMessage `json:"assistantResponseMessage,omitempty"`
|
||||
}
|
||||
|
||||
// KiroImage represents an image in Kiro API format
|
||||
type KiroImage struct {
|
||||
Format string `json:"format"`
|
||||
Source KiroImageSource `json:"source"`
|
||||
}
|
||||
|
||||
// KiroImageSource contains the image data
|
||||
type KiroImageSource struct {
|
||||
Bytes string `json:"bytes"` // base64 encoded image data
|
||||
}
|
||||
|
||||
// KiroUserInputMessage represents a user message
|
||||
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"`
|
||||
}
|
||||
|
||||
// KiroUserInputMessageContext contains tool-related context
|
||||
type KiroUserInputMessageContext struct {
|
||||
ToolResults []KiroToolResult `json:"toolResults,omitempty"`
|
||||
Tools []KiroToolWrapper `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// KiroToolResult represents a tool execution result
|
||||
type KiroToolResult struct {
|
||||
Content []KiroTextContent `json:"content"`
|
||||
Status string `json:"status"`
|
||||
ToolUseID string `json:"toolUseId"`
|
||||
}
|
||||
|
||||
// KiroTextContent represents text content
|
||||
type KiroTextContent struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// KiroToolWrapper wraps a tool specification
|
||||
type KiroToolWrapper struct {
|
||||
ToolSpecification KiroToolSpecification `json:"toolSpecification"`
|
||||
}
|
||||
|
||||
// KiroToolSpecification defines a tool's schema
|
||||
type KiroToolSpecification struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema KiroInputSchema `json:"inputSchema"`
|
||||
}
|
||||
|
||||
// KiroInputSchema wraps the JSON schema for tool input
|
||||
type KiroInputSchema struct {
|
||||
JSON interface{} `json:"json"`
|
||||
}
|
||||
|
||||
// KiroAssistantResponseMessage represents an assistant message
|
||||
type KiroAssistantResponseMessage struct {
|
||||
Content string `json:"content"`
|
||||
ToolUses []KiroToolUse `json:"toolUses,omitempty"`
|
||||
}
|
||||
|
||||
// KiroToolUse represents a tool invocation by the assistant
|
||||
type KiroToolUse struct {
|
||||
ToolUseID string `json:"toolUseId"`
|
||||
Name string `json:"name"`
|
||||
Input map[string]interface{} `json:"input"`
|
||||
IsTruncated bool `json:"-"` // Internal flag, not serialized
|
||||
TruncationInfo *TruncationInfo `json:"-"` // Truncation details, not serialized
|
||||
}
|
||||
|
||||
// ConvertClaudeRequestToKiro converts a Claude API request to Kiro format.
|
||||
// This is the main entry point for request translation.
|
||||
func ConvertClaudeRequestToKiro(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
// For Kiro, we pass through the Claude format since buildKiroPayload
|
||||
// expects Claude format and does the conversion internally.
|
||||
// The actual conversion happens in the executor when building the HTTP request.
|
||||
return inputRawJSON
|
||||
}
|
||||
|
||||
// BuildKiroPayload constructs the Kiro API request payload from Claude format.
|
||||
// Supports tool calling - tools are passed via userInputMessageContext.
|
||||
// origin parameter determines which quota to use: "CLI" for Amazon Q, "AI_EDITOR" for Kiro IDE.
|
||||
// isAgentic parameter enables chunked write optimization prompt for -agentic model variants.
|
||||
// isChatOnly parameter disables tool calling for -chat model variants (pure conversation mode).
|
||||
// headers parameter allows checking Anthropic-Beta header for thinking mode detection.
|
||||
// metadata parameter is kept for API compatibility but no longer used for thinking configuration.
|
||||
// Supports thinking mode - when enabled, injects thinking tags into system prompt.
|
||||
// Returns the payload and a boolean indicating whether thinking mode was injected.
|
||||
func BuildKiroPayload(claudeBody []byte, modelID, profileArn, origin string, isAgentic, isChatOnly bool, headers http.Header, metadata map[string]any) ([]byte, bool) {
|
||||
// Extract max_tokens for potential use in inferenceConfig
|
||||
// Handle -1 as "use maximum" (Kiro max output is ~32000 tokens)
|
||||
const kiroMaxOutputTokens = 32000
|
||||
var maxTokens int64
|
||||
if mt := gjson.GetBytes(claudeBody, "max_tokens"); mt.Exists() {
|
||||
maxTokens = mt.Int()
|
||||
if maxTokens == -1 {
|
||||
maxTokens = kiroMaxOutputTokens
|
||||
log.Debugf("kiro: max_tokens=-1 converted to %d", kiroMaxOutputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract temperature if specified
|
||||
var temperature float64
|
||||
var hasTemperature bool
|
||||
if temp := gjson.GetBytes(claudeBody, "temperature"); temp.Exists() {
|
||||
temperature = temp.Float()
|
||||
hasTemperature = true
|
||||
}
|
||||
|
||||
// Extract top_p if specified
|
||||
var topP float64
|
||||
var hasTopP bool
|
||||
if tp := gjson.GetBytes(claudeBody, "top_p"); tp.Exists() {
|
||||
topP = tp.Float()
|
||||
hasTopP = true
|
||||
log.Debugf("kiro: extracted top_p: %.2f", topP)
|
||||
}
|
||||
|
||||
// Normalize origin value for Kiro API compatibility
|
||||
origin = normalizeOrigin(origin)
|
||||
log.Debugf("kiro: normalized origin value: %s", origin)
|
||||
|
||||
messages := gjson.GetBytes(claudeBody, "messages")
|
||||
|
||||
// For chat-only mode, don't include tools
|
||||
var tools gjson.Result
|
||||
if !isChatOnly {
|
||||
tools = gjson.GetBytes(claudeBody, "tools")
|
||||
}
|
||||
|
||||
// Extract system prompt
|
||||
systemPrompt := extractSystemPrompt(claudeBody)
|
||||
|
||||
// Check for thinking mode using the comprehensive IsThinkingEnabledWithHeaders function
|
||||
// This supports Claude API format, OpenAI reasoning_effort, AMP/Cursor format, and Anthropic-Beta header
|
||||
thinkingEnabled := IsThinkingEnabledWithHeaders(claudeBody, headers)
|
||||
|
||||
// Inject timestamp context
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05 MST")
|
||||
timestampContext := fmt.Sprintf("[Context: Current time is %s]", timestamp)
|
||||
if systemPrompt != "" {
|
||||
systemPrompt = timestampContext + "\n\n" + systemPrompt
|
||||
} else {
|
||||
systemPrompt = timestampContext
|
||||
}
|
||||
log.Debugf("kiro: injected timestamp context: %s", timestamp)
|
||||
|
||||
// Inject agentic optimization prompt for -agentic model variants
|
||||
if isAgentic {
|
||||
if systemPrompt != "" {
|
||||
systemPrompt += "\n"
|
||||
}
|
||||
systemPrompt += kirocommon.KiroAgenticSystemPrompt
|
||||
}
|
||||
|
||||
// Handle tool_choice parameter - Kiro doesn't support it natively, so we inject system prompt hints
|
||||
// Claude tool_choice values: {"type": "auto/any/tool", "name": "..."}
|
||||
toolChoiceHint := extractClaudeToolChoiceHint(claudeBody)
|
||||
if toolChoiceHint != "" {
|
||||
if systemPrompt != "" {
|
||||
systemPrompt += "\n"
|
||||
}
|
||||
systemPrompt += toolChoiceHint
|
||||
log.Debugf("kiro: injected tool_choice hint into system prompt")
|
||||
}
|
||||
|
||||
// Convert Claude tools to Kiro format
|
||||
kiroTools := convertClaudeToolsToKiro(tools)
|
||||
|
||||
// Thinking mode implementation:
|
||||
// Kiro API supports official thinking/reasoning mode via <thinking_mode> tag.
|
||||
// When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent
|
||||
// rather than inline <thinking> tags in assistantResponseEvent.
|
||||
// We cap max_thinking_length to reserve space for tool outputs and prevent truncation.
|
||||
if thinkingEnabled {
|
||||
thinkingHint := `<thinking_mode>enabled</thinking_mode>
|
||||
<max_thinking_length>16000</max_thinking_length>`
|
||||
if systemPrompt != "" {
|
||||
systemPrompt = thinkingHint + "\n\n" + systemPrompt
|
||||
} else {
|
||||
systemPrompt = thinkingHint
|
||||
}
|
||||
log.Infof("kiro: injected thinking prompt (official mode), has_tools: %v", len(kiroTools) > 0)
|
||||
}
|
||||
|
||||
// Process messages and build history
|
||||
history, currentUserMsg, currentToolResults := processMessages(messages, modelID, origin)
|
||||
|
||||
// Build content with system prompt.
|
||||
// Keep thinking tags on subsequent turns so multi-turn Claude sessions
|
||||
// continue to emit reasoning events.
|
||||
if currentUserMsg != nil {
|
||||
currentUserMsg.Content = buildFinalContent(currentUserMsg.Content, systemPrompt, currentToolResults)
|
||||
|
||||
// Deduplicate currentToolResults
|
||||
currentToolResults = deduplicateToolResults(currentToolResults)
|
||||
|
||||
// Build userInputMessageContext with tools and tool results
|
||||
if len(kiroTools) > 0 || len(currentToolResults) > 0 {
|
||||
currentUserMsg.UserInputMessageContext = &KiroUserInputMessageContext{
|
||||
Tools: kiroTools,
|
||||
ToolResults: currentToolResults,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build payload
|
||||
var currentMessage KiroCurrentMessage
|
||||
if currentUserMsg != nil {
|
||||
currentMessage = KiroCurrentMessage{UserInputMessage: *currentUserMsg}
|
||||
} else {
|
||||
fallbackContent := ""
|
||||
if systemPrompt != "" {
|
||||
fallbackContent = "--- SYSTEM PROMPT ---\n" + systemPrompt + "\n--- END SYSTEM PROMPT ---\n"
|
||||
}
|
||||
currentMessage = KiroCurrentMessage{UserInputMessage: KiroUserInputMessage{
|
||||
Content: fallbackContent,
|
||||
ModelID: modelID,
|
||||
Origin: origin,
|
||||
}}
|
||||
}
|
||||
|
||||
// Build inferenceConfig if we have any inference parameters
|
||||
// Note: Kiro API doesn't actually use max_tokens for thinking budget
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Session IDs: extract from messages[].additional_kwargs (LangChain format) or random
|
||||
conversationID := extractMetadataFromMessages(messages, "conversationId")
|
||||
continuationID := extractMetadataFromMessages(messages, "continuationId")
|
||||
if conversationID == "" {
|
||||
conversationID = uuid.New().String()
|
||||
}
|
||||
|
||||
payload := KiroPayload{
|
||||
ConversationState: KiroConversationState{
|
||||
AgentTaskType: "vibe",
|
||||
ChatTriggerType: "MANUAL",
|
||||
ConversationID: conversationID,
|
||||
CurrentMessage: currentMessage,
|
||||
History: history,
|
||||
},
|
||||
ProfileArn: profileArn,
|
||||
InferenceConfig: inferenceConfig,
|
||||
}
|
||||
|
||||
// Only set AgentContinuationID if client provided
|
||||
if continuationID != "" {
|
||||
payload.ConversationState.AgentContinuationID = continuationID
|
||||
}
|
||||
|
||||
result, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Debugf("kiro: failed to marshal payload: %v", err)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return result, thinkingEnabled
|
||||
}
|
||||
|
||||
// normalizeOrigin normalizes origin value for Kiro API compatibility
|
||||
func normalizeOrigin(origin string) string {
|
||||
switch origin {
|
||||
case "KIRO_CLI":
|
||||
return "CLI"
|
||||
case "KIRO_AI_EDITOR":
|
||||
return "AI_EDITOR"
|
||||
case "AMAZON_Q":
|
||||
return "CLI"
|
||||
case "KIRO_IDE":
|
||||
return "AI_EDITOR"
|
||||
default:
|
||||
return origin
|
||||
}
|
||||
}
|
||||
|
||||
// extractMetadataFromMessages extracts metadata from messages[].additional_kwargs (LangChain format).
|
||||
// Searches from the last message backwards, returns empty string if not found.
|
||||
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 ""
|
||||
}
|
||||
|
||||
// extractSystemPrompt extracts system prompt from Claude request
|
||||
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()
|
||||
}
|
||||
|
||||
// checkThinkingMode checks if thinking mode is enabled in the Claude request
|
||||
func checkThinkingMode(claudeBody []byte) (bool, int64) {
|
||||
thinkingEnabled := false
|
||||
var budgetTokens int64 = 24000
|
||||
|
||||
thinkingField := gjson.GetBytes(claudeBody, "thinking")
|
||||
if thinkingField.Exists() {
|
||||
thinkingType := thinkingField.Get("type").String()
|
||||
if thinkingType == "enabled" {
|
||||
thinkingEnabled = true
|
||||
if bt := thinkingField.Get("budget_tokens"); bt.Exists() {
|
||||
budgetTokens = bt.Int()
|
||||
if budgetTokens <= 0 {
|
||||
thinkingEnabled = false
|
||||
log.Debugf("kiro: thinking mode disabled via budget_tokens <= 0")
|
||||
}
|
||||
}
|
||||
if thinkingEnabled {
|
||||
log.Debugf("kiro: thinking mode enabled via Claude API parameter, budget_tokens: %d", budgetTokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return thinkingEnabled, budgetTokens
|
||||
}
|
||||
|
||||
// hasThinkingTagInBody checks if the request body already contains thinking configuration tags.
|
||||
// This is used to prevent duplicate injection when client (e.g., AMP/Cursor) already includes thinking config.
|
||||
func hasThinkingTagInBody(body []byte) bool {
|
||||
bodyStr := string(body)
|
||||
return strings.Contains(bodyStr, "<thinking_mode>") || strings.Contains(bodyStr, "<max_thinking_length>")
|
||||
}
|
||||
|
||||
// IsThinkingEnabledFromHeader checks if thinking mode is enabled via Anthropic-Beta header.
|
||||
// Claude CLI uses "Anthropic-Beta: interleaved-thinking-2025-05-14" to enable thinking.
|
||||
func IsThinkingEnabledFromHeader(headers http.Header) bool {
|
||||
if headers == nil {
|
||||
return false
|
||||
}
|
||||
betaHeader := headers.Get("Anthropic-Beta")
|
||||
if betaHeader == "" {
|
||||
return false
|
||||
}
|
||||
// Check for interleaved-thinking beta feature
|
||||
if strings.Contains(betaHeader, "interleaved-thinking") {
|
||||
log.Debugf("kiro: thinking mode enabled via Anthropic-Beta header: %s", betaHeader)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsThinkingEnabled is a public wrapper to check if thinking mode is enabled.
|
||||
// This is used by the executor to determine whether to parse <thinking> tags in responses.
|
||||
// When thinking is NOT enabled in the request, <thinking> tags in responses should be
|
||||
// treated as regular text content, not as thinking blocks.
|
||||
//
|
||||
// Supports multiple formats:
|
||||
// - Claude API format: thinking.type = "enabled"
|
||||
// - OpenAI format: reasoning_effort parameter
|
||||
// - AMP/Cursor format: <thinking_mode>interleaved</thinking_mode> in system prompt
|
||||
func IsThinkingEnabled(body []byte) bool {
|
||||
return IsThinkingEnabledWithHeaders(body, nil)
|
||||
}
|
||||
|
||||
// IsThinkingEnabledWithHeaders checks if thinking mode is enabled from body or headers.
|
||||
// This is the comprehensive check that supports all thinking detection methods:
|
||||
// - Claude API format: thinking.type = "enabled"
|
||||
// - OpenAI format: reasoning_effort parameter
|
||||
// - AMP/Cursor format: <thinking_mode>interleaved</thinking_mode> in system prompt
|
||||
// - Anthropic-Beta header: interleaved-thinking-2025-05-14
|
||||
func IsThinkingEnabledWithHeaders(body []byte, headers http.Header) bool {
|
||||
// Check Anthropic-Beta header first (Claude Code uses this)
|
||||
if IsThinkingEnabledFromHeader(headers) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check Claude API format first (thinking.type = "enabled")
|
||||
enabled, _ := checkThinkingMode(body)
|
||||
if enabled {
|
||||
log.Debugf("kiro: IsThinkingEnabled returning true (Claude API format)")
|
||||
return true
|
||||
}
|
||||
|
||||
// Check OpenAI format: reasoning_effort parameter
|
||||
// Valid values: "low", "medium", "high", "auto" (not "none")
|
||||
reasoningEffort := gjson.GetBytes(body, "reasoning_effort")
|
||||
if reasoningEffort.Exists() {
|
||||
effort := reasoningEffort.String()
|
||||
if effort != "" && effort != "none" {
|
||||
log.Debugf("kiro: thinking mode enabled via OpenAI reasoning_effort: %s", effort)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check AMP/Cursor format: <thinking_mode>interleaved</thinking_mode> in system prompt
|
||||
// This is how AMP client passes thinking configuration
|
||||
bodyStr := string(body)
|
||||
if strings.Contains(bodyStr, "<thinking_mode>") && strings.Contains(bodyStr, "</thinking_mode>") {
|
||||
// Extract thinking mode value
|
||||
startTag := "<thinking_mode>"
|
||||
endTag := "</thinking_mode>"
|
||||
startIdx := strings.Index(bodyStr, startTag)
|
||||
if startIdx >= 0 {
|
||||
startIdx += len(startTag)
|
||||
endIdx := strings.Index(bodyStr[startIdx:], endTag)
|
||||
if endIdx >= 0 {
|
||||
thinkingMode := bodyStr[startIdx : startIdx+endIdx]
|
||||
if thinkingMode == "interleaved" || thinkingMode == "enabled" {
|
||||
log.Debugf("kiro: thinking mode enabled via AMP/Cursor format: %s", thinkingMode)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check OpenAI format: max_completion_tokens with reasoning (o1-style)
|
||||
// Some clients use this to indicate reasoning mode
|
||||
if gjson.GetBytes(body, "max_completion_tokens").Exists() {
|
||||
// If max_completion_tokens is set, check if model name suggests reasoning
|
||||
model := gjson.GetBytes(body, "model").String()
|
||||
if strings.Contains(strings.ToLower(model), "thinking") ||
|
||||
strings.Contains(strings.ToLower(model), "reason") {
|
||||
log.Debugf("kiro: thinking mode enabled via model name hint: %s", model)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check model name directly for thinking hints.
|
||||
// This enables thinking variants even when clients don't send explicit thinking fields.
|
||||
model := strings.TrimSpace(gjson.GetBytes(body, "model").String())
|
||||
modelLower := strings.ToLower(model)
|
||||
if strings.Contains(modelLower, "thinking") || strings.Contains(modelLower, "-reason") {
|
||||
log.Debugf("kiro: thinking mode enabled via model name hint: %s", model)
|
||||
return true
|
||||
}
|
||||
|
||||
log.Debugf("kiro: IsThinkingEnabled returning false (no thinking mode detected)")
|
||||
return false
|
||||
}
|
||||
|
||||
// shortenToolNameIfNeeded shortens tool names that exceed 64 characters.
|
||||
// MCP tools often have long names like "mcp__server-name__tool-name".
|
||||
// This preserves the "mcp__" prefix and last segment when possible.
|
||||
func shortenToolNameIfNeeded(name string) string {
|
||||
const limit = 64
|
||||
if len(name) <= limit {
|
||||
return name
|
||||
}
|
||||
// For MCP tools, try to preserve prefix and last segment
|
||||
if strings.HasPrefix(name, "mcp__") {
|
||||
idx := strings.LastIndex(name, "__")
|
||||
if idx > 0 {
|
||||
cand := "mcp__" + name[idx+2:]
|
||||
if len(cand) > limit {
|
||||
return cand[:limit]
|
||||
}
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return name[:limit]
|
||||
}
|
||||
|
||||
func ensureKiroInputSchema(parameters interface{}) interface{} {
|
||||
if parameters != nil {
|
||||
return parameters
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// convertClaudeToolsToKiro converts Claude tools to Kiro format
|
||||
func convertClaudeToolsToKiro(tools gjson.Result) []KiroToolWrapper {
|
||||
var kiroTools []KiroToolWrapper
|
||||
if !tools.IsArray() {
|
||||
return kiroTools
|
||||
}
|
||||
|
||||
for _, tool := range tools.Array() {
|
||||
name := tool.Get("name").String()
|
||||
description := tool.Get("description").String()
|
||||
inputSchemaResult := tool.Get("input_schema")
|
||||
var inputSchema interface{}
|
||||
if inputSchemaResult.Exists() && inputSchemaResult.Type != gjson.Null {
|
||||
inputSchema = inputSchemaResult.Value()
|
||||
}
|
||||
inputSchema = ensureKiroInputSchema(inputSchema)
|
||||
|
||||
// Shorten tool name if it exceeds 64 characters (common with MCP tools)
|
||||
originalName := name
|
||||
name = shortenToolNameIfNeeded(name)
|
||||
if name != originalName {
|
||||
log.Debugf("kiro: shortened tool name from '%s' to '%s'", originalName, name)
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Kiro API requires non-empty description
|
||||
if strings.TrimSpace(description) == "" {
|
||||
description = fmt.Sprintf("Tool: %s", name)
|
||||
log.Debugf("kiro: tool '%s' has empty description, using default: %s", name, description)
|
||||
}
|
||||
|
||||
// Rename web_search → remote_web_search for Kiro API compatibility
|
||||
if name == "web_search" {
|
||||
name = "remote_web_search"
|
||||
// Prefer dynamically fetched description, fall back to hardcoded constant
|
||||
if cached := GetWebSearchDescription(); cached != "" {
|
||||
description = cached
|
||||
} else {
|
||||
description = remoteWebSearchDescription
|
||||
}
|
||||
log.Debugf("kiro: renamed tool web_search → remote_web_search")
|
||||
}
|
||||
|
||||
// Truncate long descriptions (individual tool limit)
|
||||
if len(description) > kirocommon.KiroMaxToolDescLen {
|
||||
truncLen := kirocommon.KiroMaxToolDescLen - 30
|
||||
for truncLen > 0 && !utf8.RuneStart(description[truncLen]) {
|
||||
truncLen--
|
||||
}
|
||||
description = description[:truncLen] + "... (description truncated)"
|
||||
}
|
||||
|
||||
kiroTools = append(kiroTools, KiroToolWrapper{
|
||||
ToolSpecification: KiroToolSpecification{
|
||||
Name: name,
|
||||
Description: description,
|
||||
InputSchema: KiroInputSchema{JSON: inputSchema},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Apply dynamic compression if total tools size exceeds threshold
|
||||
// This prevents 500 errors when Claude Code sends too many tools
|
||||
kiroTools = compressToolsIfNeeded(kiroTools)
|
||||
|
||||
return kiroTools
|
||||
}
|
||||
|
||||
// processMessages processes Claude messages and builds Kiro history
|
||||
func processMessages(messages gjson.Result, modelID, origin string) ([]KiroHistoryMessage, *KiroUserInputMessage, []KiroToolResult) {
|
||||
var history []KiroHistoryMessage
|
||||
var currentUserMsg *KiroUserInputMessage
|
||||
var currentToolResults []KiroToolResult
|
||||
|
||||
// Merge adjacent messages with the same role
|
||||
messagesArray := kirocommon.MergeAdjacentMessages(messages.Array())
|
||||
|
||||
// FIX: Kiro API requires history to start with a user message.
|
||||
// Some clients (e.g., OpenClaw) send conversations starting with an assistant message,
|
||||
// which is valid for the Claude API but causes "Improperly formed request" on Kiro.
|
||||
// Prepend a placeholder user message so the history alternation is correct.
|
||||
if len(messagesArray) > 0 && messagesArray[0].Get("role").String() == "assistant" {
|
||||
placeholder := `{"role":"user","content":"."}`
|
||||
messagesArray = append([]gjson.Result{gjson.Parse(placeholder)}, messagesArray...)
|
||||
log.Infof("kiro: messages started with assistant role, prepended placeholder user message for Kiro API compatibility")
|
||||
}
|
||||
|
||||
for i, msg := range messagesArray {
|
||||
role := msg.Get("role").String()
|
||||
isLastMessage := i == len(messagesArray)-1
|
||||
|
||||
if role == "user" {
|
||||
userMsg, toolResults := BuildUserMessageStruct(msg, modelID, origin)
|
||||
// CRITICAL: Kiro API requires content to be non-empty for ALL user messages
|
||||
// This includes both history messages and the current message.
|
||||
// When user message contains only tool_result (no text), content will be empty.
|
||||
// This commonly happens in compaction requests from OpenCode.
|
||||
if strings.TrimSpace(userMsg.Content) == "" {
|
||||
if len(toolResults) > 0 {
|
||||
userMsg.Content = kirocommon.DefaultUserContentWithToolResults
|
||||
} else {
|
||||
userMsg.Content = kirocommon.DefaultUserContent
|
||||
}
|
||||
log.Debugf("kiro: user content was empty, using default: %s", userMsg.Content)
|
||||
}
|
||||
if isLastMessage {
|
||||
currentUserMsg = &userMsg
|
||||
currentToolResults = toolResults
|
||||
} else {
|
||||
// For history messages, embed tool results in context
|
||||
if len(toolResults) > 0 {
|
||||
userMsg.UserInputMessageContext = &KiroUserInputMessageContext{
|
||||
ToolResults: toolResults,
|
||||
}
|
||||
}
|
||||
history = append(history, KiroHistoryMessage{
|
||||
UserInputMessage: &userMsg,
|
||||
})
|
||||
}
|
||||
} else if role == "assistant" {
|
||||
assistantMsg := BuildAssistantMessageStruct(msg)
|
||||
if isLastMessage {
|
||||
history = append(history, KiroHistoryMessage{
|
||||
AssistantResponseMessage: &assistantMsg,
|
||||
})
|
||||
// Create a "Continue" user message as currentMessage
|
||||
currentUserMsg = &KiroUserInputMessage{
|
||||
Content: "Continue",
|
||||
ModelID: modelID,
|
||||
Origin: origin,
|
||||
}
|
||||
} else {
|
||||
history = append(history, KiroHistoryMessage{
|
||||
AssistantResponseMessage: &assistantMsg,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST-PROCESSING: Remove orphaned tool_results that have no matching tool_use
|
||||
// in any assistant message. This happens when Claude Code compaction truncates
|
||||
// the conversation and removes the assistant message containing the tool_use,
|
||||
// but keeps the user message with the corresponding tool_result.
|
||||
// Without this fix, Kiro API returns "Improperly formed request".
|
||||
validToolUseIDs := make(map[string]bool)
|
||||
for _, h := range history {
|
||||
if h.AssistantResponseMessage != nil {
|
||||
for _, tu := range h.AssistantResponseMessage.ToolUses {
|
||||
validToolUseIDs[tu.ToolUseID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter orphaned tool results from history user messages
|
||||
for i, h := range history {
|
||||
if h.UserInputMessage != nil && h.UserInputMessage.UserInputMessageContext != nil {
|
||||
ctx := h.UserInputMessage.UserInputMessageContext
|
||||
if len(ctx.ToolResults) > 0 {
|
||||
filtered := make([]KiroToolResult, 0, len(ctx.ToolResults))
|
||||
for _, tr := range ctx.ToolResults {
|
||||
if validToolUseIDs[tr.ToolUseID] {
|
||||
filtered = append(filtered, tr)
|
||||
} else {
|
||||
log.Debugf("kiro: dropping orphaned tool_result in history[%d]: toolUseId=%s (no matching tool_use)", i, tr.ToolUseID)
|
||||
}
|
||||
}
|
||||
ctx.ToolResults = filtered
|
||||
if len(ctx.ToolResults) == 0 && len(ctx.Tools) == 0 {
|
||||
h.UserInputMessage.UserInputMessageContext = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter orphaned tool results from current message
|
||||
if len(currentToolResults) > 0 {
|
||||
filtered := make([]KiroToolResult, 0, len(currentToolResults))
|
||||
for _, tr := range currentToolResults {
|
||||
if validToolUseIDs[tr.ToolUseID] {
|
||||
filtered = append(filtered, tr)
|
||||
} else {
|
||||
log.Debugf("kiro: dropping orphaned tool_result in currentMessage: toolUseId=%s (no matching tool_use)", tr.ToolUseID)
|
||||
}
|
||||
}
|
||||
if len(filtered) != len(currentToolResults) {
|
||||
log.Infof("kiro: dropped %d orphaned tool_result(s) from currentMessage (compaction artifact)", len(currentToolResults)-len(filtered))
|
||||
}
|
||||
currentToolResults = filtered
|
||||
}
|
||||
|
||||
return history, currentUserMsg, currentToolResults
|
||||
}
|
||||
|
||||
// buildFinalContent builds the final content with system prompt
|
||||
func buildFinalContent(content, systemPrompt string, toolResults []KiroToolResult) string {
|
||||
var contentBuilder strings.Builder
|
||||
|
||||
if systemPrompt != "" {
|
||||
contentBuilder.WriteString("--- SYSTEM PROMPT ---\n")
|
||||
contentBuilder.WriteString(systemPrompt)
|
||||
contentBuilder.WriteString("\n--- END SYSTEM PROMPT ---\n\n")
|
||||
}
|
||||
|
||||
contentBuilder.WriteString(content)
|
||||
finalContent := contentBuilder.String()
|
||||
|
||||
// CRITICAL: Kiro API requires content to be non-empty
|
||||
if strings.TrimSpace(finalContent) == "" {
|
||||
if len(toolResults) > 0 {
|
||||
finalContent = "Tool results provided."
|
||||
} else {
|
||||
finalContent = "Continue"
|
||||
}
|
||||
log.Debugf("kiro: content was empty, using default: %s", finalContent)
|
||||
}
|
||||
|
||||
return finalContent
|
||||
}
|
||||
|
||||
// deduplicateToolResults removes duplicate tool results
|
||||
func deduplicateToolResults(toolResults []KiroToolResult) []KiroToolResult {
|
||||
if len(toolResults) == 0 {
|
||||
return toolResults
|
||||
}
|
||||
|
||||
seenIDs := make(map[string]bool)
|
||||
unique := make([]KiroToolResult, 0, len(toolResults))
|
||||
for _, tr := range toolResults {
|
||||
if !seenIDs[tr.ToolUseID] {
|
||||
seenIDs[tr.ToolUseID] = true
|
||||
unique = append(unique, tr)
|
||||
} else {
|
||||
log.Debugf("kiro: skipping duplicate toolResult in currentMessage: %s", tr.ToolUseID)
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
// extractClaudeToolChoiceHint extracts tool_choice from Claude request and returns a system prompt hint.
|
||||
// Claude tool_choice values:
|
||||
// - {"type": "auto"}: Model decides (default, no hint needed)
|
||||
// - {"type": "any"}: Must use at least one tool
|
||||
// - {"type": "tool", "name": "..."}: Must use specific tool
|
||||
func extractClaudeToolChoiceHint(claudeBody []byte) string {
|
||||
toolChoice := gjson.GetBytes(claudeBody, "tool_choice")
|
||||
if !toolChoice.Exists() {
|
||||
return ""
|
||||
}
|
||||
|
||||
toolChoiceType := toolChoice.Get("type").String()
|
||||
switch toolChoiceType {
|
||||
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 := toolChoice.Get("name").String()
|
||||
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 "auto":
|
||||
// Default behavior, no hint needed
|
||||
return ""
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// BuildUserMessageStruct builds a user message and extracts tool results
|
||||
func BuildUserMessageStruct(msg gjson.Result, modelID, origin string) (KiroUserInputMessage, []KiroToolResult) {
|
||||
content := msg.Get("content")
|
||||
var contentBuilder strings.Builder
|
||||
var toolResults []KiroToolResult
|
||||
var images []KiroImage
|
||||
|
||||
// Track seen toolUseIds to deduplicate
|
||||
seenToolUseIDs := make(map[string]bool)
|
||||
|
||||
if content.IsArray() {
|
||||
for _, part := range content.Array() {
|
||||
partType := part.Get("type").String()
|
||||
switch partType {
|
||||
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()
|
||||
|
||||
// Skip duplicate toolUseIds
|
||||
if seenToolUseIDs[toolUseID] {
|
||||
log.Debugf("kiro: skipping duplicate tool_result with toolUseId: %s", toolUseID)
|
||||
continue
|
||||
}
|
||||
seenToolUseIDs[toolUseID] = true
|
||||
|
||||
isError := part.Get("is_error").Bool()
|
||||
resultContent := part.Get("content")
|
||||
|
||||
var textContents []KiroTextContent
|
||||
|
||||
// Check if this tool_result contains error from our SOFT_LIMIT_REACHED tool_use
|
||||
// The client will return an error when trying to execute a tool with marker input
|
||||
resultStr := resultContent.String()
|
||||
isSoftLimitError := strings.Contains(resultStr, "SOFT_LIMIT_REACHED") ||
|
||||
strings.Contains(resultStr, "_status") ||
|
||||
strings.Contains(resultStr, "truncated") ||
|
||||
strings.Contains(resultStr, "missing required") ||
|
||||
strings.Contains(resultStr, "invalid input") ||
|
||||
strings.Contains(resultStr, "Error writing file")
|
||||
|
||||
if isError && isSoftLimitError {
|
||||
// Replace error content with SOFT_LIMIT_REACHED guidance
|
||||
log.Infof("kiro: detected SOFT_LIMIT_REACHED in tool_result for %s, replacing with guidance", toolUseID)
|
||||
softLimitMsg := `SOFT_LIMIT_REACHED
|
||||
|
||||
Your previous tool call was incomplete due to API output size limits.
|
||||
The content was PARTIALLY transmitted but NOT executed.
|
||||
|
||||
REQUIRED ACTION:
|
||||
1. Split your content into smaller chunks (max 300 lines per call)
|
||||
2. For file writes: Create file with first chunk, then use append for remaining
|
||||
3. Do NOT regenerate content you already attempted - continue from where you stopped
|
||||
|
||||
STATUS: This is NOT an error. Continue with smaller chunks.`
|
||||
textContents = append(textContents, KiroTextContent{Text: softLimitMsg})
|
||||
// Mark as SUCCESS so Claude doesn't treat it as a failure
|
||||
isError = false
|
||||
} else if resultContent.IsArray() {
|
||||
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 = append(textContents, KiroTextContent{Text: resultContent.String()})
|
||||
}
|
||||
|
||||
if len(textContents) == 0 {
|
||||
textContents = append(textContents, KiroTextContent{Text: "Tool use was cancelled by the user"})
|
||||
}
|
||||
|
||||
status := "success"
|
||||
if isError {
|
||||
status = "error"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// BuildAssistantMessageStruct builds an assistant message with tool uses
|
||||
func BuildAssistantMessageStruct(msg gjson.Result) KiroAssistantResponseMessage {
|
||||
content := msg.Get("content")
|
||||
var contentBuilder strings.Builder
|
||||
var toolUses []KiroToolUse
|
||||
|
||||
if content.IsArray() {
|
||||
for _, part := range content.Array() {
|
||||
partType := part.Get("type").String()
|
||||
switch partType {
|
||||
case "text":
|
||||
contentBuilder.WriteString(part.Get("text").String())
|
||||
case "tool_use":
|
||||
toolUseID := part.Get("id").String()
|
||||
toolName := part.Get("name").String()
|
||||
toolInput := part.Get("input")
|
||||
|
||||
var inputMap map[string]interface{}
|
||||
if toolInput.IsObject() {
|
||||
inputMap = make(map[string]interface{})
|
||||
toolInput.ForEach(func(key, value gjson.Result) bool {
|
||||
inputMap[key.String()] = value.Value()
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Rename web_search → remote_web_search to match convertClaudeToolsToKiro
|
||||
if toolName == "web_search" {
|
||||
toolName = "remote_web_search"
|
||||
}
|
||||
|
||||
toolUses = append(toolUses, KiroToolUse{
|
||||
ToolUseID: toolUseID,
|
||||
Name: toolName,
|
||||
Input: inputMap,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
contentBuilder.WriteString(content.String())
|
||||
}
|
||||
|
||||
// CRITICAL FIX: Kiro API requires non-empty content for assistant messages
|
||||
// This can happen with compaction requests where assistant messages have only tool_use
|
||||
// (no text content). Without this fix, Kiro API returns "Improperly formed request" error.
|
||||
finalContent := contentBuilder.String()
|
||||
if strings.TrimSpace(finalContent) == "" {
|
||||
if len(toolUses) > 0 {
|
||||
finalContent = kirocommon.DefaultAssistantContentWithTools
|
||||
} else {
|
||||
finalContent = kirocommon.DefaultAssistantContent
|
||||
}
|
||||
log.Debugf("kiro: assistant content was empty, using default: %s", finalContent)
|
||||
}
|
||||
|
||||
return KiroAssistantResponseMessage{
|
||||
Content: finalContent,
|
||||
ToolUses: toolUses,
|
||||
}
|
||||
}
|
||||
230
internal/translator/kiro/claude/kiro_claude_response.go
Normal file
230
internal/translator/kiro/claude/kiro_claude_response.go
Normal file
@@ -0,0 +1,230 @@
|
||||
// Package claude provides response translation functionality for Kiro API to Claude format.
|
||||
// This package handles the conversion of Kiro API responses into Claude-compatible format,
|
||||
// including support for thinking blocks and tool use.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
kirocommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/common"
|
||||
)
|
||||
|
||||
// generateThinkingSignature generates a signature for thinking content.
|
||||
// This is required by Claude API for thinking blocks in non-streaming responses.
|
||||
// The signature is a base64-encoded hash of the thinking content.
|
||||
func generateThinkingSignature(thinkingContent string) string {
|
||||
if thinkingContent == "" {
|
||||
return ""
|
||||
}
|
||||
// Generate a deterministic signature based on content hash
|
||||
hash := sha256.Sum256([]byte(thinkingContent))
|
||||
return base64.StdEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// Local references to kirocommon constants for thinking block parsing
|
||||
var (
|
||||
thinkingStartTag = kirocommon.ThinkingStartTag
|
||||
thinkingEndTag = kirocommon.ThinkingEndTag
|
||||
)
|
||||
|
||||
// BuildClaudeResponse constructs a Claude-compatible response.
|
||||
// Supports tool_use blocks when tools are present in the response.
|
||||
// Supports thinking blocks - parses <thinking> tags and converts to Claude thinking content blocks.
|
||||
// stopReason is passed from upstream; fallback logic applied if empty.
|
||||
func BuildClaudeResponse(content string, toolUses []KiroToolUse, model string, usageInfo usage.Detail, stopReason string) []byte {
|
||||
var contentBlocks []map[string]interface{}
|
||||
|
||||
// Extract thinking blocks and text from content
|
||||
if content != "" {
|
||||
blocks := ExtractThinkingFromContent(content)
|
||||
contentBlocks = append(contentBlocks, blocks...)
|
||||
|
||||
// Log if thinking blocks were extracted
|
||||
for _, block := range blocks {
|
||||
if block["type"] == "thinking" {
|
||||
thinkingContent := block["thinking"].(string)
|
||||
log.Infof("kiro: buildClaudeResponse extracted thinking block (len: %d)", len(thinkingContent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool_use blocks - emit truncated tools with SOFT_LIMIT_REACHED marker
|
||||
hasTruncatedTools := false
|
||||
for _, toolUse := range toolUses {
|
||||
if toolUse.IsTruncated && toolUse.TruncationInfo != nil {
|
||||
// Emit tool_use with SOFT_LIMIT_REACHED marker input
|
||||
hasTruncatedTools = true
|
||||
log.Infof("kiro: buildClaudeResponse emitting truncated tool with SOFT_LIMIT_REACHED: %s (ID: %s)", toolUse.Name, toolUse.ToolUseID)
|
||||
|
||||
markerInput := map[string]interface{}{
|
||||
"_status": "SOFT_LIMIT_REACHED",
|
||||
"_message": "Tool output was truncated. Split content into smaller chunks (max 300 lines). Due to potential model hallucination, you MUST re-fetch the current working directory and generate the correct file_path.",
|
||||
}
|
||||
|
||||
contentBlocks = append(contentBlocks, map[string]interface{}{
|
||||
"type": "tool_use",
|
||||
"id": toolUse.ToolUseID,
|
||||
"name": toolUse.Name,
|
||||
"input": markerInput,
|
||||
})
|
||||
} else {
|
||||
// Normal tool use
|
||||
contentBlocks = append(contentBlocks, map[string]interface{}{
|
||||
"type": "tool_use",
|
||||
"id": toolUse.ToolUseID,
|
||||
"name": toolUse.Name,
|
||||
"input": toolUse.Input,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Log if we used SOFT_LIMIT_REACHED
|
||||
if hasTruncatedTools {
|
||||
log.Infof("kiro: buildClaudeResponse using SOFT_LIMIT_REACHED - keeping stop_reason=tool_use")
|
||||
}
|
||||
|
||||
// Ensure at least one content block (Claude API requires non-empty content)
|
||||
if len(contentBlocks) == 0 {
|
||||
contentBlocks = append(contentBlocks, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": "",
|
||||
})
|
||||
}
|
||||
|
||||
// Use upstream stopReason; apply fallback logic if not provided
|
||||
// SOFT_LIMIT_REACHED: Keep stop_reason = "tool_use" so Claude continues the loop
|
||||
if stopReason == "" {
|
||||
stopReason = "end_turn"
|
||||
if len(toolUses) > 0 {
|
||||
stopReason = "tool_use"
|
||||
}
|
||||
log.Debugf("kiro: buildClaudeResponse using fallback stop_reason: %s", stopReason)
|
||||
}
|
||||
|
||||
// Log warning if response was truncated due to max_tokens
|
||||
if stopReason == "max_tokens" {
|
||||
log.Warnf("kiro: response truncated due to max_tokens limit (buildClaudeResponse)")
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": "msg_" + uuid.New().String()[:24],
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": model,
|
||||
"content": contentBlocks,
|
||||
"stop_reason": stopReason,
|
||||
"usage": map[string]interface{}{
|
||||
"input_tokens": usageInfo.InputTokens,
|
||||
"output_tokens": usageInfo.OutputTokens,
|
||||
},
|
||||
}
|
||||
result, _ := json.Marshal(response)
|
||||
return result
|
||||
}
|
||||
|
||||
// ExtractThinkingFromContent parses content to extract thinking blocks and text.
|
||||
// Returns a list of content blocks in the order they appear in the content.
|
||||
// Handles interleaved thinking and text blocks correctly.
|
||||
func ExtractThinkingFromContent(content string) []map[string]interface{} {
|
||||
var blocks []map[string]interface{}
|
||||
|
||||
if content == "" {
|
||||
return blocks
|
||||
}
|
||||
|
||||
// Check if content contains thinking tags at all
|
||||
if !strings.Contains(content, thinkingStartTag) {
|
||||
// No thinking tags, return as plain text
|
||||
return []map[string]interface{}{
|
||||
{
|
||||
"type": "text",
|
||||
"text": content,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("kiro: extractThinkingFromContent - found thinking tags in content (len: %d)", len(content))
|
||||
|
||||
remaining := content
|
||||
|
||||
for len(remaining) > 0 {
|
||||
// Look for <thinking> tag
|
||||
startIdx := strings.Index(remaining, thinkingStartTag)
|
||||
|
||||
if startIdx == -1 {
|
||||
// No more thinking tags, add remaining as text
|
||||
if strings.TrimSpace(remaining) != "" {
|
||||
blocks = append(blocks, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": remaining,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Add text before thinking tag (if any meaningful content)
|
||||
if startIdx > 0 {
|
||||
textBefore := remaining[:startIdx]
|
||||
if strings.TrimSpace(textBefore) != "" {
|
||||
blocks = append(blocks, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": textBefore,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Move past the opening tag
|
||||
remaining = remaining[startIdx+len(thinkingStartTag):]
|
||||
|
||||
// Find closing tag
|
||||
endIdx := strings.Index(remaining, thinkingEndTag)
|
||||
|
||||
if endIdx == -1 {
|
||||
// No closing tag found, treat rest as thinking content (incomplete response)
|
||||
if strings.TrimSpace(remaining) != "" {
|
||||
// Generate signature for thinking content (required by Claude API)
|
||||
signature := generateThinkingSignature(remaining)
|
||||
blocks = append(blocks, map[string]interface{}{
|
||||
"type": "thinking",
|
||||
"thinking": remaining,
|
||||
"signature": signature,
|
||||
})
|
||||
log.Warnf("kiro: extractThinkingFromContent - missing closing </thinking> tag")
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Extract thinking content between tags
|
||||
thinkContent := remaining[:endIdx]
|
||||
if strings.TrimSpace(thinkContent) != "" {
|
||||
// Generate signature for thinking content (required by Claude API)
|
||||
signature := generateThinkingSignature(thinkContent)
|
||||
blocks = append(blocks, map[string]interface{}{
|
||||
"type": "thinking",
|
||||
"thinking": thinkContent,
|
||||
"signature": signature,
|
||||
})
|
||||
log.Debugf("kiro: extractThinkingFromContent - extracted thinking block (len: %d)", len(thinkContent))
|
||||
}
|
||||
|
||||
// Move past the closing tag
|
||||
remaining = remaining[endIdx+len(thinkingEndTag):]
|
||||
}
|
||||
|
||||
// If no blocks were created (all whitespace), return empty text block
|
||||
if len(blocks) == 0 {
|
||||
blocks = append(blocks, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": "",
|
||||
})
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
306
internal/translator/kiro/claude/kiro_claude_stream.go
Normal file
306
internal/translator/kiro/claude/kiro_claude_stream.go
Normal file
@@ -0,0 +1,306 @@
|
||||
// Package claude provides streaming SSE event building for Claude format.
|
||||
// This package handles the construction of Claude-compatible Server-Sent Events (SSE)
|
||||
// for streaming responses from Kiro API.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
||||
)
|
||||
|
||||
// BuildClaudeMessageStartEvent creates the message_start SSE event
|
||||
func BuildClaudeMessageStartEvent(model string, inputTokens int64) []byte {
|
||||
event := map[string]interface{}{
|
||||
"type": "message_start",
|
||||
"message": map[string]interface{}{
|
||||
"id": "msg_" + uuid.New().String()[:24],
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": []interface{}{},
|
||||
"model": model,
|
||||
"stop_reason": nil,
|
||||
"stop_sequence": nil,
|
||||
"usage": map[string]interface{}{"input_tokens": inputTokens, "output_tokens": 0},
|
||||
},
|
||||
}
|
||||
result, _ := json.Marshal(event)
|
||||
return []byte("event: message_start\ndata: " + string(result))
|
||||
}
|
||||
|
||||
// BuildClaudeContentBlockStartEvent creates a content_block_start SSE event
|
||||
func BuildClaudeContentBlockStartEvent(index int, blockType, toolUseID, toolName string) []byte {
|
||||
var contentBlock map[string]interface{}
|
||||
switch blockType {
|
||||
case "tool_use":
|
||||
contentBlock = map[string]interface{}{
|
||||
"type": "tool_use",
|
||||
"id": toolUseID,
|
||||
"name": toolName,
|
||||
"input": map[string]interface{}{},
|
||||
}
|
||||
case "thinking":
|
||||
contentBlock = map[string]interface{}{
|
||||
"type": "thinking",
|
||||
"thinking": "",
|
||||
}
|
||||
default:
|
||||
contentBlock = map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": "",
|
||||
}
|
||||
}
|
||||
|
||||
event := map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": contentBlock,
|
||||
}
|
||||
result, _ := json.Marshal(event)
|
||||
return []byte("event: content_block_start\ndata: " + string(result))
|
||||
}
|
||||
|
||||
// BuildClaudeStreamEvent creates a text_delta content_block_delta SSE event
|
||||
func BuildClaudeStreamEvent(contentDelta string, index int) []byte {
|
||||
event := map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": map[string]interface{}{
|
||||
"type": "text_delta",
|
||||
"text": contentDelta,
|
||||
},
|
||||
}
|
||||
result, _ := json.Marshal(event)
|
||||
return []byte("event: content_block_delta\ndata: " + string(result))
|
||||
}
|
||||
|
||||
// BuildClaudeInputJsonDeltaEvent creates an input_json_delta event for tool use streaming
|
||||
func BuildClaudeInputJsonDeltaEvent(partialJSON string, index int) []byte {
|
||||
event := map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": map[string]interface{}{
|
||||
"type": "input_json_delta",
|
||||
"partial_json": partialJSON,
|
||||
},
|
||||
}
|
||||
result, _ := json.Marshal(event)
|
||||
return []byte("event: content_block_delta\ndata: " + string(result))
|
||||
}
|
||||
|
||||
// BuildClaudeContentBlockStopEvent creates a content_block_stop SSE event
|
||||
func BuildClaudeContentBlockStopEvent(index int) []byte {
|
||||
event := map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": index,
|
||||
}
|
||||
result, _ := json.Marshal(event)
|
||||
return []byte("event: content_block_stop\ndata: " + string(result))
|
||||
}
|
||||
|
||||
// BuildClaudeThinkingBlockStopEvent creates a content_block_stop SSE event for thinking blocks.
|
||||
func BuildClaudeThinkingBlockStopEvent(index int) []byte {
|
||||
event := map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": index,
|
||||
}
|
||||
result, _ := json.Marshal(event)
|
||||
return []byte("event: content_block_stop\ndata: " + string(result))
|
||||
}
|
||||
|
||||
// BuildClaudeMessageDeltaEvent creates the message_delta event with stop_reason and usage
|
||||
func BuildClaudeMessageDeltaEvent(stopReason string, usageInfo usage.Detail) []byte {
|
||||
deltaEvent := map[string]interface{}{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]interface{}{
|
||||
"stop_reason": stopReason,
|
||||
"stop_sequence": nil,
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"input_tokens": usageInfo.InputTokens,
|
||||
"output_tokens": usageInfo.OutputTokens,
|
||||
},
|
||||
}
|
||||
deltaResult, _ := json.Marshal(deltaEvent)
|
||||
return []byte("event: message_delta\ndata: " + string(deltaResult))
|
||||
}
|
||||
|
||||
// BuildClaudeMessageStopOnlyEvent creates only the message_stop event
|
||||
func BuildClaudeMessageStopOnlyEvent() []byte {
|
||||
stopEvent := map[string]interface{}{
|
||||
"type": "message_stop",
|
||||
}
|
||||
stopResult, _ := json.Marshal(stopEvent)
|
||||
return []byte("event: message_stop\ndata: " + string(stopResult))
|
||||
}
|
||||
|
||||
// BuildClaudePingEventWithUsage creates a ping event with embedded usage information.
|
||||
// This is used for real-time usage estimation during streaming.
|
||||
func BuildClaudePingEventWithUsage(inputTokens, outputTokens int64) []byte {
|
||||
event := map[string]interface{}{
|
||||
"type": "ping",
|
||||
"usage": map[string]interface{}{
|
||||
"input_tokens": inputTokens,
|
||||
"output_tokens": outputTokens,
|
||||
"total_tokens": inputTokens + outputTokens,
|
||||
"estimated": true,
|
||||
},
|
||||
}
|
||||
result, _ := json.Marshal(event)
|
||||
return []byte("event: ping\ndata: " + string(result))
|
||||
}
|
||||
|
||||
// BuildClaudeThinkingDeltaEvent creates a thinking_delta event for Claude API compatibility.
|
||||
// This is used when streaming thinking content wrapped in <thinking> tags.
|
||||
func BuildClaudeThinkingDeltaEvent(thinkingDelta string, index int) []byte {
|
||||
event := map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": map[string]interface{}{
|
||||
"type": "thinking_delta",
|
||||
"thinking": thinkingDelta,
|
||||
},
|
||||
}
|
||||
result, _ := json.Marshal(event)
|
||||
return []byte("event: content_block_delta\ndata: " + string(result))
|
||||
}
|
||||
|
||||
// PendingTagSuffix detects if the buffer ends with a partial prefix of the given tag.
|
||||
// Returns the length of the partial match (0 if no match).
|
||||
// Based on amq2api implementation for handling cross-chunk tag boundaries.
|
||||
func PendingTagSuffix(buffer, tag string) int {
|
||||
if buffer == "" || tag == "" {
|
||||
return 0
|
||||
}
|
||||
maxLen := len(buffer)
|
||||
if maxLen > len(tag)-1 {
|
||||
maxLen = len(tag) - 1
|
||||
}
|
||||
for length := maxLen; length > 0; length-- {
|
||||
if len(buffer) >= length && buffer[len(buffer)-length:] == tag[:length] {
|
||||
return length
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GenerateSearchIndicatorEvents generates ONLY the search indicator SSE events
|
||||
// (server_tool_use + web_search_tool_result) without text summary or message termination.
|
||||
// These events trigger Claude Code's search indicator UI.
|
||||
// The caller is responsible for sending message_start before and message_delta/stop after.
|
||||
func GenerateSearchIndicatorEvents(
|
||||
query string,
|
||||
toolUseID string,
|
||||
searchResults *WebSearchResults,
|
||||
startIndex int,
|
||||
) [][]byte {
|
||||
events := make([][]byte, 0, 5)
|
||||
|
||||
// 1. content_block_start (server_tool_use)
|
||||
event1 := map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": startIndex,
|
||||
"content_block": map[string]interface{}{
|
||||
"id": toolUseID,
|
||||
"type": "server_tool_use",
|
||||
"name": "web_search",
|
||||
"input": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
data1, _ := json.Marshal(event1)
|
||||
events = append(events, []byte("event: content_block_start\ndata: "+string(data1)+"\n\n"))
|
||||
|
||||
// 2. content_block_delta (input_json_delta)
|
||||
inputJSON, _ := json.Marshal(map[string]string{"query": query})
|
||||
event2 := map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": startIndex,
|
||||
"delta": map[string]interface{}{
|
||||
"type": "input_json_delta",
|
||||
"partial_json": string(inputJSON),
|
||||
},
|
||||
}
|
||||
data2, _ := json.Marshal(event2)
|
||||
events = append(events, []byte("event: content_block_delta\ndata: "+string(data2)+"\n\n"))
|
||||
|
||||
// 3. content_block_stop (server_tool_use)
|
||||
event3 := map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": startIndex,
|
||||
}
|
||||
data3, _ := json.Marshal(event3)
|
||||
events = append(events, []byte("event: content_block_stop\ndata: "+string(data3)+"\n\n"))
|
||||
|
||||
// 4. content_block_start (web_search_tool_result)
|
||||
searchContent := make([]map[string]interface{}, 0)
|
||||
if searchResults != nil {
|
||||
for _, r := range searchResults.Results {
|
||||
snippet := ""
|
||||
if r.Snippet != nil {
|
||||
snippet = *r.Snippet
|
||||
}
|
||||
searchContent = append(searchContent, map[string]interface{}{
|
||||
"type": "web_search_result",
|
||||
"title": r.Title,
|
||||
"url": r.URL,
|
||||
"encrypted_content": snippet,
|
||||
"page_age": nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
event4 := map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": startIndex + 1,
|
||||
"content_block": map[string]interface{}{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": toolUseID,
|
||||
"content": searchContent,
|
||||
},
|
||||
}
|
||||
data4, _ := json.Marshal(event4)
|
||||
events = append(events, []byte("event: content_block_start\ndata: "+string(data4)+"\n\n"))
|
||||
|
||||
// 5. content_block_stop (web_search_tool_result)
|
||||
event5 := map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": startIndex + 1,
|
||||
}
|
||||
data5, _ := json.Marshal(event5)
|
||||
events = append(events, []byte("event: content_block_stop\ndata: "+string(data5)+"\n\n"))
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
// BuildFallbackTextEvents generates SSE events for a fallback text response
|
||||
// when the Kiro API fails during the search loop. Uses BuildClaude*Event()
|
||||
// functions to align with streamToChannel patterns.
|
||||
// Returns raw SSE byte slices ready to be sent to the client channel.
|
||||
func BuildFallbackTextEvents(contentBlockIndex int, query string, results *WebSearchResults) [][]byte {
|
||||
summary := FormatSearchContextPrompt(query, results)
|
||||
outputTokens := len(summary) / 4
|
||||
if len(summary) > 0 && outputTokens == 0 {
|
||||
outputTokens = 1
|
||||
}
|
||||
|
||||
var events [][]byte
|
||||
|
||||
// content_block_start (text)
|
||||
events = append(events, BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", ""))
|
||||
|
||||
// content_block_delta (text_delta)
|
||||
events = append(events, BuildClaudeStreamEvent(summary, contentBlockIndex))
|
||||
|
||||
// content_block_stop
|
||||
events = append(events, BuildClaudeContentBlockStopEvent(contentBlockIndex))
|
||||
|
||||
// message_delta with end_turn
|
||||
events = append(events, BuildClaudeMessageDeltaEvent("end_turn", usage.Detail{
|
||||
OutputTokens: int64(outputTokens),
|
||||
}))
|
||||
|
||||
// message_stop
|
||||
events = append(events, BuildClaudeMessageStopOnlyEvent())
|
||||
|
||||
return events
|
||||
}
|
||||
350
internal/translator/kiro/claude/kiro_claude_stream_parser.go
Normal file
350
internal/translator/kiro/claude/kiro_claude_stream_parser.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// sseEvent represents a Server-Sent Event
|
||||
type sseEvent struct {
|
||||
Event string
|
||||
Data interface{}
|
||||
}
|
||||
|
||||
// ToSSEString converts the event to SSE wire format
|
||||
func (e *sseEvent) ToSSEString() string {
|
||||
dataBytes, _ := json.Marshal(e.Data)
|
||||
return "event: " + e.Event + "\ndata: " + string(dataBytes) + "\n\n"
|
||||
}
|
||||
|
||||
// AdjustStreamIndices adjusts content block indices in SSE event data by adding an offset.
|
||||
// It also suppresses duplicate message_start events (returns shouldForward=false).
|
||||
// This is used to combine search indicator events (indices 0,1) with Kiro model response events.
|
||||
//
|
||||
// The data parameter is a single SSE "data:" line payload (JSON).
|
||||
// Returns: adjusted data, shouldForward (false = skip this event).
|
||||
func AdjustStreamIndices(data []byte, offset int) ([]byte, bool) {
|
||||
if len(data) == 0 {
|
||||
return data, true
|
||||
}
|
||||
|
||||
// Quick check: parse the JSON
|
||||
var event map[string]interface{}
|
||||
if err := json.Unmarshal(data, &event); err != nil {
|
||||
// Not valid JSON, pass through
|
||||
return data, true
|
||||
}
|
||||
|
||||
eventType, _ := event["type"].(string)
|
||||
|
||||
// Suppress duplicate message_start events
|
||||
if eventType == "message_start" {
|
||||
return data, false
|
||||
}
|
||||
|
||||
// Adjust index for content_block events
|
||||
switch eventType {
|
||||
case "content_block_start", "content_block_delta", "content_block_stop":
|
||||
if idx, ok := event["index"].(float64); ok {
|
||||
event["index"] = int(idx) + offset
|
||||
adjusted, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return data, true
|
||||
}
|
||||
return adjusted, true
|
||||
}
|
||||
}
|
||||
|
||||
// Pass through all other events unchanged (message_delta, message_stop, ping, etc.)
|
||||
return data, true
|
||||
}
|
||||
|
||||
// AdjustSSEChunk processes a raw SSE chunk (potentially containing multiple "event:/data:" pairs)
|
||||
// and adjusts content block indices. Suppresses duplicate message_start events.
|
||||
// Returns the adjusted chunk and whether it should be forwarded.
|
||||
func AdjustSSEChunk(chunk []byte, offset int) ([]byte, bool) {
|
||||
chunkStr := string(chunk)
|
||||
|
||||
// Fast path: if no "data:" prefix, pass through
|
||||
if !strings.Contains(chunkStr, "data: ") {
|
||||
return chunk, true
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
hasContent := false
|
||||
|
||||
lines := strings.Split(chunkStr, "\n")
|
||||
for i := 0; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
dataPayload := strings.TrimPrefix(line, "data: ")
|
||||
dataPayload = strings.TrimSpace(dataPayload)
|
||||
|
||||
if dataPayload == "[DONE]" {
|
||||
result.WriteString(line + "\n")
|
||||
hasContent = true
|
||||
continue
|
||||
}
|
||||
|
||||
adjusted, shouldForward := AdjustStreamIndices([]byte(dataPayload), offset)
|
||||
if !shouldForward {
|
||||
// Skip this event and its preceding "event:" line
|
||||
// Also skip the trailing empty line
|
||||
continue
|
||||
}
|
||||
|
||||
result.WriteString("data: " + string(adjusted) + "\n")
|
||||
hasContent = true
|
||||
} else if strings.HasPrefix(line, "event: ") {
|
||||
// Check if the next data line will be suppressed
|
||||
if i+1 < len(lines) && strings.HasPrefix(lines[i+1], "data: ") {
|
||||
dataPayload := strings.TrimPrefix(lines[i+1], "data: ")
|
||||
dataPayload = strings.TrimSpace(dataPayload)
|
||||
|
||||
var event map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(dataPayload), &event); err == nil {
|
||||
if eventType, ok := event["type"].(string); ok && eventType == "message_start" {
|
||||
// Skip both the event: and data: lines
|
||||
i++ // skip the data: line too
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
result.WriteString(line + "\n")
|
||||
hasContent = true
|
||||
} else {
|
||||
result.WriteString(line + "\n")
|
||||
if strings.TrimSpace(line) != "" {
|
||||
hasContent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasContent {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return []byte(result.String()), true
|
||||
}
|
||||
|
||||
// BufferedStreamResult contains the analysis of buffered SSE chunks from a Kiro API response.
|
||||
type BufferedStreamResult struct {
|
||||
// StopReason is the detected stop_reason from the stream (e.g., "end_turn", "tool_use")
|
||||
StopReason string
|
||||
// WebSearchQuery is the extracted query if the model requested another web_search
|
||||
WebSearchQuery string
|
||||
// WebSearchToolUseId is the tool_use ID from the model's response (needed for toolResults)
|
||||
WebSearchToolUseId string
|
||||
// HasWebSearchToolUse indicates whether the model requested web_search
|
||||
HasWebSearchToolUse bool
|
||||
// WebSearchToolUseIndex is the content_block index of the web_search tool_use
|
||||
WebSearchToolUseIndex int
|
||||
}
|
||||
|
||||
// AnalyzeBufferedStream scans buffered SSE chunks to detect stop_reason and web_search tool_use.
|
||||
// This is used in the search loop to determine if the model wants another search round.
|
||||
func AnalyzeBufferedStream(chunks [][]byte) BufferedStreamResult {
|
||||
result := BufferedStreamResult{WebSearchToolUseIndex: -1}
|
||||
|
||||
// Track tool use state across chunks
|
||||
var currentToolName string
|
||||
var currentToolIndex int = -1
|
||||
var toolInputBuilder strings.Builder
|
||||
|
||||
for _, chunk := range chunks {
|
||||
chunkStr := string(chunk)
|
||||
lines := strings.Split(chunkStr, "\n")
|
||||
for _, line := range lines {
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
dataPayload := strings.TrimPrefix(line, "data: ")
|
||||
dataPayload = strings.TrimSpace(dataPayload)
|
||||
if dataPayload == "[DONE]" || dataPayload == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var event map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(dataPayload), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
eventType, _ := event["type"].(string)
|
||||
|
||||
switch eventType {
|
||||
case "message_delta":
|
||||
// Extract stop_reason from message_delta
|
||||
if delta, ok := event["delta"].(map[string]interface{}); ok {
|
||||
if sr, ok := delta["stop_reason"].(string); ok && sr != "" {
|
||||
result.StopReason = sr
|
||||
}
|
||||
}
|
||||
|
||||
case "content_block_start":
|
||||
// Detect tool_use content blocks
|
||||
if cb, ok := event["content_block"].(map[string]interface{}); ok {
|
||||
if cbType, ok := cb["type"].(string); ok && cbType == "tool_use" {
|
||||
if name, ok := cb["name"].(string); ok {
|
||||
currentToolName = strings.ToLower(name)
|
||||
if idx, ok := event["index"].(float64); ok {
|
||||
currentToolIndex = int(idx)
|
||||
}
|
||||
// Capture tool use ID for toolResults handshake
|
||||
if id, ok := cb["id"].(string); ok {
|
||||
result.WebSearchToolUseId = id
|
||||
}
|
||||
toolInputBuilder.Reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "content_block_delta":
|
||||
// Accumulate tool input JSON
|
||||
if currentToolName != "" {
|
||||
if delta, ok := event["delta"].(map[string]interface{}); ok {
|
||||
if deltaType, ok := delta["type"].(string); ok && deltaType == "input_json_delta" {
|
||||
if partial, ok := delta["partial_json"].(string); ok {
|
||||
toolInputBuilder.WriteString(partial)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "content_block_stop":
|
||||
// Finalize tool use detection
|
||||
if currentToolName == "web_search" || currentToolName == "websearch" || currentToolName == "remote_web_search" {
|
||||
result.HasWebSearchToolUse = true
|
||||
result.WebSearchToolUseIndex = currentToolIndex
|
||||
// Extract query from accumulated input JSON
|
||||
inputJSON := toolInputBuilder.String()
|
||||
var input map[string]string
|
||||
if err := json.Unmarshal([]byte(inputJSON), &input); err == nil {
|
||||
if q, ok := input["query"]; ok {
|
||||
result.WebSearchQuery = q
|
||||
}
|
||||
}
|
||||
log.Debugf("kiro/websearch: detected web_search tool_use")
|
||||
}
|
||||
currentToolName = ""
|
||||
currentToolIndex = -1
|
||||
toolInputBuilder.Reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterChunksForClient processes buffered SSE chunks and removes web_search tool_use
|
||||
// content blocks. This prevents the client from seeing "Tool use" prompts for web_search
|
||||
// when the proxy is handling the search loop internally.
|
||||
// Also suppresses message_start and message_delta/message_stop events since those
|
||||
// are managed by the outer handleWebSearchStream.
|
||||
func FilterChunksForClient(chunks [][]byte, wsToolIndex int, indexOffset int) [][]byte {
|
||||
var filtered [][]byte
|
||||
|
||||
for _, chunk := range chunks {
|
||||
chunkStr := string(chunk)
|
||||
lines := strings.Split(chunkStr, "\n")
|
||||
|
||||
var resultBuilder strings.Builder
|
||||
hasContent := false
|
||||
|
||||
for i := 0; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
dataPayload := strings.TrimPrefix(line, "data: ")
|
||||
dataPayload = strings.TrimSpace(dataPayload)
|
||||
|
||||
if dataPayload == "[DONE]" {
|
||||
// Skip [DONE] — the outer loop manages stream termination
|
||||
continue
|
||||
}
|
||||
|
||||
var event map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(dataPayload), &event); err != nil {
|
||||
resultBuilder.WriteString(line + "\n")
|
||||
hasContent = true
|
||||
continue
|
||||
}
|
||||
|
||||
eventType, _ := event["type"].(string)
|
||||
|
||||
// Skip message_start (outer loop sends its own)
|
||||
if eventType == "message_start" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip message_delta and message_stop (outer loop manages these)
|
||||
if eventType == "message_delta" || eventType == "message_stop" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this event belongs to the web_search tool_use block
|
||||
if wsToolIndex >= 0 {
|
||||
if idx, ok := event["index"].(float64); ok && int(idx) == wsToolIndex {
|
||||
// Skip events for the web_search tool_use block
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Apply index offset for remaining events
|
||||
if indexOffset > 0 {
|
||||
switch eventType {
|
||||
case "content_block_start", "content_block_delta", "content_block_stop":
|
||||
if idx, ok := event["index"].(float64); ok {
|
||||
event["index"] = int(idx) + indexOffset
|
||||
adjusted, err := json.Marshal(event)
|
||||
if err == nil {
|
||||
resultBuilder.WriteString("data: " + string(adjusted) + "\n")
|
||||
hasContent = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resultBuilder.WriteString(line + "\n")
|
||||
hasContent = true
|
||||
} else if strings.HasPrefix(line, "event: ") {
|
||||
// Check if the next data line will be suppressed
|
||||
if i+1 < len(lines) && strings.HasPrefix(lines[i+1], "data: ") {
|
||||
nextData := strings.TrimPrefix(lines[i+1], "data: ")
|
||||
nextData = strings.TrimSpace(nextData)
|
||||
|
||||
var nextEvent map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(nextData), &nextEvent); err == nil {
|
||||
nextType, _ := nextEvent["type"].(string)
|
||||
if nextType == "message_start" || nextType == "message_delta" || nextType == "message_stop" {
|
||||
i++ // skip the data line
|
||||
continue
|
||||
}
|
||||
if wsToolIndex >= 0 {
|
||||
if idx, ok := nextEvent["index"].(float64); ok && int(idx) == wsToolIndex {
|
||||
i++ // skip the data line
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resultBuilder.WriteString(line + "\n")
|
||||
hasContent = true
|
||||
} else {
|
||||
resultBuilder.WriteString(line + "\n")
|
||||
if strings.TrimSpace(line) != "" {
|
||||
hasContent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasContent {
|
||||
filtered = append(filtered, []byte(resultBuilder.String()))
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
543
internal/translator/kiro/claude/kiro_claude_tools.go
Normal file
543
internal/translator/kiro/claude/kiro_claude_tools.go
Normal file
@@ -0,0 +1,543 @@
|
||||
// Package claude provides tool calling support for Kiro to Claude translation.
|
||||
// This package handles parsing embedded tool calls, JSON repair, and deduplication.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
kirocommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/common"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ToolUseState tracks the state of an in-progress tool use during streaming.
|
||||
type ToolUseState struct {
|
||||
ToolUseID string
|
||||
Name string
|
||||
InputBuffer strings.Builder
|
||||
IsComplete bool
|
||||
TruncationInfo *TruncationInfo // Truncation detection result (set when complete)
|
||||
}
|
||||
|
||||
// Pre-compiled regex patterns for performance
|
||||
var (
|
||||
// embeddedToolCallPattern matches [Called tool_name with args: {...}] format
|
||||
embeddedToolCallPattern = regexp.MustCompile(`\[Called\s+([A-Za-z0-9_.-]+)\s+with\s+args:\s*`)
|
||||
// trailingCommaPattern matches trailing commas before closing braces/brackets
|
||||
trailingCommaPattern = regexp.MustCompile(`,\s*([}\]])`)
|
||||
)
|
||||
|
||||
// ParseEmbeddedToolCalls extracts [Called tool_name with args: {...}] format from text.
|
||||
// Kiro sometimes embeds tool calls in text content instead of using toolUseEvent.
|
||||
// Returns the cleaned text (with tool calls removed) and extracted tool uses.
|
||||
func ParseEmbeddedToolCalls(text string, processedIDs map[string]bool) (string, []KiroToolUse) {
|
||||
if !strings.Contains(text, "[Called") {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
var toolUses []KiroToolUse
|
||||
cleanText := text
|
||||
|
||||
// Find all [Called markers
|
||||
matches := embeddedToolCallPattern.FindAllStringSubmatchIndex(text, -1)
|
||||
if len(matches) == 0 {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// Process matches in reverse order to maintain correct indices
|
||||
for i := len(matches) - 1; i >= 0; i-- {
|
||||
matchStart := matches[i][0]
|
||||
toolNameStart := matches[i][2]
|
||||
toolNameEnd := matches[i][3]
|
||||
|
||||
if toolNameStart < 0 || toolNameEnd < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
toolName := text[toolNameStart:toolNameEnd]
|
||||
|
||||
// Find the JSON object start (after "with args:")
|
||||
jsonStart := matches[i][1]
|
||||
if jsonStart >= len(text) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip whitespace to find the opening brace
|
||||
for jsonStart < len(text) && (text[jsonStart] == ' ' || text[jsonStart] == '\t') {
|
||||
jsonStart++
|
||||
}
|
||||
|
||||
if jsonStart >= len(text) || text[jsonStart] != '{' {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find matching closing bracket
|
||||
jsonEnd := findMatchingBracket(text, jsonStart)
|
||||
if jsonEnd < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract JSON and find the closing bracket of [Called ...]
|
||||
jsonStr := text[jsonStart : jsonEnd+1]
|
||||
|
||||
// Find the closing ] after the JSON
|
||||
closingBracket := jsonEnd + 1
|
||||
for closingBracket < len(text) && text[closingBracket] != ']' {
|
||||
closingBracket++
|
||||
}
|
||||
if closingBracket >= len(text) {
|
||||
continue
|
||||
}
|
||||
|
||||
// End index of the full tool call (closing ']' inclusive)
|
||||
matchEnd := closingBracket + 1
|
||||
|
||||
// Repair and parse JSON
|
||||
repairedJSON := RepairJSON(jsonStr)
|
||||
var inputMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(repairedJSON), &inputMap); err != nil {
|
||||
log.Debugf("kiro: failed to parse embedded tool call JSON: %v, raw: %s", err, jsonStr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate unique tool ID
|
||||
toolUseID := "toolu_" + uuid.New().String()[:12]
|
||||
|
||||
// Check for duplicates using name+input as key
|
||||
dedupeKey := toolName + ":" + repairedJSON
|
||||
if processedIDs != nil {
|
||||
if processedIDs[dedupeKey] {
|
||||
log.Debugf("kiro: skipping duplicate embedded tool call: %s", toolName)
|
||||
// Still remove from text even if duplicate
|
||||
if matchStart >= 0 && matchEnd <= len(cleanText) && matchStart <= matchEnd {
|
||||
cleanText = cleanText[:matchStart] + cleanText[matchEnd:]
|
||||
}
|
||||
continue
|
||||
}
|
||||
processedIDs[dedupeKey] = true
|
||||
}
|
||||
|
||||
toolUses = append(toolUses, KiroToolUse{
|
||||
ToolUseID: toolUseID,
|
||||
Name: toolName,
|
||||
Input: inputMap,
|
||||
})
|
||||
|
||||
log.Infof("kiro: extracted embedded tool call: %s (ID: %s)", toolName, toolUseID)
|
||||
|
||||
// Remove from clean text (index-based removal to avoid deleting the wrong occurrence)
|
||||
if matchStart >= 0 && matchEnd <= len(cleanText) && matchStart <= matchEnd {
|
||||
cleanText = cleanText[:matchStart] + cleanText[matchEnd:]
|
||||
}
|
||||
}
|
||||
|
||||
return cleanText, toolUses
|
||||
}
|
||||
|
||||
// findMatchingBracket finds the index of the closing brace/bracket that matches
|
||||
// the opening one at startPos. Handles nested objects and strings correctly.
|
||||
func findMatchingBracket(text string, startPos int) int {
|
||||
if startPos >= len(text) {
|
||||
return -1
|
||||
}
|
||||
|
||||
openChar := text[startPos]
|
||||
var closeChar byte
|
||||
switch openChar {
|
||||
case '{':
|
||||
closeChar = '}'
|
||||
case '[':
|
||||
closeChar = ']'
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
|
||||
depth := 1
|
||||
inString := false
|
||||
escapeNext := false
|
||||
|
||||
for i := startPos + 1; i < len(text); i++ {
|
||||
char := text[i]
|
||||
|
||||
if escapeNext {
|
||||
escapeNext = false
|
||||
continue
|
||||
}
|
||||
|
||||
if char == '\\' && inString {
|
||||
escapeNext = true
|
||||
continue
|
||||
}
|
||||
|
||||
if char == '"' {
|
||||
inString = !inString
|
||||
continue
|
||||
}
|
||||
|
||||
if !inString {
|
||||
if char == openChar {
|
||||
depth++
|
||||
} else if char == closeChar {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
// RepairJSON attempts to fix common JSON issues that may occur in tool call arguments.
|
||||
// Conservative repair strategy:
|
||||
// 1. First try to parse JSON directly - if valid, return as-is
|
||||
// 2. Only attempt repair if parsing fails
|
||||
// 3. After repair, validate the result - if still invalid, return original
|
||||
func RepairJSON(jsonString string) string {
|
||||
// Handle empty or invalid input
|
||||
if jsonString == "" {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
str := strings.TrimSpace(jsonString)
|
||||
if str == "" {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
// CONSERVATIVE STRATEGY: First try to parse directly
|
||||
var testParse interface{}
|
||||
if err := json.Unmarshal([]byte(str), &testParse); err == nil {
|
||||
log.Debugf("kiro: repairJSON - JSON is already valid, returning unchanged")
|
||||
return str
|
||||
}
|
||||
|
||||
log.Debugf("kiro: repairJSON - JSON parse failed, attempting repair")
|
||||
originalStr := str
|
||||
|
||||
// First, escape unescaped newlines/tabs within JSON string values
|
||||
str = escapeNewlinesInStrings(str)
|
||||
// Remove trailing commas before closing braces/brackets
|
||||
str = trailingCommaPattern.ReplaceAllString(str, "$1")
|
||||
|
||||
// Calculate bracket balance
|
||||
braceCount := 0
|
||||
bracketCount := 0
|
||||
inString := false
|
||||
escape := false
|
||||
lastValidIndex := -1
|
||||
|
||||
for i := 0; i < len(str); i++ {
|
||||
char := str[i]
|
||||
|
||||
if escape {
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
|
||||
if char == '\\' {
|
||||
escape = true
|
||||
continue
|
||||
}
|
||||
|
||||
if char == '"' {
|
||||
inString = !inString
|
||||
continue
|
||||
}
|
||||
|
||||
if inString {
|
||||
continue
|
||||
}
|
||||
|
||||
switch char {
|
||||
case '{':
|
||||
braceCount++
|
||||
case '}':
|
||||
braceCount--
|
||||
case '[':
|
||||
bracketCount++
|
||||
case ']':
|
||||
bracketCount--
|
||||
}
|
||||
|
||||
if braceCount >= 0 && bracketCount >= 0 {
|
||||
lastValidIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
// If brackets are unbalanced, try to repair
|
||||
if braceCount > 0 || bracketCount > 0 {
|
||||
if lastValidIndex > 0 && lastValidIndex < len(str)-1 {
|
||||
truncated := str[:lastValidIndex+1]
|
||||
// Recount brackets after truncation
|
||||
braceCount = 0
|
||||
bracketCount = 0
|
||||
inString = false
|
||||
escape = false
|
||||
for i := 0; i < len(truncated); i++ {
|
||||
char := truncated[i]
|
||||
if escape {
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
if char == '\\' {
|
||||
escape = true
|
||||
continue
|
||||
}
|
||||
if char == '"' {
|
||||
inString = !inString
|
||||
continue
|
||||
}
|
||||
if inString {
|
||||
continue
|
||||
}
|
||||
switch char {
|
||||
case '{':
|
||||
braceCount++
|
||||
case '}':
|
||||
braceCount--
|
||||
case '[':
|
||||
bracketCount++
|
||||
case ']':
|
||||
bracketCount--
|
||||
}
|
||||
}
|
||||
str = truncated
|
||||
}
|
||||
|
||||
// Add missing closing brackets
|
||||
for braceCount > 0 {
|
||||
str += "}"
|
||||
braceCount--
|
||||
}
|
||||
for bracketCount > 0 {
|
||||
str += "]"
|
||||
bracketCount--
|
||||
}
|
||||
}
|
||||
|
||||
// Validate repaired JSON
|
||||
if err := json.Unmarshal([]byte(str), &testParse); err != nil {
|
||||
log.Warnf("kiro: repairJSON - repair failed to produce valid JSON, returning original")
|
||||
return originalStr
|
||||
}
|
||||
|
||||
log.Debugf("kiro: repairJSON - successfully repaired JSON")
|
||||
return str
|
||||
}
|
||||
|
||||
// escapeNewlinesInStrings escapes literal newlines, tabs, and other control characters
|
||||
// that appear inside JSON string values.
|
||||
func escapeNewlinesInStrings(raw string) string {
|
||||
var result strings.Builder
|
||||
result.Grow(len(raw) + 100)
|
||||
|
||||
inString := false
|
||||
escaped := false
|
||||
|
||||
for i := 0; i < len(raw); i++ {
|
||||
c := raw[i]
|
||||
|
||||
if escaped {
|
||||
result.WriteByte(c)
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
|
||||
if c == '\\' && inString {
|
||||
result.WriteByte(c)
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
|
||||
if c == '"' {
|
||||
inString = !inString
|
||||
result.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
|
||||
if inString {
|
||||
switch c {
|
||||
case '\n':
|
||||
result.WriteString("\\n")
|
||||
case '\r':
|
||||
result.WriteString("\\r")
|
||||
case '\t':
|
||||
result.WriteString("\\t")
|
||||
default:
|
||||
result.WriteByte(c)
|
||||
}
|
||||
} else {
|
||||
result.WriteByte(c)
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// ProcessToolUseEvent handles a toolUseEvent from the Kiro stream.
|
||||
// It accumulates input fragments and emits tool_use blocks when complete.
|
||||
// Returns events to emit and updated state.
|
||||
func ProcessToolUseEvent(event map[string]interface{}, currentToolUse *ToolUseState, processedIDs map[string]bool) ([]KiroToolUse, *ToolUseState) {
|
||||
var toolUses []KiroToolUse
|
||||
|
||||
// Extract from nested toolUseEvent or direct format
|
||||
tu := event
|
||||
if nested, ok := event["toolUseEvent"].(map[string]interface{}); ok {
|
||||
tu = nested
|
||||
}
|
||||
|
||||
toolUseID := kirocommon.GetString(tu, "toolUseId")
|
||||
toolName := kirocommon.GetString(tu, "name")
|
||||
isStop := false
|
||||
if stop, ok := tu["stop"].(bool); ok {
|
||||
isStop = stop
|
||||
}
|
||||
|
||||
// Get input - can be string (fragment) or object (complete)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// New tool use starting
|
||||
if toolUseID != "" && toolName != "" {
|
||||
if currentToolUse != nil && currentToolUse.ToolUseID != toolUseID {
|
||||
log.Warnf("kiro: interleaved tool use detected - new ID %s arrived while %s in progress, completing previous",
|
||||
toolUseID, currentToolUse.ToolUseID)
|
||||
if !processedIDs[currentToolUse.ToolUseID] {
|
||||
incomplete := KiroToolUse{
|
||||
ToolUseID: currentToolUse.ToolUseID,
|
||||
Name: currentToolUse.Name,
|
||||
}
|
||||
if currentToolUse.InputBuffer.Len() > 0 {
|
||||
raw := currentToolUse.InputBuffer.String()
|
||||
repaired := RepairJSON(raw)
|
||||
|
||||
var input map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(repaired), &input); err != nil {
|
||||
log.Warnf("kiro: failed to parse interleaved tool input: %v, raw: %s", err, raw)
|
||||
input = make(map[string]interface{})
|
||||
}
|
||||
incomplete.Input = input
|
||||
}
|
||||
toolUses = append(toolUses, incomplete)
|
||||
processedIDs[currentToolUse.ToolUseID] = true
|
||||
}
|
||||
currentToolUse = nil
|
||||
}
|
||||
|
||||
if currentToolUse == nil {
|
||||
if processedIDs != nil && processedIDs[toolUseID] {
|
||||
log.Debugf("kiro: skipping duplicate toolUseEvent: %s", toolUseID)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
currentToolUse = &ToolUseState{
|
||||
ToolUseID: toolUseID,
|
||||
Name: toolName,
|
||||
}
|
||||
log.Infof("kiro: starting new tool use: %s (ID: %s)", toolName, toolUseID)
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate input fragments
|
||||
if currentToolUse != nil && inputFragment != "" {
|
||||
currentToolUse.InputBuffer.WriteString(inputFragment)
|
||||
log.Debugf("kiro: accumulated input fragment, total length: %d", currentToolUse.InputBuffer.Len())
|
||||
}
|
||||
|
||||
// If complete input object provided directly
|
||||
if currentToolUse != nil && inputMap != nil {
|
||||
inputBytes, _ := json.Marshal(inputMap)
|
||||
currentToolUse.InputBuffer.Reset()
|
||||
currentToolUse.InputBuffer.Write(inputBytes)
|
||||
}
|
||||
|
||||
// Tool use complete
|
||||
if isStop && currentToolUse != nil {
|
||||
fullInput := currentToolUse.InputBuffer.String()
|
||||
|
||||
// Repair and parse the accumulated JSON
|
||||
repairedJSON := RepairJSON(fullInput)
|
||||
var finalInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(repairedJSON), &finalInput); err != nil {
|
||||
log.Warnf("kiro: failed to parse accumulated tool input: %v, raw: %s", err, fullInput)
|
||||
finalInput = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// Detect truncation for all tools
|
||||
truncInfo := DetectTruncation(currentToolUse.Name, currentToolUse.ToolUseID, fullInput, finalInput)
|
||||
if truncInfo.IsTruncated {
|
||||
log.Warnf("kiro: TRUNCATION DETECTED for tool %s (ID: %s): type=%s, raw_size=%d bytes",
|
||||
currentToolUse.Name, currentToolUse.ToolUseID, truncInfo.TruncationType, len(fullInput))
|
||||
log.Warnf("kiro: truncation details: %s", truncInfo.ErrorMessage)
|
||||
if len(truncInfo.ParsedFields) > 0 {
|
||||
log.Infof("kiro: partial fields received: %v", truncInfo.ParsedFields)
|
||||
}
|
||||
// Store truncation info in the state for upstream handling
|
||||
currentToolUse.TruncationInfo = &truncInfo
|
||||
} else {
|
||||
log.Infof("kiro: tool use %s input length: %d bytes (no truncation)", currentToolUse.Name, len(fullInput))
|
||||
}
|
||||
|
||||
// Create the tool use with truncation info if applicable
|
||||
toolUse := KiroToolUse{
|
||||
ToolUseID: currentToolUse.ToolUseID,
|
||||
Name: currentToolUse.Name,
|
||||
Input: finalInput,
|
||||
IsTruncated: truncInfo.IsTruncated,
|
||||
TruncationInfo: nil, // Will be set below if truncated
|
||||
}
|
||||
if truncInfo.IsTruncated {
|
||||
toolUse.TruncationInfo = &truncInfo
|
||||
}
|
||||
toolUses = append(toolUses, toolUse)
|
||||
|
||||
if processedIDs != nil {
|
||||
processedIDs[currentToolUse.ToolUseID] = true
|
||||
}
|
||||
|
||||
log.Infof("kiro: completed tool use: %s (ID: %s, truncated: %v)", currentToolUse.Name, currentToolUse.ToolUseID, truncInfo.IsTruncated)
|
||||
return toolUses, nil
|
||||
}
|
||||
|
||||
return toolUses, currentToolUse
|
||||
}
|
||||
|
||||
// DeduplicateToolUses removes duplicate tool uses based on toolUseId and content.
|
||||
func DeduplicateToolUses(toolUses []KiroToolUse) []KiroToolUse {
|
||||
seenIDs := make(map[string]bool)
|
||||
seenContent := make(map[string]bool)
|
||||
var unique []KiroToolUse
|
||||
|
||||
for _, tu := range toolUses {
|
||||
if seenIDs[tu.ToolUseID] {
|
||||
log.Debugf("kiro: removing ID-duplicate tool use: %s (name: %s)", tu.ToolUseID, tu.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
inputJSON, _ := json.Marshal(tu.Input)
|
||||
contentKey := tu.Name + ":" + string(inputJSON)
|
||||
|
||||
if seenContent[contentKey] {
|
||||
log.Debugf("kiro: removing content-duplicate tool use: %s (id: %s)", tu.Name, tu.ToolUseID)
|
||||
continue
|
||||
}
|
||||
|
||||
seenIDs[tu.ToolUseID] = true
|
||||
seenContent[contentKey] = true
|
||||
unique = append(unique, tu)
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
495
internal/translator/kiro/claude/kiro_websearch.go
Normal file
495
internal/translator/kiro/claude/kiro_websearch.go
Normal file
@@ -0,0 +1,495 @@
|
||||
// Package claude provides web search functionality for Kiro translator.
|
||||
// This file implements detection, MCP request/response types, and pure data
|
||||
// transformation utilities for web search. SSE event generation, stream analysis,
|
||||
// and HTTP I/O logic reside in the executor package (kiro_executor.go).
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// cachedToolDescription stores the dynamically-fetched web_search tool description.
|
||||
// Written by the executor via SetWebSearchDescription, read by the translator
|
||||
// when building the remote_web_search tool for Kiro API requests.
|
||||
var cachedToolDescription atomic.Value // stores string
|
||||
|
||||
// GetWebSearchDescription returns the cached web_search tool description,
|
||||
// or empty string if not yet fetched. Lock-free via atomic.Value.
|
||||
func GetWebSearchDescription() string {
|
||||
if v := cachedToolDescription.Load(); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetWebSearchDescription stores the dynamically-fetched web_search tool description.
|
||||
// Called by the executor after fetching from MCP tools/list.
|
||||
func SetWebSearchDescription(desc string) {
|
||||
cachedToolDescription.Store(desc)
|
||||
}
|
||||
|
||||
// McpRequest represents a JSON-RPC 2.0 request to Kiro MCP API
|
||||
type McpRequest struct {
|
||||
ID string `json:"id"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params McpParams `json:"params"`
|
||||
}
|
||||
|
||||
// McpParams represents MCP request parameters
|
||||
type McpParams struct {
|
||||
Name string `json:"name"`
|
||||
Arguments McpArguments `json:"arguments"`
|
||||
}
|
||||
|
||||
// McpArgumentsMeta represents the _meta field in MCP arguments
|
||||
type McpArgumentsMeta struct {
|
||||
IsValid bool `json:"_isValid"`
|
||||
ActivePath []string `json:"_activePath"`
|
||||
CompletedPaths [][]string `json:"_completedPaths"`
|
||||
}
|
||||
|
||||
// McpArguments represents MCP request arguments
|
||||
type McpArguments struct {
|
||||
Query string `json:"query"`
|
||||
Meta *McpArgumentsMeta `json:"_meta,omitempty"`
|
||||
}
|
||||
|
||||
// McpResponse represents a JSON-RPC 2.0 response from Kiro MCP API
|
||||
type McpResponse struct {
|
||||
Error *McpError `json:"error,omitempty"`
|
||||
ID string `json:"id"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Result *McpResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// McpError represents an MCP error
|
||||
type McpError struct {
|
||||
Code *int `json:"code,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// McpResult represents MCP result
|
||||
type McpResult struct {
|
||||
Content []McpContent `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
|
||||
// McpContent represents MCP content item
|
||||
type McpContent struct {
|
||||
ContentType string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// WebSearchResults represents parsed search results
|
||||
type WebSearchResults struct {
|
||||
Results []WebSearchResult `json:"results"`
|
||||
TotalResults *int `json:"totalResults,omitempty"`
|
||||
Query *string `json:"query,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WebSearchResult represents a single search result
|
||||
type WebSearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Snippet *string `json:"snippet,omitempty"`
|
||||
PublishedDate *int64 `json:"publishedDate,omitempty"`
|
||||
ID *string `json:"id,omitempty"`
|
||||
Domain *string `json:"domain,omitempty"`
|
||||
MaxVerbatimWordLimit *int `json:"maxVerbatimWordLimit,omitempty"`
|
||||
PublicDomain *bool `json:"publicDomain,omitempty"`
|
||||
}
|
||||
|
||||
// isWebSearchTool checks if a tool name or type indicates a web_search tool.
|
||||
func isWebSearchTool(name, toolType string) bool {
|
||||
return name == "web_search" ||
|
||||
strings.HasPrefix(toolType, "web_search") ||
|
||||
toolType == "web_search_20250305"
|
||||
}
|
||||
|
||||
// HasWebSearchTool checks if the request contains ONLY a web_search tool.
|
||||
// Returns true only if tools array has exactly one tool named "web_search".
|
||||
// Only intercept pure web_search requests (single-tool array).
|
||||
func HasWebSearchTool(body []byte) bool {
|
||||
tools := gjson.GetBytes(body, "tools")
|
||||
if !tools.IsArray() {
|
||||
return false
|
||||
}
|
||||
|
||||
toolsArray := tools.Array()
|
||||
if len(toolsArray) != 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the single tool is web_search
|
||||
tool := toolsArray[0]
|
||||
|
||||
// Check both name and type fields for web_search detection
|
||||
name := strings.ToLower(tool.Get("name").String())
|
||||
toolType := strings.ToLower(tool.Get("type").String())
|
||||
|
||||
return isWebSearchTool(name, toolType)
|
||||
}
|
||||
|
||||
// ExtractSearchQuery extracts the search query from the request.
|
||||
// Reads messages[0].content and removes "Perform a web search for the query: " prefix.
|
||||
func ExtractSearchQuery(body []byte) string {
|
||||
messages := gjson.GetBytes(body, "messages")
|
||||
if !messages.IsArray() || len(messages.Array()) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
firstMsg := messages.Array()[0]
|
||||
content := firstMsg.Get("content")
|
||||
|
||||
var text string
|
||||
if content.IsArray() {
|
||||
// Array format: [{"type": "text", "text": "..."}]
|
||||
for _, block := range content.Array() {
|
||||
if block.Get("type").String() == "text" {
|
||||
text = block.Get("text").String()
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// String format
|
||||
text = content.String()
|
||||
}
|
||||
|
||||
// Remove prefix "Perform a web search for the query: "
|
||||
const prefix = "Perform a web search for the query: "
|
||||
if strings.HasPrefix(text, prefix) {
|
||||
text = text[len(prefix):]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// generateRandomID8 generates an 8-character random lowercase alphanumeric string
|
||||
func generateRandomID8() string {
|
||||
u := uuid.New()
|
||||
return strings.ToLower(strings.ReplaceAll(u.String(), "-", "")[:8])
|
||||
}
|
||||
|
||||
// CreateMcpRequest creates an MCP request for web search.
|
||||
// Returns (toolUseID, McpRequest)
|
||||
// ID format: web_search_tooluse_{22 random}_{timestamp_millis}_{8 random}
|
||||
func CreateMcpRequest(query string) (string, *McpRequest) {
|
||||
random22 := GenerateToolUseID()
|
||||
timestamp := time.Now().UnixMilli()
|
||||
random8 := generateRandomID8()
|
||||
|
||||
requestID := fmt.Sprintf("web_search_tooluse_%s_%d_%s", random22, timestamp, random8)
|
||||
|
||||
// tool_use_id format: srvtoolu_{32 hex chars}
|
||||
toolUseID := "srvtoolu_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:32]
|
||||
|
||||
request := &McpRequest{
|
||||
ID: requestID,
|
||||
JSONRPC: "2.0",
|
||||
Method: "tools/call",
|
||||
Params: McpParams{
|
||||
Name: "web_search",
|
||||
Arguments: McpArguments{
|
||||
Query: query,
|
||||
Meta: &McpArgumentsMeta{
|
||||
IsValid: true,
|
||||
ActivePath: []string{"query"},
|
||||
CompletedPaths: [][]string{{"query"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return toolUseID, request
|
||||
}
|
||||
|
||||
// GenerateToolUseID generates a Kiro-style tool use ID (base62-like UUID)
|
||||
func GenerateToolUseID() string {
|
||||
return strings.ReplaceAll(uuid.New().String(), "-", "")[:22]
|
||||
}
|
||||
|
||||
// ReplaceWebSearchToolDescription replaces the web_search tool description with
|
||||
// a minimal version that allows re-search without the restrictive "do not search
|
||||
// non-coding topics" instruction from the original Kiro tools/list response.
|
||||
// This keeps the tool available so the model can request additional searches.
|
||||
func ReplaceWebSearchToolDescription(body []byte) ([]byte, error) {
|
||||
tools := gjson.GetBytes(body, "tools")
|
||||
if !tools.IsArray() {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
var updated []json.RawMessage
|
||||
for _, tool := range tools.Array() {
|
||||
name := strings.ToLower(tool.Get("name").String())
|
||||
toolType := strings.ToLower(tool.Get("type").String())
|
||||
|
||||
if isWebSearchTool(name, toolType) {
|
||||
// Replace with a minimal web_search tool definition
|
||||
minimalTool := map[string]interface{}{
|
||||
"name": "web_search",
|
||||
"description": "Search the web for information. Use this when the previous search results are insufficient or when you need additional information on a different aspect of the query. Provide a refined or different search query.",
|
||||
"input_schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"query": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The search query to execute",
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
}
|
||||
minimalJSON, err := json.Marshal(minimalTool)
|
||||
if err != nil {
|
||||
return body, fmt.Errorf("failed to marshal minimal tool: %w", err)
|
||||
}
|
||||
updated = append(updated, json.RawMessage(minimalJSON))
|
||||
} else {
|
||||
updated = append(updated, json.RawMessage(tool.Raw))
|
||||
}
|
||||
}
|
||||
|
||||
updatedJSON, err := json.Marshal(updated)
|
||||
if err != nil {
|
||||
return body, fmt.Errorf("failed to marshal updated tools: %w", err)
|
||||
}
|
||||
result, err := sjson.SetRawBytes(body, "tools", updatedJSON)
|
||||
if err != nil {
|
||||
return body, fmt.Errorf("failed to set updated tools: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FormatSearchContextPrompt formats search results as a structured text block
|
||||
// for injection into the system prompt.
|
||||
func FormatSearchContextPrompt(query string, results *WebSearchResults) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("[Web Search Results for \"%s\"]\n", query))
|
||||
|
||||
if results != nil && len(results.Results) > 0 {
|
||||
for i, r := range results.Results {
|
||||
sb.WriteString(fmt.Sprintf("%d. %s - %s\n", i+1, r.Title, r.URL))
|
||||
if r.Snippet != nil && *r.Snippet != "" {
|
||||
snippet := *r.Snippet
|
||||
if len(snippet) > 500 {
|
||||
snippet = snippet[:500] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", snippet))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("No results found.\n")
|
||||
}
|
||||
|
||||
sb.WriteString("[End Web Search Results]")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// FormatToolResultText formats search results as JSON text for the toolResults content field.
|
||||
// This matches the format observed in Kiro IDE HAR captures.
|
||||
func FormatToolResultText(results *WebSearchResults) string {
|
||||
if results == nil || len(results.Results) == 0 {
|
||||
return "No search results found."
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("Found %d search result(s):\n\n", len(results.Results))
|
||||
resultJSON, err := json.MarshalIndent(results.Results, "", " ")
|
||||
if err != nil {
|
||||
return text + "Error formatting results."
|
||||
}
|
||||
return text + string(resultJSON)
|
||||
}
|
||||
|
||||
// InjectToolResultsClaude modifies a Claude-format JSON payload to append
|
||||
// tool_use (assistant) and tool_result (user) messages to the messages array.
|
||||
// BuildKiroPayload correctly translates:
|
||||
// - assistant tool_use → KiroAssistantResponseMessage.toolUses
|
||||
// - user tool_result → KiroUserInputMessageContext.toolResults
|
||||
//
|
||||
// This produces the exact same GAR request format as the Kiro IDE (HAR captures).
|
||||
// IMPORTANT: The web_search tool must remain in the "tools" array for this to work.
|
||||
// Use ReplaceWebSearchToolDescription to keep the tool available with a minimal description.
|
||||
func InjectToolResultsClaude(claudePayload []byte, toolUseId, query string, results *WebSearchResults) ([]byte, error) {
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(claudePayload, &payload); err != nil {
|
||||
return claudePayload, fmt.Errorf("failed to parse claude payload: %w", err)
|
||||
}
|
||||
|
||||
messages, _ := payload["messages"].([]interface{})
|
||||
|
||||
// 1. Append assistant message with tool_use (matches HAR: assistantResponseMessage.toolUses)
|
||||
assistantMsg := map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"content": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "tool_use",
|
||||
"id": toolUseId,
|
||||
"name": "web_search",
|
||||
"input": map[string]interface{}{"query": query},
|
||||
},
|
||||
},
|
||||
}
|
||||
messages = append(messages, assistantMsg)
|
||||
|
||||
// 2. Append user message with tool_result + search behavior instructions.
|
||||
// NOTE: We embed search instructions HERE (not in system prompt) because
|
||||
// BuildKiroPayload clears the system prompt when len(history) > 0,
|
||||
// which is always true after injecting assistant + user messages.
|
||||
now := time.Now()
|
||||
searchGuidance := fmt.Sprintf(`<search_guidance>
|
||||
Current date: %s (%s)
|
||||
|
||||
IMPORTANT: Evaluate the search results above carefully. If the results are:
|
||||
- Mostly spam, SEO junk, or unrelated websites
|
||||
- Missing actual information about the query topic
|
||||
- Outdated or not matching the requested time frame
|
||||
|
||||
Then you MUST use the web_search tool again with a refined query. Try:
|
||||
- Rephrasing in English for better coverage
|
||||
- Using more specific keywords
|
||||
- Adding date context
|
||||
|
||||
Do NOT apologize for bad results without first attempting a re-search.
|
||||
</search_guidance>`, now.Format("January 2, 2006"), now.Format("Monday"))
|
||||
|
||||
userMsg := map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": toolUseId,
|
||||
"content": FormatToolResultText(results),
|
||||
},
|
||||
map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": searchGuidance,
|
||||
},
|
||||
},
|
||||
}
|
||||
messages = append(messages, userMsg)
|
||||
|
||||
payload["messages"] = messages
|
||||
|
||||
result, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return claudePayload, fmt.Errorf("failed to marshal updated payload: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("kiro/websearch: injected tool_use+tool_result (toolUseId=%s, messages=%d)",
|
||||
toolUseId, len(messages))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// InjectSearchIndicatorsInResponse prepends server_tool_use + web_search_tool_result
|
||||
// content blocks into a non-streaming Claude JSON response. Claude Code counts
|
||||
// server_tool_use blocks to display "Did X searches in Ys".
|
||||
//
|
||||
// Input response: {"content": [{"type":"text","text":"..."}], ...}
|
||||
// Output response: {"content": [{"type":"server_tool_use",...}, {"type":"web_search_tool_result",...}, {"type":"text","text":"..."}], ...}
|
||||
func InjectSearchIndicatorsInResponse(responsePayload []byte, searches []SearchIndicator) ([]byte, error) {
|
||||
if len(searches) == 0 {
|
||||
return responsePayload, nil
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(responsePayload, &resp); err != nil {
|
||||
return responsePayload, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
existingContent, _ := resp["content"].([]interface{})
|
||||
|
||||
// Build new content: search indicators first, then existing content
|
||||
newContent := make([]interface{}, 0, len(searches)*2+len(existingContent))
|
||||
|
||||
for _, s := range searches {
|
||||
// server_tool_use block
|
||||
newContent = append(newContent, map[string]interface{}{
|
||||
"type": "server_tool_use",
|
||||
"id": s.ToolUseID,
|
||||
"name": "web_search",
|
||||
"input": map[string]interface{}{"query": s.Query},
|
||||
})
|
||||
|
||||
// web_search_tool_result block
|
||||
searchContent := make([]map[string]interface{}, 0)
|
||||
if s.Results != nil {
|
||||
for _, r := range s.Results.Results {
|
||||
snippet := ""
|
||||
if r.Snippet != nil {
|
||||
snippet = *r.Snippet
|
||||
}
|
||||
searchContent = append(searchContent, map[string]interface{}{
|
||||
"type": "web_search_result",
|
||||
"title": r.Title,
|
||||
"url": r.URL,
|
||||
"encrypted_content": snippet,
|
||||
"page_age": nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
newContent = append(newContent, map[string]interface{}{
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": s.ToolUseID,
|
||||
"content": searchContent,
|
||||
})
|
||||
}
|
||||
|
||||
// Append existing content blocks
|
||||
newContent = append(newContent, existingContent...)
|
||||
resp["content"] = newContent
|
||||
|
||||
result, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return responsePayload, fmt.Errorf("failed to marshal response: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("kiro/websearch: injected %d search indicator(s) into non-stream response", len(searches))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SearchIndicator holds the data for one search operation to inject into a response.
|
||||
type SearchIndicator struct {
|
||||
ToolUseID string
|
||||
Query string
|
||||
Results *WebSearchResults
|
||||
}
|
||||
|
||||
// BuildMcpEndpoint constructs the MCP endpoint URL for the given AWS region.
|
||||
// Centralizes the URL pattern used by both handleWebSearch and handleWebSearchStream.
|
||||
func BuildMcpEndpoint(region string) string {
|
||||
return fmt.Sprintf("https://q.%s.amazonaws.com/mcp", region)
|
||||
}
|
||||
|
||||
// ParseSearchResults extracts WebSearchResults from MCP response
|
||||
func ParseSearchResults(response *McpResponse) *WebSearchResults {
|
||||
if response == nil || response.Result == nil || len(response.Result.Content) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content := response.Result.Content[0]
|
||||
if content.ContentType != "text" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results WebSearchResults
|
||||
if err := json.Unmarshal([]byte(content.Text), &results); err != nil {
|
||||
log.Warnf("kiro/websearch: failed to parse search results: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &results
|
||||
}
|
||||
191
internal/translator/kiro/claude/tool_compression.go
Normal file
191
internal/translator/kiro/claude/tool_compression.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// Package claude provides tool compression functionality for Kiro translator.
|
||||
// This file implements dynamic tool compression to reduce tool payload size
|
||||
// when it exceeds the target threshold, preventing 500 errors from Kiro API.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"unicode/utf8"
|
||||
|
||||
kirocommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/common"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// calculateToolsSize calculates the JSON serialized size of the tools list.
|
||||
// Returns the size in bytes.
|
||||
func calculateToolsSize(tools []KiroToolWrapper) int {
|
||||
if len(tools) == 0 {
|
||||
return 0
|
||||
}
|
||||
data, err := json.Marshal(tools)
|
||||
if err != nil {
|
||||
log.Warnf("kiro: failed to marshal tools for size calculation: %v", err)
|
||||
return 0
|
||||
}
|
||||
return len(data)
|
||||
}
|
||||
|
||||
// simplifyInputSchema simplifies the input_schema by keeping only essential fields:
|
||||
// type, enum, required. Recursively processes nested properties.
|
||||
func simplifyInputSchema(schema interface{}) interface{} {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
schemaMap, ok := schema.(map[string]interface{})
|
||||
if !ok {
|
||||
return schema
|
||||
}
|
||||
|
||||
simplified := make(map[string]interface{})
|
||||
|
||||
// Keep essential fields
|
||||
if t, ok := schemaMap["type"]; ok {
|
||||
simplified["type"] = t
|
||||
}
|
||||
if enum, ok := schemaMap["enum"]; ok {
|
||||
simplified["enum"] = enum
|
||||
}
|
||||
if required, ok := schemaMap["required"]; ok {
|
||||
simplified["required"] = required
|
||||
}
|
||||
|
||||
// Recursively process properties
|
||||
if properties, ok := schemaMap["properties"].(map[string]interface{}); ok {
|
||||
simplifiedProps := make(map[string]interface{})
|
||||
for key, value := range properties {
|
||||
simplifiedProps[key] = simplifyInputSchema(value)
|
||||
}
|
||||
simplified["properties"] = simplifiedProps
|
||||
}
|
||||
|
||||
// Process items for array types
|
||||
if items, ok := schemaMap["items"]; ok {
|
||||
simplified["items"] = simplifyInputSchema(items)
|
||||
}
|
||||
|
||||
// Process additionalProperties if present
|
||||
if additionalProps, ok := schemaMap["additionalProperties"]; ok {
|
||||
simplified["additionalProperties"] = simplifyInputSchema(additionalProps)
|
||||
}
|
||||
|
||||
// Process anyOf, oneOf, allOf
|
||||
for _, key := range []string{"anyOf", "oneOf", "allOf"} {
|
||||
if arr, ok := schemaMap[key].([]interface{}); ok {
|
||||
simplifiedArr := make([]interface{}, len(arr))
|
||||
for i, item := range arr {
|
||||
simplifiedArr[i] = simplifyInputSchema(item)
|
||||
}
|
||||
simplified[key] = simplifiedArr
|
||||
}
|
||||
}
|
||||
|
||||
return simplified
|
||||
}
|
||||
|
||||
// compressToolDescription compresses a description to the target length.
|
||||
// Ensures the result is at least MinToolDescriptionLength characters.
|
||||
// Uses UTF-8 safe truncation.
|
||||
func compressToolDescription(description string, targetLength int) string {
|
||||
if targetLength < kirocommon.MinToolDescriptionLength {
|
||||
targetLength = kirocommon.MinToolDescriptionLength
|
||||
}
|
||||
|
||||
if len(description) <= targetLength {
|
||||
return description
|
||||
}
|
||||
|
||||
// Find a safe truncation point (UTF-8 boundary)
|
||||
truncLen := targetLength - 3 // Leave room for "..."
|
||||
|
||||
// Ensure we don't cut in the middle of a UTF-8 character
|
||||
for truncLen > 0 && !utf8.RuneStart(description[truncLen]) {
|
||||
truncLen--
|
||||
}
|
||||
|
||||
if truncLen <= 0 {
|
||||
return description[:kirocommon.MinToolDescriptionLength]
|
||||
}
|
||||
|
||||
return description[:truncLen] + "..."
|
||||
}
|
||||
|
||||
// compressToolsIfNeeded compresses tools if their total size exceeds the target threshold.
|
||||
// Compression strategy:
|
||||
// 1. First, check if compression is needed (size > ToolCompressionTargetSize)
|
||||
// 2. Step 1: Simplify input_schema (keep only type/enum/required)
|
||||
// 3. Step 2: Proportionally compress descriptions (minimum MinToolDescriptionLength chars)
|
||||
// Returns the compressed tools list.
|
||||
func compressToolsIfNeeded(tools []KiroToolWrapper) []KiroToolWrapper {
|
||||
if len(tools) == 0 {
|
||||
return tools
|
||||
}
|
||||
|
||||
originalSize := calculateToolsSize(tools)
|
||||
if originalSize <= kirocommon.ToolCompressionTargetSize {
|
||||
log.Debugf("kiro: tools size %d bytes is within target %d bytes, no compression needed",
|
||||
originalSize, kirocommon.ToolCompressionTargetSize)
|
||||
return tools
|
||||
}
|
||||
|
||||
log.Infof("kiro: tools size %d bytes exceeds target %d bytes, starting compression",
|
||||
originalSize, kirocommon.ToolCompressionTargetSize)
|
||||
|
||||
// Create a copy of tools to avoid modifying the original
|
||||
compressedTools := make([]KiroToolWrapper, len(tools))
|
||||
for i, tool := range tools {
|
||||
compressedTools[i] = KiroToolWrapper{
|
||||
ToolSpecification: KiroToolSpecification{
|
||||
Name: tool.ToolSpecification.Name,
|
||||
Description: tool.ToolSpecification.Description,
|
||||
InputSchema: KiroInputSchema{JSON: tool.ToolSpecification.InputSchema.JSON},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Simplify input_schema
|
||||
for i := range compressedTools {
|
||||
compressedTools[i].ToolSpecification.InputSchema.JSON =
|
||||
simplifyInputSchema(compressedTools[i].ToolSpecification.InputSchema.JSON)
|
||||
}
|
||||
|
||||
sizeAfterSchemaSimplification := calculateToolsSize(compressedTools)
|
||||
log.Debugf("kiro: size after schema simplification: %d bytes (reduced by %d bytes)",
|
||||
sizeAfterSchemaSimplification, originalSize-sizeAfterSchemaSimplification)
|
||||
|
||||
// Check if we're within target after schema simplification
|
||||
if sizeAfterSchemaSimplification <= kirocommon.ToolCompressionTargetSize {
|
||||
log.Infof("kiro: compression complete after schema simplification, final size: %d bytes",
|
||||
sizeAfterSchemaSimplification)
|
||||
return compressedTools
|
||||
}
|
||||
|
||||
// Step 2: Compress descriptions proportionally
|
||||
sizeToReduce := float64(sizeAfterSchemaSimplification - kirocommon.ToolCompressionTargetSize)
|
||||
var totalDescLen float64
|
||||
for _, tool := range compressedTools {
|
||||
totalDescLen += float64(len(tool.ToolSpecification.Description))
|
||||
}
|
||||
|
||||
if totalDescLen > 0 {
|
||||
// Assume size reduction comes primarily from descriptions.
|
||||
keepRatio := 1.0 - (sizeToReduce / totalDescLen)
|
||||
if keepRatio > 1.0 {
|
||||
keepRatio = 1.0
|
||||
} else if keepRatio < 0 {
|
||||
keepRatio = 0
|
||||
}
|
||||
|
||||
for i := range compressedTools {
|
||||
desc := compressedTools[i].ToolSpecification.Description
|
||||
targetLen := int(float64(len(desc)) * keepRatio)
|
||||
compressedTools[i].ToolSpecification.Description = compressToolDescription(desc, targetLen)
|
||||
}
|
||||
}
|
||||
|
||||
finalSize := calculateToolsSize(compressedTools)
|
||||
log.Infof("kiro: compression complete, original: %d bytes, final: %d bytes (%.1f%% reduction)",
|
||||
originalSize, finalSize, float64(originalSize-finalSize)/float64(originalSize)*100)
|
||||
|
||||
return compressedTools
|
||||
}
|
||||
532
internal/translator/kiro/claude/truncation_detector.go
Normal file
532
internal/translator/kiro/claude/truncation_detector.go
Normal file
@@ -0,0 +1,532 @@
|
||||
// Package claude provides truncation detection for Kiro tool call responses.
|
||||
// When Kiro API reaches its output token limit, tool call JSON may be truncated,
|
||||
// resulting in incomplete or unparseable input parameters.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// TruncationInfo contains details about detected truncation in a tool use event.
|
||||
type TruncationInfo struct {
|
||||
IsTruncated bool // Whether truncation was detected
|
||||
TruncationType string // Type of truncation detected
|
||||
ToolName string // Name of the truncated tool
|
||||
ToolUseID string // ID of the truncated tool use
|
||||
RawInput string // The raw (possibly truncated) input string
|
||||
ParsedFields map[string]string // Fields that were successfully parsed before truncation
|
||||
ErrorMessage string // Human-readable error message
|
||||
}
|
||||
|
||||
// TruncationType constants for different truncation scenarios
|
||||
const (
|
||||
TruncationTypeNone = "" // No truncation detected
|
||||
TruncationTypeEmptyInput = "empty_input" // No input data received at all
|
||||
TruncationTypeInvalidJSON = "invalid_json" // JSON is syntactically invalid (truncated mid-value)
|
||||
TruncationTypeMissingFields = "missing_fields" // JSON parsed but critical fields are missing
|
||||
TruncationTypeIncompleteString = "incomplete_string" // String value was cut off mid-content
|
||||
)
|
||||
|
||||
// KnownWriteTools lists tool names that typically write content and have a "content" field.
|
||||
// These tools are checked for content field truncation specifically.
|
||||
var KnownWriteTools = map[string]bool{
|
||||
"Write": true,
|
||||
"write_to_file": true,
|
||||
"fsWrite": true,
|
||||
"create_file": true,
|
||||
"edit_file": true,
|
||||
"apply_diff": true,
|
||||
"str_replace_editor": true,
|
||||
"insert": true,
|
||||
}
|
||||
|
||||
// KnownCommandTools lists tool names that execute commands.
|
||||
var KnownCommandTools = map[string]bool{
|
||||
"Bash": true,
|
||||
"execute": true,
|
||||
"run_command": true,
|
||||
"shell": true,
|
||||
"terminal": true,
|
||||
"execute_python": true,
|
||||
}
|
||||
|
||||
// RequiredFieldsByTool maps tool names to their required field groups.
|
||||
// Each outer element is a required group; each inner slice lists alternative field names (OR logic).
|
||||
// A group is satisfied when ANY one of its alternatives exists in the parsed input.
|
||||
// All groups must be satisfied for the tool input to be considered valid.
|
||||
//
|
||||
// Example:
|
||||
// {{"cmd", "command"}} means the tool needs EITHER "cmd" OR "command".
|
||||
// {{"file_path"}, {"content"}} means the tool needs BOTH "file_path" AND "content".
|
||||
var RequiredFieldsByTool = map[string][][]string{
|
||||
"Write": {{"file_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"}},
|
||||
}
|
||||
|
||||
// DetectTruncation checks if the tool use input appears to be truncated.
|
||||
// It returns detailed information about the truncation status and type.
|
||||
func DetectTruncation(toolName, toolUseID, rawInput string, parsedInput map[string]interface{}) TruncationInfo {
|
||||
info := TruncationInfo{
|
||||
ToolName: toolName,
|
||||
ToolUseID: toolUseID,
|
||||
RawInput: rawInput,
|
||||
ParsedFields: make(map[string]string),
|
||||
}
|
||||
|
||||
// Scenario 1: Empty input buffer - no data received at all
|
||||
if strings.TrimSpace(rawInput) == "" {
|
||||
info.IsTruncated = true
|
||||
info.TruncationType = TruncationTypeEmptyInput
|
||||
info.ErrorMessage = "Tool input was completely empty - API response may have been truncated before tool parameters were transmitted"
|
||||
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): empty input buffer",
|
||||
info.TruncationType, toolName, toolUseID)
|
||||
return info
|
||||
}
|
||||
|
||||
// Scenario 2: JSON parse failure - syntactically invalid JSON
|
||||
if parsedInput == nil || len(parsedInput) == 0 {
|
||||
// Check if the raw input looks like truncated JSON
|
||||
if looksLikeTruncatedJSON(rawInput) {
|
||||
info.IsTruncated = true
|
||||
info.TruncationType = TruncationTypeInvalidJSON
|
||||
info.ParsedFields = extractPartialFields(rawInput)
|
||||
info.ErrorMessage = buildTruncationErrorMessage(toolName, info.TruncationType, info.ParsedFields, rawInput)
|
||||
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): JSON parse failed, raw length=%d bytes",
|
||||
info.TruncationType, toolName, toolUseID, len(rawInput))
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario 3: JSON parsed but critical fields are missing
|
||||
if parsedInput != nil {
|
||||
requiredGroups, hasRequirements := RequiredFieldsByTool[toolName]
|
||||
if hasRequirements {
|
||||
missingFields := findMissingRequiredFields(parsedInput, requiredGroups)
|
||||
if len(missingFields) > 0 {
|
||||
info.IsTruncated = true
|
||||
info.TruncationType = TruncationTypeMissingFields
|
||||
info.ParsedFields = extractParsedFieldNames(parsedInput)
|
||||
info.ErrorMessage = buildMissingFieldsErrorMessage(toolName, missingFields, info.ParsedFields)
|
||||
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): missing required fields: %v",
|
||||
info.TruncationType, toolName, toolUseID, missingFields)
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario 4: Check for incomplete string values (very short content for write tools)
|
||||
if isWriteTool(toolName) {
|
||||
if contentTruncation := detectContentTruncation(parsedInput, rawInput); contentTruncation != "" {
|
||||
info.IsTruncated = true
|
||||
info.TruncationType = TruncationTypeIncompleteString
|
||||
info.ParsedFields = extractParsedFieldNames(parsedInput)
|
||||
info.ErrorMessage = contentTruncation
|
||||
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): %s",
|
||||
info.TruncationType, toolName, toolUseID, contentTruncation)
|
||||
return info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No truncation detected
|
||||
info.IsTruncated = false
|
||||
info.TruncationType = TruncationTypeNone
|
||||
return info
|
||||
}
|
||||
|
||||
// looksLikeTruncatedJSON checks if the raw string appears to be truncated JSON.
|
||||
func looksLikeTruncatedJSON(raw string) bool {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must start with { to be considered JSON
|
||||
if !strings.HasPrefix(trimmed, "{") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Count brackets to detect imbalance
|
||||
openBraces := strings.Count(trimmed, "{")
|
||||
closeBraces := strings.Count(trimmed, "}")
|
||||
openBrackets := strings.Count(trimmed, "[")
|
||||
closeBrackets := strings.Count(trimmed, "]")
|
||||
|
||||
// Bracket imbalance suggests truncation
|
||||
if openBraces > closeBraces || openBrackets > closeBrackets {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for obvious truncation patterns
|
||||
// - Ends with a quote but no closing brace
|
||||
// - Ends with a colon (mid key-value)
|
||||
// - Ends with a comma (mid object/array)
|
||||
lastChar := trimmed[len(trimmed)-1]
|
||||
if lastChar != '}' && lastChar != ']' {
|
||||
// Check if it's not a complete simple value
|
||||
if lastChar == '"' || lastChar == ':' || lastChar == ',' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unclosed strings (odd number of unescaped quotes)
|
||||
inString := false
|
||||
escaped := false
|
||||
for i := 0; i < len(trimmed); i++ {
|
||||
c := trimmed[i]
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if c == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
inString = !inString
|
||||
}
|
||||
}
|
||||
if inString {
|
||||
return true // Unclosed string
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractPartialFields attempts to extract any field names from malformed JSON.
|
||||
// This helps provide context about what was received before truncation.
|
||||
func extractPartialFields(raw string) map[string]string {
|
||||
fields := make(map[string]string)
|
||||
|
||||
// Simple pattern matching for "key": "value" or "key": value patterns
|
||||
// This works even with truncated JSON
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(trimmed, "{") {
|
||||
return fields
|
||||
}
|
||||
|
||||
// Remove opening brace
|
||||
content := strings.TrimPrefix(trimmed, "{")
|
||||
|
||||
// Split by comma (rough parsing)
|
||||
parts := strings.Split(content, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if colonIdx := strings.Index(part, ":"); colonIdx > 0 {
|
||||
key := strings.TrimSpace(part[:colonIdx])
|
||||
key = strings.Trim(key, `"`)
|
||||
value := strings.TrimSpace(part[colonIdx+1:])
|
||||
|
||||
// Truncate long values for display
|
||||
if len(value) > 50 {
|
||||
value = value[:50] + "..."
|
||||
}
|
||||
fields[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// extractParsedFieldNames returns the field names from a successfully parsed map.
|
||||
func extractParsedFieldNames(parsed map[string]interface{}) map[string]string {
|
||||
fields := make(map[string]string)
|
||||
for key, val := range parsed {
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
if len(v) > 50 {
|
||||
fields[key] = v[:50] + "..."
|
||||
} else {
|
||||
fields[key] = v
|
||||
}
|
||||
case nil:
|
||||
fields[key] = "<null>"
|
||||
default:
|
||||
// For complex types, just indicate presence
|
||||
fields[key] = "<present>"
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// findMissingRequiredFields checks which required field groups are unsatisfied.
|
||||
// Each group is a slice of alternative field names; the group is satisfied when ANY alternative exists.
|
||||
// Returns the list of unsatisfied groups (represented by their alternatives joined with "/").
|
||||
func findMissingRequiredFields(parsed map[string]interface{}, requiredGroups [][]string) []string {
|
||||
var missing []string
|
||||
for _, group := range requiredGroups {
|
||||
satisfied := false
|
||||
for _, field := range group {
|
||||
if _, exists := parsed[field]; exists {
|
||||
satisfied = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !satisfied {
|
||||
missing = append(missing, strings.Join(group, "/"))
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// isWriteTool checks if the tool is a known write/file operation tool.
|
||||
func isWriteTool(toolName string) bool {
|
||||
return KnownWriteTools[toolName]
|
||||
}
|
||||
|
||||
// detectContentTruncation checks if the content field appears truncated for write tools.
|
||||
func detectContentTruncation(parsed map[string]interface{}, rawInput string) string {
|
||||
// Check for content field
|
||||
content, hasContent := parsed["content"]
|
||||
if !hasContent {
|
||||
return ""
|
||||
}
|
||||
|
||||
contentStr, isString := content.(string)
|
||||
if !isString {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Heuristic: if raw input is very large but content is suspiciously short,
|
||||
// it might indicate truncation during JSON repair
|
||||
if len(rawInput) > 1000 && len(contentStr) < 100 {
|
||||
return "content field appears suspiciously short compared to raw input size"
|
||||
}
|
||||
|
||||
// Check for code blocks that appear to be cut off
|
||||
if strings.Contains(contentStr, "```") {
|
||||
openFences := strings.Count(contentStr, "```")
|
||||
if openFences%2 != 0 {
|
||||
return "content contains unclosed code fence (```) suggesting truncation"
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildTruncationErrorMessage creates a human-readable error message for truncation.
|
||||
func buildTruncationErrorMessage(toolName, truncationType string, parsedFields map[string]string, rawInput string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Tool input was truncated by the API. ")
|
||||
|
||||
switch truncationType {
|
||||
case TruncationTypeEmptyInput:
|
||||
sb.WriteString("No input data was received.")
|
||||
case TruncationTypeInvalidJSON:
|
||||
sb.WriteString("JSON was cut off mid-transmission. ")
|
||||
if len(parsedFields) > 0 {
|
||||
sb.WriteString("Partial fields received: ")
|
||||
first := true
|
||||
for k := range parsedFields {
|
||||
if !first {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
first = false
|
||||
}
|
||||
}
|
||||
case TruncationTypeMissingFields:
|
||||
sb.WriteString("Required fields are missing from the input.")
|
||||
case TruncationTypeIncompleteString:
|
||||
sb.WriteString("Content appears to be shortened or incomplete.")
|
||||
}
|
||||
|
||||
sb.WriteString(" Received ")
|
||||
sb.WriteString(string(rune(len(rawInput))))
|
||||
sb.WriteString(" bytes. Please retry with smaller content chunks.")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// buildMissingFieldsErrorMessage creates an error message for missing required fields.
|
||||
func buildMissingFieldsErrorMessage(toolName string, missingFields []string, parsedFields map[string]string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Tool '")
|
||||
sb.WriteString(toolName)
|
||||
sb.WriteString("' is missing required fields: ")
|
||||
sb.WriteString(strings.Join(missingFields, ", "))
|
||||
sb.WriteString(". Fields received: ")
|
||||
|
||||
first := true
|
||||
for k := range parsedFields {
|
||||
if !first {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
first = false
|
||||
}
|
||||
|
||||
sb.WriteString(". This usually indicates the API response was truncated.")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// IsTruncated is a convenience function to check if a tool use appears truncated.
|
||||
func IsTruncated(toolName, rawInput string, parsedInput map[string]interface{}) bool {
|
||||
info := DetectTruncation(toolName, "", rawInput, parsedInput)
|
||||
return info.IsTruncated
|
||||
}
|
||||
|
||||
// GetTruncationSummary returns a short summary string for logging.
|
||||
func GetTruncationSummary(info TruncationInfo) string {
|
||||
if !info.IsTruncated {
|
||||
return ""
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]interface{}{
|
||||
"tool": info.ToolName,
|
||||
"type": info.TruncationType,
|
||||
"parsed_fields": info.ParsedFields,
|
||||
"raw_input_size": len(info.RawInput),
|
||||
})
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// SoftFailureMessage contains the message structure for a truncation soft failure.
|
||||
// This is returned to Claude as a tool_result to guide retry behavior.
|
||||
type SoftFailureMessage struct {
|
||||
Status string // "incomplete" - not an error, just incomplete
|
||||
Reason string // Why the tool call was incomplete
|
||||
Guidance []string // Step-by-step retry instructions
|
||||
Context string // Any context about what was received
|
||||
MaxLineHint int // Suggested maximum lines per chunk
|
||||
}
|
||||
|
||||
// BuildSoftFailureMessage creates a structured message for Claude when truncation is detected.
|
||||
// This follows the "soft failure" pattern:
|
||||
// - For Claude: Clear explanation of what happened and how to fix
|
||||
// - For User: Hidden or minimized (appears as normal processing)
|
||||
//
|
||||
// Key principle: "Conclusion First"
|
||||
// 1. First state what happened (incomplete)
|
||||
// 2. Then explain how to fix (chunked approach)
|
||||
// 3. Provide specific guidance (line limits)
|
||||
func BuildSoftFailureMessage(info TruncationInfo) SoftFailureMessage {
|
||||
msg := SoftFailureMessage{
|
||||
Status: "incomplete",
|
||||
MaxLineHint: 300, // Conservative default
|
||||
}
|
||||
|
||||
// Build reason based on truncation type
|
||||
switch info.TruncationType {
|
||||
case TruncationTypeEmptyInput:
|
||||
msg.Reason = "Your tool call was too large and the input was completely lost during transmission."
|
||||
msg.MaxLineHint = 200
|
||||
case TruncationTypeInvalidJSON:
|
||||
msg.Reason = "Your tool call was truncated mid-transmission, resulting in incomplete JSON."
|
||||
msg.MaxLineHint = 250
|
||||
case TruncationTypeMissingFields:
|
||||
msg.Reason = "Your tool call was partially received but critical fields were cut off."
|
||||
msg.MaxLineHint = 300
|
||||
case TruncationTypeIncompleteString:
|
||||
msg.Reason = "Your tool call content was truncated - the full content did not arrive."
|
||||
msg.MaxLineHint = 350
|
||||
default:
|
||||
msg.Reason = "Your tool call was truncated by the API due to output size limits."
|
||||
}
|
||||
|
||||
// Build context from parsed fields
|
||||
if len(info.ParsedFields) > 0 {
|
||||
var parts []string
|
||||
for k, v := range info.ParsedFields {
|
||||
if len(v) > 30 {
|
||||
v = v[:30] + "..."
|
||||
}
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
msg.Context = "Received partial data: " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// Build retry guidance - CRITICAL: Conclusion first approach
|
||||
msg.Guidance = []string{
|
||||
"CONCLUSION: Split your output into smaller chunks and retry.",
|
||||
"",
|
||||
"REQUIRED APPROACH:",
|
||||
"1. For file writes: Write in chunks of ~" + formatInt(msg.MaxLineHint) + " lines maximum",
|
||||
"2. For new files: First create with initial chunk, then append remaining sections",
|
||||
"3. For edits: Make surgical, targeted changes - avoid rewriting entire files",
|
||||
"",
|
||||
"EXAMPLE (writing a 600-line file):",
|
||||
" - Step 1: Write lines 1-300 (create file)",
|
||||
" - Step 2: Append lines 301-600 (extend file)",
|
||||
"",
|
||||
"DO NOT attempt to write the full content again in a single call.",
|
||||
"The API has a hard output limit that cannot be bypassed.",
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// formatInt converts an integer to string (helper to avoid strconv import)
|
||||
func formatInt(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
result := ""
|
||||
for n > 0 {
|
||||
result = string(rune('0'+n%10)) + result
|
||||
n /= 10
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// BuildSoftFailureToolResult creates a tool_result content for Claude.
|
||||
// This is what Claude will see when a tool call is truncated.
|
||||
// Returns a string that should be used as the tool_result content.
|
||||
func BuildSoftFailureToolResult(info TruncationInfo) string {
|
||||
msg := BuildSoftFailureMessage(info)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("TOOL_CALL_INCOMPLETE\n")
|
||||
sb.WriteString("status: ")
|
||||
sb.WriteString(msg.Status)
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("reason: ")
|
||||
sb.WriteString(msg.Reason)
|
||||
sb.WriteString("\n")
|
||||
|
||||
if msg.Context != "" {
|
||||
sb.WriteString("context: ")
|
||||
sb.WriteString(msg.Context)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
for _, line := range msg.Guidance {
|
||||
if line != "" {
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// CreateTruncationToolResult creates a KiroToolUse that represents a soft failure.
|
||||
// Instead of returning the truncated tool_use, we return a tool with a special
|
||||
// error result that guides Claude to retry with smaller chunks.
|
||||
//
|
||||
// This is the key mechanism for "soft failure":
|
||||
// - stop_reason remains "tool_use" so Claude continues
|
||||
// - The tool_result content explains the issue and how to fix it
|
||||
// - Claude will read this and adjust its approach
|
||||
func CreateTruncationToolResult(info TruncationInfo) KiroToolUse {
|
||||
// We create a pseudo tool_use that represents the failed attempt
|
||||
// The executor will convert this to a tool_result with the guidance message
|
||||
return KiroToolUse{
|
||||
ToolUseID: info.ToolUseID,
|
||||
Name: info.ToolName,
|
||||
Input: nil, // No input since it was truncated
|
||||
IsTruncated: true,
|
||||
TruncationInfo: &info,
|
||||
}
|
||||
}
|
||||
103
internal/translator/kiro/common/constants.go
Normal file
103
internal/translator/kiro/common/constants.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Package common provides shared constants and utilities for Kiro translator.
|
||||
package common
|
||||
|
||||
const (
|
||||
// KiroMaxToolDescLen is the maximum description length for Kiro API tools.
|
||||
// Kiro API limit is 10240 bytes, leave room for "..."
|
||||
KiroMaxToolDescLen = 10237
|
||||
|
||||
// ToolCompressionTargetSize is the target total size for compressed tools (20KB).
|
||||
// If tools exceed this size, compression will be applied.
|
||||
ToolCompressionTargetSize = 20 * 1024 // 20KB
|
||||
|
||||
// MinToolDescriptionLength is the minimum description length after compression.
|
||||
// Descriptions will not be shortened below this length.
|
||||
MinToolDescriptionLength = 50
|
||||
|
||||
// ThinkingStartTag is the start tag for thinking blocks in responses.
|
||||
ThinkingStartTag = "<thinking>"
|
||||
|
||||
// ThinkingEndTag is the end tag for thinking blocks in responses.
|
||||
ThinkingEndTag = "</thinking>"
|
||||
|
||||
// CodeFenceMarker is the markdown code fence marker.
|
||||
CodeFenceMarker = "```"
|
||||
|
||||
// AltCodeFenceMarker is the alternative markdown code fence marker.
|
||||
AltCodeFenceMarker = "~~~"
|
||||
|
||||
// InlineCodeMarker is the markdown inline code marker (backtick).
|
||||
InlineCodeMarker = "`"
|
||||
|
||||
// DefaultAssistantContentWithTools is the fallback content for assistant messages
|
||||
// that have tool_use but no text content. Kiro API requires non-empty content.
|
||||
// IMPORTANT: Use a minimal neutral string that the model won't mimic in responses.
|
||||
// Previously "I'll help you with that." which caused the model to parrot it back.
|
||||
DefaultAssistantContentWithTools = "."
|
||||
|
||||
// DefaultAssistantContent is the fallback content for assistant messages
|
||||
// that have no content at all. Kiro API requires non-empty content.
|
||||
// IMPORTANT: Use a minimal neutral string that the model won't mimic in responses.
|
||||
// Previously "I understand." which could leak into model behavior.
|
||||
DefaultAssistantContent = "."
|
||||
|
||||
// DefaultUserContentWithToolResults is the fallback content for user messages
|
||||
// that have only tool_result (no text). Kiro API requires non-empty content.
|
||||
DefaultUserContentWithToolResults = "Tool results provided."
|
||||
|
||||
// DefaultUserContent is the fallback content for user messages
|
||||
// that have no content at all. Kiro API requires non-empty content.
|
||||
DefaultUserContent = "Continue"
|
||||
|
||||
// KiroAgenticSystemPrompt is injected only for -agentic models to prevent timeouts on large writes.
|
||||
// AWS Kiro API has a 2-3 minute timeout for large file write operations.
|
||||
KiroAgenticSystemPrompt = `
|
||||
# CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY)
|
||||
|
||||
You MUST follow these rules for ALL file operations. Violation causes server timeouts and task failure.
|
||||
|
||||
## ABSOLUTE LIMITS
|
||||
- **MAXIMUM 350 LINES** per single write/edit operation - NO EXCEPTIONS
|
||||
- **RECOMMENDED 300 LINES** or less for optimal performance
|
||||
- **NEVER** write entire files in one operation if >300 lines
|
||||
|
||||
## MANDATORY CHUNKED WRITE STRATEGY
|
||||
|
||||
### For NEW FILES (>300 lines total):
|
||||
1. FIRST: Write initial chunk (first 250-300 lines) using write_to_file/fsWrite
|
||||
2. THEN: Append remaining content in 250-300 line chunks using file append operations
|
||||
3. REPEAT: Continue appending until complete
|
||||
|
||||
### For EDITING EXISTING FILES:
|
||||
1. Use surgical edits (apply_diff/targeted edits) - change ONLY what's needed
|
||||
2. NEVER rewrite entire files - use incremental modifications
|
||||
3. Split large refactors into multiple small, focused edits
|
||||
|
||||
### For LARGE CODE GENERATION:
|
||||
1. Generate in logical sections (imports, types, functions separately)
|
||||
2. Write each section as a separate operation
|
||||
3. Use append operations for subsequent sections
|
||||
|
||||
## EXAMPLES OF CORRECT BEHAVIOR
|
||||
|
||||
✅ CORRECT: Writing a 600-line file
|
||||
- Operation 1: Write lines 1-300 (initial file creation)
|
||||
- Operation 2: Append lines 301-600
|
||||
|
||||
✅ CORRECT: Editing multiple functions
|
||||
- Operation 1: Edit function A
|
||||
- Operation 2: Edit function B
|
||||
- Operation 3: Edit function C
|
||||
|
||||
❌ WRONG: Writing 500 lines in single operation → TIMEOUT
|
||||
❌ WRONG: Rewriting entire file to change 5 lines → TIMEOUT
|
||||
❌ WRONG: Generating massive code blocks without chunking → TIMEOUT
|
||||
|
||||
## WHY THIS MATTERS
|
||||
- Server has 2-3 minute timeout for operations
|
||||
- Large writes exceed timeout and FAIL completely
|
||||
- Chunked writes are FASTER and more RELIABLE
|
||||
- Failed writes waste time and require retry
|
||||
|
||||
REMEMBER: When in doubt, write LESS per operation. Multiple small operations > one large operation.`
|
||||
)
|
||||
160
internal/translator/kiro/common/message_merge.go
Normal file
160
internal/translator/kiro/common/message_merge.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Package common provides shared utilities for Kiro translators.
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// MergeAdjacentMessages merges adjacent messages with the same role.
|
||||
// This reduces API call complexity and improves compatibility.
|
||||
// Based on AIClient-2-API implementation.
|
||||
// NOTE: Tool messages are NOT merged because each has a unique tool_call_id that must be preserved.
|
||||
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]
|
||||
currentRole := msg.Get("role").String()
|
||||
lastRole := lastMsg.Get("role").String()
|
||||
|
||||
// Don't merge tool messages - each has a unique tool_call_id
|
||||
if currentRole == "tool" || lastRole == "tool" {
|
||||
merged = append(merged, msg)
|
||||
continue
|
||||
}
|
||||
|
||||
if currentRole == lastRole {
|
||||
// Merge content from current message into last message
|
||||
mergedContent := mergeMessageContent(lastMsg, msg)
|
||||
var mergedToolCalls []interface{}
|
||||
if currentRole == "assistant" {
|
||||
// Preserve assistant tool_calls when adjacent assistant messages are merged.
|
||||
mergedToolCalls = mergeToolCalls(lastMsg.Get("tool_calls"), msg.Get("tool_calls"))
|
||||
}
|
||||
|
||||
// Create a new merged message JSON.
|
||||
mergedMsg := createMergedMessage(lastRole, mergedContent, mergedToolCalls)
|
||||
merged[len(merged)-1] = gjson.Parse(mergedMsg)
|
||||
} else {
|
||||
merged = append(merged, msg)
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// mergeMessageContent merges the content of two messages with the same role.
|
||||
// Handles both string content and array content (with text, tool_use, tool_result blocks).
|
||||
func mergeMessageContent(msg1, msg2 gjson.Result) string {
|
||||
content1 := msg1.Get("content")
|
||||
content2 := msg2.Get("content")
|
||||
|
||||
// Extract content blocks from both messages
|
||||
var blocks1, blocks2 []map[string]interface{}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
// Merge text blocks if both end/start with text
|
||||
if len(blocks1) > 0 && len(blocks2) > 0 {
|
||||
if blocks1[len(blocks1)-1]["type"] == "text" && blocks2[0]["type"] == "text" {
|
||||
// Merge the last text block of msg1 with the first text block of msg2
|
||||
text1 := blocks1[len(blocks1)-1]["text"].(string)
|
||||
text2 := blocks2[0]["text"].(string)
|
||||
blocks1[len(blocks1)-1]["text"] = text1 + "\n" + text2
|
||||
blocks2 = blocks2[1:] // Remove the merged block from blocks2
|
||||
}
|
||||
}
|
||||
|
||||
// Combine all blocks
|
||||
allBlocks := append(blocks1, blocks2...)
|
||||
|
||||
// Convert to JSON
|
||||
result, _ := json.Marshal(allBlocks)
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// blockToMap converts a gjson.Result block to a map[string]interface{}
|
||||
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
|
||||
}
|
||||
|
||||
// createMergedMessage creates a JSON string for a merged message.
|
||||
// toolCalls is optional and only emitted for assistant role.
|
||||
func createMergedMessage(role string, content string, toolCalls []interface{}) string {
|
||||
msg := map[string]interface{}{
|
||||
"role": role,
|
||||
"content": json.RawMessage(content),
|
||||
}
|
||||
if role == "assistant" && len(toolCalls) > 0 {
|
||||
msg["tool_calls"] = toolCalls
|
||||
}
|
||||
result, _ := json.Marshal(msg)
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// mergeToolCalls combines tool_calls from two assistant messages while preserving order.
|
||||
func mergeToolCalls(tc1, tc2 gjson.Result) []interface{} {
|
||||
var merged []interface{}
|
||||
|
||||
if tc1.IsArray() {
|
||||
for _, tc := range tc1.Array() {
|
||||
merged = append(merged, tc.Value())
|
||||
}
|
||||
}
|
||||
if tc2.IsArray() {
|
||||
for _, tc := range tc2.Array() {
|
||||
merged = append(merged, tc.Value())
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
106
internal/translator/kiro/common/message_merge_test.go
Normal file
106
internal/translator/kiro/common/message_merge_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func parseMessages(t *testing.T, raw string) []gjson.Result {
|
||||
t.Helper()
|
||||
parsed := gjson.Parse(raw)
|
||||
if !parsed.IsArray() {
|
||||
t.Fatalf("expected JSON array, got: %s", raw)
|
||||
}
|
||||
return parsed.Array()
|
||||
}
|
||||
|
||||
func TestMergeAdjacentMessages_AssistantMergePreservesToolCalls(t *testing.T) {
|
||||
messages := parseMessages(t, `[
|
||||
{"role":"assistant","content":"part1"},
|
||||
{
|
||||
"role":"assistant",
|
||||
"content":"part2",
|
||||
"tool_calls":[
|
||||
{
|
||||
"id":"call_1",
|
||||
"type":"function",
|
||||
"function":{"name":"Read","arguments":"{}"}
|
||||
}
|
||||
]
|
||||
},
|
||||
{"role":"tool","tool_call_id":"call_1","content":"ok"}
|
||||
]`)
|
||||
|
||||
merged := MergeAdjacentMessages(messages)
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("expected 2 messages after merge, got %d", len(merged))
|
||||
}
|
||||
|
||||
assistant := merged[0]
|
||||
if assistant.Get("role").String() != "assistant" {
|
||||
t.Fatalf("expected first message role assistant, got %q", assistant.Get("role").String())
|
||||
}
|
||||
|
||||
toolCalls := assistant.Get("tool_calls")
|
||||
if !toolCalls.IsArray() || len(toolCalls.Array()) != 1 {
|
||||
t.Fatalf("expected assistant.tool_calls length 1, got: %s", toolCalls.Raw)
|
||||
}
|
||||
if toolCalls.Array()[0].Get("id").String() != "call_1" {
|
||||
t.Fatalf("expected tool call id call_1, got %q", toolCalls.Array()[0].Get("id").String())
|
||||
}
|
||||
|
||||
contentRaw := assistant.Get("content").Raw
|
||||
if !strings.Contains(contentRaw, "part1") || !strings.Contains(contentRaw, "part2") {
|
||||
t.Fatalf("expected merged content to contain both parts, got: %s", contentRaw)
|
||||
}
|
||||
|
||||
if merged[1].Get("role").String() != "tool" {
|
||||
t.Fatalf("expected second message role tool, got %q", merged[1].Get("role").String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAdjacentMessages_AssistantMergeCombinesMultipleToolCalls(t *testing.T) {
|
||||
messages := parseMessages(t, `[
|
||||
{
|
||||
"role":"assistant",
|
||||
"content":"first",
|
||||
"tool_calls":[
|
||||
{"id":"call_1","type":"function","function":{"name":"Read","arguments":"{}"}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role":"assistant",
|
||||
"content":"second",
|
||||
"tool_calls":[
|
||||
{"id":"call_2","type":"function","function":{"name":"Write","arguments":"{}"}}
|
||||
]
|
||||
}
|
||||
]`)
|
||||
|
||||
merged := MergeAdjacentMessages(messages)
|
||||
if len(merged) != 1 {
|
||||
t.Fatalf("expected 1 message after merge, got %d", len(merged))
|
||||
}
|
||||
|
||||
toolCalls := merged[0].Get("tool_calls").Array()
|
||||
if len(toolCalls) != 2 {
|
||||
t.Fatalf("expected 2 merged tool calls, got %d", len(toolCalls))
|
||||
}
|
||||
if toolCalls[0].Get("id").String() != "call_1" || toolCalls[1].Get("id").String() != "call_2" {
|
||||
t.Fatalf("unexpected merged tool call ids: %q, %q", toolCalls[0].Get("id").String(), toolCalls[1].Get("id").String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAdjacentMessages_ToolMessagesRemainUnmerged(t *testing.T) {
|
||||
messages := parseMessages(t, `[
|
||||
{"role":"tool","tool_call_id":"call_1","content":"r1"},
|
||||
{"role":"tool","tool_call_id":"call_2","content":"r2"}
|
||||
]`)
|
||||
|
||||
merged := MergeAdjacentMessages(messages)
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("expected tool messages to remain separate, got %d", len(merged))
|
||||
}
|
||||
}
|
||||
16
internal/translator/kiro/common/utils.go
Normal file
16
internal/translator/kiro/common/utils.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Package common provides shared constants and utilities for Kiro translator.
|
||||
package common
|
||||
|
||||
// GetString safely extracts a string from a map.
|
||||
// Returns empty string if the key doesn't exist or the value is not a string.
|
||||
func GetString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetStringValue is an alias for GetString for backward compatibility.
|
||||
func GetStringValue(m map[string]interface{}, key string) string {
|
||||
return GetString(m, key)
|
||||
}
|
||||
20
internal/translator/kiro/openai/init.go
Normal file
20
internal/translator/kiro/openai/init.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Package openai provides translation between OpenAI Chat Completions and Kiro formats.
|
||||
package openai
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
OpenAI, // source format
|
||||
Kiro, // target format
|
||||
ConvertOpenAIRequestToKiro,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertKiroStreamToOpenAI,
|
||||
NonStream: ConvertKiroNonStreamToOpenAI,
|
||||
},
|
||||
)
|
||||
}
|
||||
371
internal/translator/kiro/openai/kiro_openai.go
Normal file
371
internal/translator/kiro/openai/kiro_openai.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Package openai provides translation between OpenAI Chat Completions and Kiro formats.
|
||||
// This package enables direct OpenAI → Kiro translation, bypassing the Claude intermediate layer.
|
||||
//
|
||||
// The Kiro executor generates Claude-compatible SSE format internally, so the streaming response
|
||||
// translation converts from Claude SSE format to OpenAI SSE format.
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
kirocommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// ConvertKiroStreamToOpenAI converts Kiro streaming response to OpenAI format.
|
||||
// The Kiro executor emits Claude-compatible SSE events, so this function translates
|
||||
// from Claude SSE format to OpenAI SSE format.
|
||||
//
|
||||
// Claude SSE format:
|
||||
// - event: message_start\ndata: {...}
|
||||
// - event: content_block_start\ndata: {...}
|
||||
// - event: content_block_delta\ndata: {...}
|
||||
// - event: content_block_stop\ndata: {...}
|
||||
// - event: message_delta\ndata: {...}
|
||||
// - event: message_stop\ndata: {...}
|
||||
//
|
||||
// OpenAI SSE format:
|
||||
// - data: {"id":"...","object":"chat.completion.chunk",...}
|
||||
// - data: [DONE]
|
||||
func ConvertKiroStreamToOpenAI(ctx context.Context, model string, originalRequest, request, rawResponse []byte, param *any) []string {
|
||||
// Initialize state if needed
|
||||
if *param == nil {
|
||||
*param = NewOpenAIStreamState(model)
|
||||
}
|
||||
state := (*param).(*OpenAIStreamState)
|
||||
|
||||
// Parse the Claude SSE event
|
||||
responseStr := string(rawResponse)
|
||||
|
||||
// Handle raw event format (event: xxx\ndata: {...})
|
||||
var eventType string
|
||||
var eventData string
|
||||
|
||||
if strings.HasPrefix(responseStr, "event:") {
|
||||
// Parse event type and data
|
||||
lines := strings.SplitN(responseStr, "\n", 2)
|
||||
if len(lines) >= 1 {
|
||||
eventType = strings.TrimSpace(strings.TrimPrefix(lines[0], "event:"))
|
||||
}
|
||||
if len(lines) >= 2 && strings.HasPrefix(lines[1], "data:") {
|
||||
eventData = strings.TrimSpace(strings.TrimPrefix(lines[1], "data:"))
|
||||
}
|
||||
} else if strings.HasPrefix(responseStr, "data:") {
|
||||
// Just data line
|
||||
eventData = strings.TrimSpace(strings.TrimPrefix(responseStr, "data:"))
|
||||
} else {
|
||||
// Try to parse as raw JSON
|
||||
eventData = strings.TrimSpace(responseStr)
|
||||
}
|
||||
|
||||
if eventData == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Parse the event data as JSON
|
||||
eventJSON := gjson.Parse(eventData)
|
||||
if !eventJSON.Exists() {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Determine event type from JSON if not already set
|
||||
if eventType == "" {
|
||||
eventType = eventJSON.Get("type").String()
|
||||
}
|
||||
|
||||
var results []string
|
||||
|
||||
switch eventType {
|
||||
case "message_start":
|
||||
// Send first chunk with role
|
||||
firstChunk := BuildOpenAISSEFirstChunk(state)
|
||||
results = append(results, firstChunk)
|
||||
|
||||
case "content_block_start":
|
||||
// Check block type
|
||||
blockType := eventJSON.Get("content_block.type").String()
|
||||
switch blockType {
|
||||
case "text":
|
||||
// Text block starting - nothing to emit yet
|
||||
case "thinking":
|
||||
// Thinking block starting - nothing to emit yet for OpenAI
|
||||
case "tool_use":
|
||||
// Tool use block starting
|
||||
toolUseID := eventJSON.Get("content_block.id").String()
|
||||
toolName := eventJSON.Get("content_block.name").String()
|
||||
chunk := BuildOpenAISSEToolCallStart(state, toolUseID, toolName)
|
||||
results = append(results, chunk)
|
||||
state.ToolCallIndex++
|
||||
}
|
||||
|
||||
case "content_block_delta":
|
||||
deltaType := eventJSON.Get("delta.type").String()
|
||||
switch deltaType {
|
||||
case "text_delta":
|
||||
textDelta := eventJSON.Get("delta.text").String()
|
||||
if textDelta != "" {
|
||||
chunk := BuildOpenAISSETextDelta(state, textDelta)
|
||||
results = append(results, chunk)
|
||||
}
|
||||
case "thinking_delta":
|
||||
// Convert thinking to reasoning_content for o1-style compatibility
|
||||
thinkingDelta := eventJSON.Get("delta.thinking").String()
|
||||
if thinkingDelta != "" {
|
||||
chunk := BuildOpenAISSEReasoningDelta(state, thinkingDelta)
|
||||
results = append(results, chunk)
|
||||
}
|
||||
case "input_json_delta":
|
||||
// Tool call arguments delta
|
||||
partialJSON := eventJSON.Get("delta.partial_json").String()
|
||||
if partialJSON != "" {
|
||||
// Get the tool index from content block index
|
||||
blockIndex := int(eventJSON.Get("index").Int())
|
||||
chunk := BuildOpenAISSEToolCallArgumentsDelta(state, partialJSON, blockIndex-1) // Adjust for 0-based tool index
|
||||
results = append(results, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
case "content_block_stop":
|
||||
// Content block ended - nothing to emit for OpenAI
|
||||
|
||||
case "message_delta":
|
||||
// Message delta with stop_reason
|
||||
stopReason := eventJSON.Get("delta.stop_reason").String()
|
||||
finishReason := mapKiroStopReasonToOpenAI(stopReason)
|
||||
if finishReason != "" {
|
||||
chunk := BuildOpenAISSEFinish(state, finishReason)
|
||||
results = append(results, chunk)
|
||||
}
|
||||
|
||||
// Extract usage if present
|
||||
if eventJSON.Get("usage").Exists() {
|
||||
inputTokens := eventJSON.Get("usage.input_tokens").Int()
|
||||
outputTokens := eventJSON.Get("usage.output_tokens").Int()
|
||||
usageInfo := usage.Detail{
|
||||
InputTokens: inputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
TotalTokens: inputTokens + outputTokens,
|
||||
}
|
||||
chunk := BuildOpenAISSEUsage(state, usageInfo)
|
||||
results = append(results, chunk)
|
||||
}
|
||||
|
||||
case "message_stop":
|
||||
// Final event - do NOT emit [DONE] here
|
||||
// The handler layer (openai_handlers.go) will send [DONE] when the stream closes
|
||||
// Emitting [DONE] here would cause duplicate [DONE] markers
|
||||
|
||||
case "ping":
|
||||
// Ping event with usage - optionally emit usage chunk
|
||||
if eventJSON.Get("usage").Exists() {
|
||||
inputTokens := eventJSON.Get("usage.input_tokens").Int()
|
||||
outputTokens := eventJSON.Get("usage.output_tokens").Int()
|
||||
usageInfo := usage.Detail{
|
||||
InputTokens: inputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
TotalTokens: inputTokens + outputTokens,
|
||||
}
|
||||
chunk := BuildOpenAISSEUsage(state, usageInfo)
|
||||
results = append(results, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ConvertKiroNonStreamToOpenAI converts Kiro non-streaming response to OpenAI format.
|
||||
// The Kiro executor returns Claude-compatible JSON responses, so this function translates
|
||||
// from Claude format to OpenAI format.
|
||||
func ConvertKiroNonStreamToOpenAI(ctx context.Context, model string, originalRequest, request, rawResponse []byte, param *any) string {
|
||||
// Parse the Claude-format response
|
||||
response := gjson.ParseBytes(rawResponse)
|
||||
|
||||
// Extract content
|
||||
var content string
|
||||
var reasoningContent string
|
||||
var toolUses []KiroToolUse
|
||||
var stopReason string
|
||||
|
||||
// Get stop_reason
|
||||
stopReason = response.Get("stop_reason").String()
|
||||
|
||||
// Process content blocks
|
||||
contentBlocks := response.Get("content")
|
||||
if contentBlocks.IsArray() {
|
||||
for _, block := range contentBlocks.Array() {
|
||||
blockType := block.Get("type").String()
|
||||
switch blockType {
|
||||
case "text":
|
||||
content += block.Get("text").String()
|
||||
case "thinking":
|
||||
// Convert thinking blocks to reasoning_content for OpenAI format
|
||||
reasoningContent += block.Get("thinking").String()
|
||||
case "tool_use":
|
||||
toolUseID := block.Get("id").String()
|
||||
toolName := block.Get("name").String()
|
||||
toolInput := block.Get("input")
|
||||
|
||||
var inputMap map[string]interface{}
|
||||
if toolInput.IsObject() {
|
||||
inputMap = make(map[string]interface{})
|
||||
toolInput.ForEach(func(key, value gjson.Result) bool {
|
||||
inputMap[key.String()] = value.Value()
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
toolUses = append(toolUses, KiroToolUse{
|
||||
ToolUseID: toolUseID,
|
||||
Name: toolName,
|
||||
Input: inputMap,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract usage
|
||||
usageInfo := usage.Detail{
|
||||
InputTokens: response.Get("usage.input_tokens").Int(),
|
||||
OutputTokens: response.Get("usage.output_tokens").Int(),
|
||||
}
|
||||
usageInfo.TotalTokens = usageInfo.InputTokens + usageInfo.OutputTokens
|
||||
|
||||
// Build OpenAI response with reasoning_content support
|
||||
openaiResponse := BuildOpenAIResponseWithReasoning(content, reasoningContent, toolUses, model, usageInfo, stopReason)
|
||||
return string(openaiResponse)
|
||||
}
|
||||
|
||||
// ParseClaudeEvent parses a Claude SSE event and returns the event type and data
|
||||
func ParseClaudeEvent(rawEvent []byte) (eventType string, eventData []byte) {
|
||||
lines := bytes.Split(rawEvent, []byte("\n"))
|
||||
for _, line := range lines {
|
||||
line = bytes.TrimSpace(line)
|
||||
if bytes.HasPrefix(line, []byte("event:")) {
|
||||
eventType = string(bytes.TrimSpace(bytes.TrimPrefix(line, []byte("event:"))))
|
||||
} else if bytes.HasPrefix(line, []byte("data:")) {
|
||||
eventData = bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:")))
|
||||
}
|
||||
}
|
||||
return eventType, eventData
|
||||
}
|
||||
|
||||
// ExtractThinkingFromContent parses content to extract thinking blocks.
|
||||
// Returns cleaned content (without thinking tags) and whether thinking was found.
|
||||
func ExtractThinkingFromContent(content string) (string, string, bool) {
|
||||
if !strings.Contains(content, kirocommon.ThinkingStartTag) {
|
||||
return content, "", false
|
||||
}
|
||||
|
||||
var cleanedContent strings.Builder
|
||||
var thinkingContent strings.Builder
|
||||
hasThinking := false
|
||||
remaining := content
|
||||
|
||||
for len(remaining) > 0 {
|
||||
startIdx := strings.Index(remaining, kirocommon.ThinkingStartTag)
|
||||
if startIdx == -1 {
|
||||
cleanedContent.WriteString(remaining)
|
||||
break
|
||||
}
|
||||
|
||||
// Add content before thinking tag
|
||||
cleanedContent.WriteString(remaining[:startIdx])
|
||||
|
||||
// Move past opening tag
|
||||
remaining = remaining[startIdx+len(kirocommon.ThinkingStartTag):]
|
||||
|
||||
// Find closing tag
|
||||
endIdx := strings.Index(remaining, kirocommon.ThinkingEndTag)
|
||||
if endIdx == -1 {
|
||||
// No closing tag - treat rest as thinking
|
||||
thinkingContent.WriteString(remaining)
|
||||
hasThinking = true
|
||||
break
|
||||
}
|
||||
|
||||
// Extract thinking content
|
||||
thinkingContent.WriteString(remaining[:endIdx])
|
||||
hasThinking = true
|
||||
remaining = remaining[endIdx+len(kirocommon.ThinkingEndTag):]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(cleanedContent.String()), strings.TrimSpace(thinkingContent.String()), hasThinking
|
||||
}
|
||||
|
||||
// ConvertOpenAIToolsToKiroFormat is a helper that converts OpenAI tools format to Kiro format
|
||||
func ConvertOpenAIToolsToKiroFormat(tools []map[string]interface{}) []KiroToolWrapper {
|
||||
var kiroTools []KiroToolWrapper
|
||||
|
||||
for _, tool := range tools {
|
||||
toolType, _ := tool["type"].(string)
|
||||
if toolType != "function" {
|
||||
continue
|
||||
}
|
||||
|
||||
fn, ok := tool["function"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
name := kirocommon.GetString(fn, "name")
|
||||
description := kirocommon.GetString(fn, "description")
|
||||
parameters := ensureKiroInputSchema(fn["parameters"])
|
||||
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if description == "" {
|
||||
description = "Tool: " + name
|
||||
}
|
||||
|
||||
kiroTools = append(kiroTools, KiroToolWrapper{
|
||||
ToolSpecification: KiroToolSpecification{
|
||||
Name: name,
|
||||
Description: description,
|
||||
InputSchema: KiroInputSchema{JSON: parameters},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return kiroTools
|
||||
}
|
||||
|
||||
// OpenAIStreamParams holds parameters for OpenAI streaming conversion
|
||||
type OpenAIStreamParams struct {
|
||||
State *OpenAIStreamState
|
||||
ThinkingState *ThinkingTagState
|
||||
ToolCallsEmitted map[string]bool
|
||||
}
|
||||
|
||||
// NewOpenAIStreamParams creates new streaming parameters
|
||||
func NewOpenAIStreamParams(model string) *OpenAIStreamParams {
|
||||
return &OpenAIStreamParams{
|
||||
State: NewOpenAIStreamState(model),
|
||||
ThinkingState: NewThinkingTagState(),
|
||||
ToolCallsEmitted: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertClaudeToolUseToOpenAI converts a Claude tool_use block to OpenAI tool_calls format
|
||||
func ConvertClaudeToolUseToOpenAI(toolUseID, toolName string, input map[string]interface{}) map[string]interface{} {
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
return map[string]interface{}{
|
||||
"id": toolUseID,
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": toolName,
|
||||
"arguments": string(inputJSON),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LogStreamEvent logs a streaming event for debugging
|
||||
func LogStreamEvent(eventType, data string) {
|
||||
log.Debugf("kiro-openai: stream event type=%s, data_len=%d", eventType, len(data))
|
||||
}
|
||||
1009
internal/translator/kiro/openai/kiro_openai_request.go
Normal file
1009
internal/translator/kiro/openai/kiro_openai_request.go
Normal file
File diff suppressed because it is too large
Load Diff
440
internal/translator/kiro/openai/kiro_openai_request_test.go
Normal file
440
internal/translator/kiro/openai/kiro_openai_request_test.go
Normal file
@@ -0,0 +1,440 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestToolResultsAttachedToCurrentMessage verifies that tool results from "tool" role messages
|
||||
// are properly attached to the current user message (the last message in the conversation).
|
||||
// This is critical for LiteLLM-translated requests where tool results appear as separate messages.
|
||||
func TestToolResultsAttachedToCurrentMessage(t *testing.T) {
|
||||
// OpenAI format request simulating LiteLLM's translation from Anthropic format
|
||||
// Sequence: user -> assistant (with tool_calls) -> tool (result) -> user
|
||||
// The last user message should have the tool results attached
|
||||
input := []byte(`{
|
||||
"model": "kiro-claude-opus-4-5-agentic",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello, can you read a file for me?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "I'll read that file for you.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Read",
|
||||
"arguments": "{\"file_path\": \"/tmp/test.txt\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_abc123",
|
||||
"content": "File contents: Hello World!"
|
||||
},
|
||||
{"role": "user", "content": "What did the file say?"}
|
||||
]
|
||||
}`)
|
||||
|
||||
result, _ := BuildKiroPayloadFromOpenAI(input, "kiro-model", "", "CLI", false, false, nil, nil)
|
||||
|
||||
var payload KiroPayload
|
||||
if err := json.Unmarshal(result, &payload); err != nil {
|
||||
t.Fatalf("Failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
// The last user message becomes currentMessage
|
||||
// History should have: user (first), assistant (with tool_calls)
|
||||
t.Logf("History count: %d", len(payload.ConversationState.History))
|
||||
if len(payload.ConversationState.History) != 2 {
|
||||
t.Errorf("Expected 2 history entries (user + assistant), got %d", len(payload.ConversationState.History))
|
||||
}
|
||||
|
||||
// Tool results should be attached to currentMessage (the last user message)
|
||||
ctx := payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext
|
||||
if ctx == nil {
|
||||
t.Fatal("Expected currentMessage to have UserInputMessageContext with tool results")
|
||||
}
|
||||
|
||||
if len(ctx.ToolResults) != 1 {
|
||||
t.Fatalf("Expected 1 tool result in currentMessage, got %d", len(ctx.ToolResults))
|
||||
}
|
||||
|
||||
tr := ctx.ToolResults[0]
|
||||
if tr.ToolUseID != "call_abc123" {
|
||||
t.Errorf("Expected toolUseId 'call_abc123', got '%s'", tr.ToolUseID)
|
||||
}
|
||||
if len(tr.Content) == 0 || tr.Content[0].Text != "File contents: Hello World!" {
|
||||
t.Errorf("Tool result content mismatch, got: %+v", tr.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolResultsInHistoryUserMessage verifies that when there are multiple user messages
|
||||
// after tool results, the tool results are attached to the correct user message in history.
|
||||
func TestToolResultsInHistoryUserMessage(t *testing.T) {
|
||||
// Sequence: user -> assistant (with tool_calls) -> tool (result) -> user -> assistant -> user
|
||||
// The first user after tool should have tool results in history
|
||||
input := []byte(`{
|
||||
"model": "kiro-claude-opus-4-5-agentic",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "I'll read the file.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Read",
|
||||
"arguments": "{}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": "File result"
|
||||
},
|
||||
{"role": "user", "content": "Thanks for the file"},
|
||||
{"role": "assistant", "content": "You're welcome"},
|
||||
{"role": "user", "content": "Bye"}
|
||||
]
|
||||
}`)
|
||||
|
||||
result, _ := BuildKiroPayloadFromOpenAI(input, "kiro-model", "", "CLI", false, false, nil, nil)
|
||||
|
||||
var payload KiroPayload
|
||||
if err := json.Unmarshal(result, &payload); err != nil {
|
||||
t.Fatalf("Failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
// History should have: user, assistant, user (with tool results), assistant
|
||||
// CurrentMessage should be: last user "Bye"
|
||||
t.Logf("History count: %d", len(payload.ConversationState.History))
|
||||
|
||||
// Find the user message in history with tool results
|
||||
foundToolResults := false
|
||||
for i, h := range payload.ConversationState.History {
|
||||
if h.UserInputMessage != nil {
|
||||
t.Logf("History[%d]: user message content=%q", i, h.UserInputMessage.Content)
|
||||
if h.UserInputMessage.UserInputMessageContext != nil {
|
||||
if len(h.UserInputMessage.UserInputMessageContext.ToolResults) > 0 {
|
||||
foundToolResults = true
|
||||
t.Logf(" Found %d tool results", len(h.UserInputMessage.UserInputMessageContext.ToolResults))
|
||||
tr := h.UserInputMessage.UserInputMessageContext.ToolResults[0]
|
||||
if tr.ToolUseID != "call_1" {
|
||||
t.Errorf("Expected toolUseId 'call_1', got '%s'", tr.ToolUseID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.AssistantResponseMessage != nil {
|
||||
t.Logf("History[%d]: assistant message content=%q", i, h.AssistantResponseMessage.Content)
|
||||
}
|
||||
}
|
||||
|
||||
if !foundToolResults {
|
||||
t.Error("Tool results were not attached to any user message in history")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolResultsWithMultipleToolCalls verifies handling of multiple tool calls
|
||||
func TestToolResultsWithMultipleToolCalls(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"model": "kiro-claude-opus-4-5-agentic",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Read two files for me"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "I'll read both files.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Read",
|
||||
"arguments": "{\"file_path\": \"/tmp/file1.txt\"}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Read",
|
||||
"arguments": "{\"file_path\": \"/tmp/file2.txt\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": "Content of file 1"
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_2",
|
||||
"content": "Content of file 2"
|
||||
},
|
||||
{"role": "user", "content": "What do they say?"}
|
||||
]
|
||||
}`)
|
||||
|
||||
result, _ := BuildKiroPayloadFromOpenAI(input, "kiro-model", "", "CLI", false, false, nil, nil)
|
||||
|
||||
var payload KiroPayload
|
||||
if err := json.Unmarshal(result, &payload); err != nil {
|
||||
t.Fatalf("Failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("History count: %d", len(payload.ConversationState.History))
|
||||
t.Logf("CurrentMessage content: %q", payload.ConversationState.CurrentMessage.UserInputMessage.Content)
|
||||
|
||||
// Check if there are any tool results anywhere
|
||||
var totalToolResults int
|
||||
for i, h := range payload.ConversationState.History {
|
||||
if h.UserInputMessage != nil && h.UserInputMessage.UserInputMessageContext != nil {
|
||||
count := len(h.UserInputMessage.UserInputMessageContext.ToolResults)
|
||||
t.Logf("History[%d] user message has %d tool results", i, count)
|
||||
totalToolResults += count
|
||||
}
|
||||
}
|
||||
|
||||
ctx := payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext
|
||||
if ctx != nil {
|
||||
t.Logf("CurrentMessage has %d tool results", len(ctx.ToolResults))
|
||||
totalToolResults += len(ctx.ToolResults)
|
||||
} else {
|
||||
t.Logf("CurrentMessage has no UserInputMessageContext")
|
||||
}
|
||||
|
||||
if totalToolResults != 2 {
|
||||
t.Errorf("Expected 2 tool results total, got %d", totalToolResults)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolResultsAtEndOfConversation verifies tool results are handled when
|
||||
// the conversation ends with tool results (no following user message)
|
||||
func TestToolResultsAtEndOfConversation(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"model": "kiro-claude-opus-4-5-agentic",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Read a file"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Reading the file.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_end",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Read",
|
||||
"arguments": "{\"file_path\": \"/tmp/test.txt\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_end",
|
||||
"content": "File contents here"
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
result, _ := BuildKiroPayloadFromOpenAI(input, "kiro-model", "", "CLI", false, false, nil, nil)
|
||||
|
||||
var payload KiroPayload
|
||||
if err := json.Unmarshal(result, &payload); err != nil {
|
||||
t.Fatalf("Failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
// When the last message is a tool result, a synthetic user message is created
|
||||
// and tool results should be attached to it
|
||||
ctx := payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext
|
||||
if ctx == nil || len(ctx.ToolResults) == 0 {
|
||||
t.Error("Expected tool results to be attached to current message when conversation ends with tool result")
|
||||
} else {
|
||||
if ctx.ToolResults[0].ToolUseID != "call_end" {
|
||||
t.Errorf("Expected toolUseId 'call_end', got '%s'", ctx.ToolResults[0].ToolUseID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolResultsFollowedByAssistant verifies handling when tool results are followed
|
||||
// by an assistant message (no intermediate user message).
|
||||
// This is the pattern from LiteLLM translation of Anthropic format where:
|
||||
// user message has ONLY tool_result blocks -> LiteLLM creates tool messages
|
||||
// then the next message is assistant
|
||||
func TestToolResultsFollowedByAssistant(t *testing.T) {
|
||||
// Sequence: user -> assistant (with tool_calls) -> tool -> tool -> assistant -> user
|
||||
// This simulates LiteLLM's translation of:
|
||||
// user: "Read files"
|
||||
// assistant: [tool_use, tool_use]
|
||||
// user: [tool_result, tool_result] <- becomes multiple "tool" role messages
|
||||
// assistant: "I've read them"
|
||||
// user: "What did they say?"
|
||||
input := []byte(`{
|
||||
"model": "kiro-claude-opus-4-5-agentic",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Read two files for me"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "I'll read both files.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Read",
|
||||
"arguments": "{\"file_path\": \"/tmp/a.txt\"}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "Read",
|
||||
"arguments": "{\"file_path\": \"/tmp/b.txt\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_1",
|
||||
"content": "Contents of file A"
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_2",
|
||||
"content": "Contents of file B"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "I've read both files."
|
||||
},
|
||||
{"role": "user", "content": "What did they say?"}
|
||||
]
|
||||
}`)
|
||||
|
||||
result, _ := BuildKiroPayloadFromOpenAI(input, "kiro-model", "", "CLI", false, false, nil, nil)
|
||||
|
||||
var payload KiroPayload
|
||||
if err := json.Unmarshal(result, &payload); err != nil {
|
||||
t.Fatalf("Failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("History count: %d", len(payload.ConversationState.History))
|
||||
|
||||
// Tool results should be attached to a synthetic user message or the history should be valid
|
||||
var totalToolResults int
|
||||
for i, h := range payload.ConversationState.History {
|
||||
if h.UserInputMessage != nil {
|
||||
t.Logf("History[%d]: user message content=%q", i, h.UserInputMessage.Content)
|
||||
if h.UserInputMessage.UserInputMessageContext != nil {
|
||||
count := len(h.UserInputMessage.UserInputMessageContext.ToolResults)
|
||||
t.Logf(" Has %d tool results", count)
|
||||
totalToolResults += count
|
||||
}
|
||||
}
|
||||
if h.AssistantResponseMessage != nil {
|
||||
t.Logf("History[%d]: assistant message content=%q", i, h.AssistantResponseMessage.Content)
|
||||
}
|
||||
}
|
||||
|
||||
ctx := payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext
|
||||
if ctx != nil {
|
||||
t.Logf("CurrentMessage has %d tool results", len(ctx.ToolResults))
|
||||
totalToolResults += len(ctx.ToolResults)
|
||||
}
|
||||
|
||||
if totalToolResults != 2 {
|
||||
t.Errorf("Expected 2 tool results total, got %d", totalToolResults)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssistantEndsConversation verifies handling when assistant is the last message
|
||||
func TestAssistantEndsConversation(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"model": "kiro-claude-opus-4-5-agentic",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Hi there!"
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
result, _ := BuildKiroPayloadFromOpenAI(input, "kiro-model", "", "CLI", false, false, nil, nil)
|
||||
|
||||
var payload KiroPayload
|
||||
if err := json.Unmarshal(result, &payload); err != nil {
|
||||
t.Fatalf("Failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
// When assistant is last, a "Continue" user message should be created
|
||||
if payload.ConversationState.CurrentMessage.UserInputMessage.Content == "" {
|
||||
t.Error("Expected a 'Continue' message to be created when assistant is last")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterOrphanedToolResults_RemovesHistoryAndCurrentOrphans(t *testing.T) {
|
||||
history := []KiroHistoryMessage{
|
||||
{
|
||||
AssistantResponseMessage: &KiroAssistantResponseMessage{
|
||||
Content: "assistant",
|
||||
ToolUses: []KiroToolUse{
|
||||
{ToolUseID: "keep-1", Name: "Read", Input: map[string]interface{}{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
UserInputMessage: &KiroUserInputMessage{
|
||||
Content: "user-with-mixed-results",
|
||||
UserInputMessageContext: &KiroUserInputMessageContext{
|
||||
ToolResults: []KiroToolResult{
|
||||
{ToolUseID: "keep-1", Status: "success", Content: []KiroTextContent{{Text: "ok"}}},
|
||||
{ToolUseID: "orphan-1", Status: "success", Content: []KiroTextContent{{Text: "bad"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
UserInputMessage: &KiroUserInputMessage{
|
||||
Content: "user-only-orphans",
|
||||
UserInputMessageContext: &KiroUserInputMessageContext{
|
||||
ToolResults: []KiroToolResult{
|
||||
{ToolUseID: "orphan-2", Status: "success", Content: []KiroTextContent{{Text: "bad"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
currentToolResults := []KiroToolResult{
|
||||
{ToolUseID: "keep-1", Status: "success", Content: []KiroTextContent{{Text: "ok"}}},
|
||||
{ToolUseID: "orphan-3", Status: "success", Content: []KiroTextContent{{Text: "bad"}}},
|
||||
}
|
||||
|
||||
filteredHistory, filteredCurrent := filterOrphanedToolResults(history, currentToolResults)
|
||||
|
||||
ctx1 := filteredHistory[1].UserInputMessage.UserInputMessageContext
|
||||
if ctx1 == nil || len(ctx1.ToolResults) != 1 || ctx1.ToolResults[0].ToolUseID != "keep-1" {
|
||||
t.Fatalf("expected mixed history message to keep only keep-1, got: %+v", ctx1)
|
||||
}
|
||||
|
||||
if filteredHistory[2].UserInputMessage.UserInputMessageContext != nil {
|
||||
t.Fatalf("expected orphan-only history context to be removed")
|
||||
}
|
||||
|
||||
if len(filteredCurrent) != 1 || filteredCurrent[0].ToolUseID != "keep-1" {
|
||||
t.Fatalf("expected current tool results to keep only keep-1, got: %+v", filteredCurrent)
|
||||
}
|
||||
}
|
||||
277
internal/translator/kiro/openai/kiro_openai_response.go
Normal file
277
internal/translator/kiro/openai/kiro_openai_response.go
Normal file
@@ -0,0 +1,277 @@
|
||||
// Package openai provides response translation from Kiro to OpenAI format.
|
||||
// This package handles the conversion of Kiro API responses into OpenAI Chat Completions-compatible
|
||||
// JSON format, transforming streaming events and non-streaming responses.
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// functionCallIDCounter provides a process-wide unique counter for function call identifiers.
|
||||
var functionCallIDCounter uint64
|
||||
|
||||
// BuildOpenAIResponse constructs an OpenAI Chat Completions-compatible response.
|
||||
// Supports tool_calls when tools are present in the response.
|
||||
// stopReason is passed from upstream; fallback logic applied if empty.
|
||||
func BuildOpenAIResponse(content string, toolUses []KiroToolUse, model string, usageInfo usage.Detail, stopReason string) []byte {
|
||||
return BuildOpenAIResponseWithReasoning(content, "", toolUses, model, usageInfo, stopReason)
|
||||
}
|
||||
|
||||
// BuildOpenAIResponseWithReasoning constructs an OpenAI Chat Completions-compatible response with reasoning_content support.
|
||||
// Supports tool_calls when tools are present in the response.
|
||||
// reasoningContent is included as reasoning_content field in the message when present.
|
||||
// stopReason is passed from upstream; fallback logic applied if empty.
|
||||
func BuildOpenAIResponseWithReasoning(content, reasoningContent string, toolUses []KiroToolUse, model string, usageInfo usage.Detail, stopReason string) []byte {
|
||||
// Build the message object
|
||||
message := map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
}
|
||||
|
||||
// Add reasoning_content if present (for thinking/reasoning models)
|
||||
if reasoningContent != "" {
|
||||
message["reasoning_content"] = reasoningContent
|
||||
}
|
||||
|
||||
// Add tool_calls if present
|
||||
if len(toolUses) > 0 {
|
||||
var toolCalls []map[string]interface{}
|
||||
for i, tu := range toolUses {
|
||||
inputJSON, _ := json.Marshal(tu.Input)
|
||||
toolCalls = append(toolCalls, map[string]interface{}{
|
||||
"id": tu.ToolUseID,
|
||||
"type": "function",
|
||||
"index": i,
|
||||
"function": map[string]interface{}{
|
||||
"name": tu.Name,
|
||||
"arguments": string(inputJSON),
|
||||
},
|
||||
})
|
||||
}
|
||||
message["tool_calls"] = toolCalls
|
||||
// When tool_calls are present, content should be null according to OpenAI spec
|
||||
if content == "" {
|
||||
message["content"] = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Use upstream stopReason; apply fallback logic if not provided
|
||||
finishReason := mapKiroStopReasonToOpenAI(stopReason)
|
||||
if finishReason == "" {
|
||||
finishReason = "stop"
|
||||
if len(toolUses) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
log.Debugf("kiro-openai: buildOpenAIResponse using fallback finish_reason: %s", finishReason)
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": "chatcmpl-" + uuid.New().String()[:24],
|
||||
"object": "chat.completion",
|
||||
"created": time.Now().Unix(),
|
||||
"model": model,
|
||||
"choices": []map[string]interface{}{
|
||||
{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": finishReason,
|
||||
},
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"prompt_tokens": usageInfo.InputTokens,
|
||||
"completion_tokens": usageInfo.OutputTokens,
|
||||
"total_tokens": usageInfo.InputTokens + usageInfo.OutputTokens,
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(response)
|
||||
return result
|
||||
}
|
||||
|
||||
// mapKiroStopReasonToOpenAI converts Kiro/Claude stop_reason to OpenAI finish_reason
|
||||
func mapKiroStopReasonToOpenAI(stopReason string) string {
|
||||
switch stopReason {
|
||||
case "end_turn":
|
||||
return "stop"
|
||||
case "stop_sequence":
|
||||
return "stop"
|
||||
case "tool_use":
|
||||
return "tool_calls"
|
||||
case "max_tokens":
|
||||
return "length"
|
||||
case "content_filtered":
|
||||
return "content_filter"
|
||||
default:
|
||||
return stopReason
|
||||
}
|
||||
}
|
||||
|
||||
// BuildOpenAIStreamChunk constructs an OpenAI Chat Completions streaming chunk.
|
||||
// This is the delta format used in streaming responses.
|
||||
func BuildOpenAIStreamChunk(model string, deltaContent string, deltaToolCalls []map[string]interface{}, finishReason string, index int) []byte {
|
||||
delta := map[string]interface{}{}
|
||||
|
||||
// First chunk should include role
|
||||
if index == 0 && deltaContent == "" && len(deltaToolCalls) == 0 {
|
||||
delta["role"] = "assistant"
|
||||
delta["content"] = ""
|
||||
} else if deltaContent != "" {
|
||||
delta["content"] = deltaContent
|
||||
}
|
||||
|
||||
// Add tool_calls delta if present
|
||||
if len(deltaToolCalls) > 0 {
|
||||
delta["tool_calls"] = deltaToolCalls
|
||||
}
|
||||
|
||||
choice := map[string]interface{}{
|
||||
"index": 0,
|
||||
"delta": delta,
|
||||
}
|
||||
|
||||
if finishReason != "" {
|
||||
choice["finish_reason"] = finishReason
|
||||
} else {
|
||||
choice["finish_reason"] = nil
|
||||
}
|
||||
|
||||
chunk := map[string]interface{}{
|
||||
"id": "chatcmpl-" + uuid.New().String()[:12],
|
||||
"object": "chat.completion.chunk",
|
||||
"created": time.Now().Unix(),
|
||||
"model": model,
|
||||
"choices": []map[string]interface{}{choice},
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(chunk)
|
||||
return result
|
||||
}
|
||||
|
||||
// BuildOpenAIStreamChunkWithToolCallStart creates a stream chunk for tool call start
|
||||
func BuildOpenAIStreamChunkWithToolCallStart(model string, toolUseID, toolName string, toolIndex int) []byte {
|
||||
toolCall := map[string]interface{}{
|
||||
"index": toolIndex,
|
||||
"id": toolUseID,
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": toolName,
|
||||
"arguments": "",
|
||||
},
|
||||
}
|
||||
|
||||
delta := map[string]interface{}{
|
||||
"tool_calls": []map[string]interface{}{toolCall},
|
||||
}
|
||||
|
||||
choice := map[string]interface{}{
|
||||
"index": 0,
|
||||
"delta": delta,
|
||||
"finish_reason": nil,
|
||||
}
|
||||
|
||||
chunk := map[string]interface{}{
|
||||
"id": "chatcmpl-" + uuid.New().String()[:12],
|
||||
"object": "chat.completion.chunk",
|
||||
"created": time.Now().Unix(),
|
||||
"model": model,
|
||||
"choices": []map[string]interface{}{choice},
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(chunk)
|
||||
return result
|
||||
}
|
||||
|
||||
// BuildOpenAIStreamChunkWithToolCallDelta creates a stream chunk for tool call arguments delta
|
||||
func BuildOpenAIStreamChunkWithToolCallDelta(model string, argumentsDelta string, toolIndex int) []byte {
|
||||
toolCall := map[string]interface{}{
|
||||
"index": toolIndex,
|
||||
"function": map[string]interface{}{
|
||||
"arguments": argumentsDelta,
|
||||
},
|
||||
}
|
||||
|
||||
delta := map[string]interface{}{
|
||||
"tool_calls": []map[string]interface{}{toolCall},
|
||||
}
|
||||
|
||||
choice := map[string]interface{}{
|
||||
"index": 0,
|
||||
"delta": delta,
|
||||
"finish_reason": nil,
|
||||
}
|
||||
|
||||
chunk := map[string]interface{}{
|
||||
"id": "chatcmpl-" + uuid.New().String()[:12],
|
||||
"object": "chat.completion.chunk",
|
||||
"created": time.Now().Unix(),
|
||||
"model": model,
|
||||
"choices": []map[string]interface{}{choice},
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(chunk)
|
||||
return result
|
||||
}
|
||||
|
||||
// BuildOpenAIStreamDoneChunk creates the final [DONE] stream event
|
||||
func BuildOpenAIStreamDoneChunk() []byte {
|
||||
return []byte("data: [DONE]")
|
||||
}
|
||||
|
||||
// BuildOpenAIStreamFinishChunk creates the final chunk with finish_reason
|
||||
func BuildOpenAIStreamFinishChunk(model string, finishReason string) []byte {
|
||||
choice := map[string]interface{}{
|
||||
"index": 0,
|
||||
"delta": map[string]interface{}{},
|
||||
"finish_reason": finishReason,
|
||||
}
|
||||
|
||||
chunk := map[string]interface{}{
|
||||
"id": "chatcmpl-" + uuid.New().String()[:12],
|
||||
"object": "chat.completion.chunk",
|
||||
"created": time.Now().Unix(),
|
||||
"model": model,
|
||||
"choices": []map[string]interface{}{choice},
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(chunk)
|
||||
return result
|
||||
}
|
||||
|
||||
// BuildOpenAIStreamUsageChunk creates a chunk with usage information (optional, for stream_options.include_usage)
|
||||
func BuildOpenAIStreamUsageChunk(model string, usageInfo usage.Detail) []byte {
|
||||
chunk := map[string]interface{}{
|
||||
"id": "chatcmpl-" + uuid.New().String()[:12],
|
||||
"object": "chat.completion.chunk",
|
||||
"created": time.Now().Unix(),
|
||||
"model": model,
|
||||
"choices": []map[string]interface{}{},
|
||||
"usage": map[string]interface{}{
|
||||
"prompt_tokens": usageInfo.InputTokens,
|
||||
"completion_tokens": usageInfo.OutputTokens,
|
||||
"total_tokens": usageInfo.InputTokens + usageInfo.OutputTokens,
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(chunk)
|
||||
return result
|
||||
}
|
||||
|
||||
// GenerateToolCallID generates a unique tool call ID in OpenAI format
|
||||
func GenerateToolCallID(toolName string) string {
|
||||
return fmt.Sprintf("call_%s_%d_%d", toolName[:min(8, len(toolName))], time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))
|
||||
}
|
||||
|
||||
// min returns the minimum of two integers
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
212
internal/translator/kiro/openai/kiro_openai_stream.go
Normal file
212
internal/translator/kiro/openai/kiro_openai_stream.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// Package openai provides streaming SSE event building for OpenAI format.
|
||||
// This package handles the construction of OpenAI-compatible Server-Sent Events (SSE)
|
||||
// for streaming responses from Kiro API.
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
||||
)
|
||||
|
||||
// OpenAIStreamState tracks the state of streaming response conversion
|
||||
type OpenAIStreamState struct {
|
||||
ChunkIndex int
|
||||
ToolCallIndex int
|
||||
HasSentFirstChunk bool
|
||||
Model string
|
||||
ResponseID string
|
||||
Created int64
|
||||
}
|
||||
|
||||
// NewOpenAIStreamState creates a new stream state for tracking
|
||||
func NewOpenAIStreamState(model string) *OpenAIStreamState {
|
||||
return &OpenAIStreamState{
|
||||
ChunkIndex: 0,
|
||||
ToolCallIndex: 0,
|
||||
HasSentFirstChunk: false,
|
||||
Model: model,
|
||||
ResponseID: "chatcmpl-" + uuid.New().String()[:24],
|
||||
Created: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// FormatSSEEvent formats a JSON payload for SSE streaming.
|
||||
// Note: This returns raw JSON data without "data:" prefix.
|
||||
// The SSE "data:" prefix is added by the Handler layer (e.g., openai_handlers.go)
|
||||
// to maintain architectural consistency and avoid double-prefix issues.
|
||||
func FormatSSEEvent(data []byte) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// BuildOpenAISSETextDelta creates an SSE event for text content delta
|
||||
func BuildOpenAISSETextDelta(state *OpenAIStreamState, textDelta string) string {
|
||||
delta := map[string]interface{}{
|
||||
"content": textDelta,
|
||||
}
|
||||
|
||||
// Include role in first chunk
|
||||
if !state.HasSentFirstChunk {
|
||||
delta["role"] = "assistant"
|
||||
state.HasSentFirstChunk = true
|
||||
}
|
||||
|
||||
chunk := buildBaseChunk(state, delta, nil)
|
||||
result, _ := json.Marshal(chunk)
|
||||
state.ChunkIndex++
|
||||
return FormatSSEEvent(result)
|
||||
}
|
||||
|
||||
// BuildOpenAISSEToolCallStart creates an SSE event for tool call start
|
||||
func BuildOpenAISSEToolCallStart(state *OpenAIStreamState, toolUseID, toolName string) string {
|
||||
toolCall := map[string]interface{}{
|
||||
"index": state.ToolCallIndex,
|
||||
"id": toolUseID,
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": toolName,
|
||||
"arguments": "",
|
||||
},
|
||||
}
|
||||
|
||||
delta := map[string]interface{}{
|
||||
"tool_calls": []map[string]interface{}{toolCall},
|
||||
}
|
||||
|
||||
// Include role in first chunk if not sent yet
|
||||
if !state.HasSentFirstChunk {
|
||||
delta["role"] = "assistant"
|
||||
state.HasSentFirstChunk = true
|
||||
}
|
||||
|
||||
chunk := buildBaseChunk(state, delta, nil)
|
||||
result, _ := json.Marshal(chunk)
|
||||
state.ChunkIndex++
|
||||
return FormatSSEEvent(result)
|
||||
}
|
||||
|
||||
// BuildOpenAISSEToolCallArgumentsDelta creates an SSE event for tool call arguments delta
|
||||
func BuildOpenAISSEToolCallArgumentsDelta(state *OpenAIStreamState, argumentsDelta string, toolIndex int) string {
|
||||
toolCall := map[string]interface{}{
|
||||
"index": toolIndex,
|
||||
"function": map[string]interface{}{
|
||||
"arguments": argumentsDelta,
|
||||
},
|
||||
}
|
||||
|
||||
delta := map[string]interface{}{
|
||||
"tool_calls": []map[string]interface{}{toolCall},
|
||||
}
|
||||
|
||||
chunk := buildBaseChunk(state, delta, nil)
|
||||
result, _ := json.Marshal(chunk)
|
||||
state.ChunkIndex++
|
||||
return FormatSSEEvent(result)
|
||||
}
|
||||
|
||||
// BuildOpenAISSEFinish creates an SSE event with finish_reason
|
||||
func BuildOpenAISSEFinish(state *OpenAIStreamState, finishReason string) string {
|
||||
chunk := buildBaseChunk(state, map[string]interface{}{}, &finishReason)
|
||||
result, _ := json.Marshal(chunk)
|
||||
state.ChunkIndex++
|
||||
return FormatSSEEvent(result)
|
||||
}
|
||||
|
||||
// BuildOpenAISSEUsage creates an SSE event with usage information
|
||||
func BuildOpenAISSEUsage(state *OpenAIStreamState, usageInfo usage.Detail) string {
|
||||
chunk := map[string]interface{}{
|
||||
"id": state.ResponseID,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": state.Created,
|
||||
"model": state.Model,
|
||||
"choices": []map[string]interface{}{},
|
||||
"usage": map[string]interface{}{
|
||||
"prompt_tokens": usageInfo.InputTokens,
|
||||
"completion_tokens": usageInfo.OutputTokens,
|
||||
"total_tokens": usageInfo.InputTokens + usageInfo.OutputTokens,
|
||||
},
|
||||
}
|
||||
result, _ := json.Marshal(chunk)
|
||||
return FormatSSEEvent(result)
|
||||
}
|
||||
|
||||
// BuildOpenAISSEDone creates the final [DONE] SSE event.
|
||||
// Note: This returns raw "[DONE]" without "data:" prefix.
|
||||
// The SSE "data:" prefix is added by the Handler layer (e.g., openai_handlers.go)
|
||||
// to maintain architectural consistency and avoid double-prefix issues.
|
||||
func BuildOpenAISSEDone() string {
|
||||
return "[DONE]"
|
||||
}
|
||||
|
||||
// buildBaseChunk creates a base chunk structure for streaming
|
||||
func buildBaseChunk(state *OpenAIStreamState, delta map[string]interface{}, finishReason *string) map[string]interface{} {
|
||||
choice := map[string]interface{}{
|
||||
"index": 0,
|
||||
"delta": delta,
|
||||
}
|
||||
|
||||
if finishReason != nil {
|
||||
choice["finish_reason"] = *finishReason
|
||||
} else {
|
||||
choice["finish_reason"] = nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"id": state.ResponseID,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": state.Created,
|
||||
"model": state.Model,
|
||||
"choices": []map[string]interface{}{choice},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildOpenAISSEReasoningDelta creates an SSE event for reasoning content delta
|
||||
// This is used for o1/o3 style models that expose reasoning tokens
|
||||
func BuildOpenAISSEReasoningDelta(state *OpenAIStreamState, reasoningDelta string) string {
|
||||
delta := map[string]interface{}{
|
||||
"reasoning_content": reasoningDelta,
|
||||
}
|
||||
|
||||
// Include role in first chunk
|
||||
if !state.HasSentFirstChunk {
|
||||
delta["role"] = "assistant"
|
||||
state.HasSentFirstChunk = true
|
||||
}
|
||||
|
||||
chunk := buildBaseChunk(state, delta, nil)
|
||||
result, _ := json.Marshal(chunk)
|
||||
state.ChunkIndex++
|
||||
return FormatSSEEvent(result)
|
||||
}
|
||||
|
||||
// BuildOpenAISSEFirstChunk creates the first chunk with role only
|
||||
func BuildOpenAISSEFirstChunk(state *OpenAIStreamState) string {
|
||||
delta := map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
}
|
||||
|
||||
state.HasSentFirstChunk = true
|
||||
chunk := buildBaseChunk(state, delta, nil)
|
||||
result, _ := json.Marshal(chunk)
|
||||
state.ChunkIndex++
|
||||
return FormatSSEEvent(result)
|
||||
}
|
||||
|
||||
// ThinkingTagState tracks state for thinking tag detection in streaming
|
||||
type ThinkingTagState struct {
|
||||
InThinkingBlock bool
|
||||
PendingStartChars int
|
||||
PendingEndChars int
|
||||
}
|
||||
|
||||
// NewThinkingTagState creates a new thinking tag state
|
||||
func NewThinkingTagState() *ThinkingTagState {
|
||||
return &ThinkingTagState{
|
||||
InThinkingBlock: false,
|
||||
PendingStartChars: 0,
|
||||
PendingEndChars: 0,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user