Files
gpt-load/internal/gateway/handler.go
T
tbphp da248f604d Merge branch 'v2' into feat/global-soft-affinity
# Conflicts:
#	internal/gateway/handler.go
#	web/src/features/monitor/LogsTab.vue
2026-08-12 08:44:03 +08:00

959 lines
28 KiB
Go

package gateway
import (
"context"
"errors"
"fmt"
"io"
"math"
"math/rand"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"gpt-load/internal/affinity"
"gpt-load/internal/channel"
"gpt-load/internal/dialect"
"gpt-load/internal/execution"
"gpt-load/internal/health"
"gpt-load/internal/httplifecycle"
"gpt-load/internal/platform/contentcoding"
"gpt-load/internal/platform/encryption"
platformheader "gpt-load/internal/platform/httpheader"
"gpt-load/internal/platform/utils"
"gpt-load/internal/pricing"
"gpt-load/internal/ratelimit"
"gpt-load/internal/scheduler"
"gpt-load/internal/state"
"gpt-load/internal/telemetry"
)
const (
maxAttempts = 3
maxRequestBodyBytes = int64(32 << 20)
maxDataPlaneModelBytes = 255
fixedCooldown = time.Minute
blacklistFailureThreshold = 3
debugHeaderGroup = "X-GPTLoad-Group"
// debugHeaderKey remains reserved so an upstream cannot inject it downstream.
debugHeaderKey = "X-GPTLoad-Key"
debugHeaderAttempts = "X-GPTLoad-Attempts"
)
var debugHeaderNames = []string{
debugHeaderGroup,
debugHeaderKey,
debugHeaderAttempts,
requestIDHeader,
}
var errRequestTooLarge = errors.New("request body is too large")
type AttemptForwarder interface {
Forward(context.Context, ForwardInput) UpstreamResult
ForwardStream(context.Context, ForwardInput, http.ResponseWriter) UpstreamResult
}
type AccessKeyRPMLimiter interface {
Allow(accessKeyID uint, limit int64) ratelimit.LimitDecision
}
// PriceTableProvider exposes the currently published immutable price table.
type PriceTableProvider interface {
Load() *pricing.Table
}
type credentialMutationCoordinator interface {
Do(uint, func())
}
type runtimeCredentialRegistry interface {
scheduler.CredentialSource
CaptureActiveCredentialRefs(groupIDs []uint) []state.CredentialRef
ActiveEncryptedCredentialDataIfMatch(ref state.CredentialRef) (string, bool)
SetCooldownWithChange(credentialID uint, until time.Time) (exists bool, changed bool)
IncrFailure(credentialID uint) (int, bool)
SetBlacklistedWithChange(credentialID uint) (exists bool, changed bool)
ClearFailure(credentialID uint) bool
}
type Handler struct {
manager *state.Manager
channels *channel.Registry
registry runtimeCredentialRegistry
encryption encryption.Service
forwarder AttemptForwarder
dialects dialect.Set
stats *health.StatsStore
mutations credentialMutationCoordinator
limiter AccessKeyRPMLimiter
requestLogSink telemetry.RequestLogSink
priceTables PriceTableProvider
newRandom func() *rand.Rand
newRequestID func() (string, error)
requestNow func() time.Time
now func() time.Time
writeTimeout time.Duration
modelListLimit int64
logger *logrus.Logger
authFailureEvents *utils.RateLimitedEventCounter
routeNotFoundEvents *utils.RateLimitedEventCounter
lifecycle *httplifecycle.Coordinator
affinityCache *affinity.Cache
}
func (handler *Handler) freezeAttemptPricing(
selection scheduler.Selection,
applicable bool,
) frozenAttemptPricing {
frozen := frozenAttemptPricing{
channelID: string(selection.ChannelID),
groupID: selection.GroupID,
upstreamModel: optionalModelValue(selection.UpstreamModelID),
applicable: applicable,
}
if handler != nil && handler.priceTables != nil {
frozen.table = handler.priceTables.Load()
}
return frozen
}
func NewHandler(
manager *state.Manager,
registry *state.CredentialRegistry,
encryptionService encryption.Service,
forwarder AttemptForwarder,
dialects dialect.Set,
stats *health.StatsStore,
mutations *health.MutationCoordinator,
limiter AccessKeyRPMLimiter,
requestLogSink telemetry.RequestLogSink,
priceTables PriceTableProvider,
) *Handler {
if limiter == nil {
limiter = unlimitedAccessKeyRPMLimiter{}
}
if requestLogSink == nil {
requestLogSink = telemetry.NoopRequestLogSink{}
}
return &Handler{
manager: manager, channels: channel.NewRegistry(), registry: registry, encryption: encryptionService,
forwarder: forwarder, dialects: dialects, stats: stats, mutations: mutations,
limiter: limiter, requestLogSink: requestLogSink, priceTables: priceTables,
affinityCache: affinity.NewCache(),
newRandom: func() *rand.Rand { return rand.New(rand.NewSource(rand.Int63())) },
newRequestID: newRequestID,
requestNow: time.Now,
now: time.Now,
writeTimeout: downstreamWriteTimeout,
modelListLimit: maxNonStreamingResponseBodyBytes,
logger: logrus.StandardLogger(),
authFailureEvents: utils.NewRateLimitedEventCounter(
time.Minute,
time.Now,
),
routeNotFoundEvents: utils.NewRateLimitedEventCounter(
time.Minute,
time.Now,
),
}
}
// NewHandlerWithLifecycle wires the process HTTP lifecycle coordinator into
// the production gateway while preserving the compact constructor used by
// focused gateway tests.
func NewHandlerWithLifecycle(
manager *state.Manager,
registry *state.CredentialRegistry,
channelRegistry *channel.Registry,
encryptionService encryption.Service,
forwarder AttemptForwarder,
dialects dialect.Set,
stats *health.StatsStore,
mutations *health.MutationCoordinator,
limiter AccessKeyRPMLimiter,
requestLogSink telemetry.RequestLogSink,
priceTables PriceTableProvider,
lifecycle *httplifecycle.Coordinator,
) *Handler {
handler := NewHandler(
manager,
registry,
encryptionService,
forwarder,
dialects,
stats,
mutations,
limiter,
requestLogSink,
priceTables,
)
if channelRegistry != nil {
handler.channels = channelRegistry
}
handler.lifecycle = lifecycle
return handler
}
type unlimitedAccessKeyRPMLimiter struct{}
func (unlimitedAccessKeyRPMLimiter) Allow(uint, int64) ratelimit.LimitDecision {
return ratelimit.LimitDecision{Allowed: true}
}
func (handler *Handler) applyCredentialAction(
credentialID uint,
decision health.Result,
statusCode int,
attemptNow time.Time,
) {
switch decision.Action {
case health.ActionCooldownCredential:
mutate := func() {
until := decision.CooldownUntil
if decision.UseFixed {
until = attemptNow.Add(fixedCooldown)
}
exists, changed := handler.registry.SetCooldownWithChange(credentialID, until)
if !exists {
return
}
handler.stats.RecordProblem(credentialID, decision.Category, statusCode, attemptNow)
if changed {
handler.logCredentialCooldown(credentialID, decision.Category, statusCode)
}
}
if handler.mutations == nil {
mutate()
} else {
handler.mutations.Do(credentialID, mutate)
}
case health.ActionFailCredential:
handler.mutations.Do(credentialID, func() {
count, ok := handler.registry.IncrFailure(credentialID)
if !ok {
return
}
becameBlacklisted := false
if count >= blacklistFailureThreshold {
var exists bool
exists, becameBlacklisted = handler.registry.SetBlacklistedWithChange(credentialID)
if !exists {
return
}
}
handler.stats.RecordFailure(credentialID, decision.Category, statusCode, attemptNow)
if becameBlacklisted {
handler.logCredentialBlacklisted(credentialID, count, decision.Category, statusCode)
}
})
}
}
func (handler *Handler) recordCredentialSuccess(credentialID uint, at time.Time) {
handler.mutations.Do(credentialID, func() {
if handler.registry.ClearFailure(credentialID) {
handler.stats.RecordSuccess(credentialID, at)
}
})
}
func (handler *Handler) Handle(ginContext *gin.Context) {
requestContext, ok := dataPlaneRequestContextFrom(ginContext)
if !ok ||
!requestContext.authenticated ||
requestContext.snapshot == nil {
handler.dataPlaneRouteNotFound(ginContext)
return
}
if requestContext.locallyRejected {
handler.dataPlaneRouteNotFound(ginContext)
return
}
requestStarted := requestContext.requestStarted
snapshot := requestContext.snapshot
accessKey := requestContext.accessKey
selectedRoute := requestContext.selectedRoute
requestID, err := handler.newRequestID()
if err != nil {
utils.LogPlaneBestEffort(
logrus.StandardLogger(),
logrus.WarnLevel,
utils.LogPlaneData,
logrus.Fields{"error": err},
"Request telemetry disabled",
)
requestID = ""
} else {
ginContext.Writer.Header().Set(requestIDHeader, requestID)
}
var recorder *requestRecorder
if selectedRoute.Kind == endpointForward && requestID != "" {
recorder = newRequestRecorder(
handler.requestLogSink,
requestID,
requestStarted,
accessKey.ID,
selectedRoute.Protocol,
handler.requestNow,
)
defer func() {
recorder.completeMissingOutcome(
ginContext.Writer.Written(),
ginContext.Writer.Status(),
)
recorder.emit()
}()
}
limitDecision := handler.limiter.Allow(accessKey.ID, accessKey.RPMLimit)
if !limitDecision.Allowed {
ginContext.Writer.Header().Set(
"Retry-After",
strconv.Itoa(retryAfterSeconds(limitDecision.RetryAfter)),
)
handler.completeReason(ginContext, recorder, reasonAccessKeyRateLimited)
return
}
if selectedRoute.Kind == endpointModels {
if !contentcoding.IdentityAcceptable(
headerFieldValues(ginContext.Request.Header, "Accept-Encoding"),
) {
handler.completeReason(ginContext, recorder, reasonNotAcceptable)
return
}
handler.writeVisibleModelList(ginContext, snapshot, accessKey, selectedRoute.Protocol)
return
}
selectedDialect, dialectReady := handler.dialects[selectedRoute.Protocol]
if !dialectReady || selectedRoute.Kind != endpointForward {
handler.logDataPlaneRouteNotFound(
ginContext.Request,
accessKey.ID,
)
handler.completeReason(ginContext, recorder, reasonEndpointNotFound)
return
}
encoding, err := contentcoding.ParseContentEncoding(
headerFieldValues(ginContext.Request.Header, "Content-Encoding"),
)
if err != nil {
ginContext.Writer.Header().Set(
"Accept-Encoding",
contentcoding.SupportedRequestEncodings,
)
handler.completeReason(
ginContext,
recorder,
reasonUnsupportedContentEncoding,
)
return
}
if !contentcoding.IdentityAcceptable(
headerFieldValues(ginContext.Request.Header, "Accept-Encoding"),
) {
handler.completeReason(ginContext, recorder, reasonNotAcceptable)
return
}
body, err := readDecodedRequestBody(
ginContext.Request,
encoding,
maxRequestBodyBytes,
maxRequestBodyBytes,
)
if err != nil {
if ginContext.Request.Context().Err() != nil {
recorder.completeCanceled(ginContext.Request.Context(), 0, -1)
return
}
if errors.Is(err, errRequestTooLarge) {
handler.completeReason(ginContext, recorder, reasonRequestTooLarge)
return
}
if errors.Is(err, contentcoding.ErrInvalidContentEncoding) {
handler.completeReason(ginContext, recorder, reasonInvalidContentEncoding)
return
}
handler.completeReason(ginContext, recorder, reasonInvalidProtocolRequest)
return
}
requestHeaders := ginContext.Request.Header.Clone()
platformheader.StripRequestRepresentationMetadata(requestHeaders)
parsed := &dialect.ParsedRequest{
Method: ginContext.Request.Method,
Path: ginContext.Request.URL.Path,
RawQuery: ginContext.Request.URL.RawQuery,
Header: requestHeaders,
Body: body,
}
metadata, err := selectedDialect.InspectRequest(parsed)
if err != nil {
if ginContext.Request.Context().Err() != nil {
recorder.completeCanceled(ginContext.Request.Context(), 0, -1)
return
}
handler.completeReason(ginContext, recorder, reasonInvalidProtocolRequest)
return
}
model := ""
if metadata.Model != nil {
model = *metadata.Model
}
if len(model) > maxDataPlaneModelBytes {
handler.completeReason(ginContext, recorder, reasonInvalidProtocolRequest)
return
}
if ginContext.Request.Context().Err() != nil {
recorder.completeCanceled(ginContext.Request.Context(), 0, -1)
return
}
query := scheduler.Query{
ClientProtocol: selectedRoute.Protocol,
Operation: metadata.Operation,
RouteRequirement: metadata.RouteRequirement,
ExternalModel: metadata.Model,
AccessKey: accessKey,
}
candidateGroupIDs := scheduler.CandidateGroupIDsForQuery(snapshot, query)
capturedRefs := handler.registry.CaptureActiveCredentialRefs(candidateGroupIDs)
allowedCredentialRefs := make(map[uint]state.CredentialRef, len(capturedRefs))
for _, ref := range capturedRefs {
allowedCredentialRefs[ref.ID] = ref
}
recorder.setClientModel(model)
recorder.setOperation(metadata.Operation)
recorder.setStream(metadata.Stream)
recorder.setReasoning(metadata.Reasoning)
recorder.setUsageApplicable(metadata.ObserveUsage)
recorder.setUsageDiagnostics(metadata.UsageDiagnostics)
allowedCredentialIDs := make(map[uint]struct{}, len(allowedCredentialRefs))
for credentialID := range allowedCredentialRefs {
allowedCredentialIDs[credentialID] = struct{}{}
}
query.AllowedCredentialIDs = allowedCredentialIDs
requestAffinity := handler.resolveRequestAffinity(
accessKey.ID,
selectedRoute.Protocol,
model,
metadata.AffinityPrefix,
allowedCredentialRefs,
)
query.PreferredCredentialID = requestAffinity.preferredCredentialID
iterator := scheduler.New(snapshot, handler.registry, query, handler.newRandom())
handler.executeAttempts(
ginContext,
iterator,
allowedCredentialRefs,
selectedDialect,
parsed,
model,
metadata.Stream,
metadata.ObserveUsage,
metadata.Operation,
metadata.RouteRequirement,
requestAffinity,
recorder,
)
}
func optionalModelValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func retryAfterSeconds(duration time.Duration) int {
seconds := int((duration + time.Second - 1) / time.Second)
if seconds < 1 {
return 1
}
if seconds > 60 {
return 60
}
return seconds
}
func (handler *Handler) completeReason(
ginContext *gin.Context,
recorder *requestRecorder,
value reason,
) {
recorder.completeReason(value)
if err := handler.writeReason(ginContext, value); err != nil {
handler.completeWriteTerminal(ginContext, recorder, value.Status)
}
}
func (handler *Handler) completeWriteTerminal(
ginContext *gin.Context,
recorder *requestRecorder,
selectedStatus int,
) {
if ginContext != nil && ginContext.Request != nil &&
ginContext.Request.Context().Err() != nil {
status := 0
if ginContext.Writer != nil && ginContext.Writer.Written() {
status = ginContext.Writer.Status()
}
recorder.completeCanceled(ginContext.Request.Context(), status, -1)
return
}
recorder.completeDownstreamWrite(selectedStatus)
}
func readDecodedRequestBody(
request *http.Request,
encoding contentcoding.Encoding,
encodedLimit int64,
decodedLimit int64,
) ([]byte, error) {
if request == nil || request.Body == nil {
return nil, fmt.Errorf("request body is required")
}
if encodedLimit < 0 {
return nil, fmt.Errorf("encoded request body limit must not be negative")
}
if decodedLimit < 0 {
return nil, fmt.Errorf("decoded request body limit must not be negative")
}
if request.ContentLength > 0 && request.ContentLength > encodedLimit {
return nil, errRequestTooLarge
}
reader := io.Reader(request.Body)
if encodedLimit < math.MaxInt64 {
reader = io.LimitReader(request.Body, encodedLimit+1)
}
encoded, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("read request body: %w", err)
}
if int64(len(encoded)) > encodedLimit {
return nil, errRequestTooLarge
}
body, err := contentcoding.DecodeLimited(encoding, encoded, decodedLimit)
if errors.Is(err, contentcoding.ErrDecodedBodyTooLarge) {
return nil, errRequestTooLarge
}
return body, err
}
func headerFieldValues(headers http.Header, name string) []string {
var values []string
for actualName, fieldValues := range headers {
if strings.EqualFold(actualName, name) {
values = append(values, fieldValues...)
}
}
return values
}
func (handler *Handler) executeAttempts(
ginContext *gin.Context,
iterator *scheduler.Iterator,
allowedCredentialRefs map[uint]state.CredentialRef,
selectedDialect dialect.Dialect,
parsed *dialect.ParsedRequest,
externalModel string,
stream bool,
observeUsage bool,
operation execution.Operation,
routeRequirement execution.RouteRequirement,
requestAffinity requestAffinity,
recorder *requestRecorder,
) {
type deferredAttempt struct {
result UpstreamResult
decision health.Result
upstreamModel string
attemptIndex int
}
var lastResponse *deferredAttempt
var lastTransport *deferredAttempt
var lastConversion *deferredAttempt
var lastProviderError *deferredAttempt
lastAttemptIndex := -1
attempts := 0
for attempts < maxAttempts {
if ginContext.Request.Context().Err() != nil {
recorder.completeCanceled(ginContext.Request.Context(), 0, lastAttemptIndex)
return
}
selection, err := iterator.Next()
if errors.Is(err, scheduler.ErrExhausted) {
break
}
if err != nil {
break
}
ref, allowed := allowedCredentialRefs[selection.CredentialID]
if !allowed || ref.GroupID != selection.GroupID {
continue
}
encrypted, active := handler.registry.ActiveEncryptedCredentialDataIfMatch(ref)
if !active {
continue
}
decryptedCredential, err := handler.encryption.Decrypt(encrypted)
if err != nil {
continue
}
normalizedCredential, err := normalizeChannelCredential(
handler.channels,
selection.ChannelID,
decryptedCredential,
)
if err != nil {
continue
}
attempts++
if attempts == 1 && requestAffinity.preferredCredentialID != 0 &&
selection.CredentialID == requestAffinity.preferredCredentialID {
recorder.setAffinityHit(true)
}
updateDebugHeaders(ginContext.Writer.Header(), selection.Group.Name, attempts)
selectedCredentialID := selection.CredentialID
executionRequestID := "untracked"
if recorder != nil {
executionRequestID = recorder.requestID
}
input := ForwardInput{
Dialect: selectedDialect, ObserveUsage: observeUsage,
Group: selection.Group, APIKey: normalizedCredential.apiKey,
CredentialSecrets: normalizedCredential.secrets, Request: parsed,
ExternalModel: externalModel,
UpstreamModelID: optionalModelValue(selection.UpstreamModelID),
RequestID: executionRequestID,
AttemptID: executionRequestID + ":" + strconv.Itoa(attempts),
AttemptSequence: uint32(attempts),
ClientProtocol: selectedDialect.Protocol(),
Operation: operation,
RouteRequirement: routeRequirement,
ChannelID: string(selection.ChannelID),
TargetKind: string(selection.ResolvedTarget.ProviderKind),
RouteMode: execution.RouteMode(selection.RouteMode),
TargetConfig: selection.ResolvedTarget.TargetConfig,
Credential: execution.NewCredentialSnapshot(
selection.CredentialID,
ref.Version,
ref.IdentityGeneration,
normalizedCredential.payload,
),
OnStreamReady: func() {
handler.recordCredentialSuccess(selectedCredentialID, handler.now())
},
OnFirstResponse: func() {
recorder.recordFirstResponse()
},
}
if recorder != nil {
recorder.freezeNextAttemptPricing(
handler.freezeAttemptPricing(selection, observeUsage),
)
}
attemptStarted := recorder.beforeForward()
var result UpstreamResult
if stream {
result = handler.forwarder.ForwardStream(ginContext.Request.Context(), input, ginContext.Writer)
} else {
result = handler.forwarder.Forward(ginContext.Request.Context(), input)
}
result = normalizeUpstreamResultEvidence(result)
attemptCompleted := time.Time{}
if recorder != nil {
attemptCompleted = recorder.now()
}
requestCanceled := ginContext.Request.Context().Err() != nil
if result.Committed {
if stream && result.Stream.EndReason == StreamEndNone {
result.Stream = prioritizeStreamObservation(
ginContext.Request.Context(),
result.Err,
result.Stream,
)
}
if recorder != nil {
recordedAttempt := recorder.recordStreamAttempt(
selection, normalizedCredential.secrets, result, attemptStarted, attemptCompleted,
)
recorder.completeStream(result, optionalModelValue(selection.UpstreamModelID), recordedAttempt)
}
if stream && result.Stream.EndReason == StreamEndCleanEOF {
handler.recordAffinitySuccess(requestAffinity, selection, ref)
}
return
}
if requestCanceled {
if recorder != nil {
recordedAttempt := recorder.recordAttempt(
selection,
normalizedCredential.secrets,
result,
health.Result{
Category: health.FailureCategoryDownstreamCancel,
Action: health.ActionTerminate,
},
attemptStarted,
attemptCompleted,
)
recorder.completeCanceled(ginContext.Request.Context(), 0, recordedAttempt)
}
return
}
attemptNow := handler.now()
if !stream && !result.ProviderErrorBeforeCommit && result.HasResponse() &&
result.StatusCode >= http.StatusOK &&
result.StatusCode < http.StatusMultipleChoices {
handler.recordCredentialSuccess(selection.CredentialID, attemptNow)
}
decision := judgeUpstreamResult(result, attemptNow)
recordedAttempt := recorder.recordAttempt(
selection, normalizedCredential.secrets, result, decision, attemptStarted, attemptCompleted,
)
lastAttemptIndex = recordedAttempt
handler.applyCredentialAction(selection.CredentialID, decision, result.StatusCode, attemptNow)
if decision.Action == health.ActionSkipGroup {
iterator.SkipGroup(selection.GroupID)
}
if result.ProviderErrorBeforeCommit {
if shouldRetryAcrossCandidates(input.Operation, input.Request.Method, result, decision) {
lastProviderError = &deferredAttempt{
result: result,
decision: decision,
upstreamModel: optionalModelValue(selection.UpstreamModelID),
attemptIndex: recordedAttempt,
}
recorder.retryIfAnotherForward(recordedAttempt)
continue
}
recorder.completeProviderError(
result,
optionalModelValue(selection.UpstreamModelID),
recordedAttempt,
)
if err := handler.writeReason(ginContext, reasonUpstreamProtocol); err != nil {
handler.completeWriteTerminal(ginContext, recorder, reasonUpstreamProtocol.Status)
}
return
}
if result.HasResponse() {
lastResponse = &deferredAttempt{
result: result, decision: decision, upstreamModel: optionalModelValue(selection.UpstreamModelID), attemptIndex: recordedAttempt,
}
if shouldRetryAcrossCandidates(input.Operation, input.Request.Method, result, decision) {
recorder.retryIfAnotherForward(recordedAttempt)
continue
}
recorder.completeResponse(result, decision, optionalModelValue(selection.UpstreamModelID), recordedAttempt)
if err := handler.writeUpstreamResponse(ginContext, result); err != nil {
handler.completeWriteTerminal(ginContext, recorder, result.StatusCode)
return
}
if result.StatusCode >= http.StatusOK && result.StatusCode < http.StatusMultipleChoices {
handler.recordAffinitySuccess(requestAffinity, selection, ref)
}
return
}
if errors.Is(result.Err, context.Canceled) {
recorder.completeCanceled(ginContext.Request.Context(), 0, recordedAttempt)
return
}
if shouldRetryAcrossCandidates(input.Operation, input.Request.Method, result, decision) {
deferred := &deferredAttempt{
result: result, decision: decision, upstreamModel: optionalModelValue(selection.UpstreamModelID), attemptIndex: recordedAttempt,
}
if isConversionUnsupportedResult(result) {
lastConversion = deferred
} else {
lastTransport = deferred
}
recorder.retryIfAnotherForward(recordedAttempt)
continue
}
value := transportReason(result)
recorder.completeTransport(value, optionalModelValue(selection.UpstreamModelID), recordedAttempt)
if err := handler.writeReason(ginContext, value); err != nil {
handler.completeWriteTerminal(ginContext, recorder, value.Status)
}
return
}
if lastProviderError != nil {
recorder.completeProviderError(
lastProviderError.result,
lastProviderError.upstreamModel,
lastProviderError.attemptIndex,
)
if err := handler.writeReason(ginContext, reasonUpstreamProtocol); err != nil {
handler.completeWriteTerminal(ginContext, recorder, reasonUpstreamProtocol.Status)
}
return
}
if lastResponse != nil {
recorder.completeResponse(
lastResponse.result,
lastResponse.decision,
lastResponse.upstreamModel,
lastResponse.attemptIndex,
)
if err := handler.writeUpstreamResponse(ginContext, lastResponse.result); err != nil {
handler.completeWriteTerminal(
ginContext,
recorder,
lastResponse.result.StatusCode,
)
return
}
return
}
if lastTransport != nil {
value := transportReason(lastTransport.result)
recorder.completeTransport(value, lastTransport.upstreamModel, lastTransport.attemptIndex)
if err := handler.writeReason(ginContext, value); err != nil {
handler.completeWriteTerminal(ginContext, recorder, value.Status)
}
return
}
if lastConversion != nil {
value := reasonProtocolConversionUnsupported
recorder.completeTransport(value, lastConversion.upstreamModel, lastConversion.attemptIndex)
if err := handler.writeReason(ginContext, value); err != nil {
handler.completeWriteTerminal(ginContext, recorder, value.Status)
}
return
}
if iterator.StaticReason() == scheduler.ReasonModelRequiredByFilter {
handler.completeReason(ginContext, recorder, reasonModelRequiredByFilter)
return
}
handler.completeReason(ginContext, recorder, reasonNoCandidate)
}
func shouldRetryAcrossCandidates(
operation execution.Operation,
method string,
result UpstreamResult,
decision health.Result,
) bool {
if !decision.ShouldRetry() || result.Committed {
return false
}
if result.DispatchState == execution.DispatchNotSent {
return true
}
if result.DispatchState != execution.DispatchMaybeSent {
return false
}
switch result.StatusCode {
case http.StatusUnauthorized, http.StatusPaymentRequired, http.StatusForbidden, http.StatusTooManyRequests:
return true
}
if decision.Category == health.FailureCategoryInvalidKey ||
decision.Category == health.FailureCategoryRateLimited ||
decision.Category == health.FailureCategoryModelUnavailable {
return true
}
if method == http.MethodGet || method == http.MethodHead {
return true
}
switch operation {
case execution.OperationResponsesRetrieve,
execution.OperationResponsesInputItems,
execution.OperationResponsesInputTokens,
execution.OperationListModels:
return true
default:
return false
}
}
func initializeDebugHeaders(headers http.Header) {
headers.Set(debugHeaderGroup, "")
headers.Set(debugHeaderAttempts, "0")
}
func updateDebugHeaders(headers http.Header, group string, attempts int) {
headers.Set(debugHeaderGroup, group)
headers.Set(debugHeaderAttempts, strconv.Itoa(attempts))
}
func transportReason(result UpstreamResult) reason {
switch {
case isConversionUnsupportedResult(result):
return reasonProtocolConversionUnsupported
case result.DispatchState == execution.DispatchNotSent && result.ExecutionError != nil &&
result.ExecutionError.Kind == execution.ErrorKindInvalidRequest:
return reasonInvalidProtocolRequest
case errors.Is(result.Err, ErrUpstreamProtocol):
return reasonUpstreamProtocol
case isTimeoutError(result.Err):
return reasonUpstreamTimeout
default:
return reasonUpstreamConnect
}
}
func isConversionUnsupportedResult(result UpstreamResult) bool {
return result.DispatchState == execution.DispatchNotSent && result.ExecutionError != nil &&
result.ExecutionError.Kind == execution.ErrorKindConversionUnsupported
}
func (handler *Handler) writeUpstreamResponse(ginContext *gin.Context, result UpstreamResult) error {
return handler.writeBufferedResponse(ginContext, result.StatusCode, result.Header, result.Body)
}
func (handler *Handler) writeBufferedResponse(
ginContext *gin.Context,
status int,
headers http.Header,
body []byte,
) (err error) {
if handler == nil || ginContext == nil {
return fmt.Errorf("downstream response writer is required")
}
controlled := newStreamWriteController(ginContext.Writer, handler.writeTimeout)
defer func() {
if clearErr := controlled.clear(); err == nil && clearErr != nil {
err = fmt.Errorf("clear downstream write deadline: %w", clearErr)
}
}()
method := ""
if ginContext.Request != nil {
method = ginContext.Request.Method
}
normalizedHeaders, writeBody := normalizeBufferedResponse(method, status, headers, body)
for name, values := range normalizedHeaders {
for _, value := range values {
ginContext.Writer.Header().Add(name, value)
}
}
if err := controlled.writeHeader(status); err != nil {
return fmt.Errorf("write downstream response headers: %w", err)
}
ginContext.Writer.WriteHeaderNow()
if writeBody && len(body) > 0 {
written, writeErr := controlled.write(body)
if writeErr != nil {
return fmt.Errorf("write downstream response: %w", writeErr)
}
if written != len(body) {
return fmt.Errorf("write downstream response: %w", io.ErrShortWrite)
}
}
if err := controlled.flush(); err != nil {
return fmt.Errorf("flush downstream response: %w", err)
}
return nil
}