mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-26 20:25:46 +00:00
372 lines
12 KiB
Go
372 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 := 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))
|
|
}
|