1967 lines
70 KiB
Go
1967 lines
70 KiB
Go
package service
|
||||
|
|
|
|||
|
|
// 本文件承载 /v1/responses 透传转发及其流式、非流式响应与错误处理。
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"bufio"
|
|||
|
|
"bytes"
|
|||
|
|
"context"
|
|||
|
|
"encoding/json"
|
|||
|
|
"errors"
|
|||
|
|
"fmt"
|
|||
|
|
"io"
|
|||
|
|
"net/http"
|
|||
|
|
"sort"
|
|||
|
|
"strconv"
|
|||
|
|
"strings"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
|||
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
|||
|
|
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
"github.com/tidwall/gjson"
|
|||
|
|
"github.com/tidwall/sjson"
|
|||
|
|
"go.uber.org/zap"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const openAIResponsesClientToolMappingContextKey = "openai_responses_client_tool_mapping"
|
|||
|
|
|
|||
|
|
func hasOpenAIResponsesClientToolMapping(mapping apicompat.ResponsesClientToolMapping) bool {
|
|||
|
|
return len(mapping.CustomTools) > 0 || mapping.ToolSearch || len(mapping.NamespaceTools) > 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func adaptOpenAIResponsesClientTools(body []byte) ([]byte, apicompat.ResponsesClientToolMapping, error) {
|
|||
|
|
if !needsOpenAIResponsesClientToolAdaptation(body) {
|
|||
|
|
return body, apicompat.ResponsesClientToolMapping{}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
|||
|
|
decoder.UseNumber()
|
|||
|
|
var requestBody map[string]any
|
|||
|
|
if err := decoder.Decode(&requestBody); err != nil {
|
|||
|
|
return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools: %w", err)
|
|||
|
|
}
|
|||
|
|
var trailingValue any
|
|||
|
|
if err := decoder.Decode(&trailingValue); !errors.Is(err, io.EOF) {
|
|||
|
|
if err == nil {
|
|||
|
|
err = errors.New("multiple JSON values")
|
|||
|
|
}
|
|||
|
|
return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("decode OpenAI Responses client tools trailing data: %w", err)
|
|||
|
|
}
|
|||
|
|
mapping, changed, err := apicompat.AdaptResponsesClientTools(requestBody)
|
|||
|
|
if err != nil || !changed {
|
|||
|
|
return body, mapping, err
|
|||
|
|
}
|
|||
|
|
rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
|
|||
|
|
if err != nil {
|
|||
|
|
return body, apicompat.ResponsesClientToolMapping{}, fmt.Errorf("encode OpenAI Responses client tools: %w", err)
|
|||
|
|
}
|
|||
|
|
return rebuilt, mapping, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func needsOpenAIResponsesClientToolAdaptation(body []byte) bool {
|
|||
|
|
needsAdaptation := false
|
|||
|
|
var visit func(gjson.Result) bool
|
|||
|
|
visit = func(value gjson.Result) bool {
|
|||
|
|
if value.IsObject() {
|
|||
|
|
switch strings.TrimSpace(value.Get("type").String()) {
|
|||
|
|
case "custom", "custom_tool_call", "custom_tool_call_output",
|
|||
|
|
"tool_search", "tool_search_call", "tool_search_output":
|
|||
|
|
needsAdaptation = true
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if value.IsObject() || value.IsArray() {
|
|||
|
|
value.ForEach(func(_, child gjson.Result) bool {
|
|||
|
|
return visit(child)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
return !needsAdaptation
|
|||
|
|
}
|
|||
|
|
visit(gjson.ParseBytes(body))
|
|||
|
|
return needsAdaptation
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func openAIResponsesClientToolMapping(c *gin.Context) (apicompat.ResponsesClientToolMapping, bool) {
|
|||
|
|
if c == nil {
|
|||
|
|
return apicompat.ResponsesClientToolMapping{}, false
|
|||
|
|
}
|
|||
|
|
value, ok := c.Get(openAIResponsesClientToolMappingContextKey)
|
|||
|
|
mapping, typed := value.(apicompat.ResponsesClientToolMapping)
|
|||
|
|
return mapping, ok && typed && hasOpenAIResponsesClientToolMapping(mapping)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// clearOpenAIResponsesClientToolMapping removes mapping state from the prior
|
|||
|
|
// forwarding attempt. Forward retries accounts on the same Gin context.
|
|||
|
|
func clearOpenAIResponsesClientToolMapping(c *gin.Context) {
|
|||
|
|
if c == nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if _, exists := c.Get(openAIResponsesClientToolMappingContextKey); exists {
|
|||
|
|
c.Set(openAIResponsesClientToolMappingContextKey, apicompat.ResponsesClientToolMapping{})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
|||
|
|
ctx context.Context,
|
|||
|
|
c *gin.Context,
|
|||
|
|
account *Account,
|
|||
|
|
body []byte,
|
|||
|
|
canonicalImageIntentBody []byte,
|
|||
|
|
reqModel string,
|
|||
|
|
attemptImageIntentInvalidated bool,
|
|||
|
|
reasoningEffort *string,
|
|||
|
|
reqStream bool,
|
|||
|
|
startTime time.Time,
|
|||
|
|
) (*OpenAIForwardResult, error) {
|
|||
|
|
upstreamPassthroughModel := ""
|
|||
|
|
if isOpenAIResponsesCompactPath(c) {
|
|||
|
|
compactMappedModel := resolveOpenAICompactForwardModel(account, reqModel)
|
|||
|
|
if compactMappedModel != "" && compactMappedModel != reqModel {
|
|||
|
|
nextBody, setErr := sjson.SetBytes(body, "model", compactMappedModel)
|
|||
|
|
if setErr != nil {
|
|||
|
|
return nil, fmt.Errorf("set compact passthrough model: %w", setErr)
|
|||
|
|
}
|
|||
|
|
body = nextBody
|
|||
|
|
upstreamPassthroughModel = compactMappedModel
|
|||
|
|
attemptImageIntentInvalidated = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if account != nil && account.Type == AccountTypeOAuth {
|
|||
|
|
if rejectReason := detectOpenAIPassthroughInstructionsRejectReason(reqModel, body); rejectReason != "" {
|
|||
|
|
rejectMsg := "OpenAI codex passthrough requires a non-empty instructions field"
|
|||
|
|
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
|||
|
|
logOpenAIPassthroughInstructionsRejected(ctx, c, account, reqModel, rejectReason, body)
|
|||
|
|
c.JSON(http.StatusForbidden, gin.H{
|
|||
|
|
"error": gin.H{
|
|||
|
|
"type": "forbidden_error",
|
|||
|
|
"message": rejectMsg,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
return nil, fmt.Errorf("openai passthrough rejected before upstream: %s", rejectReason)
|
|||
|
|
}
|
|||
|
|
if isOpenAICodexModel(reqModel) && !gjson.GetBytes(body, "instructions").Exists() {
|
|||
|
|
nextBody, setErr := sjson.SetBytes(body, "instructions", defaultCodexSynthInstructions(reqModel))
|
|||
|
|
if setErr != nil {
|
|||
|
|
return nil, fmt.Errorf("set passthrough codex instructions: %w", setErr)
|
|||
|
|
}
|
|||
|
|
body = nextBody
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBody(body, isOpenAIResponsesCompactPath(c))
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if normalized {
|
|||
|
|
body = normalizedBody
|
|||
|
|
}
|
|||
|
|
reqStream = gjson.GetBytes(body, "stream").Bool()
|
|||
|
|
|
|||
|
|
stageCodexFingerprintIDs(c, nil)
|
|||
|
|
// 指纹收敛:与非透传路径同门控(仅 OAuth、legacy compact 形态跳过)。
|
|||
|
|
// 一次性解析收敛 ID:请求体 client_metadata 在此改写(raw 字节外科
|
|||
|
|
// 手术,透传热路径禁全量 Unmarshal),出站头改写由请求构造器读取
|
|||
|
|
// context 中的同一份 IDs 完成(turn_id 等随机字段两侧必须一致)。
|
|||
|
|
if !isOpenAIResponsesCompactPath(c) {
|
|||
|
|
var clientHeaders http.Header
|
|||
|
|
if c != nil && c.Request != nil {
|
|||
|
|
clientHeaders = c.Request.Header
|
|||
|
|
}
|
|||
|
|
fpIDs := resolveCodexFingerprintIDsFromRequest(account, clientHeaders)
|
|||
|
|
if fpIDs != nil {
|
|||
|
|
fpBody, fpChanged, fpErr := applyCodexFingerprintClientMetadataRaw(body, fpIDs)
|
|||
|
|
if fpErr != nil {
|
|||
|
|
return nil, fpErr
|
|||
|
|
}
|
|||
|
|
if fpChanged {
|
|||
|
|
body = fpBody
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
stageCodexFingerprintIDs(c, fpIDs)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey &&
|
|||
|
|
!isOpenAIResponsesCompactPath(c) && needsOpenAIResponsesClientToolAdaptation(body) {
|
|||
|
|
adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body)
|
|||
|
|
if adaptErr != nil {
|
|||
|
|
return nil, adaptErr
|
|||
|
|
}
|
|||
|
|
body = adaptedBody
|
|||
|
|
c.Set(openAIResponsesClientToolMappingContextKey, mapping)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sanitizedBody, sanitized, err := sanitizeEmptyBase64InputImagesInOpenAIBody(body)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if sanitized {
|
|||
|
|
body = sanitizedBody
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Apply OpenAI fast policy to the passthrough body (filter/block by service_tier).
|
|||
|
|
// 统一使用 upstream 视角的 model:透传路径下 body 已经过 compact 映射 +
|
|||
|
|
// OAuth normalize,body 中的 model 字段即上游真正会看到的 slug。
|
|||
|
|
// 这样可以与 chat-completions / messages / native /responses 入口的
|
|||
|
|
// upstreamModel 保持一致,避免 whitelist 命中差异。当 body 中没有
|
|||
|
|
// model 字段时退回 reqModel。
|
|||
|
|
policyModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
|
|||
|
|
if policyModel == "" {
|
|||
|
|
policyModel = reqModel
|
|||
|
|
}
|
|||
|
|
updatedBody, policyErr := s.applyOpenAIFastPolicyToBody(ctx, account, policyModel, body)
|
|||
|
|
if policyErr != nil {
|
|||
|
|
var blocked *OpenAIFastBlockedError
|
|||
|
|
if errors.As(policyErr, &blocked) {
|
|||
|
|
writeOpenAIFastPolicyBlockedResponse(c, blocked)
|
|||
|
|
}
|
|||
|
|
return nil, policyErr
|
|||
|
|
}
|
|||
|
|
body = updatedBody
|
|||
|
|
|
|||
|
|
apiKey := getAPIKeyFromContext(c)
|
|||
|
|
// 同一 attempt 的最终 model/body 只判定一次,权限检查与后续图片状态设置共用该结果。
|
|||
|
|
imageIntent := resolveOpenAIPassthroughImageIntent(
|
|||
|
|
c,
|
|||
|
|
reqModel,
|
|||
|
|
canonicalImageIntentBody,
|
|||
|
|
policyModel,
|
|||
|
|
body,
|
|||
|
|
attemptImageIntentInvalidated,
|
|||
|
|
IsImageGenerationIntent,
|
|||
|
|
)
|
|||
|
|
if imageIntent && !GroupAllowsImageGeneration(apiKeyGroup(apiKey)) {
|
|||
|
|
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")
|
|||
|
|
}
|
|||
|
|
imageBillingModel := ""
|
|||
|
|
imageSizeTier := ""
|
|||
|
|
imageInputSize := ""
|
|||
|
|
if imageIntent {
|
|||
|
|
var imageCfgErr error
|
|||
|
|
imageCfg, imageCfgErr := resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, reqModel)
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway",
|
|||
|
|
"[OpenAI 自动透传] 命中自动透传分支: account=%d name=%s type=%s model=%s stream=%v",
|
|||
|
|
account.ID,
|
|||
|
|
account.Name,
|
|||
|
|
account.Type,
|
|||
|
|
reqModel,
|
|||
|
|
reqStream,
|
|||
|
|
)
|
|||
|
|
if reqStream && c != nil && c.Request != nil {
|
|||
|
|
if timeoutHeaders := collectOpenAIPassthroughTimeoutHeaders(c.Request.Header); len(timeoutHeaders) > 0 {
|
|||
|
|
streamWarnLogger := logger.FromContext(ctx).With(
|
|||
|
|
zap.String("component", "service.openai_gateway"),
|
|||
|
|
zap.Int64("account_id", account.ID),
|
|||
|
|
zap.Strings("timeout_headers", timeoutHeaders),
|
|||
|
|
)
|
|||
|
|
if s.isOpenAIPassthroughTimeoutHeadersAllowed() {
|
|||
|
|
streamWarnLogger.Warn("OpenAI passthrough 透传请求包含超时相关请求头,且当前配置为放行,可能导致上游提前断流")
|
|||
|
|
} else {
|
|||
|
|
streamWarnLogger.Warn("OpenAI passthrough 检测到超时相关请求头,将按配置过滤以降低断流风险")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get access token
|
|||
|
|
token, _, err := s.GetAccessToken(ctx, account)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
proxyURL := ""
|
|||
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
|||
|
|
proxyURL = account.Proxy.URL()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if c != nil {
|
|||
|
|
c.Set("openai_passthrough", true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
agentTaskRecoveryTried := false
|
|||
|
|
var resp *http.Response
|
|||
|
|
for {
|
|||
|
|
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
|
|||
|
|
upstreamReq, buildErr := s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token)
|
|||
|
|
releaseUpstreamCtx()
|
|||
|
|
if buildErr != nil {
|
|||
|
|
return nil, buildErr
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
upstreamStart := time.Now()
|
|||
|
|
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
|||
|
|
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
|||
|
|
if err != nil {
|
|||
|
|
// Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to
|
|||
|
|
// a failover so the handler switches to a healthy account.
|
|||
|
|
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
|
|||
|
|
}
|
|||
|
|
if resp.StatusCode < 400 {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Peek only to identify an invalid task. Restore the body so the existing
|
|||
|
|
// passthrough error handling sees the same response after recovery fails.
|
|||
|
|
probeBody := s.readUpstreamErrorBody(resp)
|
|||
|
|
_ = resp.Body.Close()
|
|||
|
|
resp.Body = io.NopCloser(bytes.NewReader(probeBody))
|
|||
|
|
if !agentTaskRecoveryTried && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, probeBody) {
|
|||
|
|
agentTaskRecoveryTried = true
|
|||
|
|
expectedTaskID := account.GetCredential("task_id")
|
|||
|
|
if recoveryErr := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); recoveryErr != nil {
|
|||
|
|
return nil, fmt.Errorf("agent identity task recovery failed: %w", recoveryErr)
|
|||
|
|
}
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 透传模式默认保持原样代理;容量错误以及 API-key 上游的瞬时
|
|||
|
|
// 5xx 应先触发多账号 failover,且此时尚未写入下游响应。
|
|||
|
|
// probeBody 已在上方任务探测时读取过一次,直接复用避免重复读取。
|
|||
|
|
if shouldFailoverOpenAIPassthroughResponse(account, resp.StatusCode, probeBody) {
|
|||
|
|
return nil, s.handleFailoverErrorResponsePassthrough(ctx, resp, c, account, body, probeBody)
|
|||
|
|
}
|
|||
|
|
return nil, s.handleErrorResponsePassthrough(ctx, resp, c, account, body, probeBody)
|
|||
|
|
}
|
|||
|
|
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 = newGrokResponsesClientToolStreamBody(resp.Body, mapping, maxLineSize)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
serviceTier := extractOpenAIServiceTierFromBody(body)
|
|||
|
|
|
|||
|
|
// x-codex-turn-state 溯源:下游回传由 writeOpenAIPassthroughResponseHeaders
|
|||
|
|
// 在各 handler 的写头点强制放行,铸造账号在此统一记录,供出站守卫剥离
|
|||
|
|
// failover 换号后的跨账号回带(openai_codex_turn_state.go)。
|
|||
|
|
if extractOpenAICodexTurnState(resp.Header) != "" {
|
|||
|
|
s.noteOpenAICodexTurnStateProvenance(c, account)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var usage *OpenAIUsage
|
|||
|
|
var firstTokenMs *int
|
|||
|
|
responseID := ""
|
|||
|
|
imageCount := 0
|
|||
|
|
var imageOutputSizes []string
|
|||
|
|
if reqStream {
|
|||
|
|
result, err := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
usage = result.usage
|
|||
|
|
firstTokenMs = result.firstTokenMs
|
|||
|
|
responseID = strings.TrimSpace(result.responseID)
|
|||
|
|
imageCount = result.imageCount
|
|||
|
|
imageOutputSizes = result.imageOutputSizes
|
|||
|
|
} else {
|
|||
|
|
result, err := s.handleNonStreamingResponsePassthrough(ctx, resp, c, reqModel, upstreamPassthroughModel)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
usage = result.usage
|
|||
|
|
responseID = strings.TrimSpace(result.responseID)
|
|||
|
|
imageCount = result.imageCount
|
|||
|
|
imageOutputSizes = result.imageOutputSizes
|
|||
|
|
}
|
|||
|
|
s.bindHTTPResponseAccount(ctx, c, account, responseID)
|
|||
|
|
|
|||
|
|
// 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
|
|||
|
|
if !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: reqModel,
|
|||
|
|
UpstreamModel: upstreamPassthroughModel,
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
return forwardResult, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func logOpenAIPassthroughInstructionsRejected(
|
|||
|
|
ctx context.Context,
|
|||
|
|
c *gin.Context,
|
|||
|
|
account *Account,
|
|||
|
|
reqModel string,
|
|||
|
|
rejectReason string,
|
|||
|
|
body []byte,
|
|||
|
|
) {
|
|||
|
|
if ctx == nil {
|
|||
|
|
ctx = context.Background()
|
|||
|
|
}
|
|||
|
|
accountID := int64(0)
|
|||
|
|
accountName := ""
|
|||
|
|
accountType := ""
|
|||
|
|
if account != nil {
|
|||
|
|
accountID = account.ID
|
|||
|
|
accountName = strings.TrimSpace(account.Name)
|
|||
|
|
accountType = strings.TrimSpace(string(account.Type))
|
|||
|
|
}
|
|||
|
|
fields := []zap.Field{
|
|||
|
|
zap.String("component", "service.openai_gateway"),
|
|||
|
|
zap.Int64("account_id", accountID),
|
|||
|
|
zap.String("account_name", accountName),
|
|||
|
|
zap.String("account_type", accountType),
|
|||
|
|
zap.String("request_model", strings.TrimSpace(reqModel)),
|
|||
|
|
zap.String("reject_reason", strings.TrimSpace(rejectReason)),
|
|||
|
|
}
|
|||
|
|
fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, body)
|
|||
|
|
logger.FromContext(ctx).With(fields...).Warn("OpenAI passthrough 本地拦截:Codex 请求缺少有效 instructions")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
|||
|
|
ctx context.Context,
|
|||
|
|
c *gin.Context,
|
|||
|
|
account *Account,
|
|||
|
|
body []byte,
|
|||
|
|
token string,
|
|||
|
|
) (*http.Request, error) {
|
|||
|
|
targetURL := openaiPlatformAPIURL
|
|||
|
|
switch account.Type {
|
|||
|
|
case AccountTypeOAuth:
|
|||
|
|
targetURL = chatgptCodexURL
|
|||
|
|
case AccountTypeAPIKey:
|
|||
|
|
baseURL := account.GetOpenAIBaseURL()
|
|||
|
|
if baseURL != "" {
|
|||
|
|
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c))
|
|||
|
|
|
|||
|
|
// DeepSeek 原生 Responses 端点为无状态实现(见 normalizeDeepSeekResponsesRequestBody)。
|
|||
|
|
body = normalizeDeepSeekResponsesRequestBody(account, body)
|
|||
|
|
|
|||
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
|
|||
|
|
|
|||
|
|
// 透传客户端请求头(安全白名单)。
|
|||
|
|
allowTimeoutHeaders := s.isOpenAIPassthroughTimeoutHeadersAllowed()
|
|||
|
|
if c != nil && c.Request != nil {
|
|||
|
|
for key, values := range c.Request.Header {
|
|||
|
|
lower := strings.ToLower(strings.TrimSpace(key))
|
|||
|
|
if !isOpenAIPassthroughAllowedRequestHeader(lower, allowTimeoutHeaders) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
for _, v := range values {
|
|||
|
|
req.Header.Add(key, v)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 客户端回带的 x-codex-turn-state 若已知由其他账号铸造(failover 换号),
|
|||
|
|
// 剥离后再出站(openai_codex_turn_state.go)。
|
|||
|
|
s.guardOpenAICodexTurnStateEcho(c, account, req.Header)
|
|||
|
|
|
|||
|
|
// 覆盖入站鉴权残留,并注入上游认证
|
|||
|
|
req.Header.Del("authorization")
|
|||
|
|
req.Header.Del("x-api-key")
|
|||
|
|
req.Header.Del("x-goog-api-key")
|
|||
|
|
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)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// OAuth 透传到 ChatGPT internal API 时补齐必要头。
|
|||
|
|
if account.Type == AccountTypeOAuth {
|
|||
|
|
// Current Codex OAuth HTTP no longer negotiates the legacy Responses
|
|||
|
|
// experiment. Passthrough may receive it from an older client, so remove
|
|||
|
|
// only that token while preserving any independent beta negotiation.
|
|||
|
|
stripOpenAILegacyResponsesBeta(req.Header)
|
|||
|
|
promptCacheKey := strings.TrimSpace(gjson.GetBytes(body, "prompt_cache_key").String())
|
|||
|
|
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)
|
|||
|
|
}
|
|||
|
|
apiKeyID := getAPIKeyIDFromContext(c)
|
|||
|
|
// 先保存客户端原始值,再做 compact 补充,避免后续统一隔离时读到已处理的值。
|
|||
|
|
clientSessionID := strings.TrimSpace(req.Header.Get("session_id"))
|
|||
|
|
clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id"))
|
|||
|
|
if isOpenAIResponsesCompactPath(c) {
|
|||
|
|
req.Header.Set("accept", "application/json")
|
|||
|
|
if req.Header.Get("version") == "" {
|
|||
|
|
req.Header.Set("version", CodexCanonicalClientVersion())
|
|||
|
|
}
|
|||
|
|
if clientSessionID == "" {
|
|||
|
|
clientSessionID = resolveOpenAICompactSessionID(c)
|
|||
|
|
}
|
|||
|
|
} else if req.Header.Get("accept") == "" {
|
|||
|
|
req.Header.Set("accept", "text/event-stream")
|
|||
|
|
}
|
|||
|
|
if req.Header.Get("originator") == "" {
|
|||
|
|
req.Header.Set("originator", resolveCodexOutboundIdentity("").originator)
|
|||
|
|
}
|
|||
|
|
// 用隔离后的 session 标识符覆盖客户端透传值,防止跨用户会话碰撞。
|
|||
|
|
if clientSessionID == "" {
|
|||
|
|
clientSessionID = promptCacheKey
|
|||
|
|
}
|
|||
|
|
if clientConversationID == "" {
|
|||
|
|
clientConversationID = promptCacheKey
|
|||
|
|
}
|
|||
|
|
if clientSessionID != "" {
|
|||
|
|
req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, clientSessionID))
|
|||
|
|
}
|
|||
|
|
if clientConversationID != "" {
|
|||
|
|
req.Header.Set("conversation_id", isolateOpenAISessionID(apiKeyID, clientConversationID))
|
|||
|
|
}
|
|||
|
|
} else if isOpenAIResponsesCompactPath(c) {
|
|||
|
|
// 透传白名单会放行客户端的 Accept: text/event-stream;compact 上游是
|
|||
|
|
// unary JSON 协议,API-key 账号同样强制 Accept,避免上游按 SSE 返回
|
|||
|
|
// (#3777 期望行为 4)。
|
|||
|
|
req.Header.Set("accept", "application/json")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 透传模式也支持账户自定义 User-Agent 与 ForceCodexCLI 兜底。
|
|||
|
|
customUA := account.GetOpenAIUserAgent()
|
|||
|
|
if customUA != "" {
|
|||
|
|
req.Header.Set("user-agent", customUA)
|
|||
|
|
}
|
|||
|
|
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
|
|||
|
|
req.Header.Set("user-agent", CodexCanonicalUserAgent())
|
|||
|
|
}
|
|||
|
|
// 指纹收敛:使用 forwardOpenAIPassthrough 中预计算的收敛 ID 改写出站头,
|
|||
|
|
// 与请求体 client_metadata 共享同一份 IDs(与非透传路径相同的相对位置:
|
|||
|
|
// 会话隔离之后、终态身份收口之前)。
|
|||
|
|
applyStagedCodexFingerprintHeaders(c, account, req.Header)
|
|||
|
|
// 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份
|
|||
|
|
// (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。
|
|||
|
|
if account.Type == AccountTypeOAuth {
|
|||
|
|
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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_passthrough", req.Header, body, "not_applicable")
|
|||
|
|
|
|||
|
|
return req, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func stripOpenAILegacyResponsesBeta(headers http.Header) {
|
|||
|
|
if headers == nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
preserved := make([]string, 0)
|
|||
|
|
for key, values := range headers {
|
|||
|
|
if !strings.EqualFold(strings.TrimSpace(key), "OpenAI-Beta") {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
delete(headers, key)
|
|||
|
|
for _, value := range values {
|
|||
|
|
parts := strings.Split(value, ",")
|
|||
|
|
kept := parts[:0]
|
|||
|
|
for _, part := range parts {
|
|||
|
|
part = strings.TrimSpace(part)
|
|||
|
|
if part == "" || strings.EqualFold(part, "responses=experimental") {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
kept = append(kept, part)
|
|||
|
|
}
|
|||
|
|
if len(kept) > 0 {
|
|||
|
|
preserved = append(preserved, strings.Join(kept, ", "))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for _, value := range preserved {
|
|||
|
|
headers.Add("OpenAI-Beta", value)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func shouldFailoverOpenAIPassthroughResponse(account *Account, statusCode int, responseBody []byte) bool {
|
|||
|
|
if isOpenAIContextWindowError("", responseBody) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
if isOpenAIRequestBodyTooLargeError(statusCode, "", responseBody) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
if account != nil && account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
switch statusCode {
|
|||
|
|
case http.StatusTooManyRequests, 529:
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
if account == nil || account.Type != AccountTypeAPIKey {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
switch statusCode {
|
|||
|
|
case http.StatusInternalServerError,
|
|||
|
|
http.StatusBadGateway,
|
|||
|
|
http.StatusServiceUnavailable,
|
|||
|
|
http.StatusGatewayTimeout,
|
|||
|
|
520, 521, 522, 523, 524:
|
|||
|
|
return true
|
|||
|
|
default:
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func writeOpenAIPassthroughErrorHeaders(dst, src http.Header) {
|
|||
|
|
if dst == nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
dst.Set("Content-Type", "application/json; charset=utf-8")
|
|||
|
|
dst.Set("Cache-Control", "no-store")
|
|||
|
|
dst.Del("Retry-After")
|
|||
|
|
if src == nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
rawRetryAfter := strings.TrimSpace(src.Get("Retry-After"))
|
|||
|
|
if validOpenAIPassthroughRetryAfter(rawRetryAfter, time.Now()) {
|
|||
|
|
dst.Set("Retry-After", rawRetryAfter)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func validOpenAIPassthroughRetryAfter(raw string, now time.Time) bool {
|
|||
|
|
raw = strings.TrimSpace(raw)
|
|||
|
|
if raw == "" {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
delaySeconds := true
|
|||
|
|
for i := 0; i < len(raw); i++ {
|
|||
|
|
if raw[i] < '0' || raw[i] > '9' {
|
|||
|
|
delaySeconds = false
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if delaySeconds {
|
|||
|
|
seconds, err := strconv.ParseUint(raw, 10, 64)
|
|||
|
|
return err == nil && seconds > 0
|
|||
|
|
}
|
|||
|
|
parsed, err := http.ParseTime(raw)
|
|||
|
|
return err == nil && parsed.After(now)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func writeSanitizedOpenAIPassthroughError(c *gin.Context, upstreamStatus int, upstreamHeaders http.Header) {
|
|||
|
|
downstreamStatus := upstreamStatus
|
|||
|
|
message := "Upstream request failed"
|
|||
|
|
switch upstreamStatus {
|
|||
|
|
case http.StatusUnauthorized:
|
|||
|
|
downstreamStatus = http.StatusBadGateway
|
|||
|
|
message = "Upstream authentication failed"
|
|||
|
|
case http.StatusForbidden:
|
|||
|
|
downstreamStatus = http.StatusBadGateway
|
|||
|
|
message = "Upstream access denied"
|
|||
|
|
default:
|
|||
|
|
if upstreamStatus >= http.StatusInternalServerError {
|
|||
|
|
message = "Upstream service temporarily unavailable"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
writeOpenAIPassthroughErrorEnvelope(c, downstreamStatus, upstreamHeaders, message)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// writeOpenAIPassthroughErrorEnvelope 以本地 JSON 信封 + 净化后的头策略写出
|
|||
|
|
// 错误响应;message 由调用方决定(净化通用文案或脱敏后的上游消息)。
|
|||
|
|
func writeOpenAIPassthroughErrorEnvelope(c *gin.Context, downstreamStatus int, upstreamHeaders http.Header, message string) {
|
|||
|
|
if c == nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
body, _ := json.Marshal(gin.H{
|
|||
|
|
"error": gin.H{
|
|||
|
|
"type": "upstream_error",
|
|||
|
|
"message": message,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
if writeOpenAICompactSSEBridge(c, downstreamStatus, body) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
writeOpenAIPassthroughErrorHeaders(c.Writer.Header(), upstreamHeaders)
|
|||
|
|
c.Data(downstreamStatus, "application/json; charset=utf-8", body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough(
|
|||
|
|
ctx context.Context,
|
|||
|
|
resp *http.Response,
|
|||
|
|
c *gin.Context,
|
|||
|
|
account *Account,
|
|||
|
|
requestBody []byte,
|
|||
|
|
responseBody []byte,
|
|||
|
|
) error {
|
|||
|
|
body := s.redactAgentIdentitySensitiveBody(ctx, account, responseBody)
|
|||
|
|
|
|||
|
|
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
|
|||
|
|
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
|||
|
|
upstreamDetail := ""
|
|||
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|||
|
|
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
|||
|
|
if maxBytes <= 0 {
|
|||
|
|
maxBytes = 2048
|
|||
|
|
}
|
|||
|
|
upstreamDetail = truncateString(string(body), maxBytes)
|
|||
|
|
}
|
|||
|
|
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
|
|||
|
|
logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
|
|||
|
|
reqModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody)
|
|||
|
|
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, reqModel)
|
|||
|
|
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, canonicalModel)
|
|||
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|||
|
|
Platform: account.Platform,
|
|||
|
|
AccountID: account.ID,
|
|||
|
|
AccountName: account.Name,
|
|||
|
|
UpstreamStatusCode: resp.StatusCode,
|
|||
|
|
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
|||
|
|
Passthrough: true,
|
|||
|
|
Kind: "failover",
|
|||
|
|
Message: upstreamMsg,
|
|||
|
|
Detail: upstreamDetail,
|
|||
|
|
UpstreamResponseBody: upstreamDetail,
|
|||
|
|
})
|
|||
|
|
return newOpenAIUpstreamFailoverError(
|
|||
|
|
resp.StatusCode,
|
|||
|
|
resp.Header,
|
|||
|
|
body,
|
|||
|
|
upstreamMsg,
|
|||
|
|
!shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIGatewayService) handleErrorResponsePassthrough(
|
|||
|
|
ctx context.Context,
|
|||
|
|
resp *http.Response,
|
|||
|
|
c *gin.Context,
|
|||
|
|
account *Account,
|
|||
|
|
requestBody []byte,
|
|||
|
|
responseBody []byte,
|
|||
|
|
) error {
|
|||
|
|
MarkResponseCommitted(c)
|
|||
|
|
body := s.redactAgentIdentitySensitiveBody(ctx, account, responseBody)
|
|||
|
|
|
|||
|
|
// cyber_policy 仍按原始 body 打内部标记,供 handler 事后写风控/邮件;面向客户端的
|
|||
|
|
// 错误体在下方统一重建。cyber 是上游网络安全策略拦截,不冷却账号,
|
|||
|
|
// 故下方跳过 handleOpenAIAccountUpstreamError(避免自定义 temp-unschedulable 规则误冷却)。
|
|||
|
|
cyberHit, cyberCode, cyberMsg := detectOpenAICyberPolicy(body)
|
|||
|
|
if cyberHit {
|
|||
|
|
MarkOpsCyberPolicy(c, CyberPolicyMark{
|
|||
|
|
Code: cyberCode,
|
|||
|
|
Message: cyberMsg,
|
|||
|
|
Body: truncateString(string(body), 4096),
|
|||
|
|
UpstreamStatus: resp.StatusCode,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
|
|||
|
|
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
|||
|
|
upstreamDetail := ""
|
|||
|
|
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|||
|
|
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
|||
|
|
if maxBytes <= 0 {
|
|||
|
|
maxBytes = 2048
|
|||
|
|
}
|
|||
|
|
upstreamDetail = truncateString(string(body), maxBytes)
|
|||
|
|
}
|
|||
|
|
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
|
|||
|
|
logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
|
|||
|
|
// 错误体虽不会原样透传,运行态账号状态仍需更新,避免粘性路由继续复用
|
|||
|
|
// 刚被限流的账号。cyber 例外:不冷却账号。
|
|||
|
|
if !cyberHit {
|
|||
|
|
reqModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody)
|
|||
|
|
canonicalModel := canonicalOpenAIAccountSchedulingModel(account, reqModel)
|
|||
|
|
_ = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, canonicalModel)
|
|||
|
|
}
|
|||
|
|
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
|||
|
|
Platform: account.Platform,
|
|||
|
|
AccountID: account.ID,
|
|||
|
|
AccountName: account.Name,
|
|||
|
|
UpstreamStatusCode: resp.StatusCode,
|
|||
|
|
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
|||
|
|
Passthrough: true,
|
|||
|
|
Kind: "http_error",
|
|||
|
|
Message: upstreamMsg,
|
|||
|
|
Detail: upstreamDetail,
|
|||
|
|
UpstreamResponseBody: upstreamDetail,
|
|||
|
|
})
|
|||
|
|
// context-window 超限是确定性请求失败(shouldFailoverOpenAIPassthroughResponse
|
|||
|
|
// 已保证不切号),其文案对客户端可操作(如触发自动压缩);在净化信封内保留
|
|||
|
|
// 脱敏后的上游消息,而不是抹成通用文案。
|
|||
|
|
if isOpenAIContextWindowError(upstreamMsg, body) && upstreamMsg != "" {
|
|||
|
|
writeOpenAIPassthroughErrorEnvelope(c, resp.StatusCode, resp.Header, upstreamMsg)
|
|||
|
|
} else {
|
|||
|
|
writeSanitizedOpenAIPassthroughError(c, resp.StatusCode, resp.Header)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return fmt.Errorf("upstream error: %d (client response sanitized)", resp.StatusCode)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func isOpenAIPassthroughAllowedRequestHeader(lowerKey string, allowTimeoutHeaders bool) bool {
|
|||
|
|
if lowerKey == "" {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
if isOpenAIPassthroughTimeoutHeader(lowerKey) {
|
|||
|
|
return allowTimeoutHeaders
|
|||
|
|
}
|
|||
|
|
return openaiPassthroughAllowedHeaders[lowerKey]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func isOpenAIPassthroughTimeoutHeader(lowerKey string) bool {
|
|||
|
|
switch lowerKey {
|
|||
|
|
case "x-stainless-timeout", "x-stainless-read-timeout", "x-stainless-connect-timeout", "x-request-timeout", "request-timeout", "grpc-timeout":
|
|||
|
|
return true
|
|||
|
|
default:
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIGatewayService) isOpenAIPassthroughTimeoutHeadersAllowed() bool {
|
|||
|
|
return s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIPassthroughAllowTimeoutHeaders
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func collectOpenAIPassthroughTimeoutHeaders(h http.Header) []string {
|
|||
|
|
if h == nil {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
var matched []string
|
|||
|
|
for key, values := range h {
|
|||
|
|
lowerKey := strings.ToLower(strings.TrimSpace(key))
|
|||
|
|
if isOpenAIPassthroughTimeoutHeader(lowerKey) {
|
|||
|
|
entry := lowerKey
|
|||
|
|
if len(values) > 0 {
|
|||
|
|
entry = fmt.Sprintf("%s=%s", lowerKey, strings.Join(values, "|"))
|
|||
|
|
}
|
|||
|
|
matched = append(matched, entry)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
sort.Strings(matched)
|
|||
|
|
return matched
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type openaiStreamingResultPassthrough struct {
|
|||
|
|
usage *OpenAIUsage
|
|||
|
|
firstTokenMs *int
|
|||
|
|
responseID string
|
|||
|
|
imageCount int
|
|||
|
|
imageOutputSizes []string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type openaiNonStreamingResultPassthrough struct {
|
|||
|
|
*OpenAIUsage
|
|||
|
|
usage *OpenAIUsage
|
|||
|
|
responseID string
|
|||
|
|
imageCount int
|
|||
|
|
imageOutputSizes []string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const openAIStreamKeepaliveBytesKey = "openai_stream_keepalive_bytes"
|
|||
|
|
|
|||
|
|
func recordOpenAIStreamKeepaliveBytes(c *gin.Context, written int) {
|
|||
|
|
if c == nil || written <= 0 {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
current := 0
|
|||
|
|
if value, ok := c.Get(openAIStreamKeepaliveBytesKey); ok {
|
|||
|
|
current, _ = value.(int)
|
|||
|
|
}
|
|||
|
|
c.Set(openAIStreamKeepaliveBytesKey, current+written)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool {
|
|||
|
|
if localStarted {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
if c == nil || c.Writer == nil {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
// compact keepalive comments commit the HTTP response as 200, but they are
|
|||
|
|
// not semantic model output and therefore must not block a safe retry.
|
|||
|
|
// Without a compact keepalive this is equivalent to checking Writer.Size().
|
|||
|
|
return OpenAICompactKeepaliveAdjustedWrittenSize(c) >= 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func openAIStreamEventIsPreamble(eventType string) bool {
|
|||
|
|
switch strings.TrimSpace(eventType) {
|
|||
|
|
case "response.created", "response.in_progress":
|
|||
|
|
return true
|
|||
|
|
default:
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func openAIStreamAddedEventStartsClientOutput(payload []byte, eventType string) bool {
|
|||
|
|
if len(payload) == 0 || !gjson.ValidBytes(payload) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
switch strings.TrimSpace(eventType) {
|
|||
|
|
case "response.output_item.added":
|
|||
|
|
item := gjson.GetBytes(payload, "item")
|
|||
|
|
if !item.Exists() || !item.IsObject() {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
switch strings.TrimSpace(item.Get("type").String()) {
|
|||
|
|
case "reasoning":
|
|||
|
|
if item.Get("encrypted_content").String() != "" {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
summary := item.Get("summary")
|
|||
|
|
if !summary.IsArray() {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
for _, part := range summary.Array() {
|
|||
|
|
if strings.TrimSpace(part.Get("type").String()) != "summary_text" || part.Get("text").String() != "" {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
case "message":
|
|||
|
|
content := item.Get("content")
|
|||
|
|
if !content.IsArray() {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
for _, part := range content.Array() {
|
|||
|
|
switch strings.TrimSpace(part.Get("type").String()) {
|
|||
|
|
case "output_text":
|
|||
|
|
if part.Get("text").String() != "" {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
case "refusal":
|
|||
|
|
if part.Get("refusal").String() != "" {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
default:
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
case "function_call":
|
|||
|
|
return item.Get("arguments").String() != ""
|
|||
|
|
case "custom_tool_call":
|
|||
|
|
return item.Get("input").String() != ""
|
|||
|
|
case "compaction":
|
|||
|
|
return item.Get("encrypted_content").String() != ""
|
|||
|
|
default:
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
case "response.content_part.added":
|
|||
|
|
part := gjson.GetBytes(payload, "part")
|
|||
|
|
if !part.Exists() || !part.IsObject() {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
switch strings.TrimSpace(part.Get("type").String()) {
|
|||
|
|
case "output_text":
|
|||
|
|
return part.Get("text").String() != ""
|
|||
|
|
case "refusal":
|
|||
|
|
return part.Get("refusal").String() != ""
|
|||
|
|
default:
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
case "response.reasoning_summary_part.added":
|
|||
|
|
part := gjson.GetBytes(payload, "part")
|
|||
|
|
if !part.Exists() || !part.IsObject() || strings.TrimSpace(part.Get("type").String()) != "summary_text" {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
return part.Get("text").String() != ""
|
|||
|
|
default:
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func openAIStreamDataStartsClientOutput(data, eventType string) bool {
|
|||
|
|
trimmed := strings.TrimSpace(data)
|
|||
|
|
if trimmed == "" {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
switch strings.TrimSpace(eventType) {
|
|||
|
|
case "response.failed":
|
|||
|
|
return false
|
|||
|
|
case "error":
|
|||
|
|
// 上游降载/瞬时故障会先推 {"type":"error"} 帧、再以 response.failed 收尾。
|
|||
|
|
// 可重试类错误帧不能算客户端输出:一旦把它当首输出 flush,
|
|||
|
|
// clientOutputStarted 即被固化,随后的 failed 事件永远进不了 pre-output
|
|||
|
|
// failover 分支,只能把致命错误原样转发给客户端。不可重试类
|
|||
|
|
// (content_policy / invalid_request 等)维持原样转发,保留上游错误细节。
|
|||
|
|
payload := []byte(trimmed)
|
|||
|
|
return !openAIStreamFailedEventShouldFailover(payload, extractOpenAISSEErrorMessage(payload))
|
|||
|
|
case "response.output_item.added", "response.content_part.added", "response.reasoning_summary_part.added":
|
|||
|
|
return openAIStreamAddedEventStartsClientOutput([]byte(trimmed), eventType)
|
|||
|
|
}
|
|||
|
|
return !openAIStreamEventIsPreamble(eventType)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func openAIStreamItemHasVisibleOutput(item gjson.Result) bool {
|
|||
|
|
if item.Get("arguments").String() != "" || item.Get("input").String() != "" || item.Get("result").String() != "" {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
for _, path := range []string{"content", "summary"} {
|
|||
|
|
for _, part := range item.Get(path).Array() {
|
|||
|
|
if part.Get("text").String() != "" || part.Get("transcript").String() != "" {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Structural progress can commit an attempt and disarm first-output failover,
|
|||
|
|
// but TTFT should start only when the stream carries content a client can use.
|
|||
|
|
func openAIStreamDataStartsVisibleOutput(data, eventType string) bool {
|
|||
|
|
trimmed := strings.TrimSpace(data)
|
|||
|
|
if trimmed == "" || trimmed == "[DONE]" || !gjson.Valid(trimmed) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
eventType = strings.TrimSpace(eventType)
|
|||
|
|
if eventType == "" {
|
|||
|
|
eventType = strings.TrimSpace(gjson.Get(trimmed, "type").String())
|
|||
|
|
}
|
|||
|
|
if strings.HasSuffix(eventType, ".delta") {
|
|||
|
|
delta := gjson.Get(trimmed, "delta")
|
|||
|
|
return delta.Exists() && delta.String() != ""
|
|||
|
|
}
|
|||
|
|
switch eventType {
|
|||
|
|
case "response.output_text.done",
|
|||
|
|
"response.reasoning_summary_text.done",
|
|||
|
|
"response.reasoning_text.done",
|
|||
|
|
"response.audio_transcript.done":
|
|||
|
|
return gjson.Get(trimmed, "text").String() != ""
|
|||
|
|
case "response.function_call_arguments.done":
|
|||
|
|
return gjson.Get(trimmed, "arguments").String() != ""
|
|||
|
|
case "response.custom_tool_call_input.done":
|
|||
|
|
return gjson.Get(trimmed, "input").String() != ""
|
|||
|
|
case "response.image_generation_call.partial_image":
|
|||
|
|
return gjson.Get(trimmed, "partial_image_b64").String() != ""
|
|||
|
|
case "response.content_part.added", "response.content_part.done",
|
|||
|
|
"response.reasoning_summary_part.added", "response.reasoning_summary_part.done":
|
|||
|
|
part := gjson.Get(trimmed, "part")
|
|||
|
|
return part.Get("text").String() != "" || part.Get("transcript").String() != ""
|
|||
|
|
case "response.output_item.added", "response.output_item.done":
|
|||
|
|
return openAIStreamItemHasVisibleOutput(gjson.Get(trimmed, "item"))
|
|||
|
|
case "response.completed", "response.done":
|
|||
|
|
for _, item := range gjson.Get(trimmed, "response.output").Array() {
|
|||
|
|
if openAIStreamItemHasVisibleOutput(item) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// openAIStreamFailedEventErrorCode 提取流内 failed 事件的错误码(小写),
|
|||
|
|
// 兼容 response.failed 的嵌套形态与裸 error 形态。
|
|||
|
|
func openAIStreamFailedEventErrorCode(payload []byte) string {
|
|||
|
|
code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
|
|||
|
|
if code == "" {
|
|||
|
|
code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
|
|||
|
|
}
|
|||
|
|
return code
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// isOpenAIUpstreamCapacityShedEvent 判断流内 failed 事件是否为上游容量降载信号。
|
|||
|
|
// 上游在容量紧张时会把请求丢进降载路径:HTTP 200 之后立刻推 event: error
|
|||
|
|
// (code=server_is_overloaded / slow_down)并以 response.failed 收尾。
|
|||
|
|
func isOpenAIUpstreamCapacityShedEvent(payload []byte) bool {
|
|||
|
|
switch openAIStreamFailedEventErrorCode(payload) {
|
|||
|
|
case "server_is_overloaded", "slow_down":
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
for _, path := range []string{"response.error.message", "error.message", "message"} {
|
|||
|
|
if isOpenAICapacityShedMessage(gjson.GetBytes(payload, path).String()) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func logOpenAICapacityFailoverSuppressed(
|
|||
|
|
ctx context.Context,
|
|||
|
|
account *Account,
|
|||
|
|
path string,
|
|||
|
|
upstreamRequestID string,
|
|||
|
|
eventType string,
|
|||
|
|
) {
|
|||
|
|
fields := []zap.Field{
|
|||
|
|
zap.String("path", path),
|
|||
|
|
zap.String("event_type", strings.TrimSpace(eventType)),
|
|||
|
|
zap.String("upstream_request_id", strings.TrimSpace(upstreamRequestID)),
|
|||
|
|
}
|
|||
|
|
if account != nil {
|
|||
|
|
fields = append(fields,
|
|||
|
|
zap.Int64("account_id", account.ID),
|
|||
|
|
zap.String("platform", account.Platform),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
logger.FromContext(ctx).Warn("gateway.failover_suppressed_after_semantic_output", fields...)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// openAICapacityShedRetryableClientCode 是把上游容量降载错误转发给客户端时改写
|
|||
|
|
// 使用的错误码。Codex CLI 按闭集对错误码分类:server_is_overloaded / slow_down
|
|||
|
|
// 被判为致命错误(客户端提示 "Selected model is at capacity. Please try a
|
|||
|
|
// different model." 并直接终止会话),而 server_error 等致命集之外的错误码会进入
|
|||
|
|
// 客户端内置的退避重试。
|
|||
|
|
const openAICapacityShedRetryableClientCode = "server_error"
|
|||
|
|
|
|||
|
|
// sanitizeOpenAICapacityShedErrorCodeForClient 把即将写给下游客户端的
|
|||
|
|
// error / response.failed 事件中的容量降载错误码改写为客户端可重试的错误码。
|
|||
|
|
// 走到转发这一步说明网关侧 failover 已不可用(流中途)或已用尽;保留原始降载码
|
|||
|
|
// 只会让客户端就地终止会话。错误消息原样保留;监控与账号状态判定都基于改写前
|
|||
|
|
// 的原始 payload,不受影响。rate_limit 等其他错误码一律不动(客户端依赖
|
|||
|
|
// rate_limit_exceeded 原码解析重试延时)。
|
|||
|
|
func sanitizeOpenAICapacityShedErrorCodeForClient(payload []byte) ([]byte, bool) {
|
|||
|
|
if len(payload) == 0 || !gjson.ValidBytes(payload) || !isOpenAIUpstreamCapacityShedEvent(payload) {
|
|||
|
|
return payload, false
|
|||
|
|
}
|
|||
|
|
updated := payload
|
|||
|
|
changed := false
|
|||
|
|
for _, path := range []string{"response.error.code", "error.code"} {
|
|||
|
|
parent := strings.TrimSuffix(path, ".code")
|
|||
|
|
if !gjson.GetBytes(updated, parent).Exists() {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(updated, path).String()))
|
|||
|
|
if code != "" && code != "server_is_overloaded" && code != "slow_down" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
next, err := sjson.SetBytes(updated, path, openAICapacityShedRetryableClientCode)
|
|||
|
|
if err != nil {
|
|||
|
|
return payload, false
|
|||
|
|
}
|
|||
|
|
updated = next
|
|||
|
|
changed = true
|
|||
|
|
}
|
|||
|
|
return updated, changed
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int {
|
|||
|
|
if isOpenAIContextWindowError(message, payload) {
|
|||
|
|
return http.StatusBadRequest
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
code := openAIStreamFailedEventErrorCode(payload)
|
|||
|
|
errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.type").String()))
|
|||
|
|
if errType == "" {
|
|||
|
|
errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String()))
|
|||
|
|
}
|
|||
|
|
combined := strings.TrimSpace(errType + " " + code + " " + strings.ToLower(strings.TrimSpace(message)))
|
|||
|
|
switch {
|
|||
|
|
case strings.Contains(combined, "rate_limit"):
|
|||
|
|
return http.StatusTooManyRequests
|
|||
|
|
case strings.Contains(errType, "invalid_request"):
|
|||
|
|
return http.StatusBadRequest
|
|||
|
|
case strings.Contains(combined, "authentication") || strings.Contains(combined, "unauthorized") || strings.Contains(combined, "invalid_api_key"):
|
|||
|
|
return http.StatusUnauthorized
|
|||
|
|
case strings.Contains(combined, "permission") || strings.Contains(combined, "forbidden") || strings.Contains(combined, "access denied"):
|
|||
|
|
return http.StatusForbidden
|
|||
|
|
case isOpenAIUpstreamCapacityShedEvent(payload):
|
|||
|
|
return http.StatusServiceUnavailable
|
|||
|
|
default:
|
|||
|
|
return http.StatusBadGateway
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func openAIStreamFailureStatus(payload []byte, message string) int {
|
|||
|
|
if len(bytes.TrimSpace(payload)) == 0 || !gjson.ValidBytes(payload) {
|
|||
|
|
return http.StatusBadGateway
|
|||
|
|
}
|
|||
|
|
// Keep the existing 502 failover behavior for other response.failed events.
|
|||
|
|
// Only rate limits need promotion because they participate in the account's
|
|||
|
|
// configurable 429 same-account retry policy.
|
|||
|
|
if openAIStreamFailedEventSemanticStatus(payload, message) == http.StatusTooManyRequests {
|
|||
|
|
return http.StatusTooManyRequests
|
|||
|
|
}
|
|||
|
|
return http.StatusBadGateway
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func openAIStreamFailedEventPassthroughBody(payload []byte, failedMessage string) []byte {
|
|||
|
|
if len(payload) == 0 || !gjson.ValidBytes(payload) {
|
|||
|
|
return payload
|
|||
|
|
}
|
|||
|
|
if gjson.GetBytes(payload, "error").Exists() {
|
|||
|
|
return payload
|
|||
|
|
}
|
|||
|
|
responseError := gjson.GetBytes(payload, "response.error")
|
|||
|
|
if !responseError.Exists() {
|
|||
|
|
if strings.TrimSpace(failedMessage) == "" {
|
|||
|
|
return payload
|
|||
|
|
}
|
|||
|
|
body, err := marshalOpenAIUpstreamJSON(gin.H{
|
|||
|
|
"error": gin.H{
|
|||
|
|
"message": failedMessage,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return payload
|
|||
|
|
}
|
|||
|
|
return body
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
errorPayload := gin.H{}
|
|||
|
|
if errType := strings.TrimSpace(gjson.Get(responseError.Raw, "type").String()); errType != "" {
|
|||
|
|
errorPayload["type"] = errType
|
|||
|
|
}
|
|||
|
|
if code := strings.TrimSpace(gjson.Get(responseError.Raw, "code").String()); code != "" {
|
|||
|
|
errorPayload["code"] = code
|
|||
|
|
}
|
|||
|
|
if param := strings.TrimSpace(gjson.Get(responseError.Raw, "param").String()); param != "" {
|
|||
|
|
errorPayload["param"] = param
|
|||
|
|
}
|
|||
|
|
message := strings.TrimSpace(gjson.Get(responseError.Raw, "message").String())
|
|||
|
|
if message == "" {
|
|||
|
|
message = strings.TrimSpace(failedMessage)
|
|||
|
|
}
|
|||
|
|
if message != "" {
|
|||
|
|
errorPayload["message"] = message
|
|||
|
|
}
|
|||
|
|
if len(errorPayload) == 0 {
|
|||
|
|
return payload
|
|||
|
|
}
|
|||
|
|
body, err := marshalOpenAIUpstreamJSON(gin.H{"error": errorPayload})
|
|||
|
|
if err != nil {
|
|||
|
|
return payload
|
|||
|
|
}
|
|||
|
|
return body
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// applyOpenAIStreamFailedErrorPassthroughRule 对 response.failed 事件应用错误透传规则:
|
|||
|
|
// 归一化 body 供关键词匹配/消息提取,并推断语义状态码使按错误码配置的规则可以命中。
|
|||
|
|
// platform 必须传 account.Platform——本服务同时承载 openai 与 grok 平台账号,规则按平台匹配。
|
|||
|
|
func applyOpenAIStreamFailedErrorPassthroughRule(
|
|||
|
|
c *gin.Context,
|
|||
|
|
platform string,
|
|||
|
|
payload []byte,
|
|||
|
|
failedMessage string,
|
|||
|
|
) (status int, errType string, errMsg string, matched bool) {
|
|||
|
|
ruleBody := openAIStreamFailedEventPassthroughBody(payload, failedMessage)
|
|||
|
|
upstreamStatus := openAIStreamFailedEventSemanticStatus(payload, failedMessage)
|
|||
|
|
return applyErrorPassthroughRule(
|
|||
|
|
c,
|
|||
|
|
platform,
|
|||
|
|
upstreamStatus,
|
|||
|
|
ruleBody,
|
|||
|
|
http.StatusBadGateway,
|
|||
|
|
"upstream_error",
|
|||
|
|
"Upstream request failed",
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool {
|
|||
|
|
if isOpenAIContextWindowError(message, payload) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
// A response.failed event is transported over HTTP 200. Prefer its semantic
|
|||
|
|
// rate-limit status over a generic/invalid_request error type so it can enter
|
|||
|
|
// the same 429 retry policy as a regular upstream HTTP response.
|
|||
|
|
if openAIStreamFailureStatus(payload, message) == http.StatusTooManyRequests {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
if isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
|
|||
|
|
if code == "" {
|
|||
|
|
code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
|
|||
|
|
}
|
|||
|
|
errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.type").String()))
|
|||
|
|
if errType == "" {
|
|||
|
|
errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String()))
|
|||
|
|
}
|
|||
|
|
combined := strings.ToLower(strings.TrimSpace(message + " " + code + " " + errType))
|
|||
|
|
if combined == "" {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
nonRetryableMarkers := []string{
|
|||
|
|
"invalid_request",
|
|||
|
|
"content_policy",
|
|||
|
|
"policy",
|
|||
|
|
"safety",
|
|||
|
|
"high-risk cyber",
|
|||
|
|
"not allowed",
|
|||
|
|
"violat",
|
|||
|
|
}
|
|||
|
|
for _, marker := range nonRetryableMarkers {
|
|||
|
|
if strings.Contains(combined, marker) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func openAIStreamErrorEventShouldFailover(payload []byte, message string) bool {
|
|||
|
|
if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) != "error" {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
if isOpenAIContextWindowError(message, payload) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
return isOpenAITransientProcessingError(http.StatusBadRequest, message, payload)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool {
|
|||
|
|
if account == nil {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
// 容量降载是请求级信号,不是账号级故障:上游只是让本次请求稍后再试。
|
|||
|
|
// 换账号并不改变被降载的因素(客户端身份、模型容量都与账号无关),
|
|||
|
|
// 只会让单个请求把整池账号逐个消耗掉,最终仍以同一个错误告终。
|
|||
|
|
// 因此先在同一账号上做有界重试,用尽后才按常规流程切号。
|
|||
|
|
if isOpenAIUpstreamCapacityShedEvent(payload) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
if !account.IsPoolMode() {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message)
|
|||
|
|
return account.IsPoolModeRetryableStatus(semanticStatus) ||
|
|||
|
|
isOpenAITransientProcessingError(http.StatusBadRequest, message, payload)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIGatewayService) recordOpenAIStreamUpstreamError(
|
|||
|
|
c *gin.Context,
|
|||
|
|
account *Account,
|
|||
|
|
passthrough bool,
|
|||
|
|
upstreamRequestID string,
|
|||
|
|
kind string,
|
|||
|
|
payload []byte,
|
|||
|
|
message string,
|
|||
|
|
) string {
|
|||
|
|
message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message))
|
|||
|
|
if message == "" {
|
|||
|
|
message = "OpenAI upstream response failed"
|
|||
|
|
}
|
|||
|
|
statusCode := openAIStreamFailureStatus(payload, message)
|
|||
|
|
detail := ""
|
|||
|
|
if len(payload) > 0 && s != nil && s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
|||
|
|
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
|||
|
|
if maxBytes <= 0 {
|
|||
|
|
maxBytes = 2048
|
|||
|
|
}
|
|||
|
|
detail = truncateString(string(payload), maxBytes)
|
|||
|
|
}
|
|||
|
|
if c != nil {
|
|||
|
|
setOpsUpstreamError(c, statusCode, message, detail)
|
|||
|
|
event := OpsUpstreamErrorEvent{
|
|||
|
|
Platform: PlatformOpenAI,
|
|||
|
|
UpstreamStatusCode: statusCode,
|
|||
|
|
UpstreamRequestID: strings.TrimSpace(upstreamRequestID),
|
|||
|
|
Passthrough: passthrough,
|
|||
|
|
Kind: kind,
|
|||
|
|
Message: message,
|
|||
|
|
Detail: detail,
|
|||
|
|
}
|
|||
|
|
if account != nil {
|
|||
|
|
event.Platform = account.Platform
|
|||
|
|
event.AccountID = account.ID
|
|||
|
|
event.AccountName = account.Name
|
|||
|
|
}
|
|||
|
|
appendOpsUpstreamError(c, event)
|
|||
|
|
}
|
|||
|
|
return message
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIGatewayService) newOpenAIStreamFailoverError(
|
|||
|
|
c *gin.Context,
|
|||
|
|
account *Account,
|
|||
|
|
passthrough bool,
|
|||
|
|
upstreamRequestID string,
|
|||
|
|
payload []byte,
|
|||
|
|
message string,
|
|||
|
|
responseHeaders ...http.Header,
|
|||
|
|
) *UpstreamFailoverError {
|
|||
|
|
message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message))
|
|||
|
|
if message == "" {
|
|||
|
|
message = "OpenAI stream disconnected before completion"
|
|||
|
|
}
|
|||
|
|
statusCode := openAIStreamFailureStatus(payload, message)
|
|||
|
|
var headers http.Header
|
|||
|
|
if len(responseHeaders) > 0 && responseHeaders[0] != nil {
|
|||
|
|
headers = responseHeaders[0].Clone()
|
|||
|
|
}
|
|||
|
|
// 流内 failed 事件承载于 HTTP 200,响应头是正常配额快照而非限流信号,
|
|||
|
|
// 不写账号级限流/封禁状态;重试与切号由 failover 引擎按
|
|||
|
|
// StatusCode/RetryableOnSameAccount 决定。
|
|||
|
|
message = s.recordOpenAIStreamUpstreamError(c, account, passthrough, upstreamRequestID, "failover", payload, message)
|
|||
|
|
errType := "upstream_error"
|
|||
|
|
if statusCode == http.StatusTooManyRequests {
|
|||
|
|
errType = "rate_limit_error"
|
|||
|
|
}
|
|||
|
|
body, _ := json.Marshal(gin.H{
|
|||
|
|
"error": gin.H{
|
|||
|
|
"type": errType,
|
|||
|
|
"message": message,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
return &UpstreamFailoverError{
|
|||
|
|
StatusCode: statusCode,
|
|||
|
|
ResponseBody: body,
|
|||
|
|
ResponseHeaders: headers,
|
|||
|
|
RetryableOnSameAccount: openAIStreamFailedEventRetryableOnSameAccount(account, payload, message),
|
|||
|
|
RequestScopedTransient: isOpenAIUpstreamCapacityShedEvent(payload),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
|||
|
|
ctx context.Context,
|
|||
|
|
resp *http.Response,
|
|||
|
|
c *gin.Context,
|
|||
|
|
account *Account,
|
|||
|
|
startTime time.Time,
|
|||
|
|
originalModel string,
|
|||
|
|
mappedModel string,
|
|||
|
|
) (*openaiStreamingResultPassthrough, error) {
|
|||
|
|
observer := upstreamResponseModelObserverFromContext(c)
|
|||
|
|
if observer == nil {
|
|||
|
|
observer = beginUpstreamResponseModelObservation(c)
|
|||
|
|
}
|
|||
|
|
writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
|||
|
|
|
|||
|
|
// SSE headers
|
|||
|
|
c.Header("Content-Type", "text/event-stream")
|
|||
|
|
c.Header("Cache-Control", "no-cache")
|
|||
|
|
c.Header("Connection", "keep-alive")
|
|||
|
|
c.Header("X-Accel-Buffering", "no")
|
|||
|
|
if v := resp.Header.Get("x-request-id"); v != "" {
|
|||
|
|
c.Header("x-request-id", v)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
w := c.Writer
|
|||
|
|
flusher, ok := w.(http.Flusher)
|
|||
|
|
if !ok {
|
|||
|
|
return nil, errors.New("streaming not supported")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
usage := &OpenAIUsage{}
|
|||
|
|
imageCounter := newOpenAIImageOutputCounter()
|
|||
|
|
var firstTokenMs *int
|
|||
|
|
responseID := ""
|
|||
|
|
clientDisconnected := false
|
|||
|
|
sawDone := false
|
|||
|
|
sawTerminalEvent := false
|
|||
|
|
sawFailedEvent := false
|
|||
|
|
semanticOutputSeen := false
|
|||
|
|
capacityFailoverSuppressedLogged := false
|
|||
|
|
failedMessage := ""
|
|||
|
|
clientOutputStarted := false
|
|||
|
|
upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
|
|||
|
|
// pendingLines 在首个可见输出前保留前导事件,确保无输出失败仍可安全 failover。
|
|||
|
|
pendingLines := make([]string, 0, 8)
|
|||
|
|
// flushPending 表示已写入但未到 SSE 空行边界的脏状态;defer 兜底函数退出前的残留,断连后不再 Flush。
|
|||
|
|
flushPending := false
|
|||
|
|
flushPendingOutput := func() {
|
|||
|
|
if clientDisconnected || !flushPending {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
flusher.Flush()
|
|||
|
|
flushPending = false
|
|||
|
|
}
|
|||
|
|
defer flushPendingOutput()
|
|||
|
|
writePendingLines := func() bool {
|
|||
|
|
for _, pending := range pendingLines {
|
|||
|
|
if _, err := fmt.Fprintln(w, pending); err != nil {
|
|||
|
|
clientDisconnected = true
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID)
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
pendingLines = pendingLines[:0]
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
scanner := bufio.NewScanner(resp.Body)
|
|||
|
|
maxLineSize := defaultMaxLineSize
|
|||
|
|
if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
|
|||
|
|
maxLineSize = s.cfg.Gateway.MaxLineSize
|
|||
|
|
}
|
|||
|
|
scanBuf := getSSEScannerBuf64K()
|
|||
|
|
scanner.Buffer(scanBuf[:0], maxLineSize)
|
|||
|
|
defer putSSEScannerBuf64K(scanBuf)
|
|||
|
|
documentScanner := newOpenAISSEJSONDocumentScanner(scanner)
|
|||
|
|
|
|||
|
|
needModelReplace := strings.TrimSpace(originalModel) != "" && strings.TrimSpace(mappedModel) != "" && strings.TrimSpace(originalModel) != strings.TrimSpace(mappedModel)
|
|||
|
|
resultWithUsage := func() *openaiStreamingResultPassthrough {
|
|||
|
|
return &openaiStreamingResultPassthrough{
|
|||
|
|
usage: usage,
|
|||
|
|
firstTokenMs: firstTokenMs,
|
|||
|
|
responseID: responseID,
|
|||
|
|
imageCount: imageCounter.Count(),
|
|||
|
|
imageOutputSizes: imageCounter.Sizes(),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for documentScanner.Scan() {
|
|||
|
|
line := documentScanner.Text()
|
|||
|
|
lineStartsClientOutput := false
|
|||
|
|
forceFlushFailedEvent := false
|
|||
|
|
if data, ok := extractOpenAISSEDataLine(line); ok {
|
|||
|
|
dataBytes := []byte(data)
|
|||
|
|
trimmedData := strings.TrimSpace(data)
|
|||
|
|
rawEventType := strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String())
|
|||
|
|
observer.ObserveOpenAI(dataBytes, rawEventType)
|
|||
|
|
if needModelReplace && strings.Contains(data, mappedModel) {
|
|||
|
|
line = s.replaceModelInSSELine(line, mappedModel, originalModel)
|
|||
|
|
if replacedData, replaced := extractOpenAISSEDataLine(line); replaced {
|
|||
|
|
dataBytes = []byte(replacedData)
|
|||
|
|
trimmedData = strings.TrimSpace(replacedData)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if normalizedData, normalized := normalizeOpenAIResponsesFunctionCallArguments(dataBytes); normalized {
|
|||
|
|
dataBytes = normalizedData
|
|||
|
|
trimmedData = strings.TrimSpace(string(normalizedData))
|
|||
|
|
line = "data: " + string(normalizedData)
|
|||
|
|
}
|
|||
|
|
if normalizedData, normalized := normalizeCompletedImageGenerationStatus(dataBytes); normalized {
|
|||
|
|
dataBytes = normalizedData
|
|||
|
|
trimmedData = strings.TrimSpace(string(normalizedData))
|
|||
|
|
line = "data: " + string(normalizedData)
|
|||
|
|
}
|
|||
|
|
if trimmedData != "[DONE]" {
|
|||
|
|
restoredData, restoreErr := restoreOpenAIResponsesNamespacePayload(c, dataBytes)
|
|||
|
|
if restoreErr != nil {
|
|||
|
|
return resultWithUsage(), fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr)
|
|||
|
|
}
|
|||
|
|
if !bytes.Equal(restoredData, dataBytes) {
|
|||
|
|
dataBytes = restoredData
|
|||
|
|
trimmedData = strings.TrimSpace(string(restoredData))
|
|||
|
|
line = "data: " + string(restoredData)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
eventType := strings.TrimSpace(gjson.Get(trimmedData, "type").String())
|
|||
|
|
if !capacityFailoverSuppressedLogged && account != nil && account.Platform == PlatformOpenAI &&
|
|||
|
|
(eventType == "error" || eventType == "response.failed") &&
|
|||
|
|
openAIStreamClientOutputStarted(c, clientOutputStarted) &&
|
|||
|
|
isOpenAIUpstreamCapacityShedEvent(dataBytes) {
|
|||
|
|
logOpenAICapacityFailoverSuppressed(ctx, account, "passthrough_sse", upstreamRequestID, eventType)
|
|||
|
|
capacityFailoverSuppressedLogged = true
|
|||
|
|
}
|
|||
|
|
if eventType == "error" && !openAIStreamClientOutputStarted(c, clientOutputStarted) {
|
|||
|
|
errorMessage := extractOpenAISSEErrorMessage(dataBytes)
|
|||
|
|
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, errorMessage); matched {
|
|||
|
|
s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, errorMessage)
|
|||
|
|
MarkResponseCommitted(c)
|
|||
|
|
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|||
|
|
c.JSON(status, gin.H{
|
|||
|
|
"error": gin.H{
|
|||
|
|
"type": errType,
|
|||
|
|
"message": errMsg,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
return resultWithUsage(), fmt.Errorf("upstream error event: passthrough rule matched message=%s", errMsg)
|
|||
|
|
}
|
|||
|
|
if openAIStreamErrorEventShouldFailover(dataBytes, errorMessage) {
|
|||
|
|
return resultWithUsage(),
|
|||
|
|
s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, errorMessage, resp.Header)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if eventType == "response.failed" {
|
|||
|
|
failedMessage = extractOpenAISSEErrorMessage(dataBytes)
|
|||
|
|
// response.failed 自带上游已消耗的 usage(input token 通常已扣);必须先解析
|
|||
|
|
// 再打 cyber 标记,否则 mark 记到的是解析前的 0,导致流式 cyber 按 0 token 计费
|
|||
|
|
// 而漏记真实用量。对齐 WS V2 / Chat 流式路径(均先解析 usage 再 Mark)。
|
|||
|
|
s.parseSSEUsageBytes(dataBytes, usage)
|
|||
|
|
if hit, code, msg := detectOpenAICyberPolicy(dataBytes); hit {
|
|||
|
|
MarkOpsCyberPolicy(c, CyberPolicyMark{
|
|||
|
|
Code: code,
|
|||
|
|
Message: msg,
|
|||
|
|
Body: truncateString(string(dataBytes), 4096),
|
|||
|
|
UpstreamStatus: http.StatusOK,
|
|||
|
|
UpstreamInTok: usage.InputTokens,
|
|||
|
|
UpstreamOutTok: usage.OutputTokens,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
|
|||
|
|
if status, errType, errMsg, matched := applyOpenAIStreamFailedErrorPassthroughRule(c, account.Platform, dataBytes, failedMessage); matched {
|
|||
|
|
// 命中透传规则也要记录 ops 上游错误事件(对齐 CC/Messages 与
|
|||
|
|
// antigravity 先例),否则透传命中的 failed 在监控中不可见。
|
|||
|
|
s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "http_error", dataBytes, failedMessage)
|
|||
|
|
MarkResponseCommitted(c)
|
|||
|
|
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|||
|
|
c.JSON(status, gin.H{
|
|||
|
|
"error": gin.H{
|
|||
|
|
"type": errType,
|
|||
|
|
"message": errMsg,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
return resultWithUsage(), fmt.Errorf("upstream response failed: passthrough rule matched message=%s", errMsg)
|
|||
|
|
}
|
|||
|
|
if openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) {
|
|||
|
|
return resultWithUsage(),
|
|||
|
|
s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, failedMessage, resp.Header)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
forceFlushFailedEvent = true
|
|||
|
|
sawFailedEvent = true
|
|||
|
|
}
|
|||
|
|
if trimmedData == "[DONE]" {
|
|||
|
|
sawDone = true
|
|||
|
|
}
|
|||
|
|
if openAIStreamEventIsTerminal(trimmedData) {
|
|||
|
|
sawTerminalEvent = true
|
|||
|
|
}
|
|||
|
|
if responseID == "" {
|
|||
|
|
responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes)
|
|||
|
|
}
|
|||
|
|
imageCounter.AddSSEData(dataBytes)
|
|||
|
|
if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient(
|
|||
|
|
dataBytes,
|
|||
|
|
eventType,
|
|||
|
|
openAIStreamClientOutputStarted(c, clientOutputStarted),
|
|||
|
|
); sanitized {
|
|||
|
|
dataBytes = sanitizedData
|
|||
|
|
trimmedData = strings.TrimSpace(string(sanitizedData))
|
|||
|
|
line = "data: " + string(sanitizedData)
|
|||
|
|
}
|
|||
|
|
lineStartsClientOutput = forceFlushFailedEvent || openAIStreamDataStartsClientOutput(trimmedData, eventType)
|
|||
|
|
if lineStartsClientOutput && trimmedData != "[DONE]" && !openAIStreamEventTypeIsTerminal(eventType) {
|
|||
|
|
semanticOutputSeen = true
|
|||
|
|
}
|
|||
|
|
// OpenAI Responses streams that terminate with an empty
|
|||
|
|
// response.completed (no output, no usage, no error, nothing sent
|
|||
|
|
// to the client) are silent upstream refusals: fail over instead of
|
|||
|
|
// recording a successful 0/0 usage turn (issue #5009).
|
|||
|
|
if (eventType == "response.completed" || eventType == "response.done") &&
|
|||
|
|
!sawFailedEvent && !semanticOutputSeen && !clientOutputStarted &&
|
|||
|
|
openAIResponsesCompletedEventIsEmpty(dataBytes, usage) {
|
|||
|
|
return resultWithUsage(), newOpenAIResponsesEmptyCompletedFailoverError(c, account, upstreamRequestID)
|
|||
|
|
}
|
|||
|
|
if firstTokenMs == nil && openAIStreamDataStartsVisibleOutput(trimmedData, eventType) {
|
|||
|
|
ms := int(time.Since(startTime).Milliseconds())
|
|||
|
|
firstTokenMs = &ms
|
|||
|
|
}
|
|||
|
|
s.parseSSEUsageBytes(dataBytes, usage)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if !clientDisconnected {
|
|||
|
|
if !clientOutputStarted && !lineStartsClientOutput {
|
|||
|
|
pendingLines = append(pendingLines, line)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if !clientOutputStarted && len(pendingLines) > 0 {
|
|||
|
|
if !writePendingLines() {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if _, err := fmt.Fprintln(w, line); err != nil {
|
|||
|
|
clientDisconnected = true
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID)
|
|||
|
|
} else {
|
|||
|
|
clientOutputStarted = true
|
|||
|
|
flushPending = true
|
|||
|
|
if line == "" {
|
|||
|
|
flushPendingOutput()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if err := documentScanner.Err(); err != nil {
|
|||
|
|
if (sawDone || sawTerminalEvent) && !sawFailedEvent {
|
|||
|
|
s.clearOpenAIProxyStreamDisconnect(account)
|
|||
|
|
return resultWithUsage(), nil
|
|||
|
|
}
|
|||
|
|
if sawFailedEvent {
|
|||
|
|
return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
|
|||
|
|
}
|
|||
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|||
|
|
return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", err)
|
|||
|
|
}
|
|||
|
|
if errors.Is(err, bufio.ErrTooLong) {
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, err)
|
|||
|
|
return resultWithUsage(), err
|
|||
|
|
}
|
|||
|
|
if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
|
|||
|
|
msg := "OpenAI stream disconnected before completion"
|
|||
|
|
if errText := strings.TrimSpace(err.Error()); errText != "" {
|
|||
|
|
msg += ": " + errText
|
|||
|
|
}
|
|||
|
|
return resultWithUsage(),
|
|||
|
|
s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, nil, msg)
|
|||
|
|
}
|
|||
|
|
if clientDisconnected {
|
|||
|
|
return resultWithUsage(), fmt.Errorf("stream usage incomplete after disconnect: %w", err)
|
|||
|
|
}
|
|||
|
|
s.recordOpenAIProxyStreamDisconnect(account, err, upstreamRequestID)
|
|||
|
|
logger.LegacyPrintf("service.openai_gateway",
|
|||
|
|
"[OpenAI passthrough] 流读取异常中断: account=%d request_id=%s err=%v",
|
|||
|
|
account.ID,
|
|||
|
|
upstreamRequestID,
|
|||
|
|
err,
|
|||
|
|
)
|
|||
|
|
return resultWithUsage(), fmt.Errorf("stream read error: %w", err)
|
|||
|
|
}
|
|||
|
|
if sawFailedEvent {
|
|||
|
|
return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
|
|||
|
|
}
|
|||
|
|
if !clientDisconnected && !sawDone && !sawTerminalEvent && ctx.Err() == nil {
|
|||
|
|
logger.FromContext(ctx).With(
|
|||
|
|
zap.String("component", "service.openai_gateway"),
|
|||
|
|
zap.Int64("account_id", account.ID),
|
|||
|
|
zap.String("upstream_request_id", upstreamRequestID),
|
|||
|
|
).Info("OpenAI passthrough 上游流在未收到 [DONE] 时结束,疑似断流")
|
|||
|
|
if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
|
|||
|
|
return resultWithUsage(),
|
|||
|
|
s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, nil, "OpenAI stream ended before a terminal event")
|
|||
|
|
}
|
|||
|
|
s.recordOpenAIProxyStreamDisconnect(account, errors.New("stream ended before terminal event"), upstreamRequestID)
|
|||
|
|
return resultWithUsage(), errors.New("stream usage incomplete: missing terminal event")
|
|||
|
|
}
|
|||
|
|
if (sawDone || sawTerminalEvent) && !sawFailedEvent {
|
|||
|
|
s.clearOpenAIProxyStreamDisconnect(account)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return resultWithUsage(), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
|
|||
|
|
ctx context.Context,
|
|||
|
|
resp *http.Response,
|
|||
|
|
c *gin.Context,
|
|||
|
|
originalModel string,
|
|||
|
|
mappedModel string,
|
|||
|
|
) (*openaiNonStreamingResultPassthrough, error) {
|
|||
|
|
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
observer := upstreamResponseModelObserverFromContext(c)
|
|||
|
|
if observer == nil {
|
|||
|
|
observer = beginUpstreamResponseModelObservation(c)
|
|||
|
|
}
|
|||
|
|
if bodyHasSSEFraming(body) {
|
|||
|
|
observeOpenAISSEBody(observer, string(body))
|
|||
|
|
} else {
|
|||
|
|
observer.ObserveOpenAI(body, strings.TrimSpace(gjson.GetBytes(body, "type").String()))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Detect SSE responses from upstream and convert to JSON.
|
|||
|
|
// Some upstreams (e.g. other sub2api instances) may return SSE even when
|
|||
|
|
// stream=false was requested. Without this conversion the client would
|
|||
|
|
// receive raw SSE text or a terminal event with empty output.
|
|||
|
|
if isEventStreamResponse(resp.Header) {
|
|||
|
|
return s.handlePassthroughSSEToJSON(resp, c, body, originalModel, mappedModel)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
usage := &OpenAIUsage{}
|
|||
|
|
usageParsed := false
|
|||
|
|
if len(body) > 0 {
|
|||
|
|
if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(body); ok {
|
|||
|
|
*usage = parsedUsage
|
|||
|
|
usageParsed = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !usageParsed {
|
|||
|
|
// 兜底:尝试从 SSE 文本中解析 usage
|
|||
|
|
usage = s.parseSSEUsageFromBody(string(body))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
|||
|
|
|
|||
|
|
contentType := resp.Header.Get("Content-Type")
|
|||
|
|
if contentType == "" {
|
|||
|
|
contentType = "application/json"
|
|||
|
|
}
|
|||
|
|
if originalModel != "" && mappedModel != "" && originalModel != mappedModel {
|
|||
|
|
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
|
|||
|
|
}
|
|||
|
|
body, err = restoreOpenAIResponsesNamespacePayload(c, body)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", err)
|
|||
|
|
}
|
|||
|
|
if mapping, ok := openAIResponsesClientToolMapping(c); ok && json.Valid(body) {
|
|||
|
|
body, _, err = apicompat.RestoreResponsesClientToolPayload(body, mapping)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("restore OpenAI Responses client tools: %w", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !writeOpenAICompactSSEBridge(c, resp.StatusCode, body) {
|
|||
|
|
c.Data(resp.StatusCode, contentType, body)
|
|||
|
|
}
|
|||
|
|
return &openaiNonStreamingResultPassthrough{
|
|||
|
|
OpenAIUsage: usage,
|
|||
|
|
usage: usage,
|
|||
|
|
responseID: extractOpenAIResponseIDFromJSONBytes(body),
|
|||
|
|
imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
|
|||
|
|
imageOutputSizes: collectOpenAIResponseImageOutputSizesFromJSONBytes(body),
|
|||
|
|
}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// handlePassthroughSSEToJSON converts an SSE response body into a JSON
|
|||
|
|
// response for the passthrough path. It mirrors handleSSEToJSON while
|
|||
|
|
// preserving passthrough payloads, except compact-only model remapping may
|
|||
|
|
// rewrite model fields back to the original requested model.
|
|||
|
|
func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel string, mappedModel string) (*openaiNonStreamingResultPassthrough, error) {
|
|||
|
|
bodyText := string(body)
|
|||
|
|
finalResponse, ok := extractCodexFinalResponse(bodyText)
|
|||
|
|
|
|||
|
|
usage := &OpenAIUsage{}
|
|||
|
|
if ok {
|
|||
|
|
if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed {
|
|||
|
|
*usage = parsedUsage
|
|||
|
|
}
|
|||
|
|
// When the terminal event has an empty output array, reconstruct
|
|||
|
|
// output from accumulated delta events so the client gets full content.
|
|||
|
|
if len(gjson.GetBytes(finalResponse, "output").Array()) == 0 {
|
|||
|
|
if outputJSON, reconstructed := reconstructResponseOutputFromSSE(bodyText); reconstructed {
|
|||
|
|
if patched, err := sjson.SetRawBytes(finalResponse, "output", outputJSON); err == nil {
|
|||
|
|
finalResponse = patched
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
finalResponse = supplementCompactionItemFromSSE(c, finalResponse, bodyText)
|
|||
|
|
body = finalResponse
|
|||
|
|
if originalModel != "" && mappedModel != "" && originalModel != mappedModel {
|
|||
|
|
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
|
|||
|
|
}
|
|||
|
|
// Correct tool calls in final response
|
|||
|
|
body = s.correctToolCallsInResponseBody(body)
|
|||
|
|
restoredBody, restoreErr := restoreOpenAIResponsesNamespacePayload(c, body)
|
|||
|
|
if restoreErr != nil {
|
|||
|
|
return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr)
|
|||
|
|
}
|
|||
|
|
body = restoredBody
|
|||
|
|
} else {
|
|||
|
|
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
|
|||
|
|
if terminalOK && terminalType == "response.failed" {
|
|||
|
|
msg := extractOpenAISSEErrorMessage(terminalPayload)
|
|||
|
|
if msg == "" {
|
|||
|
|
msg = "Upstream compact response failed"
|
|||
|
|
}
|
|||
|
|
return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg)
|
|||
|
|
}
|
|||
|
|
usage = s.parseSSEUsageFromBody(bodyText)
|
|||
|
|
if originalModel != "" && mappedModel != "" && originalModel != mappedModel {
|
|||
|
|
bodyText = s.replaceModelInSSEBody(bodyText, mappedModel, originalModel)
|
|||
|
|
}
|
|||
|
|
body = []byte(bodyText)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
|||
|
|
|
|||
|
|
contentType := "application/json; charset=utf-8"
|
|||
|
|
if !ok {
|
|||
|
|
contentType = resp.Header.Get("Content-Type")
|
|||
|
|
if contentType == "" {
|
|||
|
|
contentType = "text/event-stream"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !writeOpenAICompactSSEBridge(c, resp.StatusCode, body) {
|
|||
|
|
c.Data(resp.StatusCode, contentType, body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &openaiNonStreamingResultPassthrough{
|
|||
|
|
OpenAIUsage: usage,
|
|||
|
|
usage: usage,
|
|||
|
|
responseID: extractOpenAIResponseIDFromJSONBytes(body),
|
|||
|
|
imageCount: countOpenAIImageOutputsFromSSEBody(bodyText),
|
|||
|
|
imageOutputSizes: collectOpenAIImageOutputSizesFromSSEBody(bodyText),
|
|||
|
|
}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func writeOpenAIPassthroughResponseHeaders(dst http.Header, src http.Header, filter *responseheaders.CompiledHeaderFilter) {
|
|||
|
|
if dst == nil || src == nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if filter != nil {
|
|||
|
|
responseheaders.WriteFilteredHeaders(dst, src, filter)
|
|||
|
|
} else {
|
|||
|
|
// 兜底:尽量保留最基础的 content-type
|
|||
|
|
if v := strings.TrimSpace(src.Get("Content-Type")); v != "" {
|
|||
|
|
dst.Set("Content-Type", v)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 透传模式强制放行 x-codex-* 响应头(若上游返回)。
|
|||
|
|
// 注意:真实 http.Response.Header 的 key 一般会被 canonicalize;但为了兼容测试/自建响应,
|
|||
|
|
// 这里用 EqualFold 做一次大小写不敏感的查找。
|
|||
|
|
getCaseInsensitiveValues := func(h http.Header, want string) []string {
|
|||
|
|
if h == nil {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
for k, vals := range h {
|
|||
|
|
if strings.EqualFold(k, want) {
|
|||
|
|
return vals
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, rawKey := range []string{
|
|||
|
|
"x-codex-primary-used-percent",
|
|||
|
|
"x-codex-primary-reset-after-seconds",
|
|||
|
|
"x-codex-primary-window-minutes",
|
|||
|
|
"x-codex-secondary-used-percent",
|
|||
|
|
"x-codex-secondary-reset-after-seconds",
|
|||
|
|
"x-codex-secondary-window-minutes",
|
|||
|
|
"x-codex-primary-over-secondary-limit-percent",
|
|||
|
|
} {
|
|||
|
|
vals := getCaseInsensitiveValues(src, rawKey)
|
|||
|
|
if len(vals) == 0 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
key := http.CanonicalHeaderKey(rawKey)
|
|||
|
|
dst.Del(key)
|
|||
|
|
for _, v := range vals {
|
|||
|
|
dst.Add(key, v)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// x-codex-turn-state:Codex 回合状态头,客户端会在同回合后续请求回带。
|
|||
|
|
// 与上面的用量头不同,这里在上游缺失时也主动清除——failover 换号后残留
|
|||
|
|
// 上一账号的 blob 会构成跨账号矛盾(openai_codex_turn_state.go)。
|
|||
|
|
turnStateKey := http.CanonicalHeaderKey(openAICodexTurnStateHeader)
|
|||
|
|
dst.Del(turnStateKey)
|
|||
|
|
for _, v := range getCaseInsensitiveValues(src, openAICodexTurnStateHeader) {
|
|||
|
|
dst.Add(turnStateKey, v)
|
|||
|
|
}
|
|||
|
|
}
|