Files
CLIProxyAPIPlus/internal/translator/kiro/openai/kiro_openai_stream.go
Ravens2121 9c04c18c04 feat(kiro): enhance request translation and fix streaming issues
English:
- Fix <thinking> tag parsing: only parse at response start, avoid misinterpreting discussion text
- Add token counting support using tiktoken for local estimation
- Support top_p parameter in inference config
- Handle max_tokens=-1 as maximum (32000 tokens)
- Add tool_choice and response_format parameter handling via system prompt hints
- Support multiple thinking mode detection formats (Claude API, OpenAI reasoning_effort, AMP/Cursor)
- Shorten MCP tool names exceeding 64 characters
- Fix duplicate [DONE] marker in OpenAI SSE streaming
- Enhance token usage statistics with multiple event format support
- Add code fence markers to constants

中文:
- 修复 <thinking> 标签解析:仅在响应开头解析,避免误解析讨论文本中的标签
- 使用 tiktoken 实现本地 token 计数功能
- 支持 top_p 推理配置参数
- 处理 max_tokens=-1 转换为最大值(32000 tokens)
- 通过系统提示词注入实现 tool_choice 和 response_format 参数支持
- 支持多种思考模式检测格式(Claude API、OpenAI reasoning_effort、AMP/Cursor)
- 截断超过64字符的 MCP 工具名称
- 修复 OpenAI SSE 流中重复的 [DONE] 标记
- 增强 token 使用量统计,支持多种事件格式
- 添加代码围栏标记常量
2025-12-14 11:57:16 +08:00

212 lines
6.1 KiB
Go

// Package openai provides streaming SSE event building for OpenAI format.
// This package handles the construction of OpenAI-compatible Server-Sent Events (SSE)
// for streaming responses from Kiro API.
package openai
import (
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
)
// OpenAIStreamState tracks the state of streaming response conversion
type OpenAIStreamState struct {
ChunkIndex int
ToolCallIndex int
HasSentFirstChunk bool
Model string
ResponseID string
Created int64
}
// NewOpenAIStreamState creates a new stream state for tracking
func NewOpenAIStreamState(model string) *OpenAIStreamState {
return &OpenAIStreamState{
ChunkIndex: 0,
ToolCallIndex: 0,
HasSentFirstChunk: false,
Model: model,
ResponseID: "chatcmpl-" + uuid.New().String()[:24],
Created: time.Now().Unix(),
}
}
// FormatSSEEvent formats a JSON payload for SSE streaming.
// Note: This returns raw JSON data without "data:" prefix.
// The SSE "data:" prefix is added by the Handler layer (e.g., openai_handlers.go)
// to maintain architectural consistency and avoid double-prefix issues.
func FormatSSEEvent(data []byte) string {
return string(data)
}
// BuildOpenAISSETextDelta creates an SSE event for text content delta
func BuildOpenAISSETextDelta(state *OpenAIStreamState, textDelta string) string {
delta := map[string]interface{}{
"content": textDelta,
}
// Include role in first chunk
if !state.HasSentFirstChunk {
delta["role"] = "assistant"
state.HasSentFirstChunk = true
}
chunk := buildBaseChunk(state, delta, nil)
result, _ := json.Marshal(chunk)
state.ChunkIndex++
return FormatSSEEvent(result)
}
// BuildOpenAISSEToolCallStart creates an SSE event for tool call start
func BuildOpenAISSEToolCallStart(state *OpenAIStreamState, toolUseID, toolName string) string {
toolCall := map[string]interface{}{
"index": state.ToolCallIndex,
"id": toolUseID,
"type": "function",
"function": map[string]interface{}{
"name": toolName,
"arguments": "",
},
}
delta := map[string]interface{}{
"tool_calls": []map[string]interface{}{toolCall},
}
// Include role in first chunk if not sent yet
if !state.HasSentFirstChunk {
delta["role"] = "assistant"
state.HasSentFirstChunk = true
}
chunk := buildBaseChunk(state, delta, nil)
result, _ := json.Marshal(chunk)
state.ChunkIndex++
return FormatSSEEvent(result)
}
// BuildOpenAISSEToolCallArgumentsDelta creates an SSE event for tool call arguments delta
func BuildOpenAISSEToolCallArgumentsDelta(state *OpenAIStreamState, argumentsDelta string, toolIndex int) string {
toolCall := map[string]interface{}{
"index": toolIndex,
"function": map[string]interface{}{
"arguments": argumentsDelta,
},
}
delta := map[string]interface{}{
"tool_calls": []map[string]interface{}{toolCall},
}
chunk := buildBaseChunk(state, delta, nil)
result, _ := json.Marshal(chunk)
state.ChunkIndex++
return FormatSSEEvent(result)
}
// BuildOpenAISSEFinish creates an SSE event with finish_reason
func BuildOpenAISSEFinish(state *OpenAIStreamState, finishReason string) string {
chunk := buildBaseChunk(state, map[string]interface{}{}, &finishReason)
result, _ := json.Marshal(chunk)
state.ChunkIndex++
return FormatSSEEvent(result)
}
// BuildOpenAISSEUsage creates an SSE event with usage information
func BuildOpenAISSEUsage(state *OpenAIStreamState, usageInfo usage.Detail) string {
chunk := map[string]interface{}{
"id": state.ResponseID,
"object": "chat.completion.chunk",
"created": state.Created,
"model": state.Model,
"choices": []map[string]interface{}{},
"usage": map[string]interface{}{
"prompt_tokens": usageInfo.InputTokens,
"completion_tokens": usageInfo.OutputTokens,
"total_tokens": usageInfo.InputTokens + usageInfo.OutputTokens,
},
}
result, _ := json.Marshal(chunk)
return FormatSSEEvent(result)
}
// BuildOpenAISSEDone creates the final [DONE] SSE event.
// Note: This returns raw "[DONE]" without "data:" prefix.
// The SSE "data:" prefix is added by the Handler layer (e.g., openai_handlers.go)
// to maintain architectural consistency and avoid double-prefix issues.
func BuildOpenAISSEDone() string {
return "[DONE]"
}
// buildBaseChunk creates a base chunk structure for streaming
func buildBaseChunk(state *OpenAIStreamState, delta map[string]interface{}, finishReason *string) map[string]interface{} {
choice := map[string]interface{}{
"index": 0,
"delta": delta,
}
if finishReason != nil {
choice["finish_reason"] = *finishReason
} else {
choice["finish_reason"] = nil
}
return map[string]interface{}{
"id": state.ResponseID,
"object": "chat.completion.chunk",
"created": state.Created,
"model": state.Model,
"choices": []map[string]interface{}{choice},
}
}
// BuildOpenAISSEReasoningDelta creates an SSE event for reasoning content delta
// This is used for o1/o3 style models that expose reasoning tokens
func BuildOpenAISSEReasoningDelta(state *OpenAIStreamState, reasoningDelta string) string {
delta := map[string]interface{}{
"reasoning_content": reasoningDelta,
}
// Include role in first chunk
if !state.HasSentFirstChunk {
delta["role"] = "assistant"
state.HasSentFirstChunk = true
}
chunk := buildBaseChunk(state, delta, nil)
result, _ := json.Marshal(chunk)
state.ChunkIndex++
return FormatSSEEvent(result)
}
// BuildOpenAISSEFirstChunk creates the first chunk with role only
func BuildOpenAISSEFirstChunk(state *OpenAIStreamState) string {
delta := map[string]interface{}{
"role": "assistant",
"content": "",
}
state.HasSentFirstChunk = true
chunk := buildBaseChunk(state, delta, nil)
result, _ := json.Marshal(chunk)
state.ChunkIndex++
return FormatSSEEvent(result)
}
// ThinkingTagState tracks state for thinking tag detection in streaming
type ThinkingTagState struct {
InThinkingBlock bool
PendingStartChars int
PendingEndChars int
}
// NewThinkingTagState creates a new thinking tag state
func NewThinkingTagState() *ThinkingTagState {
return &ThinkingTagState{
InThinkingBlock: false,
PendingStartChars: 0,
PendingEndChars: 0,
}
}