322 lines
9.6 KiB
Go
322 lines
9.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry"
|
|
)
|
|
|
|
const (
|
|
maxHomeConcurrencyTupleFieldLength = 256
|
|
asciiWhitespace = " \t\r\n\v\f"
|
|
)
|
|
|
|
var ErrMalformedHomeConcurrencyTuple = errors.New("malformed Home concurrency tuple")
|
|
|
|
// HomeConcurrencyBusyError is a trusted, Home-originated concurrency admission failure.
|
|
type HomeConcurrencyBusyError struct {
|
|
cause *Error
|
|
retryAfter time.Duration
|
|
}
|
|
|
|
// NewHomeConcurrencyBusyError creates a typed Home concurrency busy error.
|
|
func NewHomeConcurrencyBusyError(message string, retryAfter time.Duration) error {
|
|
message = strings.TrimSpace(message)
|
|
if message == "" {
|
|
message = "credential concurrency limit exceeded"
|
|
}
|
|
return newHomeConcurrencyBusyError(&Error{
|
|
Code: "credential_concurrency_exceeded",
|
|
Message: message,
|
|
Retryable: true,
|
|
HTTPStatus: http.StatusTooManyRequests,
|
|
}, retryAfter)
|
|
}
|
|
|
|
func newHomeConcurrencyBusyError(cause *Error, retryAfter time.Duration) *HomeConcurrencyBusyError {
|
|
return &HomeConcurrencyBusyError{cause: cause, retryAfter: retryAfter}
|
|
}
|
|
|
|
func (e *HomeConcurrencyBusyError) Error() string {
|
|
if e == nil || e.cause == nil {
|
|
return ""
|
|
}
|
|
return e.cause.Error()
|
|
}
|
|
|
|
// Unwrap preserves the Home error's code, retryability, and status for errors.As callers.
|
|
func (e *HomeConcurrencyBusyError) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.cause
|
|
}
|
|
|
|
func (e *HomeConcurrencyBusyError) StatusCode() int {
|
|
if e == nil || e.cause == nil {
|
|
return 0
|
|
}
|
|
return e.cause.StatusCode()
|
|
}
|
|
|
|
func (e *HomeConcurrencyBusyError) RetryAfter() *time.Duration {
|
|
if e == nil || e.retryAfter <= 0 {
|
|
return nil
|
|
}
|
|
value := e.retryAfter
|
|
return &value
|
|
}
|
|
|
|
func (e *HomeConcurrencyBusyError) SafeResponseHeaders() http.Header {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return safeRetryAfterHeader(e.retryAfter)
|
|
}
|
|
|
|
type homeConcurrencyTuple struct {
|
|
Accounted bool `json:"accounted"`
|
|
CredentialID string `json:"credential_id"`
|
|
Model string `json:"model"`
|
|
}
|
|
|
|
func validateAccountedHomeConcurrencyTuple(tuple homeConcurrencyTuple) error {
|
|
model, validModel := validCanonicalHomeConcurrencyModelKey(tuple.Model)
|
|
if !tuple.Accounted || !validHomeConcurrencyTupleField(tuple.CredentialID) || !validModel || tuple.Model != model {
|
|
return ErrMalformedHomeConcurrencyTuple
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// canonicalHomeConcurrencyModelKey removes recognized reasoning suffixes from a Home limiter model key.
|
|
func canonicalHomeConcurrencyModelKey(model string) string {
|
|
if !utf8.ValidString(model) {
|
|
return ""
|
|
}
|
|
trimmed := strings.ToLower(strings.Trim(model, asciiWhitespace))
|
|
if !strings.HasSuffix(trimmed, ")") {
|
|
return trimmed
|
|
}
|
|
open := strings.LastIndexByte(trimmed, '(')
|
|
if open < 0 {
|
|
return trimmed
|
|
}
|
|
suffix := trimmed[open+1 : len(trimmed)-1]
|
|
if !recognizedHomeConcurrencySuffix(suffix) {
|
|
return trimmed
|
|
}
|
|
base := strings.Trim(trimmed[:open], asciiWhitespace)
|
|
if base == "" {
|
|
return trimmed
|
|
}
|
|
return base
|
|
}
|
|
|
|
func validCanonicalHomeConcurrencyModelKey(model string) (string, bool) {
|
|
key := canonicalHomeConcurrencyModelKey(model)
|
|
return key, key != "" && utf8.ValidString(key) && len(key) <= maxHomeConcurrencyTupleFieldLength
|
|
}
|
|
|
|
func recognizedHomeConcurrencySuffix(value string) bool {
|
|
if value == "-1" {
|
|
return true
|
|
}
|
|
switch strings.ToLower(value) {
|
|
case "none", "auto", "minimal", "low", "medium", "high", "xhigh", "max":
|
|
return true
|
|
}
|
|
if value == "" || len(value) > 10 {
|
|
return false
|
|
}
|
|
var parsed int64
|
|
for index := 0; index < len(value); index++ {
|
|
if value[index] < '0' || value[index] > '9' {
|
|
return false
|
|
}
|
|
parsed = parsed*10 + int64(value[index]-'0')
|
|
if parsed > 2_147_483_647 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validHomeConcurrencyTupleField(value string) bool {
|
|
return value != "" && utf8.ValidString(value) && strings.TrimSpace(value) == value && len(value) <= maxHomeConcurrencyTupleFieldLength
|
|
}
|
|
|
|
func installHomeConcurrencyScope(registry *executionregistry.Registry, pending *executionregistry.PendingDispatch, tuple homeConcurrencyTuple, base executionregistry.ScopeSpec) (*executionregistry.Scope, error) {
|
|
if registry == nil || pending == nil {
|
|
return nil, executionregistry.ErrInvalidPendingDispatch
|
|
}
|
|
if !tuple.Accounted {
|
|
base.Accounted = false
|
|
return registry.Install(pending, base)
|
|
}
|
|
if errValidate := validateAccountedHomeConcurrencyTuple(tuple); errValidate != nil {
|
|
return nil, errValidate
|
|
}
|
|
|
|
base.CredentialID = tuple.CredentialID
|
|
base.Model = tuple.Model
|
|
base.Accounted = true
|
|
return registry.Install(pending, base)
|
|
}
|
|
|
|
type homeDispatchConcurrencyEnvelope struct {
|
|
Tuple homeConcurrencyTuple
|
|
Present bool
|
|
}
|
|
|
|
func decodeHomeDispatchConcurrencyEnvelope(raw []byte) (homeDispatchConcurrencyEnvelope, error) {
|
|
if !utf8.Valid(raw) {
|
|
return homeDispatchConcurrencyEnvelope{}, errors.New("Home response is not valid UTF-8")
|
|
}
|
|
|
|
var fields map[string]json.RawMessage
|
|
if errUnmarshal := json.Unmarshal(raw, &fields); errUnmarshal != nil || fields == nil {
|
|
return homeDispatchConcurrencyEnvelope{}, errors.New("Home response is not a JSON object")
|
|
}
|
|
|
|
envelope := homeDispatchConcurrencyEnvelope{}
|
|
rawTuple, present := fields["concurrency"]
|
|
if !present {
|
|
return envelope, nil
|
|
}
|
|
envelope.Present = true
|
|
if errUnmarshal := json.Unmarshal(rawTuple, &envelope.Tuple); errUnmarshal != nil {
|
|
return envelope, errUnmarshal
|
|
}
|
|
if errValidate := validateAccountedHomeConcurrencyTuple(envelope.Tuple); errValidate != nil {
|
|
return envelope, errValidate
|
|
}
|
|
return envelope, nil
|
|
}
|
|
|
|
func canonicalHomeDispatchModel(responseModel, requestedModel string) string {
|
|
if model := strings.TrimSpace(responseModel); model != "" {
|
|
return model
|
|
}
|
|
return requestedModel
|
|
}
|
|
|
|
func decodeHomeDispatchError(raw []byte) error {
|
|
var fields map[string]json.RawMessage
|
|
if errUnmarshal := json.Unmarshal(raw, &fields); errUnmarshal != nil || fields == nil {
|
|
return nil
|
|
}
|
|
rawError, present := fields["error"]
|
|
if !present {
|
|
return nil
|
|
}
|
|
|
|
var detail *homeErrorDetail
|
|
if errUnmarshal := json.Unmarshal(rawError, &detail); errUnmarshal != nil || detail == nil {
|
|
return &Error{Code: "invalid_auth", Message: "home returned malformed error payload", HTTPStatus: http.StatusBadGateway}
|
|
}
|
|
code := strings.TrimSpace(detail.Type)
|
|
if code == "" {
|
|
code = strings.TrimSpace(detail.Code)
|
|
}
|
|
if code == "" {
|
|
return &Error{Code: "invalid_auth", Message: "home returned malformed error payload", HTTPStatus: http.StatusBadGateway}
|
|
}
|
|
message := strings.TrimSpace(detail.Message)
|
|
if message == "" {
|
|
message = "home returned error"
|
|
}
|
|
|
|
result := &Error{Code: code, Message: message, Retryable: detail.Retryable, HTTPStatus: http.StatusBadGateway}
|
|
switch strings.ToLower(code) {
|
|
case "model_not_found":
|
|
result.HTTPStatus = http.StatusNotFound
|
|
case "model_cooldown":
|
|
result.HTTPStatus = http.StatusTooManyRequests
|
|
cooldownErr := &homeDispatchRetryAfterError{cause: result}
|
|
if detail.RetryAfterMS > 0 {
|
|
cooldownErr.retryAfter = time.Duration(detail.RetryAfterMS) * time.Millisecond
|
|
}
|
|
if detail.RequestRetry != nil && *detail.RequestRetry >= 0 {
|
|
cooldownErr.requestRetry = *detail.RequestRetry
|
|
cooldownErr.hasRequestRetry = true
|
|
}
|
|
return cooldownErr
|
|
case "authentication_error", "unauthorized", "no_credentials", "invalid_credential":
|
|
result.HTTPStatus = http.StatusUnauthorized
|
|
case "credential_concurrency_exceeded", "credential_model_concurrency_exceeded":
|
|
result.HTTPStatus = http.StatusTooManyRequests
|
|
return newHomeConcurrencyBusyError(result, time.Duration(detail.RetryAfterMS)*time.Millisecond)
|
|
case "auth_not_found", "auth_unavailable", "refresh_temporarily_unavailable", "home_unavailable",
|
|
"concurrency_protocol_required", "concurrency_tracker_unavailable", "concurrency_node_unavailable":
|
|
result.HTTPStatus = http.StatusServiceUnavailable
|
|
}
|
|
return result
|
|
}
|
|
|
|
func invalidHomeConcurrencyResponse(message string) error {
|
|
return &Error{Code: "invalid_home_concurrency", Message: message, HTTPStatus: http.StatusBadGateway}
|
|
}
|
|
|
|
func verifyAccountedHomeConcurrencyIdentity(tuple homeConcurrencyTuple, auth *Auth, authIndex string) error {
|
|
if !tuple.Accounted {
|
|
return nil
|
|
}
|
|
if auth == nil || auth.ID != tuple.CredentialID || authIndex != tuple.CredentialID {
|
|
return invalidHomeConcurrencyResponse("Home concurrency identity does not match dispatched auth")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SafeResponseHeaders returns trusted response headers only for concrete
|
|
// Home-generated retry errors.
|
|
func SafeResponseHeaders(err error) http.Header {
|
|
var busy *HomeConcurrencyBusyError
|
|
if errors.As(err, &busy) && busy != nil {
|
|
return busy.SafeResponseHeaders()
|
|
}
|
|
var exhausted *homeRetryRoundExhaustedError
|
|
if errors.As(err, &exhausted) && exhausted != nil {
|
|
retryAfter := exhausted.RetryAfter()
|
|
if retryAfter == nil {
|
|
return nil
|
|
}
|
|
return safeRetryAfterHeader(*retryAfter)
|
|
}
|
|
var cooldown *homeDispatchRetryAfterError
|
|
if !errors.As(err, &cooldown) || cooldown == nil {
|
|
return nil
|
|
}
|
|
retryAfter := cooldown.RetryAfter()
|
|
if retryAfter == nil {
|
|
return nil
|
|
}
|
|
return safeRetryAfterHeader(*retryAfter)
|
|
}
|
|
|
|
func safeRetryAfterHeader(retryAfter time.Duration) http.Header {
|
|
if retryAfter <= 0 {
|
|
return nil
|
|
}
|
|
seconds := int64(retryAfter / time.Second)
|
|
if retryAfter%time.Second != 0 {
|
|
seconds++
|
|
}
|
|
if seconds < 1 {
|
|
seconds = 1
|
|
}
|
|
return http.Header{"Retry-After": []string{strconv.FormatInt(seconds, 10)}}
|
|
}
|
|
|
|
func homeConcurrencyInstallError(err error) error {
|
|
if errors.Is(err, ErrMalformedHomeConcurrencyTuple) {
|
|
return invalidHomeConcurrencyResponse(err.Error())
|
|
}
|
|
return &Error{Code: "home_unavailable", Message: fmt.Sprintf("home execution registry unavailable: %v", err), Retryable: true, HTTPStatus: http.StatusServiceUnavailable}
|
|
}
|