feat: 添加 OpenAI Chat Completions 兼容端点
基于 @yulate 在 PR #648 (commit 0bb6a392) 的工作,解决了与最新 main 分支的合并冲突。 原始功能(@yulate): - 添加 /v1/chat/completions 和 /chat/completions 兼容端点 - 将 Chat Completions 请求转换为 Responses API 格式并转换回来 - 添加 API Key 直连转发支持 - 包含单元测试 Co-authored-by: yulate <yulate@users.noreply.github.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -36,6 +37,7 @@ const (
|
||||
chatgptCodexURL = "https://chatgpt.com/backend-api/codex/responses"
|
||||
// OpenAI Platform API for API Key accounts (fallback)
|
||||
openaiPlatformAPIURL = "https://api.openai.com/v1/responses"
|
||||
openaiChatAPIURL = "https://api.openai.com/v1/chat/completions"
|
||||
openaiStickySessionTTL = time.Hour // 粘性会话TTL
|
||||
codexCLIUserAgent = "codex_cli_rs/0.104.0"
|
||||
// codex_cli_only 拒绝时单个请求头日志长度上限(字符)
|
||||
@@ -54,6 +56,16 @@ const (
|
||||
codexCLIVersion = "0.104.0"
|
||||
)
|
||||
|
||||
// OpenAIChatCompletionsBodyKey stores the original chat-completions payload in gin.Context.
|
||||
const OpenAIChatCompletionsBodyKey = "openai_chat_completions_body"
|
||||
|
||||
// OpenAIChatCompletionsIncludeUsageKey stores stream_options.include_usage in gin.Context.
|
||||
const OpenAIChatCompletionsIncludeUsageKey = "openai_chat_completions_include_usage"
|
||||
|
||||
// openaiSSEDataRe matches SSE data lines with optional whitespace after colon.
|
||||
// Some upstream APIs return non-standard "data:" without space (should be "data: ").
|
||||
var openaiSSEDataRe = regexp.MustCompile(`^data:\s*`)
|
||||
|
||||
// OpenAI allowed headers whitelist (for non-passthrough).
|
||||
var openaiAllowedHeaders = map[string]bool{
|
||||
"accept-language": true,
|
||||
@@ -97,6 +109,19 @@ var codexCLIOnlyDebugHeaderWhitelist = []string{
|
||||
"X-Real-IP",
|
||||
}
|
||||
|
||||
// OpenAI chat-completions allowed headers (extend responses whitelist).
|
||||
var openaiChatAllowedHeaders = map[string]bool{
|
||||
"accept-language": true,
|
||||
"content-type": true,
|
||||
"conversation_id": true,
|
||||
"user-agent": true,
|
||||
"originator": true,
|
||||
"session_id": true,
|
||||
"openai-organization": true,
|
||||
"openai-project": true,
|
||||
"openai-beta": true,
|
||||
}
|
||||
|
||||
// OpenAICodexUsageSnapshot represents Codex API usage limits from response headers
|
||||
type OpenAICodexUsageSnapshot struct {
|
||||
PrimaryUsedPercent *float64 `json:"primary_used_percent,omitempty"`
|
||||
@@ -1577,6 +1602,23 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed")
|
||||
}
|
||||
|
||||
if c != nil && account != nil && account.Type == AccountTypeAPIKey {
|
||||
if raw, ok := c.Get(OpenAIChatCompletionsBodyKey); ok {
|
||||
if rawBody, ok := raw.([]byte); ok && len(rawBody) > 0 {
|
||||
includeUsage := false
|
||||
if v, ok := c.Get(OpenAIChatCompletionsIncludeUsageKey); ok {
|
||||
if flag, ok := v.(bool); ok {
|
||||
includeUsage = flag
|
||||
}
|
||||
}
|
||||
if passthroughWriter, ok := c.Writer.(interface{ SetPassthrough() }); ok {
|
||||
passthroughWriter.SetPassthrough()
|
||||
}
|
||||
return s.forwardChatCompletions(ctx, c, account, rawBody, includeUsage, startTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
originalBody := body
|
||||
reqModel, reqStream, promptCacheKey := extractOpenAIRequestMetaFromBody(body)
|
||||
originalModel := reqModel
|
||||
|
||||
Reference in New Issue
Block a user