mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-26 20:55:47 +00:00
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 使用量统计,支持多种事件格式 - 添加代码围栏标记常量
212 lines
6.1 KiB
Go
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,
|
|
}
|
|
} |