Files
sub2api/backend/internal/service/gateway_anthropic_passthrough.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

857 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
// 本文件由 gateway_service.go 纯移动拆分而来:Anthropic APIKey 直通
// passthrough)转发路径及其流式/非流式响应与 usage 解析。仅做代码搬迁,
// 无任何行为变更。
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/gin-gonic/gin"
)
type anthropicPassthroughForwardInput struct {
Body []byte
Parsed *ParsedRequest
RequestModel string
OriginalModel string
RequestStream bool
StartTime time.Time
}
func (s *GatewayService) forwardAnthropicAPIKeyPassthrough(
ctx context.Context,
c *gin.Context,
account *Account,
body []byte,
reqModel string,
originalModel string,
reqStream bool,
startTime time.Time,
) (*ForwardResult, error) {
return s.forwardAnthropicAPIKeyPassthroughWithInput(ctx, c, account, anthropicPassthroughForwardInput{
Body: body,
RequestModel: reqModel,
OriginalModel: originalModel,
RequestStream: reqStream,
StartTime: startTime,
})
}
func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
ctx context.Context,
c *gin.Context,
account *Account,
input anthropicPassthroughForwardInput,
) (*ForwardResult, error) {
token, tokenType, err := s.GetAccessToken(ctx, account)
if err != nil {
return nil, err
}
if tokenType != "apikey" {
return nil, fmt.Errorf("anthropic api key passthrough requires apikey token, got: %s", tokenType)
}
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
logger.LegacyPrintf("service.gateway", "[Anthropic 自动透传] 命中 API Key 透传分支: account=%d name=%s model=%s stream=%v",
account.ID, account.Name, input.RequestModel, input.RequestStream)
if c != nil {
c.Set("anthropic_passthrough", true)
}
// Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400.
input.Body = StripEmptyTextBlocks(input.Body)
// Pre-filter: strip web-search history blocks the upstream cannot accept
// (emulation-synthesized ones always; genuine ones additionally for
// passback-required third-party upstreams such as GLM/Kimi/DeepSeek,
// which reject server_tool_use with 400). input.RequestModel 已是映射后的模型 ID。
input.Body = FilterWebSearchHistoryBlocks(input.Body, input.RequestModel)
if input.Parsed != nil {
// 透传分支也会改写实际 wire body,成功 usage hash 依赖这里同步当前 body。
if err := input.Parsed.ReplaceBody(input.Body); err != nil {
return nil, err
}
}
var resp *http.Response
retryStart := time.Now()
for attempt := 1; attempt <= maxRetryAttempts; attempt++ {
upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, input.RequestStream)
upstreamReq, wireBody, err := s.buildUpstreamRequestAnthropicAPIKeyPassthrough(upstreamCtx, c, account, input.Body, token)
releaseUpstreamCtx()
if err != nil {
return nil, err
}
if input.Parsed != nil && !bytes.Equal(wireBody, input.Body) {
// build 阶段会按 beta 能力清理 body,发送前同步到 ParsedRequest 当前视图。
if err := input.Parsed.ReplaceBody(wireBody); err != nil {
return nil, err
}
input.Body = input.Parsed.Body.Bytes()
}
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
if !errors.Is(err, context.Canceled) {
scheduleOllamaCloudUsageActivity(s.deferredService, account)
}
safeErr := sanitizeUpstreamErrorMessage(err.Error())
setOpsUpstreamError(c, 0, safeErr, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: 0,
UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
Passthrough: true,
Kind: "request_error",
Message: safeErr,
})
c.JSON(http.StatusBadGateway, gin.H{
"type": "error",
"error": gin.H{
"type": "upstream_error",
"message": "Upstream request failed",
},
})
return nil, fmt.Errorf("upstream request failed: %s", safeErr)
}
// 透传分支禁止 400 请求体降级重试(该重试会改写请求体)
if resp.StatusCode >= 400 && resp.StatusCode != 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
if attempt < maxRetryAttempts {
elapsed := time.Since(retryStart)
if elapsed >= maxRetryElapsed {
break
}
delay := retryBackoffDelay(attempt)
remaining := maxRetryElapsed - elapsed
if delay > remaining {
delay = remaining
}
if delay <= 0 {
break
}
respBody, _ := s.readUpstreamErrorBody(resp)
_ = resp.Body.Close()
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: resp.StatusCode,
UpstreamRequestID: resp.Header.Get("x-request-id"),
UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
Passthrough: true,
Kind: "retry",
Message: extractUpstreamErrorMessage(respBody),
Detail: func() string {
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
}
return ""
}(),
})
logger.LegacyPrintf("service.gateway", "Anthropic passthrough account %d: upstream error %d, retry %d/%d after %v (elapsed=%v/%v)",
account.ID, resp.StatusCode, attempt, maxRetryAttempts, delay, elapsed, maxRetryElapsed)
if err := sleepWithContext(ctx, delay); err != nil {
return nil, err
}
continue
}
break
}
break
}
if resp == nil || resp.Body == nil {
return nil, errors.New("upstream request failed: empty response")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
if s.shouldFailoverUpstreamError(resp.StatusCode) {
respBody, _ := s.readUpstreamErrorBody(resp)
_ = resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(respBody))
logger.LegacyPrintf("service.gateway", "[Anthropic Passthrough] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d RequestID=%s Body=%s",
account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000))
s.handleRetryExhaustedSideEffects(ctx, resp, account)
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: "retry_exhausted_failover",
Message: extractUpstreamErrorMessage(respBody),
Detail: func() string {
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
}
return ""
}(),
})
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
return s.handleRetryExhaustedError(ctx, resp, c, account)
}
if resp.StatusCode >= 400 && s.shouldFailoverUpstreamError(resp.StatusCode) {
respBody, _ := s.readUpstreamErrorBody(resp)
_ = resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(respBody))
logger.LegacyPrintf("service.gateway", "[Anthropic Passthrough] Upstream error (failover): Account=%d(%s) Status=%d RequestID=%s Body=%s",
account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000))
s.handleFailoverSideEffects(ctx, resp, account, input.RequestModel)
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: extractUpstreamErrorMessage(respBody),
Detail: func() string {
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
}
return ""
}(),
})
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
}
}
if resp.StatusCode >= 400 {
return s.handleErrorResponse(ctx, resp, c, account, input.RequestModel)
}
var usage *ClaudeUsage
var firstTokenMs *int
var clientDisconnect bool
if input.RequestStream {
streamResult, err := s.handleStreamingResponseAnthropicAPIKeyPassthrough(ctx, resp, c, account, input.StartTime, input.RequestModel)
if err != nil {
// 流中断时保留已观测到的 usage 与错误一起返回,避免上游已计量的请求
// 完全漏记漏计费(issue #5148)。
if partial := partialStreamUsageResult(c, resp, streamResult, input.OriginalModel, input.RequestModel, input.StartTime, err); partial != nil {
return partial, err
}
return nil, err
}
usage = streamResult.usage
firstTokenMs = streamResult.firstTokenMs
clientDisconnect = streamResult.clientDisconnect
} else {
usage, err = s.handleNonStreamingResponseAnthropicAPIKeyPassthrough(ctx, resp, c, account)
if err != nil {
return nil, err
}
}
if usage == nil {
usage = &ClaudeUsage{}
}
return &ForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: *usage,
Model: input.OriginalModel,
UpstreamModel: input.RequestModel,
UpstreamResponseModel: observedUpstreamResponseModel(c),
UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c),
Stream: input.RequestStream,
Duration: time.Since(input.StartTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
}, nil
}
func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
ctx context.Context,
c *gin.Context,
account *Account,
body []byte,
token string,
) (*http.Request, []byte, error) {
body = stripDeferredToolCacheControl(body)
targetURL := claudeAPIURL
baseURL := account.GetBaseURL()
if baseURL != "" {
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
if err != nil {
return nil, nil, err
}
targetURL = validatedURL + "/v1/messages?beta=true"
}
// 能力维度 body sanitize:透传路径上 anthropic-beta header 原样透传客户端值,
// 依此决定是否保留 body 中的 context_management。避免“客户端 body 带字段但
// header 忘记带 beta token”的客户端 bug 在透传场景下让上游 400。
clientBeta := ""
if c != nil && c.Request != nil {
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
}
// 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准
if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
clientBeta = beta
}
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
body = sanitized
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
if err != nil {
return nil, nil, err
}
if c != nil && c.Request != nil {
for key, values := range c.Request.Header {
lowerKey := strings.ToLower(strings.TrimSpace(key))
if !allowedHeaders[lowerKey] {
continue
}
wireKey := resolveWireCasing(key)
for _, v := range values {
addHeaderRaw(req.Header, wireKey, v)
}
}
}
// 覆盖入站鉴权残留,并注入上游认证
req.Header.Del("authorization")
req.Header.Del("x-api-key")
req.Header.Del("x-goog-api-key")
req.Header.Del("cookie")
setAnthropicAPIKeyAuthHeader(req.Header, account, token)
if getHeaderRaw(req.Header, "content-type") == "" {
setHeaderRaw(req.Header, "content-type", "application/json")
}
if getHeaderRaw(req.Header, "anthropic-version") == "" {
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
}
// 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
account.ApplyHeaderOverrides(req.Header)
return req, body, nil
}
func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough(
ctx context.Context,
resp *http.Response,
c *gin.Context,
account *Account,
startTime time.Time,
model string,
) (*streamingResult, error) {
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
if s.rateLimitService != nil {
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
}
writeAnthropicPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
if contentType == "" {
contentType = "text/event-stream"
}
c.Header("Content-Type", contentType)
if c.Writer.Header().Get("Cache-Control") == "" {
c.Header("Cache-Control", "no-cache")
}
if c.Writer.Header().Get("Connection") == "" {
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 := &ClaudeUsage{}
var firstTokenMs *int
clientDisconnected := false
sawTerminalEvent := false
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)
type scanEvent struct {
line string
err error
}
events := make(chan scanEvent, 16)
done := make(chan struct{})
sendEvent := func(ev scanEvent) bool {
select {
case events <- ev:
return true
case <-done:
return false
}
}
var lastReadAt int64
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
go func(scanBuf *sseScannerBuf64K) {
defer putSSEScannerBuf64K(scanBuf)
defer close(events)
for scanner.Scan() {
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
if !sendEvent(scanEvent{line: scanner.Text()}) {
return
}
}
if err := scanner.Err(); err != nil {
_ = sendEvent(scanEvent{err: err})
}
}(scanBuf)
defer close(done)
streamInterval := time.Duration(0)
if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 {
streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second
}
var intervalTicker *time.Ticker
if streamInterval > 0 {
intervalTicker = time.NewTicker(streamInterval)
defer intervalTicker.Stop()
}
var intervalCh <-chan time.Time
if intervalTicker != nil {
intervalCh = intervalTicker.C
}
keepaliveInterval := time.Duration(0)
if s.cfg != nil && s.cfg.Gateway.StreamKeepaliveInterval > 0 {
keepaliveInterval = time.Duration(s.cfg.Gateway.StreamKeepaliveInterval) * time.Second
}
var keepaliveTimer *time.Timer
if keepaliveInterval > 0 {
keepaliveTimer = time.NewTimer(keepaliveInterval)
defer keepaliveTimer.Stop()
}
var keepaliveCh <-chan time.Time
if keepaliveTimer != nil {
keepaliveCh = keepaliveTimer.C
}
lastDataAt := time.Now()
resetKeepaliveTimer := func() {
if keepaliveTimer == nil {
return
}
if !keepaliveTimer.Stop() {
select {
case <-keepaliveTimer.C:
default:
}
}
keepaliveTimer.Reset(keepaliveInterval)
}
inPartialEvent := false
for {
select {
case ev, ok := <-events:
if !ok {
if !clientDisconnected {
// 兜底补刷,确保最后一个未以空行结尾的事件也能及时送达客户端。
flusher.Flush()
}
if !sawTerminalEvent {
if clientDisconnected && streamInterval > 0 {
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
if time.Since(lastRead) >= streamInterval {
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, fmt.Errorf("stream usage incomplete after timeout")
}
}
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, fmt.Errorf("stream usage incomplete: missing terminal event")
}
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, nil
}
if ev.err != nil {
if sawTerminalEvent {
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, nil
}
if clientDisconnected {
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, fmt.Errorf("stream usage incomplete after disconnect: %w", ev.err)
}
if errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded) {
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, fmt.Errorf("stream usage incomplete: %w", ev.err)
}
if errors.Is(ev.err, bufio.ErrTooLong) {
logger.LegacyPrintf("service.gateway", "[Anthropic passthrough] SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, ev.err)
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, ev.err
}
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream read error: %w", ev.err)
}
line := ev.line
if data, ok := extractAnthropicSSEDataLine(line); ok {
trimmed := strings.TrimSpace(data)
observer.ObserveAnthropic([]byte(trimmed))
if anthropicStreamEventIsTerminal("", trimmed) {
sawTerminalEvent = true
}
if firstTokenMs == nil && trimmed != "" && trimmed != "[DONE]" {
ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms
}
parseSSEUsagePassthrough(data, usage)
} else {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "event:") && anthropicStreamEventIsTerminal(strings.TrimSpace(strings.TrimPrefix(trimmed, "event:")), "") {
sawTerminalEvent = true
}
}
if !clientDisconnected {
restored := string(reverseToolNamesIfPresent(c, []byte(line)))
if _, err := io.WriteString(w, restored); err != nil {
clientDisconnected = true
logger.LegacyPrintf("service.gateway", "[Anthropic passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID)
} else if _, err := io.WriteString(w, "\n"); err != nil {
clientDisconnected = true
logger.LegacyPrintf("service.gateway", "[Anthropic passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID)
} else if line == "" {
// 按 SSE 事件边界刷出,减少每行 flush 带来的 syscall 开销。
flusher.Flush()
lastDataAt = time.Now()
resetKeepaliveTimer()
inPartialEvent = false
} else {
inPartialEvent = true
}
}
case <-intervalCh:
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
if time.Since(lastRead) < streamInterval {
continue
}
if clientDisconnected {
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, fmt.Errorf("stream usage incomplete after timeout")
}
logger.LegacyPrintf("service.gateway", "[Anthropic passthrough] Stream data interval timeout: account=%d model=%s interval=%s", account.ID, model, streamInterval)
if s.rateLimitService != nil {
s.rateLimitService.HandleStreamTimeout(ctx, account, model)
}
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream data interval timeout")
case <-keepaliveCh:
if clientDisconnected {
continue
}
if inPartialEvent {
resetKeepaliveTimer()
continue
}
if time.Since(lastDataAt) < keepaliveInterval {
resetKeepaliveTimer()
continue
}
if _, err := fmt.Fprint(w, "event: ping\ndata: {\"type\": \"ping\"}\n\n"); err != nil {
clientDisconnected = true
logger.LegacyPrintf("service.gateway", "[Anthropic passthrough] Client disconnected during keepalive ping, continue draining upstream for usage: account=%d", account.ID)
continue
}
flusher.Flush()
lastDataAt = time.Now()
resetKeepaliveTimer()
}
}
}
func extractAnthropicSSEDataLine(line string) (string, bool) {
if !strings.HasPrefix(line, "data:") {
return "", false
}
start := len("data:")
for start < len(line) {
if line[start] != ' ' && line[start] != '\t' {
break
}
start++
}
return line[start:], true
}
// parseSSEUsagePassthrough 从 Anthropic SSE data 行提取 usage(包级函数:
// Anthropic 平台 passthrough 与国产供应商原生 Anthropic 直通共用)。
func parseSSEUsagePassthrough(data string, usage *ClaudeUsage) {
if usage == nil || data == "" || data == "[DONE]" {
return
}
parsed := gjson.Parse(data)
switch parsed.Get("type").String() {
case "message_start":
msgUsage := parsed.Get("message.usage")
if msgUsage.Exists() {
usage.InputTokens = int(msgUsage.Get("input_tokens").Int())
usage.CacheCreationInputTokens = int(msgUsage.Get("cache_creation_input_tokens").Int())
usage.CacheReadInputTokens = int(msgUsage.Get("cache_read_input_tokens").Int())
// 保持与通用解析一致:message_start 允许覆盖 5m/1h 明细(包括 0)。
cc5m := msgUsage.Get("cache_creation.ephemeral_5m_input_tokens")
cc1h := msgUsage.Get("cache_creation.ephemeral_1h_input_tokens")
if cc5m.Exists() || cc1h.Exists() {
usage.CacheCreation5mTokens = int(cc5m.Int())
usage.CacheCreation1hTokens = int(cc1h.Int())
}
}
case "message_delta":
deltaUsage := parsed.Get("usage")
if deltaUsage.Exists() {
if v := deltaUsage.Get("input_tokens").Int(); v > 0 {
usage.InputTokens = int(v)
}
if v := deltaUsage.Get("output_tokens").Int(); v > 0 {
usage.OutputTokens = int(v)
}
if v := deltaUsage.Get("cache_creation_input_tokens").Int(); v > 0 {
usage.CacheCreationInputTokens = int(v)
}
if v := deltaUsage.Get("cache_read_input_tokens").Int(); v > 0 {
usage.CacheReadInputTokens = int(v)
}
cc5m := deltaUsage.Get("cache_creation.ephemeral_5m_input_tokens")
cc1h := deltaUsage.Get("cache_creation.ephemeral_1h_input_tokens")
if cc5m.Exists() && cc5m.Int() > 0 {
usage.CacheCreation5mTokens = int(cc5m.Int())
}
if cc1h.Exists() && cc1h.Int() > 0 {
usage.CacheCreation1hTokens = int(cc1h.Int())
}
}
}
if usage.CacheReadInputTokens == 0 {
if cached := parsed.Get("message.usage.cached_tokens").Int(); cached > 0 {
usage.CacheReadInputTokens = int(cached)
}
if cached := parsed.Get("usage.cached_tokens").Int(); usage.CacheReadInputTokens == 0 && cached > 0 {
usage.CacheReadInputTokens = int(cached)
}
}
if usage.CacheCreationInputTokens == 0 {
cc5m := parsed.Get("message.usage.cache_creation.ephemeral_5m_input_tokens").Int()
cc1h := parsed.Get("message.usage.cache_creation.ephemeral_1h_input_tokens").Int()
if cc5m == 0 && cc1h == 0 {
cc5m = parsed.Get("usage.cache_creation.ephemeral_5m_input_tokens").Int()
cc1h = parsed.Get("usage.cache_creation.ephemeral_1h_input_tokens").Int()
}
total := cc5m + cc1h
if total > 0 {
usage.CacheCreationInputTokens = int(total)
}
}
}
func parseClaudeUsageFromResponseBody(body []byte) *ClaudeUsage {
usage := &ClaudeUsage{}
if len(body) == 0 {
return usage
}
parsed := gjson.ParseBytes(body)
usageNode := parsed.Get("usage")
if !usageNode.Exists() {
return usage
}
usage.InputTokens = int(usageNode.Get("input_tokens").Int())
usage.OutputTokens = int(usageNode.Get("output_tokens").Int())
usage.CacheCreationInputTokens = int(usageNode.Get("cache_creation_input_tokens").Int())
usage.CacheReadInputTokens = int(usageNode.Get("cache_read_input_tokens").Int())
cc5m := usageNode.Get("cache_creation.ephemeral_5m_input_tokens").Int()
cc1h := usageNode.Get("cache_creation.ephemeral_1h_input_tokens").Int()
if cc5m > 0 || cc1h > 0 {
usage.CacheCreation5mTokens = int(cc5m)
usage.CacheCreation1hTokens = int(cc1h)
}
if usage.CacheCreationInputTokens == 0 && (cc5m > 0 || cc1h > 0) {
usage.CacheCreationInputTokens = int(cc5m + cc1h)
}
if usage.CacheReadInputTokens == 0 {
if cached := usageNode.Get("cached_tokens").Int(); cached > 0 {
usage.CacheReadInputTokens = int(cached)
}
}
return usage
}
// invalidNonStreamingJSONFailoverError 把"上游 2xx 返回非 JSON body"归一为
// failover 错误(包级函数:Anthropic 平台 passthrough 与国产供应商原生
// Anthropic 直通共用)。
func invalidNonStreamingJSONFailoverError(
ctx context.Context,
rateLimitService *RateLimitService,
resp *http.Response,
account *Account,
body []byte,
parseErr error,
requestedModel ...string,
) error {
const statusCode = http.StatusBadGateway
accountID := int64(0)
accountName := ""
retryableOnSameAccount := false
if account != nil {
accountID = account.ID
accountName = account.Name
retryableOnSameAccount = account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode)
}
logger.LegacyPrintf(
"service.gateway",
"Account %d(%s): upstream returned non-JSON 2xx response, attempting failover: status=%d request_id=%s error=%v",
accountID,
accountName,
resp.StatusCode,
resp.Header.Get("x-request-id"),
parseErr,
)
if rateLimitService != nil && account != nil {
if len(requestedModel) > 0 {
rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body, requestedModel[0])
} else {
rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body)
}
}
return &UpstreamFailoverError{
StatusCode: statusCode,
ResponseBody: body,
ResponseHeaders: resp.Header,
RetryableOnSameAccount: retryableOnSameAccount,
}
}
func (s *GatewayService) handleNonStreamingResponseAnthropicAPIKeyPassthrough(
ctx context.Context,
resp *http.Response,
c *gin.Context,
account *Account,
) (*ClaudeUsage, error) {
if s.rateLimitService != nil {
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
}
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, anthropicTooLargeError)
if err != nil {
return nil, err
}
observer := upstreamResponseModelObserverFromContext(c)
if observer == nil {
observer = beginUpstreamResponseModelObservation(c)
}
observer.ObserveAnthropic(body)
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
var raw json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return nil, invalidNonStreamingJSONFailoverError(ctx, s.rateLimitService, resp, account, body, err)
}
}
usage := parseClaudeUsageFromResponseBody(body)
if IsForceCacheBilling(ctx) && usage.InputTokens > 0 {
body, err = classifyAnthropicResponseInputAsCacheRead(body, usage)
if err != nil {
return nil, err
}
}
writeAnthropicPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
if contentType == "" {
contentType = "application/json"
}
body = reverseToolNamesIfPresent(c, body)
c.Data(resp.StatusCode, contentType, body)
return usage, nil
}
func classifyAnthropicResponseInputAsCacheRead(body []byte, usage *ClaudeUsage) ([]byte, error) {
classified, err := sjson.SetBytes(body, "usage.input_tokens", 0)
if err != nil {
return nil, fmt.Errorf("classify forced cache billing input tokens: %w", err)
}
classified, err = sjson.SetBytes(classified, "usage.cache_read_input_tokens", usage.CacheReadInputTokens+usage.InputTokens)
if err != nil {
return nil, fmt.Errorf("classify forced cache billing cache read tokens: %w", err)
}
return classified, nil
}
func writeAnthropicPassthroughResponseHeaders(dst http.Header, src http.Header, filter *responseheaders.CompiledHeaderFilter) {
if dst == nil || src == nil {
return
}
if filter != nil {
responseheaders.WriteFilteredHeaders(dst, src, filter)
return
}
if v := strings.TrimSpace(src.Get("Content-Type")); v != "" {
dst.Set("Content-Type", v)
}
if v := strings.TrimSpace(src.Get("x-request-id")); v != "" {
dst.Set("x-request-id", v)
}
}