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