Files
tbphp f091528bdb feat(gateway): 接入原生 Responses WebSocket 与逐轮治理 (#616)
* feat(gateway): 接入原生 Responses WebSocket 与逐轮治理

* feat(gateway): 增加 WS 分层开关并修复连接治理

* fix(gateway): 关闭停用或删除分组的 WS 连接
2026-09-10 21:18:41 +08:00

631 lines
19 KiB
Go

package state
import (
"encoding/json"
"fmt"
"math"
"math/big"
"net/textproto"
"strings"
"time"
"gpt-load/internal/parameteroverride"
"gpt-load/internal/platform/config"
"gpt-load/internal/platform/httpheader"
)
const (
SettingFirstByteTimeout = "first_byte_timeout"
SettingRequestTimeout = "request_timeout"
SettingStreamIdleTimeout = "stream_idle_timeout"
SettingHeaderRules = "header_rules"
SettingCORS = "cors"
SettingResponseHeaderRules = "response_header_rules"
SettingRetryCount = "retry_count"
SettingRouteStrategy = "route_strategy"
SettingBlacklistThreshold = "blacklist_threshold"
SettingAffinityEnabled = "affinity_enabled"
SettingResponsesWebsocketEnabled = "responses_websocket_enabled"
SettingAffinityTTL = "affinity_ttl"
SettingAffinityCapacity = "affinity_capacity"
SettingValidationInterval = "validation_interval"
SettingRequestLogRetentionDays = "request_log_retention_days"
SettingModelsDevAutoSyncEnabled = "models_dev_auto_sync_enabled"
SettingParameterOverrides = "parameter_overrides"
)
type RouteStrategy string
const (
RouteStrategyNativeFirst RouteStrategy = "native_first"
RouteStrategyWeightedMix RouteStrategy = "weighted_mix"
)
const (
defaultRequestLogRetentionDays = 7
minRequestLogRetentionDays = 1
maxRequestLogRetentionDays = 365
defaultAffinityCapacity = 10_000
maxAffinityCapacity = 1_000_000
maxJSONSafeInteger = int64(1<<53 - 1)
)
type RuntimeSettings struct {
FirstByteTimeout time.Duration
RequestTimeout time.Duration
StreamIdleTimeout time.Duration
HeaderRules HeaderRules
CORS CORSConfig
ResponseHeaderRules HeaderRules
RetryCount int
RouteStrategy RouteStrategy
BlacklistThreshold int
AffinityEnabled bool
ResponsesWebsocketEnabled bool
AffinityTTL time.Duration
AffinityCapacity int
ValidationInterval time.Duration
RequestLogRetentionDays int
ModelsDevAutoSyncEnabled bool
}
type ResolvedGroupSettings struct {
Timeouts TimeoutConfig
HeaderRules HeaderRules
BlacklistThreshold int
AffinityEnabled bool
ResponsesWebsocketEnabled bool
ParameterOverrides parameteroverride.Rules
}
func DefaultRuntimeSettings() RuntimeSettings {
return RuntimeSettings{
FirstByteTimeout: 120 * time.Second,
RequestTimeout: 600 * time.Second,
StreamIdleTimeout: 300 * time.Second,
HeaderRules: HeaderRules{Set: map[string]string{}},
CORS: defaultCORSConfig(),
ResponseHeaderRules: HeaderRules{Set: map[string]string{}},
RetryCount: 2,
RouteStrategy: RouteStrategyNativeFirst,
BlacklistThreshold: 3,
AffinityEnabled: true,
ResponsesWebsocketEnabled: true,
AffinityTTL: time.Hour,
AffinityCapacity: defaultAffinityCapacity,
ValidationInterval: 10 * time.Minute,
RequestLogRetentionDays: defaultRequestLogRetentionDays,
ModelsDevAutoSyncEnabled: true,
}
}
func IsRuntimeSettingKey(key string) bool {
switch key {
case SettingFirstByteTimeout,
SettingRequestTimeout,
SettingStreamIdleTimeout,
SettingHeaderRules,
SettingCORS,
SettingResponseHeaderRules,
SettingRetryCount,
SettingRouteStrategy,
SettingBlacklistThreshold,
SettingAffinityEnabled,
SettingResponsesWebsocketEnabled,
SettingAffinityTTL,
SettingAffinityCapacity,
SettingValidationInterval,
SettingRequestLogRetentionDays,
SettingModelsDevAutoSyncEnabled:
return true
default:
return false
}
}
func ResolveRuntimeSettings(settings config.Settings) (RuntimeSettings, error) {
resolved := DefaultRuntimeSettings()
for key, value := range settings {
switch key {
case SettingFirstByteTimeout:
seconds, err := positiveWholeSeconds(key, value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.FirstByteTimeout = time.Duration(seconds) * time.Second
case SettingRequestTimeout:
seconds, err := positiveWholeSeconds(key, value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.RequestTimeout = time.Duration(seconds) * time.Second
case SettingStreamIdleTimeout:
seconds, err := positiveWholeSeconds(key, value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.StreamIdleTimeout = time.Duration(seconds) * time.Second
case SettingHeaderRules:
rules, err := parseHeaderRules(value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.HeaderRules = rules
case SettingCORS:
cors, err := parseCORSConfig(value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.CORS = cors
case SettingResponseHeaderRules:
rules, err := parseResponseHeaderRules(value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.ResponseHeaderRules = rules
case SettingRetryCount:
count, err := nonNegativeWholeNumber(key, value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.RetryCount = count
case SettingRouteStrategy:
strategy, err := parseRouteStrategy(value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.RouteStrategy = strategy
case SettingBlacklistThreshold:
threshold, err := nonNegativeWholeNumber(key, value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.BlacklistThreshold = threshold
case SettingAffinityEnabled:
value, err := strictBoolean(key, value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.AffinityEnabled = value
case SettingResponsesWebsocketEnabled:
value, err := strictBoolean(key, value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.ResponsesWebsocketEnabled = value
case SettingAffinityTTL:
seconds, err := positiveWholeSeconds(key, value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.AffinityTTL = time.Duration(seconds) * time.Second
case SettingAffinityCapacity:
capacity, err := wholeNumberInRange(key, value, 1, maxAffinityCapacity)
if err != nil {
return RuntimeSettings{}, err
}
resolved.AffinityCapacity = capacity
case SettingValidationInterval:
seconds, err := positiveWholeSeconds(key, value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.ValidationInterval = time.Duration(seconds) * time.Second
case SettingRequestLogRetentionDays:
days, err := wholeNumberInRange(
key,
value,
minRequestLogRetentionDays,
maxRequestLogRetentionDays,
)
if err != nil {
return RuntimeSettings{}, err
}
resolved.RequestLogRetentionDays = days
case SettingModelsDevAutoSyncEnabled:
value, err := strictBoolean(key, value)
if err != nil {
return RuntimeSettings{}, err
}
resolved.ModelsDevAutoSyncEnabled = value
default:
return RuntimeSettings{}, fmt.Errorf("unknown runtime setting %q", key)
}
}
return resolved, nil
}
func ResolveGroupRuntimeSettings(
base RuntimeSettings,
settings config.Settings,
) (ResolvedGroupSettings, error) {
resolved := ResolvedGroupSettings{
Timeouts: TimeoutConfig{
FirstByte: base.FirstByteTimeout,
Request: base.RequestTimeout,
StreamIdle: base.StreamIdleTimeout,
},
HeaderRules: cloneHeaderRules(base.HeaderRules),
BlacklistThreshold: base.BlacklistThreshold,
AffinityEnabled: base.AffinityEnabled,
ResponsesWebsocketEnabled: base.ResponsesWebsocketEnabled,
}
for key, value := range settings {
switch key {
case SettingFirstByteTimeout:
seconds, err := positiveWholeSeconds(key, value)
if err != nil {
return ResolvedGroupSettings{}, err
}
resolved.Timeouts.FirstByte = time.Duration(seconds) * time.Second
case SettingRequestTimeout:
seconds, err := positiveWholeSeconds(key, value)
if err != nil {
return ResolvedGroupSettings{}, err
}
resolved.Timeouts.Request = time.Duration(seconds) * time.Second
case SettingStreamIdleTimeout:
seconds, err := positiveWholeSeconds(key, value)
if err != nil {
return ResolvedGroupSettings{}, err
}
resolved.Timeouts.StreamIdle = time.Duration(seconds) * time.Second
case SettingHeaderRules:
parsed, err := parseHeaderRules(value)
if err != nil {
return ResolvedGroupSettings{}, err
}
resolved.HeaderRules = parsed
case SettingRetryCount:
// 兼容读取历史分组配置;重试预算仅由系统设置决定。
continue
case SettingBlacklistThreshold:
parsed, err := nonNegativeWholeNumber(key, value)
if err != nil {
return ResolvedGroupSettings{}, err
}
resolved.BlacklistThreshold = parsed
case SettingAffinityEnabled:
parsed, err := strictBoolean(key, value)
if err != nil {
return ResolvedGroupSettings{}, err
}
resolved.AffinityEnabled = parsed
case SettingResponsesWebsocketEnabled:
parsed, err := strictBoolean(key, value)
if err != nil {
return ResolvedGroupSettings{}, err
}
resolved.ResponsesWebsocketEnabled = parsed
case SettingParameterOverrides:
parsed, err := parameteroverride.Compile(value)
if err != nil {
return ResolvedGroupSettings{}, err
}
resolved.ParameterOverrides = parsed
default:
return ResolvedGroupSettings{}, fmt.Errorf("unknown group setting %q", key)
}
}
return resolved, nil
}
func ValidateRuntimeSetting(key string, value any) error {
switch key {
case SettingFirstByteTimeout,
SettingRequestTimeout,
SettingStreamIdleTimeout,
SettingValidationInterval:
_, err := positiveWholeSeconds(key, value)
return err
case SettingHeaderRules:
_, err := parseHeaderRules(value)
return err
case SettingCORS:
_, err := parseCORSConfig(value)
return err
case SettingResponseHeaderRules:
_, err := parseResponseHeaderRules(value)
return err
case SettingRetryCount, SettingBlacklistThreshold:
_, err := nonNegativeWholeNumber(key, value)
return err
case SettingRouteStrategy:
_, err := parseRouteStrategy(value)
return err
case SettingAffinityEnabled, SettingResponsesWebsocketEnabled:
_, err := strictBoolean(key, value)
return err
case SettingAffinityTTL:
_, err := positiveWholeSeconds(key, value)
return err
case SettingAffinityCapacity:
_, err := wholeNumberInRange(key, value, 1, maxAffinityCapacity)
return err
case SettingRequestLogRetentionDays:
_, err := wholeNumberInRange(
key,
value,
minRequestLogRetentionDays,
maxRequestLogRetentionDays,
)
return err
case SettingModelsDevAutoSyncEnabled:
_, err := strictBoolean(key, value)
return err
default:
return fmt.Errorf("unknown runtime setting %q", key)
}
}
func parseRouteStrategy(value any) (RouteStrategy, error) {
text, ok := value.(string)
if ok {
switch strategy := RouteStrategy(text); strategy {
case RouteStrategyNativeFirst, RouteStrategyWeightedMix:
return strategy, nil
}
}
return "", fmt.Errorf("%s must be native_first or weighted_mix", SettingRouteStrategy)
}
func strictBoolean(path string, value any) (bool, error) {
parsed, ok := value.(bool)
if !ok {
return false, fmt.Errorf("%s must be a boolean", path)
}
return parsed, nil
}
func nonNegativeWholeNumber(path string, value any) (int, error) {
var number *big.Int
switch typed := value.(type) {
case int:
number = big.NewInt(int64(typed))
case int64:
number = big.NewInt(typed)
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) {
return 0, fmt.Errorf("%s must be a non-negative whole number", path)
}
parsed := new(big.Rat).SetFloat64(typed)
if parsed == nil || !parsed.IsInt() {
return 0, fmt.Errorf("%s must be a non-negative whole number", path)
}
number = parsed.Num()
case json.Number:
literal := typed.String()
parsed, ok := new(big.Rat).SetString(literal)
if !json.Valid([]byte(literal)) || !ok || !parsed.IsInt() {
return 0, fmt.Errorf("%s must be a non-negative whole number", path)
}
number = parsed.Num()
default:
return 0, fmt.Errorf("%s must be a non-negative whole number", path)
}
// Settings are exposed as JSON numbers and consumed by the management UI.
// Keep the technical boundary lossless across Go and JavaScript; there is
// no smaller product-level limit.
maximum := big.NewInt(maxJSONSafeInteger)
if number.Sign() < 0 || number.Cmp(maximum) > 0 {
return 0, fmt.Errorf("%s must be a non-negative whole number within JSON safe integer range", path)
}
return int(number.Int64()), nil
}
func wholeNumberInRange(path string, value any, minimum, maximum int) (int, error) {
var number int64
switch typed := value.(type) {
case int:
number = int64(typed)
case int64:
number = typed
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed ||
typed < float64(minimum) || typed > float64(maximum) {
return 0, fmt.Errorf("%s must be a whole number between %d and %d", path, minimum, maximum)
}
number = int64(typed)
case json.Number:
literal := typed.String()
parsed, ok := new(big.Rat).SetString(literal)
if !json.Valid([]byte(literal)) || !ok || !parsed.IsInt() || !parsed.Num().IsInt64() {
return 0, fmt.Errorf("%s must be a whole number between %d and %d", path, minimum, maximum)
}
number = parsed.Num().Int64()
default:
return 0, fmt.Errorf("%s must be a whole number between %d and %d", path, minimum, maximum)
}
if number < int64(minimum) || number > int64(maximum) {
return 0, fmt.Errorf("%s must be a whole number between %d and %d", path, minimum, maximum)
}
return int(number), nil
}
func positiveWholeSeconds(path string, value any) (int64, error) {
const maxTimeoutSeconds = int64((1<<63)-1) / int64(time.Second)
var seconds int64
switch typed := value.(type) {
case int:
seconds = int64(typed)
case int64:
seconds = typed
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > float64(maxTimeoutSeconds) {
return 0, fmt.Errorf("%s must be a positive whole number", path)
}
seconds = int64(typed)
case json.Number:
literal := typed.String()
parsed, ok := new(big.Rat).SetString(literal)
if !json.Valid([]byte(literal)) || !ok || !parsed.IsInt() || !parsed.Num().IsInt64() {
return 0, fmt.Errorf("%s must be a positive whole number", path)
}
seconds = parsed.Num().Int64()
default:
return 0, fmt.Errorf("%s must be a positive whole number", path)
}
if seconds <= 0 || seconds > maxTimeoutSeconds {
return 0, fmt.Errorf("%s must be a positive whole number within duration range", path)
}
return seconds, nil
}
func parseHeaderRules(value any) (HeaderRules, error) {
return parseHeaderRulesWithPolicy(
value,
SettingHeaderRules,
func(name string) bool {
return httpheader.IsForbiddenRequestRuleName(name) ||
httpheader.IsCredentialName(name)
},
)
}
func parseResponseHeaderRules(value any) (HeaderRules, error) {
return parseHeaderRulesWithPolicy(
value,
SettingResponseHeaderRules,
httpheader.IsForbiddenResponseRuleName,
)
}
func parseHeaderRulesWithPolicy(
value any,
settingName string,
forbidden func(string) bool,
) (HeaderRules, error) {
rules := HeaderRules{Set: make(map[string]string)}
object, ok := value.(map[string]any)
if !ok {
return HeaderRules{}, fmt.Errorf("%s must be an object", settingName)
}
for key := range object {
if key != "set" && key != "remove" {
return HeaderRules{}, fmt.Errorf("unknown %s field %q", settingName, key)
}
}
seen := make(map[string]struct{})
if rawSet, exists := object["set"]; exists {
set, ok := rawSet.(map[string]any)
if !ok {
return HeaderRules{}, fmt.Errorf("%s.set must be an object", settingName)
}
for name, rawValue := range set {
if !validHTTPHeaderName(name) {
return HeaderRules{}, fmt.Errorf("%s.set contains invalid header name %q", settingName, name)
}
canonicalName := textproto.CanonicalMIMEHeaderKey(name)
if forbidden != nil && forbidden(canonicalName) {
return HeaderRules{}, fmt.Errorf(
"%s.set cannot set forbidden header %q",
settingName,
canonicalName,
)
}
identity := strings.ToLower(name)
if _, duplicate := seen[identity]; duplicate {
return HeaderRules{}, fmt.Errorf(
"%s.set contains duplicate header %q",
settingName,
canonicalName,
)
}
seen[identity] = struct{}{}
text, ok := rawValue.(string)
if !ok {
return HeaderRules{}, fmt.Errorf("%s.set.%s must be a string", settingName, name)
}
if !validHTTPHeaderValue(text) {
return HeaderRules{}, fmt.Errorf("%s.set.%s contains invalid header value", settingName, name)
}
rules.Set[canonicalName] = text
}
}
if rawRemove, exists := object["remove"]; exists {
remove, ok := rawRemove.([]any)
if !ok {
return HeaderRules{}, fmt.Errorf("%s.remove must be an array", settingName)
}
rules.Remove = make([]string, 0, len(remove))
for index, rawName := range remove {
name, ok := rawName.(string)
if !ok {
return HeaderRules{}, fmt.Errorf("%s.remove[%d] must be a string", settingName, index)
}
if !validHTTPHeaderName(name) {
return HeaderRules{}, fmt.Errorf(
"%s.remove[%d] contains invalid header name %q",
settingName,
index,
name,
)
}
canonicalName := textproto.CanonicalMIMEHeaderKey(name)
if forbidden != nil && forbidden(canonicalName) {
return HeaderRules{}, fmt.Errorf(
"%s.remove cannot remove forbidden header %q",
settingName,
canonicalName,
)
}
identity := strings.ToLower(name)
if _, duplicate := seen[identity]; duplicate {
return HeaderRules{}, fmt.Errorf(
"%s.remove contains duplicate header %q",
settingName,
canonicalName,
)
}
seen[identity] = struct{}{}
rules.Remove = append(rules.Remove, canonicalName)
}
}
return rules, nil
}
func cloneHeaderRules(source HeaderRules) HeaderRules {
cloned := HeaderRules{Set: make(map[string]string, len(source.Set))}
for name, value := range source.Set {
cloned.Set[name] = value
}
cloned.Remove = append([]string(nil), source.Remove...)
return cloned
}
func validHTTPHeaderName(name string) bool {
if name == "" {
return false
}
for index := range len(name) {
if !isHTTPTokenByte(name[index]) {
return false
}
}
return true
}
func isHTTPTokenByte(value byte) bool {
switch {
case value >= '0' && value <= '9':
return true
case value >= 'a' && value <= 'z':
return true
case value >= 'A' && value <= 'Z':
return true
default:
return strings.IndexByte("!#$%&'*+-.^_`|~", value) >= 0
}
}
func validHTTPHeaderValue(value string) bool {
for index := range len(value) {
character := value[index]
if (character < ' ' && character != '\t') || character == 0x7f {
return false
}
}
return true
}