1397 lines
47 KiB
Go
1397 lines
47 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/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"
|
||
)
|
||
|
||
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)
|
||
}
|
||
|
||
normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBody(body, isOpenAIResponsesCompactPath(c))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if normalized {
|
||
body = normalizedBody
|
||
}
|
||
reqStream = gjson.GetBytes(body, "stream").Bool()
|
||
}
|
||
|
||
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() }()
|
||
|
||
serviceTier := extractOpenAIServiceTierFromBody(body)
|
||
|
||
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,
|
||
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 = buildOpenAIResponsesURL(validatedURL)
|
||
}
|
||
}
|
||
targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c))
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 覆盖入站鉴权残留,并注入上游认证
|
||
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 {
|
||
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", codexCLIVersion)
|
||
}
|
||
if clientSessionID == "" {
|
||
clientSessionID = resolveOpenAICompactSessionID(c)
|
||
}
|
||
} else if req.Header.Get("accept") == "" {
|
||
req.Header.Set("accept", "text/event-stream")
|
||
}
|
||
if req.Header.Get("OpenAI-Beta") == "" {
|
||
req.Header.Set("OpenAI-Beta", "responses=experimental")
|
||
}
|
||
if req.Header.Get("originator") == "" {
|
||
req.Header.Set("originator", "codex_cli_rs")
|
||
}
|
||
// 用隔离后的 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", codexCLIUserAgent)
|
||
}
|
||
// 浏览器型 UA 兜底:仅 OAuth(ChatGPT 内部接口)账号生效,若最终 user-agent 仍为浏览器
|
||
// (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。
|
||
s.overrideBrowserUserAgent(ctx, account, req)
|
||
|
||
// 终态收口:originator 必须与最终 User-Agent 首段配套且为官方身份,非官方 UA 整体回退为
|
||
// 默认 Codex CLI 身份(承接原「非 Codex UA 安全兜底」,并修复其把 codex-tui 等官方 UA 改写为
|
||
// codex_cli_rs 造成的 originator 错配 404),详见 issue #3901。
|
||
if account.Type == AccountTypeOAuth {
|
||
enforceCodexIdentityHeaders(req.Header)
|
||
}
|
||
|
||
if req.Header.Get("content-type") == "" {
|
||
req.Header.Set("content-type", "application/json")
|
||
}
|
||
|
||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
||
account.ApplyHeaderOverrides(req.Header)
|
||
|
||
return req, nil
|
||
}
|
||
|
||
func shouldFailoverOpenAIPassthroughResponse(account *Account, statusCode int, responseBody []byte) bool {
|
||
if isOpenAIContextWindowError("", responseBody) {
|
||
return false
|
||
}
|
||
if isOpenAIRequestBodyTooLargeError(statusCode, "", responseBody) {
|
||
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
|
||
}
|
||
|
||
func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool {
|
||
if localStarted {
|
||
return true
|
||
}
|
||
return c != nil && c.Writer != nil && c.Writer.Written()
|
||
}
|
||
|
||
func openAIStreamEventIsPreamble(eventType string) bool {
|
||
switch strings.TrimSpace(eventType) {
|
||
case "response.created", "response.in_progress":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func openAIStreamDataStartsClientOutput(data, eventType string) bool {
|
||
trimmed := strings.TrimSpace(data)
|
||
if trimmed == "" {
|
||
return false
|
||
}
|
||
if strings.TrimSpace(eventType) == "response.failed" {
|
||
return false
|
||
}
|
||
return !openAIStreamEventIsPreamble(eventType)
|
||
}
|
||
|
||
func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int {
|
||
if isOpenAIContextWindowError(message, payload) {
|
||
return http.StatusBadRequest
|
||
}
|
||
|
||
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.TrimSpace(errType + " " + code + " " + strings.ToLower(strings.TrimSpace(message)))
|
||
switch {
|
||
case strings.Contains(errType, "invalid_request"):
|
||
return http.StatusBadRequest
|
||
case strings.Contains(combined, "rate_limit"):
|
||
return http.StatusTooManyRequests
|
||
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 code == "server_is_overloaded" || code == "slow_down":
|
||
return http.StatusServiceUnavailable
|
||
default:
|
||
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
|
||
}
|
||
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 (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"
|
||
}
|
||
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, http.StatusBadGateway, message, detail)
|
||
event := OpsUpstreamErrorEvent{
|
||
Platform: PlatformOpenAI,
|
||
UpstreamStatusCode: http.StatusBadGateway,
|
||
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,
|
||
) *UpstreamFailoverError {
|
||
message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message))
|
||
if message == "" {
|
||
message = "OpenAI stream disconnected before completion"
|
||
}
|
||
message = s.recordOpenAIStreamUpstreamError(c, account, passthrough, upstreamRequestID, "failover", payload, message)
|
||
body, _ := json.Marshal(gin.H{
|
||
"error": gin.H{
|
||
"type": "upstream_error",
|
||
"message": message,
|
||
},
|
||
})
|
||
return &UpstreamFailoverError{
|
||
StatusCode: http.StatusBadGateway,
|
||
ResponseBody: body,
|
||
}
|
||
}
|
||
|
||
func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||
ctx context.Context,
|
||
resp *http.Response,
|
||
c *gin.Context,
|
||
account *Account,
|
||
startTime time.Time,
|
||
originalModel string,
|
||
mappedModel string,
|
||
) (*openaiStreamingResultPassthrough, error) {
|
||
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
|
||
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)
|
||
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 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)
|
||
}
|
||
}
|
||
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 firstTokenMs == nil && lineStartsClientOutput && trimmedData != "[DONE]" {
|
||
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 sawTerminalEvent && !sawFailedEvent {
|
||
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)
|
||
}
|
||
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")
|
||
}
|
||
return resultWithUsage(), errors.New("stream usage incomplete: missing terminal event")
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 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 !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)
|
||
}
|
||
}
|
||
}
|