422 lines
12 KiB
Go
422 lines
12 KiB
Go
package state
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"math/big"
|
|
"net/textproto"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
SettingInjectUsageOptions = "inject_usage_options"
|
|
SettingValidationInterval = "validation_interval"
|
|
SettingRequestLogRetentionDays = "request_log_retention_days"
|
|
SettingModelsDevAutoSyncEnabled = "models_dev_auto_sync_enabled"
|
|
)
|
|
|
|
const (
|
|
defaultRequestLogRetentionDays = 7
|
|
minRequestLogRetentionDays = 1
|
|
maxRequestLogRetentionDays = 365
|
|
)
|
|
|
|
type RuntimeSettings struct {
|
|
FirstByteTimeout time.Duration
|
|
RequestTimeout time.Duration
|
|
StreamIdleTimeout time.Duration
|
|
HeaderRules HeaderRules
|
|
InjectUsageOptions bool
|
|
ValidationInterval time.Duration
|
|
RequestLogRetentionDays int
|
|
ModelsDevAutoSyncEnabled bool
|
|
}
|
|
|
|
type ResolvedGroupSettings struct {
|
|
Timeouts TimeoutConfig
|
|
HeaderRules HeaderRules
|
|
InjectUsageOptions bool
|
|
}
|
|
|
|
func DefaultRuntimeSettings() RuntimeSettings {
|
|
return RuntimeSettings{
|
|
FirstByteTimeout: 120 * time.Second,
|
|
RequestTimeout: 600 * time.Second,
|
|
StreamIdleTimeout: 300 * time.Second,
|
|
HeaderRules: HeaderRules{Set: map[string]string{}},
|
|
InjectUsageOptions: true,
|
|
ValidationInterval: 10 * time.Minute,
|
|
RequestLogRetentionDays: defaultRequestLogRetentionDays,
|
|
ModelsDevAutoSyncEnabled: true,
|
|
}
|
|
}
|
|
|
|
func IsRuntimeSettingKey(key string) bool {
|
|
switch key {
|
|
case SettingFirstByteTimeout,
|
|
SettingRequestTimeout,
|
|
SettingStreamIdleTimeout,
|
|
SettingHeaderRules,
|
|
SettingInjectUsageOptions,
|
|
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 SettingInjectUsageOptions:
|
|
value, err := strictBoolean(key, value)
|
|
if err != nil {
|
|
return RuntimeSettings{}, err
|
|
}
|
|
resolved.InjectUsageOptions = value
|
|
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),
|
|
InjectUsageOptions: base.InjectUsageOptions,
|
|
}
|
|
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 SettingInjectUsageOptions:
|
|
parsed, err := strictBoolean(key, value)
|
|
if err != nil {
|
|
return ResolvedGroupSettings{}, err
|
|
}
|
|
resolved.InjectUsageOptions = 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 SettingInjectUsageOptions:
|
|
_, err := strictBoolean(key, value)
|
|
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 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 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) {
|
|
rules := HeaderRules{Set: make(map[string]string)}
|
|
object, ok := value.(map[string]any)
|
|
if !ok {
|
|
return HeaderRules{}, fmt.Errorf("header_rules must be an object")
|
|
}
|
|
for key := range object {
|
|
if key != "set" && key != "remove" {
|
|
return HeaderRules{}, fmt.Errorf("unknown header_rules field %q", key)
|
|
}
|
|
}
|
|
seen := make(map[string]struct{})
|
|
if rawSet, exists := object["set"]; exists {
|
|
set, ok := rawSet.(map[string]any)
|
|
if !ok {
|
|
return HeaderRules{}, fmt.Errorf("header_rules.set must be an object")
|
|
}
|
|
for name, rawValue := range set {
|
|
if !validHTTPHeaderName(name) {
|
|
return HeaderRules{}, fmt.Errorf("header_rules.set contains invalid header name %q", name)
|
|
}
|
|
canonicalName := textproto.CanonicalMIMEHeaderKey(name)
|
|
if httpheader.IsForbiddenRequestRuleName(canonicalName) ||
|
|
httpheader.IsCredentialName(canonicalName) {
|
|
return HeaderRules{}, fmt.Errorf(
|
|
"header_rules.set cannot set forbidden header %q",
|
|
canonicalName,
|
|
)
|
|
}
|
|
identity := strings.ToLower(name)
|
|
if _, duplicate := seen[identity]; duplicate {
|
|
return HeaderRules{}, fmt.Errorf(
|
|
"header_rules.set contains duplicate header %q",
|
|
canonicalName,
|
|
)
|
|
}
|
|
seen[identity] = struct{}{}
|
|
text, ok := rawValue.(string)
|
|
if !ok {
|
|
return HeaderRules{}, fmt.Errorf("header_rules.set.%s must be a string", name)
|
|
}
|
|
if !validHTTPHeaderValue(text) {
|
|
return HeaderRules{}, fmt.Errorf("header_rules.set.%s contains invalid header value", name)
|
|
}
|
|
rules.Set[canonicalName] = text
|
|
}
|
|
}
|
|
if rawRemove, exists := object["remove"]; exists {
|
|
remove, ok := rawRemove.([]any)
|
|
if !ok {
|
|
return HeaderRules{}, fmt.Errorf("header_rules.remove must be an array")
|
|
}
|
|
rules.Remove = make([]string, 0, len(remove))
|
|
for index, rawName := range remove {
|
|
name, ok := rawName.(string)
|
|
if !ok {
|
|
return HeaderRules{}, fmt.Errorf("header_rules.remove[%d] must be a string", index)
|
|
}
|
|
if !validHTTPHeaderName(name) {
|
|
return HeaderRules{}, fmt.Errorf(
|
|
"header_rules.remove[%d] contains invalid header name %q",
|
|
index,
|
|
name,
|
|
)
|
|
}
|
|
canonicalName := textproto.CanonicalMIMEHeaderKey(name)
|
|
if httpheader.IsForbiddenRequestRuleName(canonicalName) ||
|
|
httpheader.IsCredentialName(canonicalName) {
|
|
return HeaderRules{}, fmt.Errorf(
|
|
"header_rules.remove cannot remove forbidden header %q",
|
|
canonicalName,
|
|
)
|
|
}
|
|
identity := strings.ToLower(name)
|
|
if _, duplicate := seen[identity]; duplicate {
|
|
return HeaderRules{}, fmt.Errorf(
|
|
"header_rules.remove contains duplicate header %q",
|
|
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
|
|
}
|