1703 lines
54 KiB
Go
1703 lines
54 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
cliproxysession "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/session"
|
|
coreusage "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
func claudeOAuthRequestCancellation(ctx context.Context, auth *Auth, err error) error {
|
|
if auth == nil || !strings.EqualFold(strings.TrimSpace(auth.Provider), "claude") || !strings.EqualFold(strings.TrimSpace(auth.Attributes["auth_kind"]), "oauth") {
|
|
return nil
|
|
}
|
|
if ctx != nil && errors.Is(ctx.Err(), context.Canceled) {
|
|
return ctx.Err()
|
|
}
|
|
if errors.Is(err, context.Canceled) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Execute performs a non-streaming execution using the configured selector and executor.
|
|
// It supports multiple providers for the same model and round-robins the starting provider per model.
|
|
func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
req, opts = cliproxysession.Enrich(req, opts)
|
|
normalized := m.normalizeProviders(providers)
|
|
if len(normalized) == 0 {
|
|
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
|
}
|
|
if m.HomeEnabled() {
|
|
resp, errHome := m.executeHome(ctx, normalized, req, opts, false)
|
|
return resp, unwrapRequestStopError(errHome)
|
|
}
|
|
|
|
defaultRequestRetry, maxRetryCredentials, maxWait := m.retrySettings()
|
|
|
|
var lastErr error
|
|
retryModel := authSelectionModelFromOptions(opts, req.Model)
|
|
for attempt := 0; ; attempt++ {
|
|
resp, errExec := m.executeMixedOnce(ctx, normalized, req, opts, maxRetryCredentials, attempt, defaultRequestRetry)
|
|
if errExec == nil {
|
|
return resp, nil
|
|
}
|
|
if isRequestTerminatedError(errExec) || isRequestStopError(errExec) {
|
|
return cliproxyexecutor.Response{}, unwrapRequestStopError(errExec)
|
|
}
|
|
lastErr = errExec
|
|
wait, shouldRetry := m.shouldRetryAfterErrorWithHomeRetryLimit(ctx, opts, errExec, attempt, normalized, retryModel, maxWait, -1, defaultRequestRetry)
|
|
if !shouldRetry {
|
|
break
|
|
}
|
|
if errWait := waitForCooldown(ctx, wait, maxWait); errWait != nil {
|
|
return cliproxyexecutor.Response{}, errWait
|
|
}
|
|
}
|
|
if lastErr != nil {
|
|
lastErr = unwrapRequestStopError(lastErr)
|
|
if hasAntigravityProvider(normalized) && shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) {
|
|
if resp, ok, errCredits := m.tryAntigravityCreditsExecute(ctx, req, opts); errCredits != nil {
|
|
return cliproxyexecutor.Response{}, errCredits
|
|
} else if ok {
|
|
return resp, nil
|
|
}
|
|
}
|
|
return cliproxyexecutor.Response{}, lastErr
|
|
}
|
|
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
|
|
}
|
|
|
|
// It supports multiple providers for the same model and round-robins the starting provider per model.
|
|
func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
req, opts = cliproxysession.Enrich(req, opts)
|
|
normalized := m.normalizeProviders(providers)
|
|
if len(normalized) == 0 {
|
|
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
|
}
|
|
if m.HomeEnabled() {
|
|
resp, errHome := m.executeHome(ctx, normalized, req, opts, true)
|
|
return resp, unwrapRequestStopError(errHome)
|
|
}
|
|
|
|
defaultRequestRetry, maxRetryCredentials, maxWait := m.retrySettings()
|
|
|
|
var lastErr error
|
|
retryModel := authSelectionModelFromOptions(opts, req.Model)
|
|
for attempt := 0; ; attempt++ {
|
|
resp, errExec := m.executeCountMixedOnce(ctx, normalized, req, opts, maxRetryCredentials, attempt, defaultRequestRetry)
|
|
if errExec == nil {
|
|
return resp, nil
|
|
}
|
|
if isRequestTerminatedError(errExec) || isRequestStopError(errExec) {
|
|
return cliproxyexecutor.Response{}, unwrapRequestStopError(errExec)
|
|
}
|
|
lastErr = errExec
|
|
wait, shouldRetry := m.shouldRetryAfterErrorWithHomeRetryLimit(ctx, opts, errExec, attempt, normalized, retryModel, maxWait, -1, defaultRequestRetry)
|
|
if !shouldRetry {
|
|
break
|
|
}
|
|
if errWait := waitForCooldown(ctx, wait, maxWait); errWait != nil {
|
|
return cliproxyexecutor.Response{}, errWait
|
|
}
|
|
}
|
|
if lastErr != nil {
|
|
return cliproxyexecutor.Response{}, unwrapRequestStopError(lastErr)
|
|
}
|
|
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
|
|
}
|
|
|
|
// ExecuteStream performs a streaming execution using the configured selector and executor.
|
|
// It supports multiple providers for the same model and round-robins the starting provider per model.
|
|
func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
|
|
req, opts = cliproxysession.Enrich(req, opts)
|
|
if m.HomeEnabled() {
|
|
if unlockSession := m.lockHomeWebsocketSession(ctx, opts); unlockSession != nil {
|
|
defer unlockSession()
|
|
}
|
|
}
|
|
normalized := m.normalizeProviders(providers)
|
|
if len(normalized) == 0 {
|
|
return nil, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
|
}
|
|
|
|
defaultRequestRetry, maxRetryCredentials, maxWait := m.retrySettings()
|
|
|
|
var lastErr error
|
|
homeRetryLimit := -1
|
|
retryModel := authSelectionModelFromOptions(opts, req.Model)
|
|
attempt := 0
|
|
retryRoundPending := false
|
|
retryRoundWaited := false
|
|
for {
|
|
result, errStream := m.executeStreamMixedOnce(ctx, normalized, req, opts, maxRetryCredentials, &homeRetryLimit, attempt, defaultRequestRetry)
|
|
if errStream == nil {
|
|
return result, nil
|
|
}
|
|
if m.HomeEnabled() && retryRoundPending {
|
|
if wait, okWait := pendingHomeRetryRoundDelay(errStream, maxWait, &homeRetryLimit, pinnedAuthIDFromMetadata(opts.Metadata) == ""); okWait && m.homeRetryAllowed(attempt-1, homeRetryLimit) {
|
|
if retryRoundWaited {
|
|
return nil, errStream
|
|
}
|
|
if errWait := waitForCooldown(ctx, wait, maxWait); errWait != nil {
|
|
return nil, errWait
|
|
}
|
|
retryRoundWaited = true
|
|
continue
|
|
}
|
|
}
|
|
retryRoundPending = false
|
|
retryRoundWaited = false
|
|
if isRequestTerminatedError(errStream) || isRequestStopError(errStream) {
|
|
return nil, unwrapRequestStopError(errStream)
|
|
}
|
|
lastErr = errStream
|
|
wait, shouldRetry := m.shouldRetryAfterErrorWithHomeRetryLimit(ctx, opts, errStream, attempt, normalized, retryModel, maxWait, homeRetryLimit, defaultRequestRetry)
|
|
if !shouldRetry {
|
|
break
|
|
}
|
|
if errWait := waitForCooldown(ctx, wait, maxWait); errWait != nil {
|
|
return nil, errWait
|
|
}
|
|
attempt++
|
|
retryRoundPending = m.HomeEnabled()
|
|
retryRoundWaited = false
|
|
}
|
|
if lastErr != nil {
|
|
lastErr = unwrapRequestStopError(lastErr)
|
|
if hasAntigravityProvider(normalized) && shouldAttemptAntigravityCreditsFallback(m, lastErr, normalized) {
|
|
if result, ok, errCredits := m.tryAntigravityCreditsExecuteStream(ctx, req, opts); errCredits != nil {
|
|
return nil, errCredits
|
|
} else if ok {
|
|
return result, nil
|
|
}
|
|
}
|
|
var bootstrapErr *streamBootstrapError
|
|
if errors.As(lastErr, &bootstrapErr) && bootstrapErr != nil {
|
|
return streamErrorResult(bootstrapErr.Headers(), lastErr), nil
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
return nil, &Error{Code: "auth_not_found", Message: "no auth available"}
|
|
}
|
|
|
|
type requestToFormatResolver interface {
|
|
RequestToFormat(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) sdktranslator.Format
|
|
}
|
|
|
|
func isRequestTerminatedError(err error) bool {
|
|
var terminated *cliproxyexecutor.RequestTerminatedError
|
|
return errors.As(err, &terminated) && terminated != nil
|
|
}
|
|
|
|
func applyRequestAfterAuthInterceptor(ctx context.Context, executor ProviderExecutor, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, requestedModel string) (cliproxyexecutor.Request, cliproxyexecutor.Options, error) {
|
|
if opts.RequestAfterAuthInterceptor == nil {
|
|
return req, opts, nil
|
|
}
|
|
toFormat := requestToFormat(provider, executor, req, opts)
|
|
resp := opts.RequestAfterAuthInterceptor(ctx, cliproxyexecutor.RequestAfterAuthInterceptRequest{
|
|
SourceFormat: opts.SourceFormat,
|
|
ToFormat: toFormat,
|
|
Model: req.Model,
|
|
RequestedModel: requestedModel,
|
|
Stream: opts.Stream,
|
|
Headers: cloneRequestHeaders(opts.Headers),
|
|
Body: bytes.Clone(req.Payload),
|
|
Metadata: opts.Metadata,
|
|
})
|
|
opts.Headers = mergeRequestHeaders(opts.Headers, resp.Headers, resp.ClearHeaders)
|
|
if len(resp.Body) > 0 {
|
|
req.Payload = bytes.Clone(resp.Body)
|
|
opts.OriginalRequest = bytes.Clone(resp.Body)
|
|
}
|
|
if resp.Terminate {
|
|
return req, opts, &cliproxyexecutor.RequestTerminatedError{
|
|
HTTPStatus: resp.StatusCode,
|
|
Header: cloneRequestHeaders(resp.ResponseHeaders),
|
|
Body: bytes.Clone(resp.ResponseBody),
|
|
}
|
|
}
|
|
return req, opts, nil
|
|
}
|
|
|
|
func requestToFormat(provider string, executor ProviderExecutor, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) sdktranslator.Format {
|
|
resolver, ok := executor.(requestToFormatResolver)
|
|
if ok && resolver != nil {
|
|
formatRequestTo := resolver.RequestToFormat(req, opts)
|
|
if formatRequestTo != "" {
|
|
return formatRequestTo
|
|
}
|
|
}
|
|
source := opts.SourceFormat.String()
|
|
if source == "openai-image" || source == "openai-video" {
|
|
return opts.SourceFormat
|
|
}
|
|
if opts.Alt == "responses/compact" && !opts.Stream {
|
|
return sdktranslator.FormatOpenAIResponse
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
|
case "codex":
|
|
return sdktranslator.FormatCodex
|
|
case "xai":
|
|
return sdktranslator.FormatCodex
|
|
case "claude":
|
|
return sdktranslator.FormatClaude
|
|
case "gemini", "vertex", "aistudio":
|
|
return sdktranslator.FormatGemini
|
|
case "kimi":
|
|
return sdktranslator.FormatOpenAI
|
|
case "antigravity":
|
|
return sdktranslator.FormatAntigravity
|
|
default:
|
|
return sdktranslator.FormatOpenAI
|
|
}
|
|
}
|
|
|
|
func cloneRequestHeaders(src http.Header) http.Header {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
dst := make(http.Header, len(src))
|
|
for key, values := range src {
|
|
dst[key] = append([]string(nil), values...)
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func mergeRequestHeaders(current, updates http.Header, clear []string) http.Header {
|
|
if updates == nil && len(clear) == 0 {
|
|
return current
|
|
}
|
|
out := cloneRequestHeaders(current)
|
|
if out == nil && (len(updates) > 0 || len(clear) > 0) {
|
|
out = make(http.Header)
|
|
}
|
|
for _, key := range clear {
|
|
out.Del(key)
|
|
}
|
|
for key, values := range updates {
|
|
out.Del(key)
|
|
for _, value := range values {
|
|
out.Add(key, value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, maxRetryCredentials int, retryRound int, defaultRequestRetry int) (cliproxyexecutor.Response, error) {
|
|
if len(providers) == 0 {
|
|
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
|
}
|
|
routeModel := authSelectionModelFromOptions(opts, req.Model)
|
|
executionModel, restoreExecutionModel := executionModelForAuthSelection(opts, req.Model)
|
|
opts = ensureRequestedModelMetadata(opts, routeModel)
|
|
homeMode := m.HomeEnabled()
|
|
homeAuthCount := 1
|
|
tried := make(map[string]struct{})
|
|
if !homeMode {
|
|
for authID := range m.requestRetryRoundExclusions(retryRound, defaultRequestRetry) {
|
|
tried[authID] = struct{}{}
|
|
}
|
|
}
|
|
attempted := make(map[string]struct{})
|
|
var lastErr error
|
|
for {
|
|
if maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
|
|
if lastErr != nil {
|
|
return cliproxyexecutor.Response{}, lastErr
|
|
}
|
|
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
|
|
}
|
|
pickOpts := opts
|
|
if homeMode {
|
|
pickOpts = withHomeRetryRound(pickOpts, retryRound)
|
|
pickOpts = withHomeAuthCount(pickOpts, homeAuthCount)
|
|
pickOpts = withHomeExcludedAuthIDs(pickOpts, tried)
|
|
}
|
|
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried)
|
|
if errPick != nil {
|
|
if shouldReturnLastErrorOnPickFailure(homeMode, lastErr, errPick) {
|
|
return cliproxyexecutor.Response{}, lastErr
|
|
}
|
|
return cliproxyexecutor.Response{}, errPick
|
|
}
|
|
|
|
entry := logEntryWithRequestID(ctx)
|
|
debugLogAuthSelection(entry, auth, provider, routeModel)
|
|
publishSelectedAuthMetadata(opts.Metadata, auth)
|
|
|
|
tried[auth.ID] = struct{}{}
|
|
execCtx := ctx
|
|
if rt := m.roundTripperFor(auth); rt != nil {
|
|
execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)
|
|
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
|
}
|
|
execCtx = contextWithRequestedModelAlias(execCtx, opts, routeModel)
|
|
|
|
models, pooled, aliasResult, routing := m.preparedExecutionModelsWithAlias(auth, routeModel)
|
|
if len(models) == 0 {
|
|
continue
|
|
}
|
|
attempted[auth.ID] = struct{}{}
|
|
var errPrepare error
|
|
auth, errPrepare = m.prepareRequestAuth(execCtx, executor, auth)
|
|
if errPrepare != nil {
|
|
if errCancel := claudeOAuthRequestCancellation(execCtx, auth, errPrepare); errCancel != nil {
|
|
return cliproxyexecutor.Response{}, errCancel
|
|
}
|
|
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: resultErrorFromError(errPrepare), Options: pickOpts}
|
|
m.MarkResult(execCtx, result)
|
|
lastErr = errPrepare
|
|
continue
|
|
}
|
|
var authErr error
|
|
didRefreshOnUnauthorized := false
|
|
for _, upstreamModel := range models {
|
|
resultModel := m.stateModelForExecution(auth, routeModel, upstreamModel, pooled)
|
|
execReq := req
|
|
execReq.Model = upstreamModel
|
|
if restoreExecutionModel {
|
|
execReq.Model = executionModel
|
|
}
|
|
execOpts := opts
|
|
var errIntercept error
|
|
execReq, execOpts, errIntercept = applyRequestAfterAuthInterceptor(execCtx, executor, provider, execReq, execOpts, requestedModelAliasFromOptions(execOpts, routeModel))
|
|
if errIntercept != nil {
|
|
return cliproxyexecutor.Response{}, errIntercept
|
|
}
|
|
if !restoreExecutionModel {
|
|
execReq = attachResolvedAPIKeyModelInfo(routing, execReq, auth, routeModel, upstreamModel)
|
|
}
|
|
startExec := time.Now()
|
|
resp, errExec := executor.Execute(execCtx, auth, execReq, execOpts)
|
|
durationExec := time.Since(startExec)
|
|
if errExec != nil {
|
|
if errCtx := execCtx.Err(); errCtx != nil {
|
|
return cliproxyexecutor.Response{}, errCtx
|
|
}
|
|
if refreshed, okRefresh := m.tryRefreshAfterUnauthorized(execCtx, auth, errExec, didRefreshOnUnauthorized); okRefresh {
|
|
auth = refreshed
|
|
didRefreshOnUnauthorized = true
|
|
startRetry := time.Now()
|
|
resp, errExec = executor.Execute(execCtx, auth, execReq, execOpts)
|
|
durationRetry := time.Since(startRetry)
|
|
if errExec != nil {
|
|
warnLogUpstreamFailure(execCtx, entry, provider, upstreamModel, auth, durationRetry, errExec)
|
|
if errCtx := execCtx.Err(); errCtx != nil {
|
|
return cliproxyexecutor.Response{}, errCtx
|
|
}
|
|
}
|
|
} else {
|
|
warnLogUpstreamFailure(execCtx, entry, provider, upstreamModel, auth, durationExec, errExec)
|
|
}
|
|
}
|
|
if errCancel := claudeOAuthRequestCancellation(execCtx, auth, errExec); errCancel != nil {
|
|
return cliproxyexecutor.Response{}, errCancel
|
|
}
|
|
result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, Success: errExec == nil, Options: execOpts}
|
|
if errExec != nil {
|
|
result.Error = resultErrorFromError(errExec)
|
|
if ra := retryAfterFromError(errExec); ra != nil {
|
|
result.RetryAfter = ra
|
|
}
|
|
if isCredentialScopedError(errExec) {
|
|
result.CredentialScope = true
|
|
}
|
|
action, okAction := matchRequestScopedErrorAction(auth, errExec, m.runtimeConfigSnapshot())
|
|
applyRequestScopedActionToResult(action, okAction, &result)
|
|
if isResponsesCompactAvailabilityNeutralError(execOpts, errExec, result.Error) {
|
|
m.recordAvailabilityNeutralResult(execCtx, result)
|
|
} else {
|
|
m.MarkResult(execCtx, result)
|
|
}
|
|
if okAction {
|
|
if isRequestScopedStop(action, okAction) {
|
|
return cliproxyexecutor.Response{}, wrapRequestStopError(errExec)
|
|
}
|
|
authErr = errExec
|
|
if result.CredentialScope {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
if isResponsesCompactRequestFaultError(execOpts, errExec) || isRequestInvalidError(errExec) {
|
|
return cliproxyexecutor.Response{}, errExec
|
|
}
|
|
authErr = errExec
|
|
if result.CredentialScope {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
m.MarkResult(execCtx, result)
|
|
attemptAliasResult := resolveAttemptAliasResult(routing, auth, routeModel, upstreamModel, aliasResult)
|
|
rewriteForceMappedResponse(&resp, attemptAliasResult)
|
|
return resp, nil
|
|
}
|
|
if authErr != nil {
|
|
action, okAction := matchRequestScopedErrorAction(auth, authErr, m.runtimeConfigSnapshot())
|
|
if okAction {
|
|
if isRequestScopedStop(action, okAction) {
|
|
return cliproxyexecutor.Response{}, wrapRequestStopError(authErr)
|
|
}
|
|
lastErr = authErr
|
|
if homeMode {
|
|
homeAuthCount++
|
|
}
|
|
continue
|
|
}
|
|
if isResponsesCompactRequestFaultError(opts, authErr) || isRequestInvalidError(authErr) {
|
|
return cliproxyexecutor.Response{}, authErr
|
|
}
|
|
lastErr = authErr
|
|
if homeMode {
|
|
homeAuthCount++
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, maxRetryCredentials int, retryRound int, defaultRequestRetry int) (cliproxyexecutor.Response, error) {
|
|
if len(providers) == 0 {
|
|
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
|
}
|
|
routeModel := authSelectionModelFromOptions(opts, req.Model)
|
|
executionModel, restoreExecutionModel := executionModelForAuthSelection(opts, req.Model)
|
|
opts = ensureRequestedModelMetadata(opts, routeModel)
|
|
homeMode := m.HomeEnabled()
|
|
homeAuthCount := 1
|
|
tried := make(map[string]struct{})
|
|
if !homeMode {
|
|
for authID := range m.requestRetryRoundExclusions(retryRound, defaultRequestRetry) {
|
|
tried[authID] = struct{}{}
|
|
}
|
|
}
|
|
attempted := make(map[string]struct{})
|
|
var lastErr error
|
|
for {
|
|
if maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials {
|
|
if lastErr != nil {
|
|
return cliproxyexecutor.Response{}, lastErr
|
|
}
|
|
return cliproxyexecutor.Response{}, &Error{Code: "auth_not_found", Message: "no auth available"}
|
|
}
|
|
pickOpts := opts
|
|
if homeMode {
|
|
pickOpts = withHomeRetryRound(pickOpts, retryRound)
|
|
pickOpts = withHomeAuthCount(pickOpts, homeAuthCount)
|
|
pickOpts = withHomeExcludedAuthIDs(pickOpts, tried)
|
|
}
|
|
auth, executor, provider, errPick := m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried)
|
|
if errPick != nil {
|
|
if shouldReturnLastErrorOnPickFailure(homeMode, lastErr, errPick) {
|
|
return cliproxyexecutor.Response{}, lastErr
|
|
}
|
|
return cliproxyexecutor.Response{}, errPick
|
|
}
|
|
|
|
entry := logEntryWithRequestID(ctx)
|
|
debugLogAuthSelection(entry, auth, provider, routeModel)
|
|
publishSelectedAuthMetadata(opts.Metadata, auth)
|
|
|
|
tried[auth.ID] = struct{}{}
|
|
execCtx := ctx
|
|
if rt := m.roundTripperFor(auth); rt != nil {
|
|
execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)
|
|
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
|
}
|
|
execCtx = contextWithRequestedModelAlias(execCtx, opts, routeModel)
|
|
|
|
models, pooled, aliasResult, routing := m.preparedExecutionModelsWithAlias(auth, routeModel)
|
|
if len(models) == 0 {
|
|
continue
|
|
}
|
|
attempted[auth.ID] = struct{}{}
|
|
var errPrepare error
|
|
auth, errPrepare = m.prepareRequestAuth(execCtx, executor, auth)
|
|
if errPrepare != nil {
|
|
if errCancel := claudeOAuthRequestCancellation(execCtx, auth, errPrepare); errCancel != nil {
|
|
return cliproxyexecutor.Response{}, errCancel
|
|
}
|
|
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: resultErrorFromError(errPrepare), Options: pickOpts}
|
|
m.MarkResult(execCtx, result)
|
|
lastErr = errPrepare
|
|
continue
|
|
}
|
|
var authErr error
|
|
didRefreshOnUnauthorized := false
|
|
for _, upstreamModel := range models {
|
|
resultModel := m.stateModelForExecution(auth, routeModel, upstreamModel, pooled)
|
|
execReq := req
|
|
execReq.Model = upstreamModel
|
|
if restoreExecutionModel {
|
|
execReq.Model = executionModel
|
|
}
|
|
execOpts := opts
|
|
var errIntercept error
|
|
execReq, execOpts, errIntercept = applyRequestAfterAuthInterceptor(execCtx, executor, provider, execReq, execOpts, requestedModelAliasFromOptions(execOpts, routeModel))
|
|
if errIntercept != nil {
|
|
return cliproxyexecutor.Response{}, errIntercept
|
|
}
|
|
if !restoreExecutionModel {
|
|
execReq = attachResolvedAPIKeyModelInfo(routing, execReq, auth, routeModel, upstreamModel)
|
|
}
|
|
startExec := time.Now()
|
|
resp, errExec := executor.CountTokens(execCtx, auth, execReq, execOpts)
|
|
durationExec := time.Since(startExec)
|
|
if errExec != nil {
|
|
if errCtx := execCtx.Err(); errCtx != nil {
|
|
return cliproxyexecutor.Response{}, errCtx
|
|
}
|
|
if refreshed, okRefresh := m.tryRefreshAfterUnauthorized(execCtx, auth, errExec, didRefreshOnUnauthorized); okRefresh {
|
|
auth = refreshed
|
|
didRefreshOnUnauthorized = true
|
|
startRetry := time.Now()
|
|
resp, errExec = executor.CountTokens(execCtx, auth, execReq, execOpts)
|
|
durationRetry := time.Since(startRetry)
|
|
if errExec != nil {
|
|
warnLogUpstreamFailure(execCtx, entry, provider, upstreamModel, auth, durationRetry, errExec)
|
|
if errCtx := execCtx.Err(); errCtx != nil {
|
|
return cliproxyexecutor.Response{}, errCtx
|
|
}
|
|
}
|
|
} else {
|
|
warnLogUpstreamFailure(execCtx, entry, provider, upstreamModel, auth, durationExec, errExec)
|
|
}
|
|
}
|
|
if errCancel := claudeOAuthRequestCancellation(execCtx, auth, errExec); errCancel != nil {
|
|
return cliproxyexecutor.Response{}, errCancel
|
|
}
|
|
result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, Success: errExec == nil, Options: execOpts}
|
|
if errExec != nil {
|
|
result.Error = resultErrorFromError(errExec)
|
|
if ra := retryAfterFromError(errExec); ra != nil {
|
|
result.RetryAfter = ra
|
|
}
|
|
action, okAction := matchRequestScopedErrorAction(auth, errExec, m.runtimeConfigSnapshot())
|
|
applyRequestScopedActionToResult(action, okAction, &result)
|
|
// Some Anthropic-compatible upstreams do not implement the
|
|
// count_tokens route and return a generic endpoint 404. Record
|
|
// the failure for hooks and metrics without suspending a model
|
|
// that remains usable through the messages endpoint.
|
|
if isCountTokensEndpointNotFoundError(errExec, execReq.Model) && (result.Error == nil || result.Error.Code != ErrorCodeForceCooldown) {
|
|
m.recordAvailabilityNeutralResult(execCtx, result)
|
|
} else {
|
|
if isCredentialScopedError(errExec) {
|
|
result.CredentialScope = true
|
|
}
|
|
m.MarkResult(execCtx, result)
|
|
}
|
|
if okAction {
|
|
if isRequestScopedStop(action, okAction) {
|
|
return cliproxyexecutor.Response{}, wrapRequestStopError(errExec)
|
|
}
|
|
authErr = errExec
|
|
if result.CredentialScope {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
if isRequestInvalidError(errExec) {
|
|
return cliproxyexecutor.Response{}, errExec
|
|
}
|
|
authErr = errExec
|
|
if result.CredentialScope {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
m.MarkResult(execCtx, result)
|
|
attemptAliasResult := resolveAttemptAliasResult(routing, auth, routeModel, upstreamModel, aliasResult)
|
|
rewriteForceMappedResponse(&resp, attemptAliasResult)
|
|
return resp, nil
|
|
}
|
|
if authErr != nil {
|
|
action, okAction := matchRequestScopedErrorAction(auth, authErr, m.runtimeConfigSnapshot())
|
|
if okAction {
|
|
if isRequestScopedStop(action, okAction) {
|
|
return cliproxyexecutor.Response{}, wrapRequestStopError(authErr)
|
|
}
|
|
lastErr = authErr
|
|
if homeMode {
|
|
homeAuthCount++
|
|
}
|
|
continue
|
|
}
|
|
if isRequestInvalidError(authErr) {
|
|
return cliproxyexecutor.Response{}, authErr
|
|
}
|
|
lastErr = authErr
|
|
if homeMode {
|
|
homeAuthCount++
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, maxRetryCredentials int, homeRetryLimit *int, retryRound int, defaultRequestRetry int) (*cliproxyexecutor.StreamResult, error) {
|
|
if len(providers) == 0 {
|
|
return nil, &Error{Code: "provider_not_found", Message: "no provider supplied"}
|
|
}
|
|
routeModel := authSelectionModelFromOptions(opts, req.Model)
|
|
responseAlias := requestedModelAliasFromOptions(opts, routeModel)
|
|
executionModel, restoreExecutionModel := executionModelForAuthSelection(opts, req.Model)
|
|
opts = ensureRequestedModelMetadata(opts, routeModel)
|
|
homeMode := m.HomeEnabled()
|
|
homeAuthCount := 1
|
|
tried := make(map[string]struct{})
|
|
if !homeMode {
|
|
for authID := range m.requestRetryRoundExclusions(retryRound, defaultRequestRetry) {
|
|
tried[authID] = struct{}{}
|
|
}
|
|
}
|
|
homeExcludedAuthIDs := make(map[string]struct{})
|
|
homeSameAuthRetries := make(map[string]int)
|
|
lastHomeAuthID := ""
|
|
homeSameAuthRetryPending := false
|
|
attempted := make(map[string]struct{})
|
|
unauthorizedRefreshTried := make(map[string]struct{})
|
|
var lastErr error
|
|
var roundTiming homeRetryRoundTiming
|
|
for {
|
|
allowSameAuthRetry := homeMode && homeSameAuthRetryPending && lastHomeAuthID != "" && homeSameAuthRetries[lastHomeAuthID] == 0
|
|
if maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials && !allowSameAuthRetry {
|
|
if lastErr != nil {
|
|
if homeMode {
|
|
return nil, markHomeRetryRoundExhausted(lastErr, roundTiming.RetryAfter(), true)
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
return nil, &Error{Code: "auth_not_found", Message: "no auth available"}
|
|
}
|
|
pickOpts := opts
|
|
if homeMode {
|
|
pickOpts = withHomeRetryRound(pickOpts, retryRound)
|
|
pickOpts = withHomeAuthCount(pickOpts, homeAuthCount)
|
|
pickOpts = withHomeExcludedAuthIDs(pickOpts, homeExcludedAuthIDs)
|
|
}
|
|
|
|
var selection *HomeDispatchSelection
|
|
var auth *Auth
|
|
var executor ProviderExecutor
|
|
var provider string
|
|
var errPick error
|
|
if homeMode {
|
|
selection, errPick = m.pickHomeDispatchSelection(ctx, routeModel, pickOpts)
|
|
if selection != nil {
|
|
auth = selection.CloneAuthForRoute(routeModel)
|
|
executor = selection.Executor
|
|
provider = selection.Provider
|
|
}
|
|
} else {
|
|
auth, executor, provider, errPick = m.pickNextMixed(ctx, providers, routeModel, pickOpts, tried)
|
|
}
|
|
if errPick != nil {
|
|
var homeCooldown *homeDispatchRetryAfterError
|
|
if homeMode && lastErr != nil && errors.As(errPick, &homeCooldown) && homeCooldown != nil {
|
|
observeHomeCooldownRetryLimit(homeCooldown, homeRetryLimit, pinnedAuthIDFromMetadata(opts.Metadata) == "")
|
|
return nil, markHomeRetryRoundExhausted(lastErr, homeCooldown.RetryAfter(), false)
|
|
}
|
|
if shouldReturnLastErrorOnPickFailure(homeMode, lastErr, errPick) {
|
|
if homeMode {
|
|
return nil, markHomeRetryRoundExhausted(lastErr, roundTiming.RetryAfter(), isHomeNextRoundImmediatelyAvailable(errPick))
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
return nil, errPick
|
|
}
|
|
if auth == nil || executor == nil {
|
|
if selection != nil {
|
|
selection.End("missing_execution_target")
|
|
}
|
|
return nil, &Error{Code: "executor_not_found", Message: "executor not registered"}
|
|
}
|
|
if homeMode {
|
|
m.observeHomeRetryLimit(auth, selection, homeRetryLimit)
|
|
}
|
|
if selection != nil && allowSameAuthRetry && maxRetryCredentials > 0 && len(attempted) >= maxRetryCredentials && auth.ID != lastHomeAuthID {
|
|
if errEnd := m.endHomeSelectionBeforeRedispatch(ctx, selection, "max_retry_credentials"); errEnd != nil {
|
|
return nil, errEnd
|
|
}
|
|
if lastErr != nil {
|
|
return nil, markHomeRetryRoundExhausted(lastErr, roundTiming.RetryAfter(), true)
|
|
}
|
|
return nil, &Error{Code: "auth_not_found", Message: "no auth available"}
|
|
}
|
|
if homeMode && lastHomeAuthID != "" && auth.ID != lastHomeAuthID {
|
|
homeSameAuthRetryPending = false
|
|
}
|
|
if selection != nil {
|
|
// A legacy Home may ignore excluded_auth_ids and return the same
|
|
// credential again. Reject credentials explicitly excluded from this
|
|
// round while retaining the explicit same-auth retry path, which
|
|
// intentionally leaves the credential out of homeExcludedAuthIDs.
|
|
if _, alreadyTried := tried[auth.ID]; alreadyTried {
|
|
if _, excluded := homeExcludedAuthIDs[auth.ID]; excluded {
|
|
if errEnd := m.endHomeSelectionBeforeRedispatch(ctx, selection, "repeated_excluded_auth"); errEnd != nil {
|
|
return nil, errEnd
|
|
}
|
|
if lastErr != nil {
|
|
return nil, markHomeRetryRoundExhausted(lastErr, roundTiming.RetryAfter(), false)
|
|
}
|
|
return nil, repeatedHomeAuthError()
|
|
} else {
|
|
homeSameAuthRetries[auth.ID]++
|
|
if homeSameAuthRetries[auth.ID] > 1 {
|
|
// A fresh Home selection may retry the same auth once for
|
|
// connection lifecycle or authorization recovery. Repeated
|
|
// failures must still rotate away from this credential.
|
|
homeExcludedAuthIDs[auth.ID] = struct{}{}
|
|
if errEnd := m.endHomeSelectionBeforeRedispatch(ctx, selection, "repeated_same_auth"); errEnd != nil {
|
|
return nil, errEnd
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
if _, refreshedAlready := unauthorizedRefreshTried[auth.ID]; refreshedAlready {
|
|
homeExcludedAuthIDs[auth.ID] = struct{}{}
|
|
homeSameAuthRetryPending = false
|
|
if errEnd := m.endHomeSelectionBeforeRedispatch(ctx, selection, "repeated_refresh_auth"); errEnd != nil {
|
|
return nil, errEnd
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
|
|
entry := logEntryWithRequestID(ctx)
|
|
debugLogAuthSelection(entry, auth, provider, routeModel)
|
|
if selection != nil {
|
|
if errRuntimeAuth := m.bindHomeSelectionRuntimeAuth(ctx, opts, selection); errRuntimeAuth != nil {
|
|
selection.End("runtime_auth_bind_failed")
|
|
return nil, errRuntimeAuth
|
|
}
|
|
}
|
|
publishSelectedAuthMetadata(opts.Metadata, auth)
|
|
|
|
tried[auth.ID] = struct{}{}
|
|
execCtx := ctx
|
|
releaseAttempt := func() {}
|
|
if selection != nil {
|
|
var errBind error
|
|
execCtx, releaseAttempt, errBind = homeExecutionAttemptContext(ctx, selection)
|
|
if errBind != nil {
|
|
selection.End("attempt_bind_failed")
|
|
return nil, errBind
|
|
}
|
|
}
|
|
if rt := m.roundTripperFor(auth); rt != nil {
|
|
execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)
|
|
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
|
|
}
|
|
// Enrich before auth preparation so prepare-stage usage records observe the client request.
|
|
execCtx = contextWithRequestedModelAlias(execCtx, opts, routeModel)
|
|
models, pooled, aliasResult, routing := m.preparedExecutionModelsWithAlias(auth, routeModel)
|
|
if selection != nil && aliasResult.ForceMapping && responseAlias != "" {
|
|
aliasResult.OriginalAlias = responseAlias
|
|
}
|
|
if len(models) == 0 {
|
|
if selection != nil {
|
|
homeExcludedAuthIDs[auth.ID] = struct{}{}
|
|
lastHomeAuthID = auth.ID
|
|
homeSameAuthRetryPending = false
|
|
releaseAttempt()
|
|
if errEnd := m.endHomeSelectionBeforeRedispatch(ctx, selection, "no_execution_models"); errEnd != nil {
|
|
return nil, errEnd
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
attempted[auth.ID] = struct{}{}
|
|
var errPrepare error
|
|
if selection != nil {
|
|
auth, errPrepare = m.prepareHomeRequestAuth(execCtx, executor, selection)
|
|
} else {
|
|
auth, errPrepare = m.prepareRequestAuth(execCtx, executor, auth)
|
|
}
|
|
if errPrepare != nil {
|
|
if selection != nil {
|
|
excludeAuth := shouldExcludeHomeAuthAfterStreamError(execCtx, auth, errPrepare)
|
|
if _, refreshedAlready := unauthorizedRefreshTried[auth.ID]; refreshedAlready || homeSameAuthRetries[auth.ID] > 0 {
|
|
excludeAuth = true
|
|
}
|
|
if excludeAuth {
|
|
homeExcludedAuthIDs[auth.ID] = struct{}{}
|
|
}
|
|
lastHomeAuthID = auth.ID
|
|
homeSameAuthRetryPending = !excludeAuth
|
|
}
|
|
if selection == nil {
|
|
if errCancel := claudeOAuthRequestCancellation(execCtx, auth, errPrepare); errCancel != nil {
|
|
return nil, errCancel
|
|
}
|
|
}
|
|
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: resultErrorFromError(errPrepare), Options: pickOpts}
|
|
if selection != nil {
|
|
m.reportHomeResult(execCtx, result, auth)
|
|
releaseAttempt()
|
|
} else {
|
|
m.MarkResult(execCtx, result)
|
|
}
|
|
lastErr = errPrepare
|
|
if homeMode {
|
|
roundTiming.Observe(lastErr)
|
|
}
|
|
if selection != nil {
|
|
if errEnd := m.endHomeSelectionBeforeRedispatch(ctx, selection, "prepare_failed"); errEnd != nil {
|
|
return nil, errEnd
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
execReq := sanitizeDownstreamWebsocketFallbackRequest(execCtx, auth, req)
|
|
streamExecutionModel := ""
|
|
if restoreExecutionModel {
|
|
streamExecutionModel = executionModel
|
|
}
|
|
execOpts := opts
|
|
if selection != nil {
|
|
execOpts.ExecutionLifecycle = selection
|
|
}
|
|
if homeMode && len(models) > 1 {
|
|
models = models[:1]
|
|
pooled = false
|
|
}
|
|
streamResult, errStream := m.executeStreamWithModelPool(execCtx, executor, auth, provider, execReq, execOpts, routeModel, streamExecutionModel, models, pooled, aliasResult, routing, !homeMode || selection != nil, selection != nil, unauthorizedRefreshTried)
|
|
if errStream != nil {
|
|
if selection != nil {
|
|
excludeAuth := shouldExcludeHomeAuthAfterStreamError(execCtx, auth, errStream)
|
|
if _, refreshedAlready := unauthorizedRefreshTried[auth.ID]; refreshedAlready || homeSameAuthRetries[auth.ID] > 0 {
|
|
excludeAuth = true
|
|
}
|
|
if excludeAuth {
|
|
homeExcludedAuthIDs[auth.ID] = struct{}{}
|
|
}
|
|
lastHomeAuthID = auth.ID
|
|
homeSameAuthRetryPending = !excludeAuth
|
|
}
|
|
if selection != nil {
|
|
releaseAttempt()
|
|
if errEnd := m.endHomeSelectionBeforeRedispatch(ctx, selection, "stream_start_failed"); errEnd != nil {
|
|
return nil, errEnd
|
|
}
|
|
}
|
|
if errCtx := execCtx.Err(); errCtx != nil && ctx != nil && ctx.Err() != nil {
|
|
return nil, errCtx
|
|
}
|
|
action, okAction := matchRequestScopedErrorAction(auth, errStream, m.runtimeConfigSnapshot())
|
|
if okAction {
|
|
if isRequestScopedStop(action, okAction) {
|
|
return nil, wrapRequestStopError(errStream)
|
|
}
|
|
lastErr = errStream
|
|
if homeMode {
|
|
roundTiming.Observe(lastErr)
|
|
}
|
|
if homeMode {
|
|
homeAuthCount++
|
|
}
|
|
continue
|
|
}
|
|
if isRequestInvalidError(errStream) {
|
|
return nil, errStream
|
|
}
|
|
lastErr = errStream
|
|
if homeMode {
|
|
roundTiming.Observe(lastErr)
|
|
}
|
|
if homeMode {
|
|
homeAuthCount++
|
|
}
|
|
continue
|
|
}
|
|
if selection != nil {
|
|
if m.retainHomeWebsocketSelection(ctx, opts, routeModel, selection) {
|
|
return wrapHomeStream(ctx, streamResult, nil, releaseAttempt), nil
|
|
}
|
|
return wrapHomeStream(ctx, streamResult, selection, releaseAttempt), nil
|
|
}
|
|
return streamResult, nil
|
|
}
|
|
}
|
|
|
|
func shouldExcludeHomeAuthAfterStreamError(ctx context.Context, auth *Auth, err error) bool {
|
|
if err == nil || isConnectionLifecycleError(err) {
|
|
return false
|
|
}
|
|
// A 426 during a downstream websocket attempt is a transport fallback
|
|
// signal. OAuth authorization failures may also recover after a refresh.
|
|
// Both paths may retry the same credential once.
|
|
if cliproxyexecutor.DownstreamWebsocket(ctx) && statusCodeFromError(err) == http.StatusUpgradeRequired {
|
|
return false
|
|
}
|
|
return !isUnauthorizedError(err) || auth == nil || auth.AuthKind() != AuthKindOAuth
|
|
}
|
|
|
|
func cloneRequestMetadata(src map[string]any) map[string]any {
|
|
if len(src) == 0 {
|
|
return make(map[string]any, 4)
|
|
}
|
|
dst := make(map[string]any, len(src)+4)
|
|
for k, v := range src {
|
|
dst[k] = v
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func ensureRequestedModelMetadata(opts cliproxyexecutor.Options, requestedModel string) cliproxyexecutor.Options {
|
|
opts.Metadata = cloneRequestMetadata(opts.Metadata)
|
|
requestedModel = strings.TrimSpace(requestedModel)
|
|
if requestedModel == "" {
|
|
return opts
|
|
}
|
|
if hasRequestedModelMetadata(opts.Metadata) {
|
|
return opts
|
|
}
|
|
opts.Metadata[cliproxyexecutor.RequestedModelMetadataKey] = requestedModel
|
|
return opts
|
|
}
|
|
|
|
func authSelectionModelFromOptions(opts cliproxyexecutor.Options, fallback string) string {
|
|
fallback = strings.TrimSpace(fallback)
|
|
if len(opts.Metadata) == 0 {
|
|
return fallback
|
|
}
|
|
raw, ok := opts.Metadata[cliproxyexecutor.AuthSelectionModelMetadataKey]
|
|
if !ok || raw == nil {
|
|
return fallback
|
|
}
|
|
switch value := raw.(type) {
|
|
case string:
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
case []byte:
|
|
if strings.TrimSpace(string(value)) != "" {
|
|
return strings.TrimSpace(string(value))
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func executionModelForAuthSelection(opts cliproxyexecutor.Options, model string) (string, bool) {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
return "", false
|
|
}
|
|
selectionModel := authSelectionModelFromOptions(opts, model)
|
|
if selectionModel == model {
|
|
return "", false
|
|
}
|
|
return model, true
|
|
}
|
|
|
|
func withHomeAuthCount(opts cliproxyexecutor.Options, count int) cliproxyexecutor.Options {
|
|
if count <= 0 {
|
|
count = 1
|
|
}
|
|
meta := make(map[string]any, len(opts.Metadata)+1)
|
|
for k, v := range opts.Metadata {
|
|
meta[k] = v
|
|
}
|
|
meta[homeAuthCountMetadataKey] = count
|
|
opts.Metadata = meta
|
|
return opts
|
|
}
|
|
|
|
func withHomeRetryRound(opts cliproxyexecutor.Options, retryRound int) cliproxyexecutor.Options {
|
|
meta := make(map[string]any, len(opts.Metadata)+1)
|
|
for key, value := range opts.Metadata {
|
|
meta[key] = value
|
|
}
|
|
if retryRound > 0 {
|
|
meta[homeRetryRoundMetadataKey] = retryRound
|
|
} else {
|
|
delete(meta, homeRetryRoundMetadataKey)
|
|
}
|
|
opts.Metadata = meta
|
|
return opts
|
|
}
|
|
|
|
func withHomeExcludedAuthIDs(opts cliproxyexecutor.Options, tried map[string]struct{}) cliproxyexecutor.Options {
|
|
meta := make(map[string]any, len(opts.Metadata)+1)
|
|
for key, value := range opts.Metadata {
|
|
meta[key] = value
|
|
}
|
|
excluded := make(map[string]struct{})
|
|
for _, authID := range homeExcludedAuthIDsFromMetadata(meta) {
|
|
excluded[authID] = struct{}{}
|
|
}
|
|
for authID := range tried {
|
|
if authID = strings.TrimSpace(authID); authID != "" {
|
|
excluded[authID] = struct{}{}
|
|
}
|
|
}
|
|
if len(excluded) == 0 {
|
|
delete(meta, ExcludedAuthIDsMetadataKey)
|
|
} else {
|
|
ids := make([]string, 0, len(excluded))
|
|
for authID := range excluded {
|
|
ids = append(ids, authID)
|
|
}
|
|
sort.Strings(ids)
|
|
meta[ExcludedAuthIDsMetadataKey] = ids
|
|
}
|
|
opts.Metadata = meta
|
|
return opts
|
|
}
|
|
|
|
func homeAuthCountFromMetadata(meta map[string]any) int {
|
|
if len(meta) == 0 {
|
|
return 1
|
|
}
|
|
switch value := meta[homeAuthCountMetadataKey].(type) {
|
|
case int:
|
|
if value > 0 {
|
|
return value
|
|
}
|
|
case int64:
|
|
if value > 0 {
|
|
return int(value)
|
|
}
|
|
case float64:
|
|
if value > 0 {
|
|
return int(value)
|
|
}
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func homeExcludedAuthIDsFromMetadata(meta map[string]any) []string {
|
|
if len(meta) == 0 {
|
|
return nil
|
|
}
|
|
raw, ok := meta[ExcludedAuthIDsMetadataKey]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{})
|
|
ids := make([]string, 0)
|
|
appendID := func(value string) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return
|
|
}
|
|
if _, exists := seen[value]; exists {
|
|
return
|
|
}
|
|
seen[value] = struct{}{}
|
|
ids = append(ids, value)
|
|
}
|
|
switch values := raw.(type) {
|
|
case []string:
|
|
for _, value := range values {
|
|
appendID(value)
|
|
}
|
|
case []any:
|
|
for _, value := range values {
|
|
if text, okText := value.(string); okText {
|
|
appendID(text)
|
|
}
|
|
}
|
|
case map[string]struct{}:
|
|
for value := range values {
|
|
appendID(value)
|
|
}
|
|
case map[string]bool:
|
|
for value, enabled := range values {
|
|
if enabled {
|
|
appendID(value)
|
|
}
|
|
}
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
sort.Strings(ids)
|
|
return ids
|
|
}
|
|
|
|
func hasRequestedModelMetadata(meta map[string]any) bool {
|
|
if len(meta) == 0 {
|
|
return false
|
|
}
|
|
raw, ok := meta[cliproxyexecutor.RequestedModelMetadataKey]
|
|
if !ok || raw == nil {
|
|
return false
|
|
}
|
|
switch v := raw.(type) {
|
|
case string:
|
|
return strings.TrimSpace(v) != ""
|
|
case []byte:
|
|
return strings.TrimSpace(string(v)) != ""
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type requestAuthPrepareLock struct {
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// prepareHomeRequestAuth prepares a dispatch auth without reading or updating local auth state.
|
|
func (m *Manager) prepareHomeRequestAuth(ctx context.Context, executor ProviderExecutor, selection *HomeDispatchSelection) (*Auth, error) {
|
|
if selection == nil {
|
|
return nil, nil
|
|
}
|
|
return m.prepareHomeAuthSnapshot(ctx, executor, selection.CloneAuth())
|
|
}
|
|
|
|
func (m *Manager) prepareHomeAuthSnapshot(ctx context.Context, executor ProviderExecutor, auth *Auth) (*Auth, error) {
|
|
if m == nil || executor == nil || auth == nil {
|
|
return auth, nil
|
|
}
|
|
preparer, ok := executor.(RequestAuthPreparer)
|
|
if !ok || preparer == nil || !preparer.ShouldPrepareRequestAuth(auth) {
|
|
return auth, nil
|
|
}
|
|
|
|
prepare := func() (*Auth, error) {
|
|
target := auth.Clone()
|
|
if !preparer.ShouldPrepareRequestAuth(target) {
|
|
return target, nil
|
|
}
|
|
updated, errPrepare := preparer.PrepareRequestAuth(ctx, target)
|
|
if errPrepare != nil {
|
|
return auth, errPrepare
|
|
}
|
|
if updated == nil {
|
|
return target, nil
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
id := strings.TrimSpace(auth.ID)
|
|
if id == "" {
|
|
return prepare()
|
|
}
|
|
lockValue, _ := m.requestPrepareLocks.LoadOrStore(id, &requestAuthPrepareLock{})
|
|
lock, ok := lockValue.(*requestAuthPrepareLock)
|
|
if !ok || lock == nil {
|
|
return prepare()
|
|
}
|
|
lock.mu.Lock()
|
|
defer lock.mu.Unlock()
|
|
return prepare()
|
|
}
|
|
|
|
func (m *Manager) prepareRequestAuth(ctx context.Context, executor ProviderExecutor, auth *Auth) (*Auth, error) {
|
|
if m == nil || executor == nil || auth == nil {
|
|
return auth, nil
|
|
}
|
|
preparer, ok := executor.(RequestAuthPreparer)
|
|
if !ok || preparer == nil || !preparer.ShouldPrepareRequestAuth(auth) {
|
|
return auth, nil
|
|
}
|
|
|
|
id := strings.TrimSpace(auth.ID)
|
|
if id == "" {
|
|
return preparer.PrepareRequestAuth(ctx, auth.Clone())
|
|
}
|
|
|
|
lockValue, _ := m.requestPrepareLocks.LoadOrStore(id, &requestAuthPrepareLock{})
|
|
lock, ok := lockValue.(*requestAuthPrepareLock)
|
|
if !ok || lock == nil {
|
|
return preparer.PrepareRequestAuth(ctx, auth.Clone())
|
|
}
|
|
|
|
lock.mu.Lock()
|
|
defer lock.mu.Unlock()
|
|
|
|
target := auth.Clone()
|
|
m.mu.RLock()
|
|
if current := m.auths[id]; current != nil {
|
|
target = current.Clone()
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
if !preparer.ShouldPrepareRequestAuth(target) {
|
|
return target, nil
|
|
}
|
|
|
|
updated, errPrepare := preparer.PrepareRequestAuth(ctx, target)
|
|
if errPrepare != nil {
|
|
return auth, errPrepare
|
|
}
|
|
if updated == nil {
|
|
return target, nil
|
|
}
|
|
|
|
saved, errUpdate := m.Update(ctx, updated)
|
|
if errUpdate != nil {
|
|
return updated, errUpdate
|
|
}
|
|
if saved != nil {
|
|
return saved, nil
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
func contextWithRequestedModelAlias(ctx context.Context, opts cliproxyexecutor.Options, fallback string) context.Context {
|
|
alias := requestedModelAliasFromOptions(opts, fallback)
|
|
ctx = coreusage.WithRequestedModelAlias(ctx, alias)
|
|
effort := reasoningEffortFromOptions(opts)
|
|
if effort != "" {
|
|
ctx = coreusage.WithReasoningEffort(ctx, effort)
|
|
}
|
|
serviceTier := serviceTierFromOptions(opts)
|
|
if serviceTier != "" {
|
|
ctx = coreusage.WithServiceTier(ctx, serviceTier)
|
|
}
|
|
if generate, ok := generateFromOptions(opts); ok {
|
|
ctx = coreusage.WithGenerate(ctx, generate)
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
func requestedModelAliasFromOptions(opts cliproxyexecutor.Options, fallback string) string {
|
|
fallback = strings.TrimSpace(fallback)
|
|
if len(opts.Metadata) == 0 {
|
|
return fallback
|
|
}
|
|
raw, ok := opts.Metadata[cliproxyexecutor.RequestedModelMetadataKey]
|
|
if !ok || raw == nil {
|
|
return fallback
|
|
}
|
|
switch value := raw.(type) {
|
|
case string:
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return strings.TrimSpace(value)
|
|
case []byte:
|
|
if len(value) == 0 {
|
|
return fallback
|
|
}
|
|
return strings.TrimSpace(string(value))
|
|
default:
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
func reasoningEffortFromOptions(opts cliproxyexecutor.Options) string {
|
|
if len(opts.Metadata) == 0 {
|
|
return ""
|
|
}
|
|
raw, ok := opts.Metadata[cliproxyexecutor.ReasoningEffortMetadataKey]
|
|
if !ok || raw == nil {
|
|
return ""
|
|
}
|
|
switch value := raw.(type) {
|
|
case string:
|
|
return strings.TrimSpace(value)
|
|
case []byte:
|
|
return strings.TrimSpace(string(value))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func serviceTierFromOptions(opts cliproxyexecutor.Options) string {
|
|
return stringMetadataValue(opts.Metadata, cliproxyexecutor.ServiceTierMetadataKey)
|
|
}
|
|
|
|
func generateFromOptions(opts cliproxyexecutor.Options) (bool, bool) {
|
|
if len(opts.Metadata) == 0 {
|
|
return false, false
|
|
}
|
|
raw, ok := opts.Metadata[cliproxyexecutor.GenerateMetadataKey]
|
|
if !ok || raw == nil {
|
|
return false, false
|
|
}
|
|
switch value := raw.(type) {
|
|
case bool:
|
|
return value, true
|
|
default:
|
|
return false, false
|
|
}
|
|
}
|
|
|
|
func stringMetadataValue(metadata map[string]any, key string) string {
|
|
if len(metadata) == 0 {
|
|
return ""
|
|
}
|
|
raw, ok := metadata[key]
|
|
if !ok || raw == nil {
|
|
return ""
|
|
}
|
|
switch value := raw.(type) {
|
|
case string:
|
|
return strings.TrimSpace(value)
|
|
case []byte:
|
|
return strings.TrimSpace(string(value))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func pinnedAuthIDFromMetadata(meta map[string]any) string {
|
|
if len(meta) == 0 {
|
|
return ""
|
|
}
|
|
raw, ok := meta[cliproxyexecutor.PinnedAuthMetadataKey]
|
|
if !ok || raw == nil {
|
|
return ""
|
|
}
|
|
switch val := raw.(type) {
|
|
case string:
|
|
return strings.TrimSpace(val)
|
|
case []byte:
|
|
return strings.TrimSpace(string(val))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func disallowFreeAuthFromMetadata(meta map[string]any) bool {
|
|
if len(meta) == 0 {
|
|
return false
|
|
}
|
|
raw, ok := meta[cliproxyexecutor.DisallowFreeAuthMetadataKey]
|
|
if !ok || raw == nil {
|
|
return false
|
|
}
|
|
switch val := raw.(type) {
|
|
case bool:
|
|
return val
|
|
case string:
|
|
parsed, err := strconv.ParseBool(strings.TrimSpace(val))
|
|
return err == nil && parsed
|
|
case []byte:
|
|
parsed, err := strconv.ParseBool(strings.TrimSpace(string(val)))
|
|
return err == nil && parsed
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isFreeCodexAuth(auth *Auth) bool {
|
|
if auth == nil || auth.Attributes == nil {
|
|
return false
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
|
|
return false
|
|
}
|
|
return strings.EqualFold(strings.TrimSpace(auth.Attributes["plan_type"]), "free")
|
|
}
|
|
|
|
func publishSelectedAuthMetadata(meta map[string]any, auth *Auth) {
|
|
if len(meta) == 0 || auth == nil {
|
|
return
|
|
}
|
|
if authID := strings.TrimSpace(auth.ID); authID != "" {
|
|
meta[cliproxyexecutor.SelectedAuthMetadataKey] = authID
|
|
if callback, ok := meta[cliproxyexecutor.SelectedAuthCallbackMetadataKey].(func(string)); ok && callback != nil {
|
|
callback(authID)
|
|
}
|
|
}
|
|
if authIndex := strings.TrimSpace(auth.EnsureIndex()); authIndex != "" {
|
|
meta[cliproxyexecutor.SelectedAuthIndexMetadataKey] = authIndex
|
|
if callback, ok := meta[cliproxyexecutor.SelectedAuthIndexCallbackMetadataKey].(func(string)); ok && callback != nil {
|
|
callback(authIndex)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) executorFor(provider string) ProviderExecutor {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.executors[provider]
|
|
}
|
|
|
|
// roundTripperContextKey is an unexported context key type to avoid collisions.
|
|
type roundTripperContextKey struct{}
|
|
|
|
// roundTripperFor retrieves an HTTP RoundTripper for the given auth if a provider is registered.
|
|
func (m *Manager) roundTripperFor(auth *Auth) http.RoundTripper {
|
|
m.mu.RLock()
|
|
p := m.rtProvider
|
|
m.mu.RUnlock()
|
|
if p == nil || auth == nil {
|
|
return nil
|
|
}
|
|
return p.RoundTripperFor(auth)
|
|
}
|
|
|
|
// RoundTripperProvider defines a minimal provider of per-auth HTTP transports.
|
|
type RoundTripperProvider interface {
|
|
RoundTripperFor(auth *Auth) http.RoundTripper
|
|
}
|
|
|
|
// RequestPreparer is an optional interface that provider executors can implement
|
|
// to mutate outbound HTTP requests with provider credentials.
|
|
type RequestPreparer interface {
|
|
PrepareRequest(req *http.Request, auth *Auth) error
|
|
}
|
|
|
|
func executorKeyFromAuth(auth *Auth) string {
|
|
if auth == nil {
|
|
return ""
|
|
}
|
|
if auth.Attributes != nil {
|
|
providerKey := strings.TrimSpace(auth.Attributes["provider_key"])
|
|
compatName := strings.TrimSpace(auth.Attributes["compat_name"])
|
|
if compatName != "" {
|
|
if providerKey == "" {
|
|
providerKey = compatName
|
|
}
|
|
return util.OpenAICompatibleProviderKey(providerKey)
|
|
}
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(auth.Provider), "openai-compatibility") {
|
|
providerKey := strings.TrimSpace(auth.Label)
|
|
if providerKey == "" {
|
|
providerKey = "openai-compatibility"
|
|
}
|
|
return util.OpenAICompatibleProviderKey(providerKey)
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(auth.Provider))
|
|
}
|
|
|
|
// logEntryWithRequestID returns a logrus entry with request_id field if available in context.
|
|
func logEntryWithRequestID(ctx context.Context) *log.Entry {
|
|
if ctx == nil {
|
|
return log.NewEntry(log.StandardLogger())
|
|
}
|
|
if reqID := logging.GetRequestID(ctx); reqID != "" {
|
|
return log.WithField("request_id", reqID)
|
|
}
|
|
return log.NewEntry(log.StandardLogger())
|
|
}
|
|
|
|
func debugLogAuthSelection(entry *log.Entry, auth *Auth, provider string, model string) {
|
|
if !log.IsLevelEnabled(log.DebugLevel) {
|
|
return
|
|
}
|
|
if entry == nil || auth == nil {
|
|
return
|
|
}
|
|
accountType, accountInfo := auth.AccountInfo()
|
|
proxyInfo := auth.ProxyInfo()
|
|
suffix := ""
|
|
if proxyInfo != "" {
|
|
suffix = " " + proxyInfo
|
|
}
|
|
switch accountType {
|
|
case "api_key":
|
|
entry.Debugf("Use API key %s for model %s%s", util.HideAPIKey(accountInfo), model, suffix)
|
|
case "oauth":
|
|
ident := formatOauthIdentity(auth, provider, accountInfo)
|
|
entry.Debugf("Use OAuth %s for model %s%s", ident, model, suffix)
|
|
}
|
|
}
|
|
|
|
func formatOauthIdentity(auth *Auth, provider string, accountInfo string) string {
|
|
if auth == nil {
|
|
return ""
|
|
}
|
|
// Prefer the auth's provider when available.
|
|
providerName := strings.TrimSpace(auth.Provider)
|
|
if providerName == "" {
|
|
providerName = strings.TrimSpace(provider)
|
|
}
|
|
// Only log the basename to avoid leaking host paths.
|
|
// FileName may be unset for some auth backends; fall back to ID.
|
|
authFile := strings.TrimSpace(auth.FileName)
|
|
if authFile == "" {
|
|
authFile = strings.TrimSpace(auth.ID)
|
|
}
|
|
if authFile != "" {
|
|
authFile = filepath.Base(authFile)
|
|
}
|
|
parts := make([]string, 0, 3)
|
|
if providerName != "" {
|
|
parts = append(parts, "provider="+providerName)
|
|
}
|
|
if authFile != "" {
|
|
parts = append(parts, "auth_file="+authFile)
|
|
}
|
|
if len(parts) == 0 {
|
|
return accountInfo
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func formatAuthIdentity(auth *Auth, provider string) string {
|
|
if auth == nil {
|
|
return "auth=nil"
|
|
}
|
|
accountType, accountInfo := auth.AccountInfo()
|
|
switch accountType {
|
|
case "api_key":
|
|
return fmt.Sprintf("api_key=%s", util.HideAPIKey(accountInfo))
|
|
case "oauth":
|
|
return formatOauthIdentity(auth, provider, accountInfo)
|
|
default:
|
|
if auth.FileName != "" {
|
|
return fmt.Sprintf("auth_file=%s", filepath.Base(auth.FileName))
|
|
}
|
|
if auth.ID != "" {
|
|
return fmt.Sprintf("auth_id=%s", auth.ID)
|
|
}
|
|
if accountInfo != "" {
|
|
return accountInfo
|
|
}
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func summarizeErrorForLog(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
msg := strings.TrimSpace(err.Error())
|
|
const maxRunes = 300
|
|
runes := []rune(msg)
|
|
if len(runes) > maxRunes {
|
|
return string(runes[:maxRunes]) + "..."
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func warnLogUpstreamFailure(ctx context.Context, entry *log.Entry, provider, model string, auth *Auth, duration time.Duration, err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
if ctx != nil && errors.Is(ctx.Err(), context.Canceled) {
|
|
return
|
|
}
|
|
if errors.Is(err, context.Canceled) {
|
|
return
|
|
}
|
|
if isRequestInvalidError(err) {
|
|
return
|
|
}
|
|
if entry == nil {
|
|
if ctx != nil {
|
|
entry = logEntryWithRequestID(ctx)
|
|
} else {
|
|
entry = log.NewEntry(log.StandardLogger())
|
|
}
|
|
}
|
|
authIdent := formatAuthIdentity(auth, provider)
|
|
errSummary := summarizeErrorForLog(err)
|
|
entry.Warnf("upstream execution failed: provider=%s model=%s auth=%s duration=%s err=%s", provider, model, authIdent, duration.Round(time.Millisecond), errSummary)
|
|
}
|
|
|
|
// InjectCredentials delegates per-provider HTTP request preparation when supported.
|
|
// If the registered executor for the auth provider implements RequestPreparer,
|
|
// it will be invoked to modify the request (e.g., add headers).
|
|
func (m *Manager) InjectCredentials(req *http.Request, authID string) error {
|
|
if req == nil || authID == "" {
|
|
return nil
|
|
}
|
|
m.mu.RLock()
|
|
a := m.auths[authID]
|
|
var exec ProviderExecutor
|
|
if a != nil {
|
|
exec = m.executors[executorKeyFromAuth(a)]
|
|
}
|
|
m.mu.RUnlock()
|
|
if a == nil || exec == nil {
|
|
return nil
|
|
}
|
|
if p, ok := exec.(RequestPreparer); ok && p != nil {
|
|
return p.PrepareRequest(req, a)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PrepareHttpRequest injects provider credentials into the supplied HTTP request.
|
|
func (m *Manager) PrepareHttpRequest(ctx context.Context, auth *Auth, req *http.Request) error {
|
|
if m == nil {
|
|
return &Error{Code: "provider_not_found", Message: "manager is nil"}
|
|
}
|
|
if auth == nil {
|
|
return &Error{Code: "auth_not_found", Message: "auth is nil"}
|
|
}
|
|
if req == nil {
|
|
return &Error{Code: "invalid_request", Message: "http request is nil"}
|
|
}
|
|
if ctx != nil {
|
|
*req = *req.WithContext(ctx)
|
|
}
|
|
providerKey := executorKeyFromAuth(auth)
|
|
if providerKey == "" {
|
|
return &Error{Code: "provider_not_found", Message: "auth provider is empty"}
|
|
}
|
|
exec := m.executorFor(providerKey)
|
|
if exec == nil {
|
|
return &Error{Code: "provider_not_found", Message: "executor not registered for provider: " + providerKey}
|
|
}
|
|
preparer, ok := exec.(RequestPreparer)
|
|
if !ok || preparer == nil {
|
|
return &Error{Code: "not_supported", Message: "executor does not support http request preparation"}
|
|
}
|
|
return preparer.PrepareRequest(req, auth)
|
|
}
|
|
|
|
// NewHttpRequest constructs a new HTTP request and injects provider credentials into it.
|
|
func (m *Manager) NewHttpRequest(ctx context.Context, auth *Auth, method, targetURL string, body []byte, headers http.Header) (*http.Request, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
method = strings.TrimSpace(method)
|
|
if method == "" {
|
|
method = http.MethodGet
|
|
}
|
|
var reader io.Reader
|
|
if body != nil {
|
|
reader = bytes.NewReader(body)
|
|
}
|
|
httpReq, err := http.NewRequestWithContext(ctx, method, targetURL, reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if headers != nil {
|
|
httpReq.Header = headers.Clone()
|
|
}
|
|
if errPrepare := m.PrepareHttpRequest(ctx, auth, httpReq); errPrepare != nil {
|
|
return nil, errPrepare
|
|
}
|
|
return httpReq, nil
|
|
}
|
|
|
|
// HttpRequest injects provider credentials into the supplied HTTP request and executes it.
|
|
func (m *Manager) HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error) {
|
|
if m == nil {
|
|
return nil, &Error{Code: "provider_not_found", Message: "manager is nil"}
|
|
}
|
|
if auth == nil {
|
|
return nil, &Error{Code: "auth_not_found", Message: "auth is nil"}
|
|
}
|
|
if req == nil {
|
|
return nil, &Error{Code: "invalid_request", Message: "http request is nil"}
|
|
}
|
|
providerKey := executorKeyFromAuth(auth)
|
|
if providerKey == "" {
|
|
return nil, &Error{Code: "provider_not_found", Message: "auth provider is empty"}
|
|
}
|
|
exec := m.executorFor(providerKey)
|
|
if exec == nil {
|
|
return nil, &Error{Code: "provider_not_found", Message: "executor not registered for provider: " + providerKey}
|
|
}
|
|
return exec.HttpRequest(ctx, auth, req)
|
|
}
|