| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513 |
- 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) {
- stageMode1Request(c, account, body)
- beginUpstreamResponseModelObservation(c)
- ClearActualOpenAIUpstreamEndpoint(c)
- if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
- SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions")
- }
- filteredBody, filterErr := filterOpenAIResponsesNoneReasoningEffortForAccount(account, body)
- if filterErr != nil {
- return nil, filterErr
- }
- body = filteredBody
- clearGrokResponsesClientToolMapping(c)
- clearOpenAIResponsesClientToolMapping(c)
- clearOpenAIResponsesNamespaceNames(c)
- setCodexToolNameReverse(c, nil)
- if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil {
- return nil, err
- }
- 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
- }
- legacyIngressBody, legacyIngressChanged, legacyIngressErr := normalizeOpenAIResponsesLegacyIngress(body)
- if legacyIngressErr != nil {
- return nil, legacyIngressErr
- }
- if legacyIngressChanged {
- body = legacyIngressBody
- }
- // 在分流到 passthrough / Codex transform / 原生 ChatCompletions 之前统一修正
- // 显式为 null 的工具 Schema type,否则 upstream 的 400 会被归一成可重试的 502,
- // 同一份坏定义在账号池里反复重放。
- if sanitizedToolBody, toolSchemaSanitized, toolSchemaErr := sanitizeOpenAIResponsesToolSchemasForPlatform(body, account.Platform); toolSchemaErr != nil {
- return nil, toolSchemaErr
- } else if toolSchemaSanitized {
- body = sanitizedToolBody
- }
- if account.IsOpenAIOAuthLike() {
- reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(body)
- if reasoningErr != nil {
- return nil, fmt.Errorf("normalize OpenAI Responses reasoning.mode: %w", reasoningErr)
- }
- if reasoningChanged {
- body = reasoningBody
- }
- }
- responsesLite := account.IsOpenAI() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader))
- if responsesLite {
- liteBody, changed, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(body, account)
- if liteErr != nil {
- param := "tools"
- var validationErr *openAIResponsesLiteValidationError
- if errors.As(liteErr, &validationErr) {
- param = validationErr.param
- }
- setOpsUpstreamError(c, http.StatusBadRequest, liteErr.Error(), "")
- c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
- "type": "invalid_request_error", "message": liteErr.Error(), "param": param,
- }})
- 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,
- )
- 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
- }
- }
- nativeCNResponses := account.UsesNativeCNResponses()
- nativeDeepSeekResponses := account.Platform == PlatformDeepseek && nativeCNResponses
- if nativeDeepSeekResponses && account.Type == AccountTypeAPIKey && !compactPath &&
- needsOpenAIResponsesClientToolAdaptation(body) {
- adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body)
- if adaptErr != nil {
- return nil, fmt.Errorf("adapt DeepSeek Responses client tools: %w", adaptErr)
- }
- body = adaptedBody
- setOpenAIResponsesClientToolMapping(c, mapping)
- }
- originalBody := body
- requestView := newOpenAIRequestView(body)
- reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey
- originalModel := reqModel
- 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 account.IsOpenAIApiKey() {
- if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body, responsesLite); normalizeErr != nil {
- return nil, normalizeErr
- } else if changed {
- body = normalized
- originalBody = normalized
- }
- if normalized, changed, normalizeErr := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body, isOpenAIResponsesCompactPath(c)); normalizeErr != nil {
- return nil, normalizeErr
- } else if changed {
- body = normalized
- originalBody = normalized
- }
- requestView = newOpenAIRequestView(body)
- reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
- originalModel = reqModel
- }
- if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
- return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
- }
- if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) {
- normalizedReasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningContentReplay(body)
- if reasoningErr != nil {
- return nil, fmt.Errorf("normalize OpenAI Responses reasoning content replay: %w", reasoningErr)
- }
- if reasoningChanged {
- body = normalizedReasoningBody
- originalBody = normalizedReasoningBody
- requestView = newOpenAIRequestView(normalizedReasoningBody)
- reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
- originalModel = reqModel
- }
- 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
- clientPromptCacheKey := promptCacheKey
- 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")
- }
- isCompactRequest := compactPath
- requestedModel := reqModel
- billingModel, upstreamModel := resolveOpenAIForwardMappedModels(account, requestedModel, isCompactRequest)
- if isCompactRequest {
- if compactModel := s.resolveOpenAICompactFallbackModel(account, requestedModel); compactModel != "" {
- upstreamModel = compactModel
- }
- }
- instructions := gjson.GetBytes(body, "instructions")
- instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == ""
- if instructionsEmpty && account.UsesOpenAICodexProtocol() && !compatMessagesBridge && !nativeCNResponses {
- markPatchSet("instructions", defaultCodexSynthInstructions(upstreamModel))
- }
- if billingModel != requestedModel {
- logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", requestedModel, billingModel, account.Name, isCodexCLI)
- }
- reqModel = billingModel
- if upstreamModel != requestedModel {
- markPatchSet("model", upstreamModel)
- }
- if upstreamModel != billingModel {
- if isCompactRequest {
- logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", requestedModel, upstreamModel, account.Name, isCodexCLI)
- } else {
- logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", billingModel, upstreamModel, account.Name, account.Type, isCodexCLI)
- }
- }
- 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)
- }
- if strings.TrimSpace(gjson.GetBytes(body, "text.format.type").String()) == "json_schema" ||
- strings.TrimSpace(gjson.GetBytes(body, "response_format.type").String()) == "json_schema" {
- decoded, decodeErr := ensureReqBody()
- if decodeErr != nil {
- return nil, decodeErr
- }
- if normalizeOpenAIResponseFormatSchemas(decoded) {
- markDecodedModified()
- logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized Responses JSON schema compatibility")
- }
- }
- 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.UsesOpenAICodexProtocol() {
- decoded, decodeErr := ensureReqBody()
- if decodeErr != nil {
- return nil, decodeErr
- }
- // Responses OAuth 与 Chat 兼容入口保持一致:纯文本 system 可以无损提升后删除,
- // JSON object 模式仍需在 input 中保留 JSON 指令供上游兼容校验。
- omitPromotedSystemMessages := !strings.EqualFold(
- strings.TrimSpace(gjson.GetBytes(body, "text.format.type").String()),
- "json_object",
- )
- codexResult := codexTransformResult{}
- if compatMessagesBridge {
- codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{
- IsCodexCLI: isCodexCLI,
- IsCompact: isCompactRequest,
- SkipDefaultInstructions: true,
- PreserveToolCallIDs: true,
- OmitPromotedSystemMessagesFromInput: omitPromotedSystemMessages,
- })
- ensureCodexOAuthInstructionsField(decoded)
- markDecodedModified()
- } else {
- codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{
- IsCodexCLI: isCodexCLI,
- IsCompact: isCompactRequest,
- OmitPromotedSystemMessagesFromInput: omitPromotedSystemMessages,
- })
- }
- if codexResult.Error != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": codexResult.Error.Error()}})
- return nil, codexResult.Error
- }
- setCodexToolNameReverse(c, codexResult.ToolNameReverse)
- if codexResult.Modified {
- markDecodedModified()
- }
- // 带真实 device_id 时补齐 client_metadata 安装标识,与真实 Codex 对齐(compact 形态不同,跳过)。
- if !isCompactRequest && applyCodexClientMetadata(decoded, account) {
- markDecodedModified()
- }
- if currentClientPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok {
- clientPromptCacheKey = currentClientPromptCacheKey
- }
- // Account namespace is orthogonal to fingerprint convergence: preserve
- // each client's identity cardinality, but never reuse it across OAuth
- // credentials after scheduler failover.
- if !isCompactRequest && applyCodexAccountIdentityClientMetadataMap(decoded, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) {
- 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 strings.TrimSpace(clientPromptCacheKey) != "" {
- // The body now carries an account-scoped value. Keep the original here
- // so the header builder derives the same namespace exactly once.
- promptCacheKey = clientPromptCacheKey
- } else if currentPromptCacheKey, ok := decoded["prompt_cache_key"].(string); ok && currentPromptCacheKey != "" {
- // Fingerprint convergence may inject a default key when the client did
- // not provide one; preserve that existing fallback.
- 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)
- }
- }
- }
- // Ollama Cloud(实际 Responses 上游为 ollama.com)输出上限 clamp:对 Codex 与
- // 非 Codex 客户端一律执行(真实 Codex 客户端同样会带超限 max_output_tokens 被
- // ollama.com 以 400 拒绝)。在 `!isCodexCLI` 归一化块之后独立调用:非 Codex 时
- // 位于平台字段归一化之后,不跳过原有平台 switch(patch 按追加顺序应用,set 在
- // 先前的 delete/set 之后生效);Codex 请求不做归一化,直接按 body 现值判定。
- if clampedCap, ok := ollamaCloudResponsesMaxOutputTokensClamp(account, upstreamModel, body); ok {
- markPatchSet("max_output_tokens", clampedCap)
- }
- if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 &&
- !account.IsOpenAIApiKey() && 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()
- }
- }
- rawTier := requestView.ServiceTier
- if openAIGroupForcesFast(ctx, account) {
- rawTier = OpenAIFastTierPriority
- if requestView.ServiceTier != OpenAIFastTierPriority {
- markPatchSet("service_tier", OpenAIFastTierPriority)
- }
- }
- if 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 account.UsesOpenAICodexProtocol() {
- decoded, decodeErr := ensureReqBody()
- if decodeErr != nil {
- return nil, decodeErr
- }
- if input, ok := decoded["input"].([]any); ok && sanitizeOpenAIResponsesOrphanToolOutputs(
- decoded,
- input,
- strings.TrimSpace(firstNonEmptyString(decoded["previous_response_id"])) != "",
- ) {
- markDecodedModified()
- }
- }
- if reqBody != nil || openAIResponsesInputMayNeedTruncation(body) {
- decoded, decodeErr := ensureReqBody()
- if decodeErr != nil {
- return nil, decodeErr
- }
- if truncateOpenAIResponsesInputText(decoded) {
- markDecodedModified()
- }
- }
- 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)
- }
- }
- // Run after orphan-output filtering and all request-map rebuilds so a
- // compaction trigger cannot remain ahead of surviving history items.
- if normalizedBody, changed, normalizeErr := NormalizeCompactionTriggerInputOrder(body); normalizeErr != nil {
- return nil, fmt.Errorf("normalize compaction trigger order: %w", normalizeErr)
- } else if changed {
- body = normalizedBody
- requestView = newOpenAIRequestView(body)
- reqBody = nil
- }
- // 剥离本会话已被上游判定失效的加密项(invalid_encrypted_content lineage),
- // 阻断同一失效密文随客户端历史在每一轮重复触发"被拒→剥离→重试/重连"。
- // lineage 会话键统一按进场形态的 body 派生:后续重试可能改写 body,
- // 延迟计算会与下一请求的进场键漂移。
- lineageGroupID := getOpenAIGroupIDFromContext(c)
- lineageEntryBody := body
- lineageSessionHash := ""
- if stateStore := s.getOpenAIWSStateStore(); stateStore != nil && stateStore.HasAnySessionInvalidEncryptedContent() {
- lineageSessionHash = s.GenerateSessionHash(c, body)
- if invalidDigests := stateStore.GetSessionInvalidEncryptedContentDigests(lineageGroupID, lineageSessionHash); len(invalidDigests) > 0 {
- strippedBody, strippedCount := s.stripSessionInvalidEncryptedContentLogged(
- body, invalidDigests, "invalid_encrypted_lineage_strip", account.ID, 0,
- )
- if strippedCount > 0 {
- body = strippedBody
- requestView = newOpenAIRequestView(body)
- reqBody = nil
- }
- }
- }
- 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
- }
- SetOpsUpstreamModel(c, upstreamModel)
- // 命中 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 isMode1ProtectionEnabled(account) {
- return false
- }
- if wsInvalidEncryptedContentRecoveryTried {
- return false
- }
- // 写入 lineage 后,同一失效密文在后续 turn 进场时被预剥离,不再重复
- // 触发上游拒绝与重连。摘要取自进场形态的 body(密文项只可能来自
- // 客户端进场请求,重复摘要幂等)。
- invalidDigests := collectOpenAIEncryptedContentDigestsRaw(lineageEntryBody)
- 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
- }
- if len(invalidDigests) > 0 {
- if lineageSessionHash == "" {
- lineageSessionHash = s.GenerateSessionHash(c, lineageEntryBody)
- }
- s.markOpenAIWSInvalidEncryptedContentLineage(lineageGroupID, lineageSessionHash, invalidDigests)
- }
- 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,
- clientPromptCacheKey,
- 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
- compactModelFallbackRetried := false
- agentTaskRecoveryTried := false
- rejectedFieldRetryState := openAIResponsesRejectedFieldRetryStateForRequest(c, 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.doOpenAIUpstream(upstreamReq, proxyURL, account)
- 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, opsUpstreamProxyID(account), opsUpstreamProxyName(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 !isMode1ProtectionEnabled(account) && !httpInvalidEncryptedContentRetryTried && resp.StatusCode == http.StatusBadRequest && upstreamCode == "invalid_encrypted_content" {
- decoded, decodeErr := ensureReqBody()
- if decodeErr != nil {
- return nil, decodeErr
- }
- invalidDigests := collectOpenAIEncryptedContentDigestsRaw(lineageEntryBody)
- if trimOpenAIEncryptedReasoningItems(decoded) {
- body, err = marshalOpenAIUpstreamJSON(decoded)
- if err != nil {
- return nil, fmt.Errorf("serialize invalid_encrypted_content retry body: %w", err)
- }
- if len(invalidDigests) > 0 {
- if lineageSessionHash == "" {
- lineageSessionHash = s.GenerateSessionHash(c, lineageEntryBody)
- }
- s.markOpenAIWSInvalidEncryptedContentLineage(lineageGroupID, lineageSessionHash, invalidDigests)
- }
- 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 retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
- c, account, requestedModel, body, resp.StatusCode, upstreamMsg, respBody, compactModelFallbackRetried,
- ); retry {
- s.appendOpenAICompactFallbackRetryOps(c, account, resp, respBody, upstreamMsg, false)
- fromModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
- body = retryBody
- requestView = newOpenAIRequestView(body)
- reqBody = nil
- upstreamModel = fallbackModel
- compactModelFallbackRetried = true
- SetOpsUpstreamModel(c, fallbackModel)
- logger.LegacyPrintf(
- "service.openai_gateway",
- "[OpenAI] Retrying explicit compact request once with fallback model (account: %s, from: %s, to: %s, upstream_code: %s)",
- account.Name, fromModel, fallbackModel, upstreamCode,
- )
- continue
- }
- if s.shouldFailoverOpenAIUpstreamResponse(account, 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{
- ProxyID: opsUpstreamProxyID(account),
- ProxyName: opsUpstreamProxyName(account),
- 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, s.newOpenAIAccountFailoverError(
- account,
- resp.StatusCode,
- resp.Header,
- respBody,
- upstreamMsg,
- shouldDisable,
- !shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
- )
- }
- return s.handleErrorResponse(ctx, resp, c, account, body, resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel))
- }
- defer func() { _ = resp.Body.Close() }()
- if mapping, ok := openAIResponsesClientToolMapping(c); ok && isEventStreamResponse(resp.Header) {
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
- }
- resp.Body = newResponsesClientToolStreamBody(resp.Body, mapping, maxLineSize)
- }
- 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 {
- if signal, ok := asOpenAICompactFallbackSignal(err); ok {
- if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
- c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried,
- ); retry {
- s.appendOpenAICompactFallbackRetryOps(c, account, resp, signal.payload, signal.message, false)
- body = retryBody
- requestView = newOpenAIRequestView(body)
- upstreamModel = fallbackModel
- compactModelFallbackRetried = true
- SetOpsUpstreamModel(c, fallbackModel)
- continue
- }
- if resp.Body != nil {
- _ = resp.Body.Close()
- }
- compactResp, compactBody := openAICompactFallbackErrorResponse(resp, signal)
- if s.shouldFailoverOpenAIUpstreamResponse(account, compactResp.StatusCode, signal.message, compactBody) {
- appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
- ProxyID: opsUpstreamProxyID(account),
- ProxyName: opsUpstreamProxyName(account),
- Platform: account.Platform,
- AccountID: account.ID,
- AccountName: account.Name,
- UpstreamStatusCode: compactResp.StatusCode,
- UpstreamRequestID: compactResp.Header.Get("x-request-id"),
- Kind: "failover",
- Message: signal.message,
- })
- shouldDisable := s.handleFailoverSideEffects(ctx, compactResp, account, compactBody, upstreamModel)
- return nil, s.newOpenAIAccountFailoverError(
- account, compactResp.StatusCode, compactResp.Header, compactBody, signal.message, shouldDisable,
- !shouldDisable && account.IsPoolMode() && (account.IsPoolModeRetryableStatus(compactResp.StatusCode) || isOpenAITransientProcessingError(compactResp.StatusCode, signal.message, compactBody)),
- )
- }
- return s.handleErrorResponse(ctx, compactResp, c, account, body, resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel))
- }
- 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 {
- if signal, ok := asOpenAICompactFallbackSignal(err); ok {
- if retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
- c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, compactModelFallbackRetried,
- ); retry {
- s.appendOpenAICompactFallbackRetryOps(c, account, resp, signal.payload, signal.message, false)
- body = retryBody
- requestView = newOpenAIRequestView(body)
- upstreamModel = fallbackModel
- compactModelFallbackRetried = true
- SetOpsUpstreamModel(c, fallbackModel)
- continue
- }
- }
- 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.UsesOpenAICodexProtocol() && !account.IsShadow() {
- if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil {
- s.updateCodexUsageSnapshot(ctx, account.ID, snapshot)
- }
- } else if account.IsShadow() && account.ParentAccountID != nil {
- notifyOpenAIAutoReset(*account.ParentAccountID)
- }
- if usage == nil {
- usage = &OpenAIUsage{}
- }
- forwardResult := &OpenAIForwardResult{
- RequestID: resp.Header.Get("x-request-id"),
- UpstreamHeaders: resp.Header,
- ResponseID: responseID,
- Usage: *usage,
- Model: originalModel,
- BillingModel: billingModel,
- UpstreamModel: upstreamModel,
- UpstreamResponseModel: observedUpstreamResponseModel(c),
- UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
- UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c),
- ServiceTier: resolvedOpenAIUpstreamServiceTier(c, 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 / Kimi
- // 有原生 Responses,GLM 回退 Chat Completions。
- switch account.GetAPIProtocol() {
- case APIProtocolChatCompletions:
- return true
- case APIProtocolAdaptive:
- return !account.SupportsNativeCNResponses()
- 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) {
- if err := validateMode1StagedRequest(c, account, body); err != nil {
- return nil, err
- }
- // Determine target URL based on account type
- var targetURL string
- switch account.Type {
- case AccountTypeOAuth:
- // OAuth accounts use ChatGPT internal API
- targetURL = chatgptCodexURL
- case AccountTypeSetupToken:
- if account.IsOpenAIOAuthLike() {
- targetURL = chatgptCodexURL
- } else {
- targetURL = openaiPlatformAPIURL
- }
- case AccountTypeAPIKey:
- // API Key accounts use Platform API or custom base URL
- baseURL := account.GetOpenAIBaseURL()
- if account.UsesNativeCNResponses() && 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 / Kimi 原生 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.UsesOpenAICodexProtocol() {
- // 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.UsesOpenAICodexProtocol() {
- 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", isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), compactSession))
- } else {
- req.Header.Set("accept", "text/event-stream")
- }
- if promptCacheKey != "" {
- isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), 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())
- }
- // 账号 namespace 不改变客户端身份基数,但确保 scheduler failover 后不会把
- // 同一组 Codex IDs 发送给另一份 OAuth 凭据。可选指纹收敛随后仍可覆盖这些值。
- applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
- // 指纹收敛:使用 Forward() 中预计算的收敛 ID 改写出站头,与请求体使用同一份 IDs。
- applyStagedCodexFingerprintHeaders(c, account, req.Header)
- // 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。
- // 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。
- if account.UsesOpenAICodexProtocol() {
- 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)
- applyOpenCodeSessionHeader(c, account, targetURL, 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()
- }
|