614 lines
17 KiB
Go
614 lines
17 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"gpt-load/internal/dialect"
|
|
"gpt-load/internal/health"
|
|
"gpt-load/internal/platform/encryption"
|
|
"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
|
|
}
|
|
|
|
type keyMutationCoordinator interface {
|
|
Do(uint, func())
|
|
}
|
|
|
|
type runtimeKeyRegistry interface {
|
|
scheduler.KeySource
|
|
CaptureActiveKeyRefs(groupIDs []uint) []state.KeyRef
|
|
ActiveEncryptedValueIfMatch(ref state.KeyRef) (string, bool)
|
|
SetCooldown(keyID uint, until time.Time) bool
|
|
IncrFailure(keyID uint) (int, bool)
|
|
SetBlacklisted(keyID uint) bool
|
|
ClearFailure(keyID uint) bool
|
|
}
|
|
|
|
type Handler struct {
|
|
manager *state.Manager
|
|
registry runtimeKeyRegistry
|
|
encryption encryption.Service
|
|
forwarder AttemptForwarder
|
|
dialects dialect.Set
|
|
stats *health.StatsStore
|
|
mutations keyMutationCoordinator
|
|
limiter AccessKeyRPMLimiter
|
|
requestLogSink telemetry.RequestLogSink
|
|
newRandom func() *rand.Rand
|
|
newRequestID func() (string, error)
|
|
requestNow func() time.Time
|
|
now func() time.Time
|
|
writeTimeout time.Duration
|
|
modelListLimit int64
|
|
}
|
|
|
|
func NewHandler(
|
|
manager *state.Manager,
|
|
registry *state.KeyRegistry,
|
|
encryptionService encryption.Service,
|
|
forwarder AttemptForwarder,
|
|
dialects dialect.Set,
|
|
stats *health.StatsStore,
|
|
mutations *health.MutationCoordinator,
|
|
limiter AccessKeyRPMLimiter,
|
|
requestLogSink telemetry.RequestLogSink,
|
|
) *Handler {
|
|
if limiter == nil {
|
|
limiter = unlimitedAccessKeyRPMLimiter{}
|
|
}
|
|
if requestLogSink == nil {
|
|
requestLogSink = telemetry.NoopRequestLogSink{}
|
|
}
|
|
return &Handler{
|
|
manager: manager, registry: registry, encryption: encryptionService,
|
|
forwarder: forwarder, dialects: dialects, stats: stats, mutations: mutations,
|
|
limiter: limiter, requestLogSink: requestLogSink,
|
|
newRandom: func() *rand.Rand { return rand.New(rand.NewSource(rand.Int63())) },
|
|
newRequestID: newRequestID,
|
|
requestNow: time.Now,
|
|
now: time.Now,
|
|
writeTimeout: downstreamWriteTimeout,
|
|
modelListLimit: maxNonStreamingResponseBodyBytes,
|
|
}
|
|
}
|
|
|
|
type unlimitedAccessKeyRPMLimiter struct{}
|
|
|
|
func (unlimitedAccessKeyRPMLimiter) Allow(uint, int64) ratelimit.LimitDecision {
|
|
return ratelimit.LimitDecision{Allowed: true}
|
|
}
|
|
|
|
func (handler *Handler) applyKeyAction(
|
|
keyID uint,
|
|
decision health.Result,
|
|
attemptNow time.Time,
|
|
) {
|
|
switch decision.Action {
|
|
case health.ActionCooldownKey:
|
|
until := decision.CooldownUntil
|
|
if decision.UseFixed {
|
|
until = attemptNow.Add(fixedCooldown)
|
|
}
|
|
_ = handler.registry.SetCooldown(keyID, until)
|
|
case health.ActionFailKey:
|
|
handler.mutations.Do(keyID, func() {
|
|
count, ok := handler.registry.IncrFailure(keyID)
|
|
if ok && count >= blacklistFailureThreshold {
|
|
_ = handler.registry.SetBlacklisted(keyID)
|
|
}
|
|
handler.stats.Record(keyID, false, attemptNow)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (handler *Handler) recordSuccess(keyID uint, at time.Time) {
|
|
handler.mutations.Do(keyID, func() {
|
|
_ = handler.registry.ClearFailure(keyID)
|
|
handler.stats.Record(keyID, true, at)
|
|
})
|
|
}
|
|
|
|
func (handler *Handler) RegisterRoutes(engine *gin.Engine) {
|
|
engine.POST("/v1/chat/completions", handler.Handle)
|
|
engine.NoRoute(handler.Handle)
|
|
}
|
|
|
|
func (handler *Handler) Handle(ginContext *gin.Context) {
|
|
requestStarted := handler.requestNow()
|
|
snapshot := handler.manager.Current()
|
|
initializeDebugHeaders(ginContext.Writer.Header())
|
|
accessKey, ok := authenticate(ginContext.Request, snapshot, handler.encryption)
|
|
if !ok {
|
|
_ = handler.writeReason(ginContext, reasonInvalidAccessKey)
|
|
return
|
|
}
|
|
selectedRoute, ok := determineRoute(ginContext.Request.Method, ginContext.Request.URL.Path, ginContext.Request.Header)
|
|
if !ok {
|
|
_ = handler.writeReason(ginContext, reasonEndpointNotFound)
|
|
return
|
|
}
|
|
|
|
requestID, err := handler.newRequestID()
|
|
if err != nil {
|
|
logrus.WithError(err).Warn("gateway request ID generation failed; request telemetry disabled")
|
|
requestID = ""
|
|
} else {
|
|
ginContext.Writer.Header().Set(requestIDHeader, requestID)
|
|
}
|
|
|
|
var recorder *requestRecorder
|
|
if selectedRoute.Kind == endpointChat && 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 {
|
|
handler.writeVisibleModelList(ginContext, snapshot, accessKey, selectedRoute.Protocol)
|
|
return
|
|
}
|
|
|
|
selectedDialect, dialectReady := handler.dialects[selectedRoute.Protocol]
|
|
if !dialectReady || selectedRoute.Kind != endpointChat {
|
|
handler.completeReason(ginContext, recorder, reasonEndpointNotFound)
|
|
return
|
|
}
|
|
|
|
candidateGroupIDs := scheduler.CandidateGroupIDs(
|
|
snapshot,
|
|
selectedRoute.Protocol,
|
|
accessKey,
|
|
)
|
|
capturedRefs := handler.registry.CaptureActiveKeyRefs(candidateGroupIDs)
|
|
allowedKeyRefs := make(map[uint]state.KeyRef, len(capturedRefs))
|
|
for _, ref := range capturedRefs {
|
|
allowedKeyRefs[ref.ID] = ref
|
|
}
|
|
|
|
body, err := readRequestBody(ginContext.Request.Body, maxRequestBodyBytes)
|
|
if err != nil {
|
|
if ginContext.Request.Context().Err() != nil {
|
|
recorder.completeCanceled(0)
|
|
return
|
|
}
|
|
if errors.Is(err, errRequestTooLarge) {
|
|
handler.completeReason(ginContext, recorder, reasonRequestTooLarge)
|
|
return
|
|
}
|
|
handler.completeReason(ginContext, recorder, reasonCannotExtractModel)
|
|
return
|
|
}
|
|
parsed := &dialect.ParsedRequest{
|
|
Method: ginContext.Request.Method,
|
|
Path: ginContext.Request.URL.Path,
|
|
RawQuery: ginContext.Request.URL.RawQuery,
|
|
Header: ginContext.Request.Header.Clone(),
|
|
Body: body,
|
|
}
|
|
model, stream, err := selectedDialect.ExtractModel(parsed)
|
|
if err != nil {
|
|
if ginContext.Request.Context().Err() != nil {
|
|
recorder.completeCanceled(0)
|
|
return
|
|
}
|
|
handler.completeReason(ginContext, recorder, reasonCannotExtractModel)
|
|
return
|
|
}
|
|
if len(model) > maxDataPlaneModelBytes {
|
|
handler.completeReason(ginContext, recorder, reasonCannotExtractModel)
|
|
return
|
|
}
|
|
recorder.setClientModel(model)
|
|
|
|
allowedKeyIDs := make(map[uint]struct{}, len(allowedKeyRefs))
|
|
for keyID := range allowedKeyRefs {
|
|
allowedKeyIDs[keyID] = struct{}{}
|
|
}
|
|
iterator := scheduler.New(snapshot, handler.registry, scheduler.Query{
|
|
Protocol: selectedRoute.Protocol, ExternalModel: model, AccessKey: accessKey,
|
|
AllowedKeyIDs: allowedKeyIDs,
|
|
}, handler.newRandom())
|
|
handler.executeAttempts(
|
|
ginContext,
|
|
iterator,
|
|
allowedKeyRefs,
|
|
selectedDialect,
|
|
parsed,
|
|
model,
|
|
stream,
|
|
recorder,
|
|
)
|
|
}
|
|
|
|
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(status)
|
|
return
|
|
}
|
|
recorder.completeDownstreamWrite(selectedStatus)
|
|
}
|
|
|
|
func readRequestBody(reader io.Reader, limit int64) ([]byte, error) {
|
|
if reader == nil {
|
|
return nil, fmt.Errorf("request body is required")
|
|
}
|
|
if limit < 0 {
|
|
return nil, fmt.Errorf("request body limit must not be negative")
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(reader, limit+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read request body: %w", err)
|
|
}
|
|
if int64(len(body)) > limit {
|
|
return nil, errRequestTooLarge
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func (handler *Handler) executeAttempts(
|
|
ginContext *gin.Context,
|
|
iterator *scheduler.Iterator,
|
|
allowedKeyRefs map[uint]state.KeyRef,
|
|
selectedDialect dialect.Dialect,
|
|
parsed *dialect.ParsedRequest,
|
|
externalModel string,
|
|
stream bool,
|
|
recorder *requestRecorder,
|
|
) {
|
|
type deferredAttempt struct {
|
|
result UpstreamResult
|
|
decision health.Result
|
|
upstreamModel string
|
|
attemptIndex int
|
|
}
|
|
var lastResponse *deferredAttempt
|
|
var lastTransport *deferredAttempt
|
|
var lastProviderError *deferredAttempt
|
|
attempts := 0
|
|
for attempts < maxAttempts {
|
|
if ginContext.Request.Context().Err() != nil {
|
|
recorder.completeCanceled(0)
|
|
return
|
|
}
|
|
selection, err := iterator.Next()
|
|
if errors.Is(err, scheduler.ErrExhausted) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
ref, allowed := allowedKeyRefs[selection.KeyID]
|
|
if !allowed || ref.GroupID != selection.GroupID {
|
|
continue
|
|
}
|
|
encrypted, active := handler.registry.ActiveEncryptedValueIfMatch(ref)
|
|
if !active {
|
|
continue
|
|
}
|
|
apiKey, err := handler.encryption.Decrypt(encrypted)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
attempts++
|
|
updateDebugHeaders(ginContext.Writer.Header(), selection.Group.Name, attempts)
|
|
selectedKeyID := selection.KeyID
|
|
input := ForwardInput{
|
|
Dialect: selectedDialect, Group: selection.Group, APIKey: apiKey, Request: parsed,
|
|
ExternalModel: externalModel,
|
|
UpstreamModelID: selection.UpstreamModelID,
|
|
OnStreamReady: func() {
|
|
handler.recordSuccess(selectedKeyID, handler.now())
|
|
},
|
|
}
|
|
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)
|
|
}
|
|
attemptCompleted := time.Time{}
|
|
if recorder != nil {
|
|
attemptCompleted = recorder.now()
|
|
}
|
|
requestCanceled := ginContext.Request.Context().Err() != nil
|
|
if result.Committed {
|
|
if recorder != nil {
|
|
result.Stream = prioritizeStreamObservation(
|
|
ginContext.Request.Context(),
|
|
result.Err,
|
|
result.Stream,
|
|
)
|
|
recordedAttempt := recorder.recordStreamAttempt(
|
|
selection, apiKey, result, attemptStarted, attemptCompleted,
|
|
)
|
|
recorder.completeStream(result, selection.UpstreamModelID, recordedAttempt)
|
|
}
|
|
return
|
|
}
|
|
if requestCanceled {
|
|
if recorder != nil {
|
|
recorder.recordAttempt(
|
|
selection,
|
|
apiKey,
|
|
result,
|
|
health.Result{
|
|
Category: health.FailureCategoryDownstreamCancel,
|
|
Action: health.ActionTerminate,
|
|
},
|
|
attemptStarted,
|
|
attemptCompleted,
|
|
)
|
|
recorder.completeCanceled(0)
|
|
}
|
|
return
|
|
}
|
|
attemptNow := handler.now()
|
|
if !stream && !result.ProviderErrorBeforeCommit && result.HasResponse() &&
|
|
result.StatusCode >= http.StatusOK &&
|
|
result.StatusCode < http.StatusMultipleChoices {
|
|
handler.recordSuccess(selection.KeyID, attemptNow)
|
|
}
|
|
decision := health.Judge(selectedDialect, health.Attempt{
|
|
StatusCode: result.StatusCode, Body: result.ClassificationBody,
|
|
Header: result.Header, Now: attemptNow,
|
|
Err: result.Err, RequestWritten: result.RequestWritten,
|
|
Committed: result.Committed, RetryableBeforeCommit: result.RetryableBeforeCommit,
|
|
ProviderErrorBeforeCommit: result.ProviderErrorBeforeCommit,
|
|
})
|
|
recordedAttempt := recorder.recordAttempt(
|
|
selection, apiKey, result, decision, attemptStarted, attemptCompleted,
|
|
)
|
|
handler.applyKeyAction(selection.KeyID, decision, attemptNow)
|
|
if decision.Action == health.ActionSkipGroup {
|
|
iterator.SkipGroup(selection.GroupID)
|
|
}
|
|
if result.ProviderErrorBeforeCommit {
|
|
if decision.ShouldRetry() {
|
|
lastProviderError = &deferredAttempt{
|
|
result: result,
|
|
decision: decision,
|
|
upstreamModel: selection.UpstreamModelID,
|
|
attemptIndex: recordedAttempt,
|
|
}
|
|
recorder.retryIfAnotherForward(recordedAttempt)
|
|
continue
|
|
}
|
|
recorder.completeProviderError(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: selection.UpstreamModelID, attemptIndex: recordedAttempt,
|
|
}
|
|
if decision.ShouldRetry() {
|
|
recorder.retryIfAnotherForward(recordedAttempt)
|
|
continue
|
|
}
|
|
recorder.completeResponse(result, decision, selection.UpstreamModelID, recordedAttempt)
|
|
if err := handler.writeUpstreamResponse(ginContext, result); err != nil {
|
|
handler.completeWriteTerminal(ginContext, recorder, result.StatusCode)
|
|
return
|
|
}
|
|
return
|
|
}
|
|
if errors.Is(result.Err, context.Canceled) {
|
|
recorder.completeCanceled(0)
|
|
return
|
|
}
|
|
if decision.ShouldRetry() {
|
|
lastTransport = &deferredAttempt{
|
|
result: result, decision: decision, upstreamModel: selection.UpstreamModelID,
|
|
}
|
|
recorder.retryIfAnotherForward(recordedAttempt)
|
|
continue
|
|
}
|
|
value := transportReason(result)
|
|
recorder.completeTransport(value, selection.UpstreamModelID)
|
|
if err := handler.writeReason(ginContext, value); err != nil {
|
|
handler.completeWriteTerminal(ginContext, recorder, value.Status)
|
|
}
|
|
return
|
|
}
|
|
|
|
if lastProviderError != nil {
|
|
recorder.completeProviderError(
|
|
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)
|
|
if err := handler.writeReason(ginContext, value); err != nil {
|
|
handler.completeWriteTerminal(ginContext, recorder, value.Status)
|
|
}
|
|
return
|
|
}
|
|
handler.completeReason(ginContext, recorder, reasonNoCandidate)
|
|
}
|
|
|
|
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 errors.Is(result.Err, ErrUpstreamProtocol):
|
|
return reasonUpstreamProtocol
|
|
case isTimeoutError(result.Err):
|
|
return reasonUpstreamTimeout
|
|
default:
|
|
return reasonUpstreamConnect
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}()
|
|
|
|
for name, values := range cloneEndToEndHeaders(headers) {
|
|
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 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
|
|
}
|