Files
CLIProxyAPIPlus/internal/translator/kiro/openai/kiro_openai.go
2025-12-16 05:01:40 +08:00

371 lines
12 KiB
Go

// 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 := 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))
}