Files
kirogo/proxy/translator.go
huangzhenpc a6e11c6d22
Some checks failed
Build Docker Image / build (push) Has been cancelled
fix: drop --- SYSTEM PROMPT --- wrapper in Claude path to avoid Kiro injection detection
Kiro's upstream model is trained to identify and resist
--- SYSTEM PROMPT --- marker blocks as injection attempts, causing it
to actively reject the user's system prompt and self-correct its
identity. Switch the Claude path to the same plain-prepend approach
already used by the OpenAI path: system content is joined directly
before the user message without any marker, matching natural context.

The sanitizer (reSysPromptBlock) still strips the old marker format
from conversation history until existing contamination clears out.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 11:41:44 +08:00

1366 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package proxy
import (
"encoding/base64"
"encoding/json"
"regexp"
"strings"
"time"
"github.com/google/uuid"
)
// reSysPromptBlock matches the --- SYSTEM PROMPT --- wrapper this proxy injects around system
// prompts before forwarding to Kiro. When Kiro echoes the content back the block can end up
// in subsequent conversation history and must be stripped before the next upstream request.
var reSysPromptBlock = regexp.MustCompile(`(?s)---+\s*SYSTEM\s+PROMPT\s*---+.*?---+\s*END\s+SYSTEM\s+PROMPT\s*---+\n?`)
// reSystemReminder matches <system-reminder> blocks injected by Claude Code into conversation
// context; these are internal metadata and must not be forwarded to Kiro.
var reSystemReminder = regexp.MustCompile(`(?s)<system-reminder>.*?</system-reminder>\n?`)
// sanitizeText removes known proxy-injected and tool-injected marker blocks from message text
// so they are not forwarded upstream where they can pollute Kiro's context.
func sanitizeText(s string) string {
s = reSysPromptBlock.ReplaceAllString(s, "")
s = reSystemReminder.ReplaceAllString(s, "")
return strings.TrimSpace(s)
}
// 模型映射(有序,长 key 优先匹配,避免 "claude-sonnet-4" 误匹配 "claude-sonnet-4.5"
type modelMapping struct {
key string
value string
boundary bool // 仅在 key 末位为单纯版本号时启用:要求 key 后面不能再跟 .X / -X / 数字
}
var modelMapOrdered = []modelMapping{
{key: "claude-sonnet-4-20250514", value: "claude-sonnet-4"},
{key: "claude-sonnet-4-5", value: "claude-sonnet-4.5"},
{key: "claude-sonnet-4.5", value: "claude-sonnet-4.5"},
{key: "claude-sonnet-4-6", value: "claude-sonnet-4.6"},
{key: "claude-sonnet-4.6", value: "claude-sonnet-4.6"},
{key: "claude-sonnet-4-7", value: "claude-sonnet-4.7"},
{key: "claude-sonnet-4.7", value: "claude-sonnet-4.7"},
{key: "claude-opus-4-7", value: "claude-opus-4.7"},
{key: "claude-opus-4.7", value: "claude-opus-4.7"},
{key: "claude-haiku-4-5", value: "claude-haiku-4.5"},
{key: "claude-haiku-4.5", value: "claude-haiku-4.5"},
{key: "claude-haiku-4-7", value: "claude-haiku-4.7"},
{key: "claude-haiku-4.7", value: "claude-haiku-4.7"},
{key: "claude-opus-4-5", value: "claude-opus-4.5"},
{key: "claude-opus-4.5", value: "claude-opus-4.5"},
{key: "claude-opus-4-6", value: "claude-opus-4.6"},
{key: "claude-opus-4.6", value: "claude-opus-4.6"},
{key: "claude-sonnet-4", value: "claude-sonnet-4", boundary: true},
{key: "claude-3-5-sonnet", value: "claude-sonnet-4.5"},
{key: "claude-3-opus", value: "claude-sonnet-4.5"},
{key: "claude-3-sonnet", value: "claude-sonnet-4"},
{key: "claude-3-haiku", value: "claude-haiku-4.5"},
{key: "gpt-4-turbo", value: "claude-sonnet-4.5"},
{key: "gpt-4o", value: "claude-sonnet-4.5"},
{key: "gpt-4", value: "claude-sonnet-4.5"},
{key: "gpt-3.5-turbo", value: "claude-sonnet-4.5"},
}
// modelKeyMatches 判断 input 是否匹配 mapping 的 key。
// 当 mapping.boundary=true 时,要求 key 后紧跟的字符不属于版本号延续
// (数字、点、或 "-数字"),防止 "claude-sonnet-4" 误吃 "claude-sonnet-4.7"。
func modelKeyMatches(input string, m modelMapping) bool {
idx := strings.Index(input, m.key)
if idx < 0 {
return false
}
if !m.boundary {
return true
}
end := idx + len(m.key)
if end >= len(input) {
return true
}
next := input[end]
if (next >= '0' && next <= '9') || next == '.' {
return false
}
if next == '-' && end+1 < len(input) {
n2 := input[end+1]
if n2 >= '0' && n2 <= '9' {
return false
}
}
return true
}
// Thinking 模式提示
const ThinkingModePrompt = `<thinking_mode>enabled</thinking_mode>
<max_thinking_length>200000</max_thinking_length>`
const minimalFallbackUserContent = "."
const toolResultsContinuationPrefix = "Tool results:"
// ParseModelAndThinking 解析模型名称,返回实际模型和是否启用 thinking
func ParseModelAndThinking(model string, thinkingSuffix string) (string, bool) {
lower := strings.ToLower(model)
thinking := false
// 使用配置的后缀检查
suffixLower := strings.ToLower(thinkingSuffix)
if strings.HasSuffix(lower, suffixLower) {
thinking = true
model = model[:len(model)-len(thinkingSuffix)]
lower = strings.ToLower(model)
}
// 映射模型(有序匹配,长 key 优先;带 boundary 标记的 key 要求版本号边界)
for _, m := range modelMapOrdered {
if modelKeyMatches(lower, m) {
return m.value, thinking
}
}
// 如果已经是有效的 Kiro 模型,直接返回
if strings.HasPrefix(lower, "claude-") {
return model, thinking
}
return model, thinking
}
func resolveClaudeThinkingMode(model string, thinkingCfg *ClaudeThinkingConfig, thinkingSuffix string) (string, bool) {
actualModel, suffixThinking := ParseModelAndThinking(model, thinkingSuffix)
return actualModel, suffixThinking || isClaudeThinkingRequested(thinkingCfg)
}
func isClaudeThinkingRequested(thinkingCfg *ClaudeThinkingConfig) bool {
if thinkingCfg == nil {
return false
}
kind := strings.ToLower(strings.TrimSpace(thinkingCfg.Type))
return kind == "enabled" || kind == "adaptive"
}
func MapModel(model string) string {
mapped, _ := ParseModelAndThinking(model, "-thinking")
return mapped
}
// ==================== Claude API 类型 ====================
type ClaudeRequest struct {
Model string `json:"model"`
Messages []ClaudeMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"`
System interface{} `json:"system,omitempty"` // string or []SystemBlock
Thinking *ClaudeThinkingConfig `json:"thinking,omitempty"`
Tools []ClaudeTool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
}
type ClaudeThinkingConfig struct {
Type string `json:"type,omitempty"`
BudgetTokens int `json:"budget_tokens,omitempty"`
Display string `json:"display,omitempty"`
}
type ClaudeMessage struct {
Role string `json:"role"`
Content interface{} `json:"content"` // string or []ContentBlock
}
type ClaudeContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Thinking string `json:"thinking,omitempty"`
Signature string `json:"signature,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input interface{} `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content interface{} `json:"content,omitempty"` // for tool_result
Source *ImageSource `json:"source,omitempty"`
}
type ImageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
}
type ClaudeTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema interface{} `json:"input_schema"`
}
type ClaudeResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []ClaudeContentBlock `json:"content"`
Model string `json:"model"`
StopReason string `json:"stop_reason"`
StopSequence *string `json:"stop_sequence"`
Usage ClaudeUsage `json:"usage"`
}
type ClaudeCacheCreationUsage struct {
Ephemeral5mInputTokens int `json:"ephemeral_5m_input_tokens,omitempty"`
Ephemeral1hInputTokens int `json:"ephemeral_1h_input_tokens,omitempty"`
}
type ClaudeUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"`
CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
CacheCreation *ClaudeCacheCreationUsage `json:"cache_creation,omitempty"`
}
// ==================== Claude -> Kiro 转换 ====================
const maxToolDescLen = 10237
func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload {
modelID := MapModel(req.Model)
origin := "AI_EDITOR"
// 提取系统提示
systemPrompt := buildClaudeSystemPrompt(req.System, thinking)
// 构建历史消息
history := make([]KiroHistoryMessage, 0)
var currentContent string
var currentImages []KiroImage
var currentToolResults []KiroToolResult
for i, msg := range req.Messages {
isLast := i == len(req.Messages)-1
if msg.Role == "user" {
content, images, toolResults := extractClaudeUserContent(msg.Content)
content = sanitizeText(content)
content = normalizeUserContent(content, len(images) > 0)
if isLast {
currentContent = content
currentImages = images
currentToolResults = toolResults
} else {
userMsg := KiroUserInputMessage{
Content: content,
ModelID: modelID,
Origin: origin,
}
if len(images) > 0 {
userMsg.Images = images
}
if len(toolResults) > 0 {
userMsg.UserInputMessageContext = &UserInputMessageContext{
ToolResults: toolResults,
}
}
history = append(history, KiroHistoryMessage{
UserInputMessage: &userMsg,
})
}
} else if msg.Role == "assistant" {
content, toolUses := extractClaudeAssistantContent(msg.Content)
content = sanitizeText(content)
history = append(history, KiroHistoryMessage{
AssistantResponseMessage: &KiroAssistantResponseMessage{
Content: content,
ToolUses: toolUses,
},
})
}
}
history = trimLeadingAssistantHistory(history)
// 构建最终内容(系统提示直接拼接,不加 --- SYSTEM PROMPT --- 标记以避免 Kiro 将其识别为注入攻击)
finalContent := ""
if systemPrompt != "" {
finalContent = systemPrompt + "\n\n"
}
if currentContent != "" {
finalContent += currentContent
} else if len(currentImages) > 0 {
finalContent += normalizeUserContent("", true)
} else if len(currentToolResults) > 0 {
finalContent += buildToolResultsContinuation(currentToolResults)
} else {
finalContent += minimalFallbackUserContent
}
// 转换工具
kiroTools := convertClaudeTools(req.Tools)
// 构建 payload
payload := &KiroPayload{}
payload.ConversationState.ChatTriggerType = "MANUAL"
payload.ConversationState.ConversationID = buildConversationID(modelID, systemPrompt, firstClaudeConversationAnchor(req.Messages))
payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{
Content: finalContent,
ModelID: modelID,
Origin: origin,
Images: currentImages,
}
if len(kiroTools) > 0 || len(currentToolResults) > 0 {
payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext = &UserInputMessageContext{
Tools: kiroTools,
ToolResults: currentToolResults,
}
}
if len(history) > 0 {
payload.ConversationState.History = history
}
if req.MaxTokens > 0 || req.Temperature > 0 || req.TopP > 0 {
payload.InferenceConfig = &InferenceConfig{
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
TopP: req.TopP,
}
}
return payload
}
func buildClaudeSystemPrompt(system interface{}, thinking bool) string {
systemPrompt := extractSystemPrompt(system)
if !thinking {
return systemPrompt
}
if systemPrompt == "" {
return ThinkingModePrompt
}
return ThinkingModePrompt + "\n\n" + systemPrompt
}
func cloneClaudeRequestForThinking(req *ClaudeRequest, thinking bool) *ClaudeRequest {
if req == nil {
return nil
}
cloned := *req
if thinking {
cloned.System = prependThinkingSystem(req.System)
}
return &cloned
}
func prependThinkingSystem(system interface{}) interface{} {
thinkingText := ThinkingModePrompt
if hasClaudeSystemContent(system) {
thinkingText += "\n"
}
thinkingBlock := map[string]interface{}{
"type": "text",
"text": thinkingText,
}
switch v := system.(type) {
case nil:
return []interface{}{thinkingBlock}
case string:
if v == "" {
return []interface{}{thinkingBlock}
}
return []interface{}{
thinkingBlock,
map[string]interface{}{
"type": "text",
"text": v,
},
}
case []interface{}:
blocks := make([]interface{}, 0, len(v)+1)
blocks = append(blocks, thinkingBlock)
blocks = append(blocks, v...)
return blocks
case []string:
blocks := make([]interface{}, 0, len(v)+1)
blocks = append(blocks, thinkingBlock)
for _, block := range v {
blocks = append(blocks, map[string]interface{}{
"type": "text",
"text": block,
})
}
return blocks
default:
return []interface{}{thinkingBlock}
}
}
func hasClaudeSystemContent(system interface{}) bool {
switch v := system.(type) {
case nil:
return false
case string:
return v != ""
case []interface{}:
return len(v) > 0
case []string:
return len(v) > 0
default:
return true
}
}
func extractSystemPrompt(system interface{}) string {
if system == nil {
return ""
}
if s, ok := system.(string); ok {
return s
}
if blocks, ok := system.([]interface{}); ok {
var parts []string
for _, b := range blocks {
if block, ok := b.(map[string]interface{}); ok {
if text, ok := block["text"].(string); ok {
parts = append(parts, text)
}
}
}
return strings.Join(parts, "\n")
}
return ""
}
func extractClaudeUserContent(content interface{}) (string, []KiroImage, []KiroToolResult) {
var text string
var images []KiroImage
var toolResults []KiroToolResult
if s, ok := content.(string); ok {
return s, nil, nil
}
if blocks, ok := content.([]interface{}); ok {
for _, b := range blocks {
block, ok := b.(map[string]interface{})
if !ok {
continue
}
blockType, _ := block["type"].(string)
switch blockType {
case "text", "input_text":
if t, ok := block["text"].(string); ok {
text += t
}
case "image", "image_url", "input_image":
if img := extractImageFromClaudeBlock(block); img != nil {
images = append(images, *img)
}
case "tool_result":
toolUseID, _ := block["tool_use_id"].(string)
resultContent := extractToolResultContent(block["content"])
toolResults = append(toolResults, KiroToolResult{
ToolUseID: toolUseID,
Content: []KiroResultContent{{Text: resultContent}},
Status: "success",
})
}
}
}
return text, images, toolResults
}
func extractImageFromClaudeBlock(block map[string]interface{}) *KiroImage {
if source, ok := block["source"].(map[string]interface{}); ok {
if data, ok := source["data"].(string); ok {
if img := parseDataURL(data); img != nil {
return img
}
mediaType, _ := source["media_type"].(string)
if mediaType == "" {
mediaType, _ = source["mediaType"].(string)
}
if mediaType == "" {
mediaType, _ = source["mime_type"].(string)
}
format := strings.TrimPrefix(strings.ToLower(mediaType), "image/")
if img := parseBase64Image(data, format); img != nil {
return img
}
}
if url, ok := source["url"].(string); ok {
if img := parseDataURL(url); img != nil {
return img
}
}
}
if img := extractImageFromOpenAIPart(block); img != nil {
return img
}
if data, ok := block["data"].(string); ok {
if img := parseDataURL(data); img != nil {
return img
}
}
return nil
}
func extractToolResultContent(content interface{}) string {
if s, ok := content.(string); ok {
return s
}
if blocks, ok := content.([]interface{}); ok {
var parts []string
for _, b := range blocks {
if block, ok := b.(map[string]interface{}); ok {
if text, ok := block["text"].(string); ok {
parts = append(parts, text)
}
}
}
return strings.Join(parts, "")
}
return ""
}
func extractClaudeAssistantContent(content interface{}) (string, []KiroToolUse) {
var text string
var toolUses []KiroToolUse
if s, ok := content.(string); ok {
return s, nil
}
if blocks, ok := content.([]interface{}); ok {
for _, b := range blocks {
block, ok := b.(map[string]interface{})
if !ok {
continue
}
blockType, _ := block["type"].(string)
switch blockType {
case "text":
if t, ok := block["text"].(string); ok {
text += t
}
case "tool_use":
id, _ := block["id"].(string)
name, _ := block["name"].(string)
input, _ := block["input"].(map[string]interface{})
if input == nil {
input = make(map[string]interface{})
}
toolUses = append(toolUses, KiroToolUse{
ToolUseID: id,
Name: name,
Input: input,
})
}
}
}
return text, toolUses
}
func convertClaudeTools(tools []ClaudeTool) []KiroToolWrapper {
if len(tools) == 0 {
return nil
}
result := make([]KiroToolWrapper, len(tools))
for i, tool := range tools {
desc := tool.Description
if len(desc) > maxToolDescLen {
desc = desc[:maxToolDescLen] + "..."
}
result[i] = KiroToolWrapper{}
result[i].ToolSpecification.Name = shortenToolName(tool.Name)
result[i].ToolSpecification.Description = desc
result[i].ToolSpecification.InputSchema = InputSchema{JSON: tool.InputSchema}
}
return result
}
func shortenToolName(name string) string {
if len(name) <= 64 {
return name
}
// MCP tools: mcp__server__tool -> mcp__tool
if strings.HasPrefix(name, "mcp__") {
lastIdx := strings.LastIndex(name, "__")
if lastIdx > 5 {
shortened := "mcp__" + name[lastIdx+2:]
if len(shortened) <= 64 {
return shortened
}
}
}
return name[:64]
}
// ==================== Kiro -> Claude 转换 ====================
func KiroToClaudeResponse(content, thinkingContent string, includeEmptyThinkingBlock bool, toolUses []KiroToolUse, inputTokens, outputTokens int, model string) *ClaudeResponse {
blocks := make([]ClaudeContentBlock, 0)
if thinkingContent != "" || includeEmptyThinkingBlock {
blocks = append(blocks, ClaudeContentBlock{
Type: "thinking",
Thinking: thinkingContent,
})
}
if content != "" {
blocks = append(blocks, ClaudeContentBlock{
Type: "text",
Text: content,
})
}
for _, tu := range toolUses {
blocks = append(blocks, ClaudeContentBlock{
Type: "tool_use",
ID: tu.ToolUseID,
Name: tu.Name,
Input: tu.Input,
})
}
stopReason := "end_turn"
if len(toolUses) > 0 {
stopReason = "tool_use"
}
return &ClaudeResponse{
ID: "msg_" + uuid.New().String(),
Type: "message",
Role: "assistant",
Content: blocks,
Model: model,
StopReason: stopReason,
Usage: ClaudeUsage{
InputTokens: inputTokens,
OutputTokens: outputTokens,
},
}
}
// ==================== OpenAI API 类型 ====================
type OpenAIRequest struct {
Model string `json:"model"`
Messages []OpenAIMessage `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []OpenAITool `json:"tools,omitempty"`
}
type OpenAIMessage struct {
Role string `json:"role"`
Content interface{} `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
type OpenAITool struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters interface{} `json:"parameters"`
} `json:"function"`
}
type OpenAIResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []OpenAIChoice `json:"choices"`
Usage OpenAIUsage `json:"usage"`
}
type OpenAIChoice struct {
Index int `json:"index"`
Message OpenAIMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type OpenAIUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// ==================== OpenAI -> Kiro 转换 ====================
func OpenAIToKiro(req *OpenAIRequest, thinking bool) *KiroPayload {
modelID := MapModel(req.Model)
origin := "AI_EDITOR"
// 提取系统提示
var systemPrompt string
var nonSystemMessages []OpenAIMessage
for _, msg := range req.Messages {
if msg.Role == "system" {
if s := extractOpenAIMessageText(msg.Content); s != "" {
systemPrompt += s + "\n"
}
} else {
nonSystemMessages = append(nonSystemMessages, msg)
}
}
// 如果启用 thinking 模式,注入 thinking 提示
if thinking {
systemPrompt = ThinkingModePrompt + "\n\n" + systemPrompt
}
// 构建历史消息
history := make([]KiroHistoryMessage, 0)
var currentContent string
var currentImages []KiroImage
var currentToolResults []KiroToolResult
systemMerged := false
for i, msg := range nonSystemMessages {
isLast := i == len(nonSystemMessages)-1
switch msg.Role {
case "user":
content, images := extractOpenAIUserContent(msg.Content)
content = sanitizeText(content)
content = normalizeUserContent(content, len(images) > 0)
// 第一条 user 消息合并 system prompt
if !systemMerged && systemPrompt != "" {
content = systemPrompt + "\n" + content
systemMerged = true
}
if isLast {
currentContent = content
currentImages = images
} else {
history = append(history, KiroHistoryMessage{
UserInputMessage: &KiroUserInputMessage{
Content: content,
ModelID: modelID,
Origin: origin,
Images: images,
},
})
}
case "assistant":
content := sanitizeText(extractOpenAIMessageText(msg.Content))
var toolUses []KiroToolUse
for _, tc := range msg.ToolCalls {
var input map[string]interface{}
json.Unmarshal([]byte(tc.Function.Arguments), &input)
if input == nil {
input = make(map[string]interface{})
}
toolUses = append(toolUses, KiroToolUse{
ToolUseID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
}
history = append(history, KiroHistoryMessage{
AssistantResponseMessage: &KiroAssistantResponseMessage{
Content: content,
ToolUses: toolUses,
},
})
case "tool":
content := extractOpenAIMessageText(msg.Content)
currentToolResults = append(currentToolResults, KiroToolResult{
ToolUseID: msg.ToolCallID,
Content: []KiroResultContent{{Text: content}},
Status: "success",
})
// 检查下一条是否还是 tool
nextIdx := i + 1
if nextIdx >= len(nonSystemMessages) || nonSystemMessages[nextIdx].Role != "tool" {
if !isLast {
history = append(history, KiroHistoryMessage{
UserInputMessage: &KiroUserInputMessage{
Content: buildToolResultsContinuation(currentToolResults),
ModelID: modelID,
Origin: origin,
UserInputMessageContext: &UserInputMessageContext{
ToolResults: currentToolResults,
},
},
})
currentToolResults = nil
}
}
}
}
// 构建最终内容
finalContent := currentContent
if finalContent == "" {
if len(currentImages) > 0 {
finalContent = normalizeUserContent("", true)
} else if len(currentToolResults) > 0 {
finalContent = buildToolResultsContinuation(currentToolResults)
} else {
finalContent = minimalFallbackUserContent
}
}
if !systemMerged && systemPrompt != "" {
finalContent = systemPrompt + "\n" + finalContent
}
// 转换工具
kiroTools := convertOpenAITools(req.Tools)
// 构建 payload
payload := &KiroPayload{}
payload.ConversationState.ChatTriggerType = "MANUAL"
payload.ConversationState.ConversationID = buildConversationID(modelID, systemPrompt, firstOpenAIConversationAnchor(nonSystemMessages))
payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{
Content: finalContent,
ModelID: modelID,
Origin: origin,
Images: currentImages,
}
if len(kiroTools) > 0 || len(currentToolResults) > 0 {
payload.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext = &UserInputMessageContext{
Tools: kiroTools,
ToolResults: currentToolResults,
}
}
if len(history) > 0 {
payload.ConversationState.History = history
}
if req.MaxTokens > 0 || req.Temperature > 0 || req.TopP > 0 {
payload.InferenceConfig = &InferenceConfig{
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
TopP: req.TopP,
}
}
return payload
}
func extractOpenAIUserContent(content interface{}) (string, []KiroImage) {
if s, ok := content.(string); ok {
return s, nil
}
var text string
var images []KiroImage
if part, ok := content.(map[string]interface{}); ok {
if t, ok := extractOpenAITextPart(part); ok {
text += t
}
if img := extractImageFromOpenAIPart(part); img != nil {
images = append(images, *img)
}
}
if parts, ok := content.([]interface{}); ok {
for _, p := range parts {
part, ok := p.(map[string]interface{})
if !ok {
continue
}
if t, ok := extractOpenAITextPart(part); ok {
text += t
}
if img := extractImageFromOpenAIPart(part); img != nil {
images = append(images, *img)
}
}
}
if len(images) > 0 {
text = sanitizeImagePlaceholders(text)
}
return text, images
}
func extractOpenAIMessageText(content interface{}) string {
if content == nil {
return ""
}
if s, ok := content.(string); ok {
return s
}
if text, _ := extractOpenAIUserContent(content); strings.TrimSpace(text) != "" {
return text
}
switch v := content.(type) {
case map[string]interface{}:
if nested, ok := v["content"]; ok {
if nestedText := extractOpenAIMessageText(nested); strings.TrimSpace(nestedText) != "" {
return nestedText
}
}
if raw, err := json.Marshal(v); err == nil {
return string(raw)
}
case []interface{}:
parts := make([]string, 0, len(v))
for _, item := range v {
partText := extractOpenAIMessageText(item)
if strings.TrimSpace(partText) != "" {
parts = append(parts, partText)
}
}
if len(parts) > 0 {
return strings.Join(parts, "")
}
if raw, err := json.Marshal(v); err == nil {
return string(raw)
}
default:
if raw, err := json.Marshal(v); err == nil {
return string(raw)
}
}
return ""
}
func buildToolResultsContinuation(toolResults []KiroToolResult) string {
if len(toolResults) == 0 {
return minimalFallbackUserContent
}
parts := make([]string, 0, len(toolResults))
for _, tr := range toolResults {
if len(tr.Content) == 0 {
continue
}
for _, c := range tr.Content {
if strings.TrimSpace(c.Text) != "" {
parts = append(parts, c.Text)
}
}
}
if len(parts) == 0 {
return minimalFallbackUserContent
}
joined := toolResultsContinuationPrefix + "\n\n" + strings.Join(parts, "\n\n")
if len(joined) > 4000 {
return joined[:4000]
}
return joined
}
func trimLeadingAssistantHistory(history []KiroHistoryMessage) []KiroHistoryMessage {
idx := 0
for idx < len(history) && history[idx].AssistantResponseMessage != nil {
idx++
}
if idx == 0 {
return history
}
if idx >= len(history) {
return nil
}
return history[idx:]
}
func firstClaudeConversationAnchor(messages []ClaudeMessage) string {
for _, msg := range messages {
if msg.Role != "user" {
continue
}
text, _, toolResults := extractClaudeUserContent(msg.Content)
if strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
if len(toolResults) > 0 {
continue
}
}
return ""
}
func firstOpenAIConversationAnchor(messages []OpenAIMessage) string {
for _, msg := range messages {
if msg.Role != "user" {
continue
}
text := extractOpenAIMessageText(msg.Content)
if strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func buildConversationID(modelID, systemPrompt, anchor string) string {
anchor = strings.TrimSpace(anchor)
if isSyntheticConversationAnchor(anchor) {
return uuid.New().String()
}
seed := strings.Join([]string{modelID, strings.TrimSpace(systemPrompt), anchor}, "\n")
return uuid.NewSHA1(uuid.NameSpaceURL, []byte(seed)).String()
}
func isSyntheticConversationAnchor(anchor string) bool {
if strings.TrimSpace(anchor) == "" {
return true
}
normalized := strings.ToLower(strings.Join(strings.Fields(anchor), " "))
switch normalized {
case ".", "begin conversation", "please analyze the attached image.", strings.ToLower(minimalFallbackUserContent):
return true
default:
return false
}
}
func extractOpenAITextPart(part map[string]interface{}) (string, bool) {
partType, _ := part["type"].(string)
switch partType {
case "text", "input_text":
if t, ok := part["text"].(string); ok {
return t, true
}
}
if t, ok := part["text"].(string); ok {
return t, true
}
return "", false
}
func extractImageFromOpenAIPart(part map[string]interface{}) *KiroImage {
partType, _ := part["type"].(string)
if partType != "" {
switch partType {
case "image", "image_url", "input_image", "file", "input_file":
default:
return nil
}
}
if fileObj, ok := part["file"].(map[string]interface{}); ok {
if img := extractImageFromOpenAIPart(fileObj); img != nil {
return img
}
}
if sourceObj, ok := part["source"].(map[string]interface{}); ok {
if img := extractImageFromOpenAIPart(sourceObj); img != nil {
return img
}
}
if raw, ok := part["mime"].(string); ok && !strings.HasPrefix(strings.ToLower(raw), "image/") {
return nil
}
if raw, ok := part["media_type"].(string); ok && !strings.HasPrefix(strings.ToLower(raw), "image/") {
return nil
}
if raw, ok := part["mime_type"].(string); ok && !strings.HasPrefix(strings.ToLower(raw), "image/") {
return nil
}
if raw, ok := part["url"].(string); ok {
if img := parseDataURL(raw); img != nil {
return img
}
}
if raw, ok := part["b64_json"].(string); ok {
if img := parseBase64Image(raw, "png"); img != nil {
return img
}
}
if raw, ok := part["image_url"]; ok {
switch v := raw.(type) {
case string:
if img := parseDataURL(v); img != nil {
return img
}
case map[string]interface{}:
if u, ok := v["url"].(string); ok {
if img := parseDataURL(u); img != nil {
return img
}
}
}
}
if raw, ok := part["image_base64"].(string); ok {
if img := parseBase64Image(raw, "png"); img != nil {
return img
}
}
if raw, ok := part["data"].(string); ok {
if img := parseDataURL(raw); img != nil {
return img
}
if img := parseBase64Image(raw, "png"); img != nil {
return img
}
}
return nil
}
func sanitizeImagePlaceholders(text string) string {
re := regexp.MustCompile(`\[Image\s+\d+\]`)
cleaned := re.ReplaceAllString(text, "")
cleaned = strings.Join(strings.Fields(cleaned), " ")
return strings.TrimSpace(cleaned)
}
func normalizeUserContent(text string, hasImages bool) string {
trimmed := strings.TrimSpace(text)
if trimmed == "" && hasImages {
return "Please analyze the attached image."
}
return trimmed
}
func parseDataURL(url string) *KiroImage {
cleaned := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(url, "\n", ""), "\r", ""))
if strings.Contains(cleaned, "[Image") {
return nil
}
re := regexp.MustCompile(`^data:image/([a-zA-Z0-9+.-]+)(;[a-zA-Z0-9=._:+-]+)*;base64,(.+)$`)
matches := re.FindStringSubmatch(cleaned)
if len(matches) == 4 {
return parseBase64Image(matches[3], matches[1])
}
if len(matches) != 3 {
return nil
}
return parseBase64Image(matches[2], matches[1])
}
func parseBase64Image(data, format string) *KiroImage {
format = strings.ToLower(format)
if format == "jpg" {
format = "jpeg"
}
// 验证 base64
if _, err := base64.StdEncoding.DecodeString(data); err != nil {
if _, errRaw := base64.RawStdEncoding.DecodeString(data); errRaw != nil {
if _, errURL := base64.URLEncoding.DecodeString(data); errURL != nil {
if _, errRawURL := base64.RawURLEncoding.DecodeString(data); errRawURL != nil {
return nil
}
}
}
}
if format == "" {
format = "png"
}
return &KiroImage{
Format: format,
Source: struct {
Bytes string `json:"bytes"`
}{Bytes: data},
}
}
func convertOpenAITools(tools []OpenAITool) []KiroToolWrapper {
if len(tools) == 0 {
return nil
}
result := make([]KiroToolWrapper, 0, len(tools))
for _, tool := range tools {
if tool.Type != "function" {
continue
}
desc := tool.Function.Description
if len(desc) > maxToolDescLen {
desc = desc[:maxToolDescLen] + "..."
}
wrapper := KiroToolWrapper{}
wrapper.ToolSpecification.Name = shortenToolName(tool.Function.Name)
wrapper.ToolSpecification.Description = desc
wrapper.ToolSpecification.InputSchema = InputSchema{JSON: tool.Function.Parameters}
result = append(result, wrapper)
}
return result
}
// ==================== Kiro -> OpenAI 转换 ====================
func KiroToOpenAIResponse(content string, toolUses []KiroToolUse, inputTokens, outputTokens int, model string) *OpenAIResponse {
msg := OpenAIMessage{
Role: "assistant",
}
finishReason := "stop"
if len(toolUses) > 0 {
msg.Content = nil
msg.ToolCalls = make([]ToolCall, len(toolUses))
for i, tu := range toolUses {
args, _ := json.Marshal(tu.Input)
msg.ToolCalls[i] = ToolCall{
ID: tu.ToolUseID,
Type: "function",
}
msg.ToolCalls[i].Function.Name = tu.Name
msg.ToolCalls[i].Function.Arguments = string(args)
}
finishReason = "tool_calls"
} else {
msg.Content = content
}
return &OpenAIResponse{
ID: "chatcmpl-" + uuid.New().String(),
Object: "chat.completion",
Created: time.Now().Unix(),
Model: model,
Choices: []OpenAIChoice{{
Index: 0,
Message: msg,
FinishReason: finishReason,
}},
Usage: OpenAIUsage{
PromptTokens: inputTokens,
CompletionTokens: outputTokens,
TotalTokens: inputTokens + outputTokens,
},
}
}
// extractThinkingFromContent 从内容中提取 <thinking> 标签内的内容
func extractThinkingFromContent(content string) (string, string) {
var reasoning string
result := content
for {
start := strings.Index(result, "<thinking>")
if start == -1 {
break
}
end := strings.Index(result[start:], "</thinking>")
if end == -1 {
break
}
end += start
// 提取 thinking 内容
thinkingContent := result[start+10 : end]
reasoning += thinkingContent
// 从结果中移除 thinking 标签
result = result[:start] + result[end+11:]
}
return strings.TrimSpace(result), reasoning
}
// KiroToOpenAIResponseWithReasoning 带 reasoning_content 的 OpenAI 响应
func KiroToOpenAIResponseWithReasoning(content, reasoningContent string, toolUses []KiroToolUse, inputTokens, outputTokens int, model, thinkingFormat string) map[string]interface{} {
finishReason := "stop"
message := map[string]interface{}{
"role": "assistant",
}
if len(toolUses) > 0 {
message["content"] = nil
toolCalls := make([]map[string]interface{}, len(toolUses))
for i, tu := range toolUses {
args, _ := json.Marshal(tu.Input)
toolCalls[i] = map[string]interface{}{
"id": tu.ToolUseID,
"type": "function",
"function": map[string]string{
"name": tu.Name,
"arguments": string(args),
},
}
}
message["tool_calls"] = toolCalls
finishReason = "tool_calls"
} else {
// 根据配置格式化 thinking 输出
if reasoningContent != "" {
switch thinkingFormat {
case "thinking":
message["content"] = "<thinking>" + reasoningContent + "</thinking>" + content
case "think":
message["content"] = "<think>" + reasoningContent + "</think>" + content
default: // "reasoning_content"
message["content"] = content
message["reasoning_content"] = reasoningContent
}
} else {
message["content"] = content
}
}
return map[string]interface{}{
"id": "chatcmpl-" + uuid.New().String(),
"object": "chat.completion",
"created": time.Now().Unix(),
"model": model,
"choices": []map[string]interface{}{{
"index": 0,
"message": message,
"finish_reason": finishReason,
}},
"usage": map[string]int{
"prompt_tokens": inputTokens,
"completion_tokens": outputTokens,
"total_tokens": inputTokens + outputTokens,
},
}
}