1313 lines
33 KiB
Go
1313 lines
33 KiB
Go
package proxy
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// 模型映射(有序,长 key 优先匹配,避免 "claude-sonnet-4" 误匹配 "claude-sonnet-4.5")
|
||
type modelMapping struct {
|
||
key string
|
||
value string
|
||
}
|
||
|
||
var modelMapOrdered = []modelMapping{
|
||
{"claude-sonnet-4-20250514", "claude-sonnet-4"},
|
||
{"claude-sonnet-4-5", "claude-sonnet-4.5"},
|
||
{"claude-sonnet-4.5", "claude-sonnet-4.5"},
|
||
{"claude-sonnet-4-6", "claude-sonnet-4.6"},
|
||
{"claude-sonnet-4.6", "claude-sonnet-4.6"},
|
||
{"claude-opus-4-7", "claude-opus-4.7"},
|
||
{"claude-opus-4.7", "claude-opus-4.7"},
|
||
{"claude-haiku-4-5", "claude-haiku-4.5"},
|
||
{"claude-haiku-4.5", "claude-haiku-4.5"},
|
||
{"claude-opus-4-5", "claude-opus-4.5"},
|
||
{"claude-opus-4.5", "claude-opus-4.5"},
|
||
{"claude-opus-4-6", "claude-opus-4.6"},
|
||
{"claude-opus-4.6", "claude-opus-4.6"},
|
||
{"claude-sonnet-4", "claude-sonnet-4"},
|
||
{"claude-3-5-sonnet", "claude-sonnet-4.5"},
|
||
{"claude-3-opus", "claude-sonnet-4.5"},
|
||
{"claude-3-sonnet", "claude-sonnet-4"},
|
||
{"claude-3-haiku", "claude-haiku-4.5"},
|
||
{"gpt-4-turbo", "claude-sonnet-4.5"},
|
||
{"gpt-4o", "claude-sonnet-4.5"},
|
||
{"gpt-4", "claude-sonnet-4.5"},
|
||
{"gpt-3.5-turbo", "claude-sonnet-4.5"},
|
||
}
|
||
|
||
// 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 优先)
|
||
for _, m := range modelMapOrdered {
|
||
if strings.Contains(lower, m.key) {
|
||
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 = 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)
|
||
history = append(history, KiroHistoryMessage{
|
||
AssistantResponseMessage: &KiroAssistantResponseMessage{
|
||
Content: content,
|
||
ToolUses: toolUses,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
history = trimLeadingAssistantHistory(history)
|
||
|
||
// 构建最终内容
|
||
finalContent := ""
|
||
if systemPrompt != "" {
|
||
finalContent = "--- SYSTEM PROMPT ---\n" + systemPrompt + "\n--- END SYSTEM PROMPT ---\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 = 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 := 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,
|
||
},
|
||
}
|
||
}
|