1216 lines
47 KiB
Go
1216 lines
47 KiB
Go
package service
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"bytes"
|
|||
|
|
"context"
|
|||
|
|
"errors"
|
|||
|
|
"fmt"
|
|||
|
|
"io"
|
|||
|
|
"net/http"
|
|||
|
|
"strings"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
|||
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
|||
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
"github.com/tidwall/gjson"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// Forward forwards request to OpenAI API
|
|||
|
|
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
|
|||
|
|
beginUpstreamResponseModelObservation(c)
|
|||
|
|
clearGrokResponsesClientToolMapping(c)
|
|||
|
|
clearOpenAIResponsesClientToolMapping(c)
|
|||
|
|
clearOpenAIResponsesNamespaceNames(c)
|
|||
|
|
startTime := time.Now()
|
|||
|
|
// 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。
|
|||
|
|
canonicalImageIntentBody := body
|
|||
|
|
|
|||
|
|
restrictionResult := s.detectCodexClientRestriction(c, account, body)
|
|||
|
|
apiKeyID := getAPIKeyIDFromContext(c)
|
|||
|
|
logCodexCLIOnlyDetection(ctx, c, account, apiKeyID, restrictionResult, body)
|
|||
|
|
if restrictionResult.Enabled && !restrictionResult.Matched {
|
|||
|
|
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
|||
|
|
c.JSON(http.StatusForbidden, gin.H{
|
|||
|
|
"error": gin.H{
|
|||
|
|
"type": "forbidden_error",
|
|||
|
|
"message": CodexClientRestrictionMessage(restrictionResult),
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
normalizedBody, normalized, err := normalizeOpenAICodexCompactReasoningEffortForAccount(c, account, body)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if normalized {
|
|||
|
|
body = normalizedBody
|
|||
|
|
}
|
|||
|
|
// 在分流到 passthrough / Codex transform / 原生 ChatCompletions 之前统一修正
|
|||
|
|
// 显式为 null 的工具 Schema type,否则 upstream 的 400 会被归一成可重试的 502,
|
|||
|
|
// 同一份坏定义在账号池里反复重放。
|
|||
|
|
sanitizedToolBody, toolSchemaSanitized, toolSchemaErr := sanitizeOpenAIResponsesToolParameterTypes(body)
|
|||
|
|
if toolSchemaErr != nil {
|
|||
|
|
return nil, fmt.Errorf("sanitize OpenAI Responses tool parameters: %w", toolSchemaErr)
|
|||
|
|
}
|
|||
|
|
if toolSchemaSanitized {
|
|||
|
|
body = sanitizedToolBody
|
|||
|
|
}
|
|||
|
|
if account.IsOpenAIOAuth() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) {
|
|||
|
|
liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body)
|
|||
|
|
if liteErr != nil {
|
|||
|
|
setOpsUpstreamError(c, http.StatusBadRequest, liteErr.Error(), "")
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
|||
|
|
"type": "invalid_request_error", "message": liteErr.Error(), "param": "tools",
|
|||
|
|
}})
|
|||
|
|
return nil, liteErr
|
|||
|
|
}
|
|||
|
|
if changed {
|
|||
|
|
body = liteBody
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account)
|
|||
|
|
// 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。
|
|||
|
|
wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c))
|
|||
|
|
passthroughEnabled := account.IsOpenAIPassthroughEnabled()
|
|||
|
|
compactPath := isOpenAIResponsesCompactPath(c)
|
|||
|
|
if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled, compactPath) {
|
|||
|
|
body, err = flattenOpenAIResponsesNamespaces(c, body)
|
|||
|
|
if err != nil {
|
|||
|
|
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
|||
|
|
"type": "invalid_request_error", "message": err.Error(), "param": "tools",
|
|||
|
|
}})
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) {
|
|||
|
|
keepToolCallNamespaces := shouldKeepOpenAIResponsesToolCallNamespaces(
|
|||
|
|
account, wsDecision.Transport, passthroughEnabled, compactPath,
|
|||
|
|
)
|
|||
|
|
body, err = stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
|
|||
|
|
if err != nil {
|
|||
|
|
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
|||
|
|
"type": "invalid_request_error", "message": err.Error(), "param": "input",
|
|||
|
|
}})
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
originalBody := body
|
|||
|
|
requestView := newOpenAIRequestView(body)
|
|||
|
|
reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey
|
|||
|
|
originalModel := reqModel
|
|||
|
|
nativeDeepSeekResponses := account.Platform == PlatformDeepseek &&
|
|||
|
|
(account.GetAPIProtocol() == APIProtocolResponses || account.IsAdaptiveAPIProtocol())
|
|||
|
|
|
|||
|
|
if account.Platform == PlatformGrok {
|
|||
|
|
return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// CN 供应商 anthropic 协议账号:/v1/responses 入站是交叉协议组合
|
|||
|
|
// (Responses 客户端 × Anthropic 上游),转成 Anthropic 请求走原生端点。
|
|||
|
|
// 不能落到下面的 raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。
|
|||
|
|
if account.IsAnthropicProtocol() {
|
|||
|
|
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
|||
|
|
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
|
|||
|
|
}
|
|||
|
|
if account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey {
|
|||
|
|
sanitizedBody, changed, sanitizeErr := sanitizeOpenAIResponsesInputItemIDs(body)
|
|||
|
|
if sanitizeErr != nil {
|
|||
|
|
return nil, fmt.Errorf("sanitize OpenAI Responses input item IDs: %w", sanitizeErr)
|
|||
|
|
}
|
|||
|
|
if changed {
|
|||
|
|
body = sanitizedBody
|
|||
|
|
originalBody = sanitizedBody
|
|||
|
|
requestView = newOpenAIRequestView(sanitizedBody)
|
|||
|
|
reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
|
|||
|
|
originalModel = reqModel
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
compatMessagesBridge := isOpenAICompatMessagesBridgeBody(body)
|
|||
|
|
setOpenAICompatMessagesBridgeContext(c, compatMessagesBridge)
|
|||
|
|
|
|||
|
|
isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI)
|
|||
|
|
codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow
|
|||
|
|
if isCodexCLI {
|
|||
|
|
codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy()
|
|||
|
|
}
|
|||
|
|
if c != nil {
|
|||
|
|
c.Set("openai_ws_transport_decision", string(wsDecision.Transport))
|
|||
|
|
c.Set("openai_ws_transport_reason", wsDecision.Reason)
|
|||
|
|
}
|
|||
|
|
if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 {
|
|||
|
|
logOpenAIWSModeDebug(
|
|||
|
|
"selected account_id=%d account_type=%s transport=%s reason=%s model=%s stream=%v",
|
|||
|
|
account.ID,
|
|||
|
|
account.Type,
|
|||
|
|
normalizeOpenAIWSLogValue(string(wsDecision.Transport)),
|
|||
|
|
normalizeOpenAIWSLogValue(wsDecision.Reason),
|
|||
|
|
reqModel,
|
|||
|
|
reqStream,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
// 当前仅支持 WSv2;WSv1 命中时直接返回错误,避免出现“配置可开但行为不确定”。
|
|||
|
|
if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocket {
|
|||
|
|
if c != nil {
|
|||
|
|
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|||
|
|
"error": gin.H{
|
|||
|
|
"type": "invalid_request_error",
|
|||
|
|
"message": "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.",
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
return nil, errors.New("openai ws v1 is temporarily unsupported; use ws v2")
|
|||
|
|
}
|
|||
|
|
if passthroughEnabled {
|
|||
|
|
attemptImageIntentInvalidated := false
|
|||
|
|
if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip {
|
|||
|
|
strippedBody, changed, stripErr := stripOpenAIImageGenerationToolsFromRawPayload(body)
|
|||
|
|
if stripErr != nil {
|
|||
|
|
return nil, stripErr
|
|||
|
|
}
|
|||
|
|
if changed {
|
|||
|
|
body = strippedBody
|
|||
|
|
originalBody = strippedBody
|
|||
|
|
attemptImageIntentInvalidated = true
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 透传分支只需要轻量提取字段,避免热路径全量 Unmarshal。
|
|||
|
|
mappedModel := account.GetMappedModel(reqModel)
|
|||
|
|
reasoningEffort := extractOpenAIReasoningEffortFromBody(body, mappedModel)
|
|||
|
|
// 国产模型默认 effort 补充:也要用 mappedModel 判定是否是 passback-required 上游。
|
|||
|
|
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, mappedModel)
|
|||
|
|
return s.forwardOpenAIPassthrough(
|
|||
|
|
ctx,
|
|||
|
|
c,
|
|||
|
|
account,
|
|||
|
|
originalBody,
|
|||
|
|
canonicalImageIntentBody,
|
|||
|
|
reqModel,
|
|||
|
|
attemptImageIntentInvalidated,
|
|||
|
|
reasoningEffort,
|
|||
|
|
reqStream,
|
|||
|
|
startTime,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bodyModified := false
|
|||
|
|
var reqBody map[string]any
|
|||
|
|
ensureReqBody := func() (map[string]any, error) {
|
|||
|
|
if requestView.HasPatches() {
|
|||
|
|
patchedBody, patchErr := requestView.ApplyPatches()
|
|||
|
|
if patchErr != nil {
|
|||
|
|
return nil, patchErr
|
|||
|
|
}
|
|||
|
|
body = patchedBody
|
|||
|
|
requestView = newOpenAIRequestView(body)
|
|||
|
|
reqBody = nil
|
|||
|
|
bodyModified = false
|
|||
|
|
}
|
|||
|
|
if reqBody != nil {
|
|||
|
|
return reqBody, nil
|
|||
|
|
}
|
|||
|
|
decoded, decodeErr := requestView.Decode(c)
|
|||
|
|
if decodeErr != nil {
|
|||
|
|
return nil, decodeErr
|
|||
|
|
}
|
|||
|
|
reqBody = decoded
|
|||
|
|
return reqBody, nil
|
|||
|
|
}
|
|||
|
|
markPatchSet := func(path string, value any) {
|
|||
|
|
bodyModified = true
|
|||
|
|
if requestView.patchesDisabled {
|
|||
|
|
if reqBody != nil {
|
|||
|
|
setOpenAIRequestMapPath(reqBody, path, value)
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
requestView.MarkPatchSet(path, value)
|
|||
|
|
}
|
|||
|
|
markPatchDelete := func(path string) {
|
|||
|
|
bodyModified = true
|
|||
|
|
if requestView.patchesDisabled {
|
|||
|
|
if reqBody != nil {
|
|||
|
|
deleteOpenAIRequestMapPath(reqBody, path)
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
requestView.MarkPatchDelete(path)
|
|||
|
|
}
|
|||
|
|
disablePatch := func() {
|
|||
|
|
requestView.DisablePatches()
|
|||
|
|
}
|
|||
|
|
markDecodedModified := func() {
|
|||
|
|
bodyModified = true
|
|||
|
|
disablePatch()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
apiKey := getAPIKeyFromContext(c)
|
|||
|
|
imageGenerationAllowed := GroupAllowsImageGeneration(nil)
|
|||
|
|
if apiKey != nil {
|
|||
|
|
imageGenerationAllowed = GroupAllowsImageGeneration(apiKey.Group)
|
|||
|
|
}
|
|||
|
|
codexImageGenerationBridgeEnabled := isCodexCLI &&
|
|||
|
|
!isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) &&
|
|||
|
|
imageGenerationAllowed &&
|
|||
|
|
codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip &&
|
|||
|
|
s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
|
|||
|
|
var imageIntent bool
|
|||
|
|
canonicalImageIntent := resolveOpenAIImageIntentHint(c, reqModel, canonicalImageIntentBody, IsImageGenerationIntent)
|
|||
|
|
if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip {
|
|||
|
|
decoded, decodeErr := ensureReqBody()
|
|||
|
|
if decodeErr != nil {
|
|||
|
|
return nil, decodeErr
|
|||
|
|
}
|
|||
|
|
if stripOpenAIImageGenerationTools(decoded) {
|
|||
|
|
markDecodedModified()
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy")
|
|||
|
|
}
|
|||
|
|
imageIntent = IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, decoded)
|
|||
|
|
} else {
|
|||
|
|
imageIntent = canonicalImageIntent
|
|||
|
|
}
|
|||
|
|
if imageIntent && !imageGenerationAllowed {
|
|||
|
|
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|||
|
|
c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}})
|
|||
|
|
return nil, errors.New("image generation disabled for group")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
instructions := gjson.GetBytes(body, "instructions")
|
|||
|
|
instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == ""
|
|||
|
|
if instructionsEmpty && !compatMessagesBridge && !nativeDeepSeekResponses {
|
|||
|
|
markPatchSet("instructions", defaultCodexSynthInstructions(reqModel))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
billingModel := account.GetMappedModel(reqModel)
|
|||
|
|
if billingModel != reqModel {
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, billingModel, account.Name, isCodexCLI)
|
|||
|
|
reqModel = billingModel
|
|||
|
|
markPatchSet("model", billingModel)
|
|||
|
|
}
|
|||
|
|
upstreamModel := billingModel
|
|||
|
|
isCompactRequest := compactPath
|
|||
|
|
compactMapped := false
|
|||
|
|
if isCompactRequest {
|
|||
|
|
compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel)
|
|||
|
|
if compactMappedModel != "" && compactMappedModel != billingModel {
|
|||
|
|
compactMapped = true
|
|||
|
|
upstreamModel = compactMappedModel
|
|||
|
|
reqModel = compactMappedModel
|
|||
|
|
markPatchSet("model", compactMappedModel)
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", billingModel, compactMappedModel, account.Name, isCodexCLI)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !compactMapped {
|
|||
|
|
modelForNormalize := reqModel
|
|||
|
|
if modelForNormalize == "" {
|
|||
|
|
modelForNormalize = requestView.Model
|
|||
|
|
}
|
|||
|
|
upstreamModel = normalizeOpenAIModelForUpstream(account, modelForNormalize)
|
|||
|
|
if upstreamModel != "" && upstreamModel != modelForNormalize {
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", modelForNormalize, upstreamModel, account.Name, account.Type, isCodexCLI)
|
|||
|
|
reqModel = upstreamModel
|
|||
|
|
markPatchSet("model", upstreamModel)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) == "minimal" {
|
|||
|
|
markPatchSet("reasoning.effort", "none")
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized reasoning.effort: minimal -> none (account: %s)", account.Name)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
imageIntent = imageIntent || IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, nil) || isOpenAIImageGenerationModel(upstreamModel)
|
|||
|
|
if imageIntent && !imageGenerationAllowed {
|
|||
|
|
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|||
|
|
c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}})
|
|||
|
|
return nil, errors.New("image generation disabled for group")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// /responses/compact 是会话压缩请求:上游不接受 tool_choice(400 unknown_parameter),
|
|||
|
|
// 注入 image_generation 工具也没有意义,整块豁免。
|
|||
|
|
if imageGenerationAllowed && !isCompactRequest && (codexImageGenerationBridgeEnabled || isOpenAIImageGenerationModel(requestView.Model) || openAIRequestBodyImageGenerationToolNeedsNormalization(body) || isOpenAIImageGenerationModel(upstreamModel)) {
|
|||
|
|
decoded, decodeErr := ensureReqBody()
|
|||
|
|
if decodeErr != nil {
|
|||
|
|
return nil, decodeErr
|
|||
|
|
}
|
|||
|
|
if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationTool(decoded) {
|
|||
|
|
markDecodedModified()
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Injected /responses image_generation tool for Codex client")
|
|||
|
|
}
|
|||
|
|
if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationToolChoiceAuto(decoded) {
|
|||
|
|
markDecodedModified()
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Set /responses image_generation tool_choice=auto for Codex client")
|
|||
|
|
}
|
|||
|
|
if normalizeOpenAIResponsesImageGenerationTools(decoded) {
|
|||
|
|
markDecodedModified()
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image_generation tool payload")
|
|||
|
|
}
|
|||
|
|
if normalizeOpenAIResponsesImageOnlyModel(decoded) {
|
|||
|
|
markDecodedModified()
|
|||
|
|
if model, ok := decoded["model"].(string); ok {
|
|||
|
|
upstreamModel = strings.TrimSpace(model)
|
|||
|
|
}
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image-only model request inbound_model=%s image_model=%s upstream_model=%s", requestView.Model, billingModel, upstreamModel)
|
|||
|
|
}
|
|||
|
|
if err := validateOpenAIResponsesImageModel(decoded, upstreamModel); err != nil {
|
|||
|
|
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "model"}})
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if hasOpenAIImageGenerationTool(decoded) {
|
|||
|
|
imageIntent = true
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type)
|
|||
|
|
}
|
|||
|
|
if codexImageGenerationBridgeEnabled && applyCodexImageGenerationBridgeInstructions(decoded) {
|
|||
|
|
markDecodedModified()
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Added Codex image_generation bridge instructions")
|
|||
|
|
}
|
|||
|
|
} else if imageGenerationAllowed && imageIntent && openAIRequestBodyHasImageGenerationDeclaration(body) {
|
|||
|
|
// 完整 image_generation tool 只做 raw 计费读取,校验/桥接/旧字段迁移命中时才展开大 input map。
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if isCodexSparkModel(upstreamModel) && openAIRequestBodyMayContainImageInput(body) {
|
|||
|
|
decoded, decodeErr := ensureReqBody()
|
|||
|
|
if decodeErr != nil {
|
|||
|
|
return nil, decodeErr
|
|||
|
|
}
|
|||
|
|
if err := validateCodexSparkInput(decoded, upstreamModel); err != nil {
|
|||
|
|
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "input"}})
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// gpt-5.3-codex-spark also rejects the image_generation tool (HTTP 400,
|
|||
|
|
// param=tools). Strip it here so both APIKey and OAuth /responses paths are
|
|||
|
|
// covered regardless of the image-generation feature gate.
|
|||
|
|
if isCodexSparkModel(upstreamModel) && openAIRequestBodyHasImageGenerationDeclaration(body) {
|
|||
|
|
decoded, decodeErr := ensureReqBody()
|
|||
|
|
if decodeErr != nil {
|
|||
|
|
return nil, decodeErr
|
|||
|
|
}
|
|||
|
|
if stripCodexSparkImageGenerationTools(decoded) {
|
|||
|
|
markDecodedModified()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if account.Type == AccountTypeOAuth {
|
|||
|
|
decoded, decodeErr := ensureReqBody()
|
|||
|
|
if decodeErr != nil {
|
|||
|
|
return nil, decodeErr
|
|||
|
|
}
|
|||
|
|
codexResult := codexTransformResult{}
|
|||
|
|
if compatMessagesBridge {
|
|||
|
|
codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{IsCodexCLI: isCodexCLI, IsCompact: isCompactRequest, SkipDefaultInstructions: true, PreserveToolCallIDs: true})
|
|||
|
|
ensureCodexOAuthInstructionsField(decoded)
|
|||
|
|
markDecodedModified()
|
|||
|
|
} else {
|
|||
|
|
codexResult = applyCodexOAuthTransform(decoded, isCodexCLI, isCompactRequest)
|
|||
|
|
}
|
|||
|
|
if codexResult.Modified {
|
|||
|
|
markDecodedModified()
|
|||
|
|
}
|
|||
|
|
// 带真实 device_id 时补齐 client_metadata 安装标识,与真实 Codex 对齐(compact 形态不同,跳过)。
|
|||
|
|
if !isCompactRequest && applyCodexClientMetadata(decoded, account) {
|
|||
|
|
markDecodedModified()
|
|||
|
|
}
|
|||
|
|
stageCodexFingerprintIDs(c, nil)
|
|||
|
|
// 指纹收敛:一次性解析收敛 ID,请求体和出站头共享同一份 IDs(保证 turn_id 等随机字段一致)。
|
|||
|
|
// fingerprintIDs 在此处解析,后续 buildUpstreamRequest 中使用同一份。
|
|||
|
|
if !isCompactRequest {
|
|||
|
|
var clientHeaders http.Header
|
|||
|
|
if c != nil && c.Request != nil {
|
|||
|
|
clientHeaders = c.Request.Header
|
|||
|
|
}
|
|||
|
|
fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders)
|
|||
|
|
if fpIDs != nil {
|
|||
|
|
if applyCodexFingerprintClientMetadata(decoded, fpIDs) {
|
|||
|
|
markDecodedModified()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 将 fpIDs 存入 gin context,供 buildUpstreamRequest 中头改写使用。
|
|||
|
|
// 无条件覆写(含 nil):failover 从收敛账号切到 off 账号时,上一
|
|||
|
|
// 账号的 IDs 不得残留(stageCodexFingerprintIDs 注释)。
|
|||
|
|
stageCodexFingerprintIDs(c, fpIDs)
|
|||
|
|
}
|
|||
|
|
if codexResult.NormalizedModel != "" {
|
|||
|
|
upstreamModel = codexResult.NormalizedModel
|
|||
|
|
}
|
|||
|
|
if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" {
|
|||
|
|
promptCacheKey = currentPromptCacheKey
|
|||
|
|
} else if codexResult.PromptCacheKey != "" {
|
|||
|
|
promptCacheKey = codexResult.PromptCacheKey
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if !SupportsVerbosity(upstreamModel) && gjson.GetBytes(body, "text.verbosity").Exists() {
|
|||
|
|
markPatchDelete("text.verbosity")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if !isCodexCLI {
|
|||
|
|
maxOutputTokens := gjson.GetBytes(body, "max_output_tokens")
|
|||
|
|
if maxOutputTokens.Exists() {
|
|||
|
|
switch account.Platform {
|
|||
|
|
case PlatformOpenAI, PlatformDeepseek:
|
|||
|
|
// Preserve Responses-native output limits unless the selected upstream
|
|||
|
|
// explicitly rejects the field in the bounded HTTP retry loop below.
|
|||
|
|
case PlatformAnthropic:
|
|||
|
|
decoded, decodeErr := ensureReqBody()
|
|||
|
|
if decodeErr != nil {
|
|||
|
|
return nil, decodeErr
|
|||
|
|
}
|
|||
|
|
delete(decoded, "max_output_tokens")
|
|||
|
|
if _, hasMaxTokens := decoded["max_tokens"]; !hasMaxTokens {
|
|||
|
|
decoded["max_tokens"] = maxOutputTokens.Value()
|
|||
|
|
}
|
|||
|
|
markDecodedModified()
|
|||
|
|
case PlatformGemini:
|
|||
|
|
markPatchDelete("max_output_tokens")
|
|||
|
|
default:
|
|||
|
|
markPatchDelete("max_output_tokens")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// /v1/responses 的规范输出上限字段是 max_output_tokens;部分客户端仍按
|
|||
|
|
// Chat Completions 习惯发送 max_tokens,兼容 Responses 上游会拒绝该字段(#4417)。
|
|||
|
|
// 仅对 OpenAI 平台归一化:Anthropic 合法使用 max_tokens,其 max_output_tokens
|
|||
|
|
// 反向转换已在上方 switch 中处理。
|
|||
|
|
if account.Platform == PlatformOpenAI {
|
|||
|
|
if maxTokens := gjson.GetBytes(body, "max_tokens"); maxTokens.Exists() {
|
|||
|
|
if !gjson.GetBytes(body, "max_output_tokens").Exists() {
|
|||
|
|
markPatchSet("max_output_tokens", maxTokens.Value())
|
|||
|
|
}
|
|||
|
|
markPatchDelete("max_tokens")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if gjson.GetBytes(body, "max_completion_tokens").Exists() && (account.Type == AccountTypeAPIKey || account.Platform != PlatformOpenAI) {
|
|||
|
|
markPatchDelete("max_completion_tokens")
|
|||
|
|
}
|
|||
|
|
for _, unsupportedField := range []string{"prompt_cache_retention", "safety_identifier", "prompt_cache_options"} {
|
|||
|
|
if gjson.GetBytes(body, unsupportedField).Exists() {
|
|||
|
|
markPatchDelete(unsupportedField)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 && gjson.GetBytes(body, "previous_response_id").Exists() {
|
|||
|
|
markPatchDelete("previous_response_id")
|
|||
|
|
}
|
|||
|
|
if openAIRequestBodyMayContainEmptyBase64InputImage(body) {
|
|||
|
|
decoded, decodeErr := ensureReqBody()
|
|||
|
|
if decodeErr != nil {
|
|||
|
|
return nil, decodeErr
|
|||
|
|
}
|
|||
|
|
if sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(decoded) {
|
|||
|
|
markDecodedModified()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if rawTier := requestView.ServiceTier; rawTier != "" {
|
|||
|
|
if normTier := normalizedOpenAIServiceTierValue(rawTier); normTier != "" {
|
|||
|
|
action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, upstreamModel, normTier)
|
|||
|
|
switch action {
|
|||
|
|
case BetaPolicyActionBlock:
|
|||
|
|
msg := errMsg
|
|||
|
|
if msg == "" {
|
|||
|
|
msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, upstreamModel)
|
|||
|
|
}
|
|||
|
|
blocked := &OpenAIFastBlockedError{Message: msg}
|
|||
|
|
writeOpenAIFastPolicyBlockedResponse(c, blocked)
|
|||
|
|
return nil, blocked
|
|||
|
|
case BetaPolicyActionFilter:
|
|||
|
|
markPatchDelete("service_tier")
|
|||
|
|
case OpenAIFastPolicyActionForcePriority:
|
|||
|
|
if rawTier != OpenAIFastTierPriority {
|
|||
|
|
markPatchSet("service_tier", OpenAIFastTierPriority)
|
|||
|
|
}
|
|||
|
|
default:
|
|||
|
|
if normTier != rawTier {
|
|||
|
|
markPatchSet("service_tier", normTier)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if bodyModified {
|
|||
|
|
if requestView.HasPatches() {
|
|||
|
|
if patchedBody, patchErr := requestView.ApplyPatches(); patchErr == nil {
|
|||
|
|
body = patchedBody
|
|||
|
|
requestView = newOpenAIRequestView(body)
|
|||
|
|
reqBody = nil
|
|||
|
|
bodyModified = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if bodyModified {
|
|||
|
|
decoded, decodeErr := ensureReqBody()
|
|||
|
|
if decodeErr != nil {
|
|||
|
|
return nil, decodeErr
|
|||
|
|
}
|
|||
|
|
var marshalErr error
|
|||
|
|
body, marshalErr = marshalOpenAIUpstreamJSON(decoded)
|
|||
|
|
if marshalErr != nil {
|
|||
|
|
return nil, fmt.Errorf("serialize request body: %w", marshalErr)
|
|||
|
|
}
|
|||
|
|
requestView = newOpenAIRequestView(body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
imageBillingModel := ""
|
|||
|
|
imageSizeTier := ""
|
|||
|
|
imageInputSize := ""
|
|||
|
|
if imageIntent {
|
|||
|
|
var imageCfg OpenAIResponsesImageBillingConfig
|
|||
|
|
var imageCfgErr error
|
|||
|
|
if reqBody != nil {
|
|||
|
|
imageCfg, imageCfgErr = resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, billingModel)
|
|||
|
|
} else {
|
|||
|
|
imageCfg, imageCfgErr = resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, billingModel)
|
|||
|
|
}
|
|||
|
|
if imageCfgErr != nil {
|
|||
|
|
setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "")
|
|||
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": imageCfgErr.Error(), "param": "size"}})
|
|||
|
|
return nil, imageCfgErr
|
|||
|
|
}
|
|||
|
|
imageBillingModel = imageCfg.Model
|
|||
|
|
imageSizeTier = imageCfg.SizeTier
|
|||
|
|
imageInputSize = imageCfg.InputSize
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get access token
|
|||
|
|
token, _, err := s.GetAccessToken(ctx, account)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 命中 WS 时仅走 WebSocket Mode;不再自动回退 HTTP。
|
|||
|
|
if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 {
|
|||
|
|
// WS 分支需要结构化 payload 与重连恢复,命中后再触发 full-map decode。
|
|||
|
|
wsReqBody, err := ensureReqBody()
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
_, hasPreviousResponseID := wsReqBody["previous_response_id"]
|
|||
|
|
logOpenAIWSModeDebug(
|
|||
|
|
"forward_start account_id=%d account_type=%s model=%s stream=%v has_previous_response_id=%v",
|
|||
|
|
account.ID,
|
|||
|
|
account.Type,
|
|||
|
|
upstreamModel,
|
|||
|
|
reqStream,
|
|||
|
|
hasPreviousResponseID,
|
|||
|
|
)
|
|||
|
|
maxAttempts := openAIWSReconnectRetryLimit + 1
|
|||
|
|
wsAttempts := 0
|
|||
|
|
var wsResult *OpenAIForwardResult
|
|||
|
|
var wsErr error
|
|||
|
|
wsLastFailureReason := ""
|
|||
|
|
agentTaskRecoveryTried := false
|
|||
|
|
wsPrevResponseRecoveryTried := false
|
|||
|
|
wsInvalidEncryptedContentRecoveryTried := false
|
|||
|
|
recoverPrevResponseNotFound := func(attempt int) bool {
|
|||
|
|
if wsPrevResponseRecoveryTried {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id")
|
|||
|
|
if previousResponseID == "" {
|
|||
|
|
logOpenAIWSModeInfo(
|
|||
|
|
"reconnect_prev_response_recovery_skip account_id=%d attempt=%d reason=missing_previous_response_id previous_response_id_present=false",
|
|||
|
|
account.ID,
|
|||
|
|
attempt,
|
|||
|
|
)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
if HasFunctionCallOutput(wsReqBody) {
|
|||
|
|
logOpenAIWSModeInfo(
|
|||
|
|
"reconnect_prev_response_recovery_skip account_id=%d attempt=%d reason=has_function_call_output previous_response_id_present=true",
|
|||
|
|
account.ID,
|
|||
|
|
attempt,
|
|||
|
|
)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
delete(wsReqBody, "previous_response_id")
|
|||
|
|
wsPrevResponseRecoveryTried = true
|
|||
|
|
logOpenAIWSModeInfo(
|
|||
|
|
"reconnect_prev_response_recovery account_id=%d attempt=%d action=drop_previous_response_id retry=1 previous_response_id=%s previous_response_id_kind=%s",
|
|||
|
|
account.ID,
|
|||
|
|
attempt,
|
|||
|
|
truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
|
|||
|
|
normalizeOpenAIWSLogValue(ClassifyOpenAIPreviousResponseIDKind(previousResponseID)),
|
|||
|
|
)
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
recoverInvalidEncryptedContent := func(attempt int) bool {
|
|||
|
|
if wsInvalidEncryptedContentRecoveryTried {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
removedReasoningItems := trimOpenAIEncryptedReasoningItems(wsReqBody)
|
|||
|
|
if !removedReasoningItems {
|
|||
|
|
logOpenAIWSModeInfo(
|
|||
|
|
"reconnect_invalid_encrypted_content_recovery_skip account_id=%d attempt=%d reason=missing_encrypted_reasoning_items",
|
|||
|
|
account.ID,
|
|||
|
|
attempt,
|
|||
|
|
)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id")
|
|||
|
|
hasFunctionCallOutput := HasFunctionCallOutput(wsReqBody)
|
|||
|
|
if previousResponseID != "" && !hasFunctionCallOutput {
|
|||
|
|
delete(wsReqBody, "previous_response_id")
|
|||
|
|
}
|
|||
|
|
wsInvalidEncryptedContentRecoveryTried = true
|
|||
|
|
logOpenAIWSModeInfo(
|
|||
|
|
"reconnect_invalid_encrypted_content_recovery account_id=%d attempt=%d action=drop_encrypted_reasoning_items retry=1 previous_response_id_present=%v previous_response_id=%s previous_response_id_kind=%s has_function_call_output=%v dropped_previous_response_id=%v",
|
|||
|
|
account.ID,
|
|||
|
|
attempt,
|
|||
|
|
previousResponseID != "",
|
|||
|
|
truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
|
|||
|
|
normalizeOpenAIWSLogValue(ClassifyOpenAIPreviousResponseIDKind(previousResponseID)),
|
|||
|
|
hasFunctionCallOutput,
|
|||
|
|
previousResponseID != "" && !hasFunctionCallOutput,
|
|||
|
|
)
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
retryBudget := s.openAIWSRetryTotalBudget()
|
|||
|
|
retryStartedAt := time.Now()
|
|||
|
|
wsRetryLoop:
|
|||
|
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
|||
|
|
wsAttempts = attempt
|
|||
|
|
wsResult, wsErr = s.forwardOpenAIWSV2(
|
|||
|
|
ctx,
|
|||
|
|
c,
|
|||
|
|
account,
|
|||
|
|
wsReqBody,
|
|||
|
|
token,
|
|||
|
|
wsDecision,
|
|||
|
|
isCodexCLI,
|
|||
|
|
reqStream,
|
|||
|
|
originalModel,
|
|||
|
|
upstreamModel,
|
|||
|
|
startTime,
|
|||
|
|
attempt,
|
|||
|
|
wsLastFailureReason,
|
|||
|
|
&agentTaskRecoveryTried,
|
|||
|
|
)
|
|||
|
|
if wsErr == nil {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
if c != nil && c.Writer != nil && c.Writer.Written() {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
var taskRecoveredErr *agentIdentityTaskRecoveredError
|
|||
|
|
if errors.As(wsErr, &taskRecoveredErr) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
reason, retryable := classifyOpenAIWSReconnectReason(wsErr)
|
|||
|
|
if reason != "" {
|
|||
|
|
wsLastFailureReason = reason
|
|||
|
|
}
|
|||
|
|
// previous_response_not_found 说明续链锚点不可用:
|
|||
|
|
// 对非 function_call_output 场景,允许一次“去掉 previous_response_id 后重放”。
|
|||
|
|
if reason == "previous_response_not_found" && recoverPrevResponseNotFound(attempt) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if reason == "invalid_encrypted_content" && recoverInvalidEncryptedContent(attempt) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if retryable && attempt < maxAttempts {
|
|||
|
|
backoff := s.openAIWSRetryBackoff(attempt)
|
|||
|
|
if retryBudget > 0 && time.Since(retryStartedAt)+backoff > retryBudget {
|
|||
|
|
s.recordOpenAIWSRetryExhausted()
|
|||
|
|
logOpenAIWSModeInfo(
|
|||
|
|
"reconnect_budget_exhausted account_id=%d attempts=%d max_retries=%d reason=%s elapsed_ms=%d budget_ms=%d",
|
|||
|
|
account.ID,
|
|||
|
|
attempt,
|
|||
|
|
openAIWSReconnectRetryLimit,
|
|||
|
|
normalizeOpenAIWSLogValue(reason),
|
|||
|
|
time.Since(retryStartedAt).Milliseconds(),
|
|||
|
|
retryBudget.Milliseconds(),
|
|||
|
|
)
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
s.recordOpenAIWSRetryAttempt(backoff)
|
|||
|
|
logOpenAIWSModeInfo(
|
|||
|
|
"reconnect_retry account_id=%d retry=%d max_retries=%d reason=%s backoff_ms=%d",
|
|||
|
|
account.ID,
|
|||
|
|
attempt,
|
|||
|
|
openAIWSReconnectRetryLimit,
|
|||
|
|
normalizeOpenAIWSLogValue(reason),
|
|||
|
|
backoff.Milliseconds(),
|
|||
|
|
)
|
|||
|
|
if backoff > 0 {
|
|||
|
|
timer := time.NewTimer(backoff)
|
|||
|
|
select {
|
|||
|
|
case <-ctx.Done():
|
|||
|
|
if !timer.Stop() {
|
|||
|
|
<-timer.C
|
|||
|
|
}
|
|||
|
|
wsErr = wrapOpenAIWSFallback("retry_backoff_canceled", ctx.Err())
|
|||
|
|
break wsRetryLoop
|
|||
|
|
case <-timer.C:
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if retryable {
|
|||
|
|
s.recordOpenAIWSRetryExhausted()
|
|||
|
|
logOpenAIWSModeInfo(
|
|||
|
|
"reconnect_exhausted account_id=%d attempts=%d max_retries=%d reason=%s",
|
|||
|
|
account.ID,
|
|||
|
|
attempt,
|
|||
|
|
openAIWSReconnectRetryLimit,
|
|||
|
|
normalizeOpenAIWSLogValue(reason),
|
|||
|
|
)
|
|||
|
|
} else if reason != "" {
|
|||
|
|
s.recordOpenAIWSNonRetryableFastFallback()
|
|||
|
|
logOpenAIWSModeInfo(
|
|||
|
|
"reconnect_stop account_id=%d attempt=%d reason=%s",
|
|||
|
|
account.ID,
|
|||
|
|
attempt,
|
|||
|
|
normalizeOpenAIWSLogValue(reason),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
if wsErr == nil {
|
|||
|
|
firstTokenMs := int64(0)
|
|||
|
|
hasFirstTokenMs := wsResult != nil && wsResult.FirstTokenMs != nil
|
|||
|
|
if hasFirstTokenMs {
|
|||
|
|
firstTokenMs = int64(*wsResult.FirstTokenMs)
|
|||
|
|
}
|
|||
|
|
requestID := ""
|
|||
|
|
if wsResult != nil {
|
|||
|
|
requestID = strings.TrimSpace(wsResult.RequestID)
|
|||
|
|
}
|
|||
|
|
logOpenAIWSModeDebug(
|
|||
|
|
"forward_succeeded account_id=%d request_id=%s stream=%v has_first_token_ms=%v first_token_ms=%d ws_attempts=%d",
|
|||
|
|
account.ID,
|
|||
|
|
requestID,
|
|||
|
|
reqStream,
|
|||
|
|
hasFirstTokenMs,
|
|||
|
|
firstTokenMs,
|
|||
|
|
wsAttempts,
|
|||
|
|
)
|
|||
|
|
wsResult.UpstreamModel = upstreamModel
|
|||
|
|
if wsResult.BillingModel == "" {
|
|||
|
|
wsResult.BillingModel = billingModel
|
|||
|
|
}
|
|||
|
|
if wsResult.ImageCount > 0 {
|
|||
|
|
wsResult.ImageSize = imageSizeTier
|
|||
|
|
wsResult.ImageInputSize = imageInputSize
|
|||
|
|
wsResult.BillingModel = imageBillingModel
|
|||
|
|
}
|
|||
|
|
return wsResult, nil
|
|||
|
|
}
|
|||
|
|
s.writeOpenAIWSFallbackErrorResponse(c, account, wsErr)
|
|||
|
|
return nil, wsErr
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
reasoningEffort := extractOpenAIReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel)
|
|||
|
|
// 国产模型默认 effort 补充:此处 reqModel 已被 mapping 重写为 billingModel。
|
|||
|
|
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, reqModel)
|
|||
|
|
reasoningEffortValue := ""
|
|||
|
|
if reasoningEffort != nil {
|
|||
|
|
reasoningEffortValue = *reasoningEffort
|
|||
|
|
}
|
|||
|
|
firstOutputTimeout := time.Duration(0)
|
|||
|
|
if reqStream && account.Platform == PlatformOpenAI {
|
|||
|
|
firstOutputTimeout = s.openAIFirstOutputTimeout(reasoningEffortValue)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
httpInvalidEncryptedContentRetryTried := false
|
|||
|
|
agentTaskRecoveryTried := false
|
|||
|
|
rejectedFieldRetryState := newOpenAIResponsesRejectedFieldRetryState(body)
|
|||
|
|
for {
|
|||
|
|
// Build upstream request
|
|||
|
|
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
|
|||
|
|
var headerGuard *openAIFirstOutputHeaderGuard
|
|||
|
|
if firstOutputTimeout > 0 {
|
|||
|
|
upstreamCtx, headerGuard = newOpenAIFirstOutputHeaderGuard(
|
|||
|
|
upstreamCtx, releaseUpstreamCtx, startTime.Add(firstOutputTimeout),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, reqStream, promptCacheKey, isCodexCLI)
|
|||
|
|
if headerGuard == nil {
|
|||
|
|
releaseUpstreamCtx()
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
if headerGuard != nil {
|
|||
|
|
headerGuard.close()
|
|||
|
|
}
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get proxy URL
|
|||
|
|
proxyURL := ""
|
|||
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
|||
|
|
proxyURL = account.Proxy.URL()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Send request
|
|||
|
|
upstreamStart := time.Now()
|
|||
|
|
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
|||
|
|
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
|||
|
|
if headerGuard != nil && headerGuard.stopHeaderWait() {
|
|||
|
|
if resp != nil && resp.Body != nil {
|
|||
|
|
_ = resp.Body.Close()
|
|||
|
|
}
|
|||
|
|
headerGuard.close()
|
|||
|
|
return nil, s.newOpenAIFirstOutputTimeoutError(
|
|||
|
|
ctx, c, account, startTime, originalModel, reasoningEffortValue,
|
|||
|
|
firstOutputTimeout, "response_headers", nil,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
if resp != nil && resp.Body != nil {
|
|||
|
|
_ = resp.Body.Close()
|
|||
|
|
}
|
|||
|
|
if headerGuard != nil {
|
|||
|
|
headerGuard.close()
|
|||
|
|
}
|
|||
|
|
// Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to
|
|||
|
|
// a failover so the handler switches to a healthy account, and temporarily
|
|||
|
|
// unschedule the account on durable faults (e.g. rejected proxy credentials).
|
|||
|
|
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
|||
|
|
}
|
|||
|
|
if headerGuard != nil {
|
|||
|
|
resp.Body = &openAIRequestContextReadCloser{ReadCloser: resp.Body, cleanup: headerGuard.close}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Handle error response
|
|||
|
|
if resp.StatusCode >= 400 {
|
|||
|
|
respBody := s.readUpstreamErrorBody(resp)
|
|||
|
|
_ = resp.Body.Close()
|
|||
|
|
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
|||
|
|
|
|||
|
|
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
|
|||
|
|
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
|||
|
|
upstreamCode := extractUpstreamErrorCode(respBody)
|
|||
|
|
if !agentTaskRecoveryTried && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, respBody) {
|
|||
|
|
agentTaskRecoveryTried = true
|
|||
|
|
expectedTaskID := account.GetCredential("task_id")
|
|||
|
|
if err := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); err != nil {
|
|||
|
|
return nil, fmt.Errorf("agent identity task recovery failed: %w", err)
|
|||
|
|
}
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
respBody = s.redactAgentIdentitySensitiveBody(ctx, account, respBody)
|
|||
|
|
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
|||
|
|
if !httpInvalidEncryptedContentRetryTried && resp.StatusCode == http.StatusBadRequest && upstreamCode == "invalid_encrypted_content" {
|
|||
|
|
decoded, decodeErr := ensureReqBody()
|
|||
|
|
if decodeErr != nil {
|
|||
|
|
return nil, decodeErr
|
|||
|
|
}
|
|||
|
|
if trimOpenAIEncryptedReasoningItems(decoded) {
|
|||
|
|
body, err = marshalOpenAIUpstreamJSON(decoded)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("serialize invalid_encrypted_content retry body: %w", err)
|
|||
|
|
}
|
|||
|
|
httpInvalidEncryptedContentRetryTried = true
|
|||
|
|
rejectedFieldRetryState.remember(body)
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request once after invalid_encrypted_content (account: %s)", account.Name)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Skip non-WSv2 invalid_encrypted_content retry because encrypted reasoning items are missing (account: %s)", account.Name)
|
|||
|
|
}
|
|||
|
|
if retryBody, reason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, respBody); retryErr != nil {
|
|||
|
|
return nil, fmt.Errorf("normalize rejected Responses field retry body: %w", retryErr)
|
|||
|
|
} else if changed && rejectedFieldRetryState.Allow(retryBody) {
|
|||
|
|
body = retryBody
|
|||
|
|
requestView = newOpenAIRequestView(body)
|
|||
|
|
reqBody = nil
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request after %s (account: %s)", reason, account.Name)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
|
|||
|
|
upstreamDetail := ""
|
|||
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|||
|
|
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
|||
|
|
if maxBytes <= 0 {
|
|||
|
|
maxBytes = 2048
|
|||
|
|
}
|
|||
|
|
upstreamDetail = truncateString(string(respBody), maxBytes)
|
|||
|
|
}
|
|||
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|||
|
|
Platform: account.Platform,
|
|||
|
|
AccountID: account.ID,
|
|||
|
|
AccountName: account.Name,
|
|||
|
|
UpstreamStatusCode: resp.StatusCode,
|
|||
|
|
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
|||
|
|
Kind: "failover",
|
|||
|
|
Message: upstreamMsg,
|
|||
|
|
Detail: upstreamDetail,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
shouldDisable := s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
|
|||
|
|
return nil, newOpenAIUpstreamFailoverError(
|
|||
|
|
resp.StatusCode,
|
|||
|
|
resp.Header,
|
|||
|
|
respBody,
|
|||
|
|
upstreamMsg,
|
|||
|
|
!shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
return s.handleErrorResponse(ctx, resp, c, account, body, billingModel)
|
|||
|
|
}
|
|||
|
|
defer func() { _ = resp.Body.Close() }()
|
|||
|
|
|
|||
|
|
serviceTier := extractOpenAIServiceTierFromBody(body)
|
|||
|
|
// 上游接受后只保留计费需要的标量,避免响应处理期间继续保活完整 input/tools map。
|
|||
|
|
reqBody = nil
|
|||
|
|
|
|||
|
|
// Handle normal response
|
|||
|
|
var usage *OpenAIUsage
|
|||
|
|
var firstTokenMs *int
|
|||
|
|
responseID := ""
|
|||
|
|
imageCount := 0
|
|||
|
|
searchCount := 0
|
|||
|
|
var imageOutputSizes []string
|
|||
|
|
if reqStream {
|
|||
|
|
streamResult, err := s.handleStreamingResponseWithReasoning(ctx, resp, c, account, startTime, originalModel, upstreamModel, reasoningEffortValue)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
usage = streamResult.usage
|
|||
|
|
firstTokenMs = streamResult.firstTokenMs
|
|||
|
|
responseID = strings.TrimSpace(streamResult.responseID)
|
|||
|
|
imageCount = streamResult.imageCount
|
|||
|
|
imageOutputSizes = streamResult.imageOutputSizes
|
|||
|
|
searchCount = streamResult.searchCount
|
|||
|
|
} else {
|
|||
|
|
nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
usage = nonStreamResult.usage
|
|||
|
|
responseID = strings.TrimSpace(nonStreamResult.responseID)
|
|||
|
|
imageCount = nonStreamResult.imageCount
|
|||
|
|
imageOutputSizes = nonStreamResult.imageOutputSizes
|
|||
|
|
searchCount = nonStreamResult.searchCount
|
|||
|
|
}
|
|||
|
|
s.bindHTTPResponseAccount(ctx, c, account, responseID)
|
|||
|
|
|
|||
|
|
// Extract and save Codex usage snapshot from response headers (for OAuth accounts).
|
|||
|
|
// 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
|
|||
|
|
if account.Type == AccountTypeOAuth && !account.IsShadow() {
|
|||
|
|
if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil {
|
|||
|
|
s.updateCodexUsageSnapshot(ctx, account.ID, snapshot)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if usage == nil {
|
|||
|
|
usage = &OpenAIUsage{}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
forwardResult := &OpenAIForwardResult{
|
|||
|
|
RequestID: resp.Header.Get("x-request-id"),
|
|||
|
|
ResponseID: responseID,
|
|||
|
|
Usage: *usage,
|
|||
|
|
Model: originalModel,
|
|||
|
|
BillingModel: billingModel,
|
|||
|
|
UpstreamModel: upstreamModel,
|
|||
|
|
UpstreamResponseModel: observedUpstreamResponseModel(c),
|
|||
|
|
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
|
|||
|
|
ServiceTier: serviceTier,
|
|||
|
|
ReasoningEffort: reasoningEffort,
|
|||
|
|
Stream: reqStream,
|
|||
|
|
OpenAIWSMode: false,
|
|||
|
|
Duration: time.Since(startTime),
|
|||
|
|
FirstTokenMs: firstTokenMs,
|
|||
|
|
}
|
|||
|
|
if imageCount > 0 {
|
|||
|
|
forwardResult.ImageCount = imageCount
|
|||
|
|
forwardResult.ImageSize = imageSizeTier
|
|||
|
|
forwardResult.ImageInputSize = imageInputSize
|
|||
|
|
forwardResult.ImageOutputSizes = imageOutputSizes
|
|||
|
|
forwardResult.BillingModel = imageBillingModel
|
|||
|
|
}
|
|||
|
|
// Grok-native web_search / x_search / tool_search tool invocations (per-1k pricing).
|
|||
|
|
// Token cost still applies separately when usage is present; search is additive only
|
|||
|
|
// when search_price_per_1k is configured (nil price → $0 from CalculateSearchCost).
|
|||
|
|
if searchCount > 0 && account != nil && account.IsGrok() {
|
|||
|
|
forwardResult.SearchCount = searchCount
|
|||
|
|
}
|
|||
|
|
return forwardResult, nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool {
|
|||
|
|
if account == nil || account.Type != AccountTypeAPIKey {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
if account.IsCNProvider() {
|
|||
|
|
// CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek 有原生
|
|||
|
|
// Responses,Kimi/GLM 回退 Chat Completions。
|
|||
|
|
switch account.GetAPIProtocol() {
|
|||
|
|
case APIProtocolChatCompletions:
|
|||
|
|
return true
|
|||
|
|
case APIProtocolAdaptive:
|
|||
|
|
return account.Platform != PlatformDeepseek
|
|||
|
|
default:
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return !openai_compat.ShouldUseResponsesAPI(account.Extra)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) {
|
|||
|
|
// Determine target URL based on account type
|
|||
|
|
var targetURL string
|
|||
|
|
switch account.Type {
|
|||
|
|
case AccountTypeOAuth:
|
|||
|
|
// OAuth accounts use ChatGPT internal API
|
|||
|
|
targetURL = chatgptCodexURL
|
|||
|
|
case AccountTypeAPIKey:
|
|||
|
|
// API Key accounts use Platform API or custom base URL
|
|||
|
|
baseURL := account.GetOpenAIBaseURL()
|
|||
|
|
if account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol() {
|
|||
|
|
baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses)
|
|||
|
|
}
|
|||
|
|
if baseURL == "" {
|
|||
|
|
targetURL = openaiPlatformAPIURL
|
|||
|
|
} else {
|
|||
|
|
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL)
|
|||
|
|
}
|
|||
|
|
default:
|
|||
|
|
targetURL = openaiPlatformAPIURL
|
|||
|
|
}
|
|||
|
|
targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c))
|
|||
|
|
|
|||
|
|
// DeepSeek 原生 Responses 端点为无状态实现:强制 store=false、清除
|
|||
|
|
// previous_response_id,避免携带状态字段被上游拒绝。
|
|||
|
|
body = normalizeDeepSeekResponsesRequestBody(account, body)
|
|||
|
|
|
|||
|
|
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
|
|||
|
|
|
|||
|
|
// Build authentication for this request. Agent Identity signs a fresh
|
|||
|
|
// assertion here; OAuth/PAT/API-key keep their existing Bearer behavior.
|
|||
|
|
authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("build openai authentication headers: %w", err)
|
|||
|
|
}
|
|||
|
|
for key, values := range authHeaders {
|
|||
|
|
for _, value := range values {
|
|||
|
|
req.Header.Add(key, value)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Set headers specific to OAuth accounts (ChatGPT internal API)
|
|||
|
|
if account.Type == AccountTypeOAuth {
|
|||
|
|
// Required: set Host for ChatGPT API (must use req.Host, not Header.Set)
|
|||
|
|
req.Host = "chatgpt.com"
|
|||
|
|
if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
|
|||
|
|
return nil, fmt.Errorf("resolve chatgpt account headers: %w", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Whitelist passthrough headers
|
|||
|
|
for key, values := range c.Request.Header {
|
|||
|
|
lowerKey := strings.ToLower(key)
|
|||
|
|
if openaiAllowedHeaders[lowerKey] {
|
|||
|
|
for _, v := range values {
|
|||
|
|
req.Header.Add(key, v)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号),
|
|||
|
|
// 剥离后再出站——异账号 blob 与本账号的(指纹收敛后)出站身份自相矛盾。
|
|||
|
|
s.guardOpenAICodexTurnStateEcho(c, account, req.Header)
|
|||
|
|
if account.Type == AccountTypeOAuth {
|
|||
|
|
compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body)
|
|||
|
|
// 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。
|
|||
|
|
clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id"))
|
|||
|
|
req.Header.Del("conversation_id")
|
|||
|
|
req.Header.Del("session_id")
|
|||
|
|
|
|||
|
|
if compatMessagesBridge {
|
|||
|
|
req.Header.Del("OpenAI-Beta")
|
|||
|
|
req.Header.Del("originator")
|
|||
|
|
} else {
|
|||
|
|
req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI))
|
|||
|
|
}
|
|||
|
|
apiKeyID := getAPIKeyIDFromContext(c)
|
|||
|
|
if isOpenAIResponsesCompactPath(c) {
|
|||
|
|
req.Header.Set("accept", "application/json")
|
|||
|
|
if req.Header.Get("version") == "" {
|
|||
|
|
req.Header.Set("version", CodexCanonicalClientVersion())
|
|||
|
|
}
|
|||
|
|
compactSession := resolveOpenAICompactSessionID(c)
|
|||
|
|
req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession))
|
|||
|
|
} else {
|
|||
|
|
req.Header.Set("accept", "text/event-stream")
|
|||
|
|
}
|
|||
|
|
if promptCacheKey != "" {
|
|||
|
|
isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey)
|
|||
|
|
req.Header.Set("session_id", isolated)
|
|||
|
|
if !compatMessagesBridge || clientConversationID != "" {
|
|||
|
|
req.Header.Set("conversation_id", isolated)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else if isOpenAIResponsesCompactPath(c) {
|
|||
|
|
// compact 上游是 unary JSON 协议:API-key 账号也显式声明 Accept,
|
|||
|
|
// 避免 OpenAI 兼容网关按 SSE 返回(#3777 期望行为 4)。
|
|||
|
|
req.Header.Set("accept", "application/json")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Apply custom User-Agent if configured
|
|||
|
|
customUA := account.GetOpenAIUserAgent()
|
|||
|
|
if customUA != "" {
|
|||
|
|
req.Header.Set("user-agent", customUA)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为规范 Codex 身份。
|
|||
|
|
// 用于网关未透传/改写 User-Agent 时,仍能命中 Codex 侧识别逻辑。
|
|||
|
|
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
|
|||
|
|
req.Header.Set("user-agent", CodexCanonicalUserAgent())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。
|
|||
|
|
applyStagedCodexFingerprintHeaders(c, account, req.Header)
|
|||
|
|
|
|||
|
|
// 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。
|
|||
|
|
// 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。
|
|||
|
|
if account.Type == AccountTypeOAuth {
|
|||
|
|
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Ensure required headers exist
|
|||
|
|
if req.Header.Get("content-type") == "" {
|
|||
|
|
req.Header.Set("content-type", "application/json")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
|||
|
|
account.ApplyHeaderOverrides(req.Header)
|
|||
|
|
// x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后,
|
|||
|
|
// 保证不被覆盖丢失)。
|
|||
|
|
applyOpenAICodexBetaFeatures(c, account, req.Header)
|
|||
|
|
setOpenAICodexRoutingHintFromBody(req.Header, account, body)
|
|||
|
|
logOpenAIRoutingDiagnosticsFromBody(ctx, account, "http", req.Header, body, "not_applicable")
|
|||
|
|
|
|||
|
|
return req, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// codexIdentityOverrideUA 返回账号级显式配置的出站 User-Agent,供强制统一身份时作为覆写来源。
|
|||
|
|
// ForceCodexCLI 语义是「强制使用 Codex CLI 身份」,等价于使用网关规范身份,故返回空串;
|
|||
|
|
// 该优先级与历史行为一致(ForceCodexCLI 在账号自定义 UA 之后生效)。
|
|||
|
|
func (s *OpenAIGatewayService) codexIdentityOverrideUA(account *Account) string {
|
|||
|
|
if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
return account.GetOpenAIUserAgent()
|
|||
|
|
}
|