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
857 lines
28 KiB
Go
857 lines
28 KiB
Go
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)
|
||
}
|
||
}
|