* feat(responses): 实现响应状态续接路由 * fix(responses): 限制响应 ID 长度并兼容缺失字段删除 * fix(responses): 按协议存储能力匹配续接路由
425 lines
13 KiB
Go
425 lines
13 KiB
Go
package execution
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"gpt-load/internal/protocol"
|
|
)
|
|
|
|
// Validate validates the credential identity and secret data.
|
|
func (c CredentialSnapshot) Validate() error {
|
|
if c.ID == 0 {
|
|
return validationError("credential.id", "must be greater than zero")
|
|
}
|
|
if c.Version == 0 {
|
|
return validationError("credential.version", "must be greater than zero")
|
|
}
|
|
if c.IdentityGeneration == 0 {
|
|
return validationError("credential.identity_generation", "must be greater than zero")
|
|
}
|
|
if len(c.data) == 0 {
|
|
return validationError("credential.data", "must not be empty")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate validates the timeout values.
|
|
func (t AttemptTimeouts) Validate() error {
|
|
if t.FirstByte < 0 {
|
|
return validationError("timeouts.first_byte", "must not be negative")
|
|
}
|
|
if t.Request < 0 {
|
|
return validationError("timeouts.request", "must not be negative")
|
|
}
|
|
if t.StreamIdle < 0 {
|
|
return validationError("timeouts.stream_idle", "must not be negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate validates the complete attempt specification.
|
|
func (s AttemptSpec) Validate() error {
|
|
if strings.TrimSpace(s.RequestID) == "" {
|
|
return validationError("request_id", "must not be empty")
|
|
}
|
|
if strings.TrimSpace(s.AttemptID) == "" {
|
|
return validationError("attempt_id", "must not be empty")
|
|
}
|
|
if s.Sequence == 0 {
|
|
return validationError("sequence", "must be greater than zero")
|
|
}
|
|
if strings.TrimSpace(s.ChannelID) == "" {
|
|
return validationError("channel_id", "must not be empty")
|
|
}
|
|
if !s.RouteMode.Valid() {
|
|
return validationError("route_mode", "unsupported value")
|
|
}
|
|
if !s.ClientProtocol.Valid() {
|
|
return validationError("client_protocol", "unsupported value")
|
|
}
|
|
if !s.Operation.Valid() {
|
|
return validationError("operation", "unsupported value")
|
|
}
|
|
if !s.RouteRequirement.Valid() {
|
|
return validationError("route_requirement", "unsupported value")
|
|
}
|
|
if !s.ResponsesStorePreference.Valid() {
|
|
return validationError("responses_store_preference", "unsupported value")
|
|
}
|
|
if s.ResponsesStorePreference == ResponsesStorePreferenceRequireStored &&
|
|
(s.ClientProtocol != protocol.OpenAIResponses || s.Operation != OperationResponsesCreate ||
|
|
s.RouteRequirement.Normalize() != RouteRequirementNative) {
|
|
return validationError("responses_store_preference", "requires a native Responses Create request")
|
|
}
|
|
if s.ResponsesStoreDowngraded &&
|
|
(s.ClientProtocol != protocol.OpenAIResponses ||
|
|
s.Operation != OperationResponsesCreate ||
|
|
s.RouteRequirement.Normalize() != RouteRequirementAny) {
|
|
return validationError("responses_store_downgraded", "requires a Responses Create stateless route")
|
|
}
|
|
if operationRequiresModel(s.Operation) {
|
|
if strings.TrimSpace(s.ClientModel) == "" {
|
|
return validationError("client_model", "must not be empty for this operation")
|
|
}
|
|
if strings.TrimSpace(s.UpstreamModel) == "" {
|
|
return validationError("upstream_model", "must not be empty for this operation")
|
|
}
|
|
}
|
|
if s.Operation == OperationProbe {
|
|
if s.Method != "" || s.Path != "" || len(s.Query) != 0 || s.RawQuery != "" || len(s.Body) != 0 {
|
|
return validationError("probe", "must not contain provider wire fields")
|
|
}
|
|
} else {
|
|
if !validHTTPToken(s.Method) {
|
|
return validationError("method", "must be a valid HTTP method")
|
|
}
|
|
if err := validateRequestPath(s.Path); err != nil {
|
|
return validationError("path", "must be an absolute request path without query or fragment")
|
|
}
|
|
if s.RawQuery != "" {
|
|
if len(s.Query) != 0 {
|
|
return validationError("raw_query", "must not be combined with query")
|
|
}
|
|
if strings.Contains(s.RawQuery, "#") || containsControl(s.RawQuery) {
|
|
return validationError("raw_query", "contains a fragment or control characters")
|
|
}
|
|
}
|
|
if err := validateValues(s.Query); err != nil {
|
|
return validationError("query", "contains invalid control characters")
|
|
}
|
|
}
|
|
if err := validateHeader(s.Header); err != nil {
|
|
return validationError("header", "contains an invalid name or value")
|
|
}
|
|
if len(s.TargetConfig) > 0 && !json.Valid(s.TargetConfig) {
|
|
return validationError("target_config", "must contain valid JSON")
|
|
}
|
|
if err := s.Timeouts.Validate(); err != nil {
|
|
return err
|
|
}
|
|
return s.Credential.Validate()
|
|
}
|
|
|
|
// Validate validates error evidence.
|
|
func (e ErrorEvidence) Validate() error {
|
|
if !e.Kind.Valid() {
|
|
return validationError("error.kind", "unsupported value")
|
|
}
|
|
if !e.Hint.Valid() {
|
|
return validationError("error.hint", "unsupported value")
|
|
}
|
|
if !e.OriginHint.Valid() {
|
|
return validationError("error.origin_hint", "unsupported value")
|
|
}
|
|
if !e.ScopeHint.Valid() {
|
|
return validationError("error.scope_hint", "unsupported value")
|
|
}
|
|
if !e.ReplaySafety.Valid() {
|
|
return validationError("error.replay_safety", "unsupported value")
|
|
}
|
|
if !validOptionalHTTPStatus(e.StatusCode) {
|
|
return validationError("error.status_code", "must be zero or a valid HTTP status")
|
|
}
|
|
if strings.TrimSpace(e.Summary) == "" {
|
|
return validationError("error.summary", "must not be empty")
|
|
}
|
|
if utf8.RuneCountInString(e.Summary) > MaxErrorSummaryLength {
|
|
return validationError("error.summary", "exceeds maximum length")
|
|
}
|
|
if containsControl(e.Summary) {
|
|
return validationError("error.summary", "must be a single safe line")
|
|
}
|
|
if containsControl(e.Type) {
|
|
return validationError("error.type", "contains control characters")
|
|
}
|
|
if containsControl(e.Code) {
|
|
return validationError("error.code", "contains control characters")
|
|
}
|
|
if containsControl(e.RequestID) {
|
|
return validationError("error.request_id", "contains control characters")
|
|
}
|
|
if e.RetryAfter < 0 {
|
|
return validationError("error.retry_after", "must not be negative")
|
|
}
|
|
if err := validateSafeEvidenceHeader(e.Header); err != nil {
|
|
return validationError("error.header", "contains a non-safe header")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate validates a terminal attempt result.
|
|
func (r AttemptResult) Validate() error {
|
|
if r.UpstreamProtocol != "" && !r.UpstreamProtocol.Valid() {
|
|
return validationError("upstream_protocol", "unsupported value")
|
|
}
|
|
if err := validateResultMetadata(
|
|
r.DispatchState,
|
|
r.ResponseStarted,
|
|
r.StatusCode,
|
|
r.Header,
|
|
r.UpstreamRequestID,
|
|
r.Error,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if r.DispatchState == DispatchNotSent && len(r.Body) > 0 {
|
|
return validationError("body", "must be empty when dispatch_state is not_sent")
|
|
}
|
|
if !r.ResponseStarted && len(r.Body) > 0 {
|
|
return validationError("body", "requires response_started")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate validates a stream event.
|
|
func (e StreamEvent) Validate() error {
|
|
if !e.Kind.Valid() {
|
|
return validationError("stream_event.kind", "unsupported value")
|
|
}
|
|
if e.Sequence == 0 {
|
|
return validationError("stream_event.sequence", "must be greater than zero")
|
|
}
|
|
switch e.Kind {
|
|
case StreamEventReady:
|
|
if e.Sequence != 1 {
|
|
return validationError("stream_event.sequence", "ready event must be first")
|
|
}
|
|
if !validHTTPStatus(e.StatusCode) {
|
|
return validationError("stream_event.status_code", "ready event requires a valid HTTP status")
|
|
}
|
|
if e.Header == nil {
|
|
return validationError("stream_event.header", "ready event requires initial headers")
|
|
}
|
|
if err := validateHeader(e.Header); err != nil {
|
|
return validationError("stream_event.header", "contains an invalid name or value")
|
|
}
|
|
if len(e.Data) > 0 || e.Usage != nil {
|
|
return validationError("stream_event.ready", "must not contain data or usage")
|
|
}
|
|
case StreamEventData:
|
|
if e.Sequence == 1 {
|
|
return validationError("stream_event.sequence", "data must follow a ready event")
|
|
}
|
|
if len(e.Data) == 0 {
|
|
return validationError("stream_event.data", "must not be empty")
|
|
}
|
|
if e.Usage != nil || hasReadyMetadata(e) {
|
|
return validationError("stream_event.data", "must not contain ready metadata or usage")
|
|
}
|
|
case StreamEventUsage:
|
|
if e.Sequence == 1 {
|
|
return validationError("stream_event.sequence", "usage must follow a ready event")
|
|
}
|
|
if e.Usage == nil {
|
|
return validationError("stream_event.usage", "must not be empty")
|
|
}
|
|
if len(e.Data) > 0 || hasReadyMetadata(e) {
|
|
return validationError("stream_event.usage", "must not contain ready metadata or data")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate validates a terminal streaming result.
|
|
func (r StreamResult) Validate() error {
|
|
if r.UpstreamProtocol != "" && !r.UpstreamProtocol.Valid() {
|
|
return validationError("upstream_protocol", "unsupported value")
|
|
}
|
|
if err := validateResultMetadata(
|
|
r.DispatchState,
|
|
r.ResponseStarted,
|
|
r.StatusCode,
|
|
r.Header,
|
|
r.UpstreamRequestID,
|
|
r.Error,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateResultMetadata(
|
|
dispatchState DispatchState,
|
|
responseStarted bool,
|
|
statusCode int,
|
|
header http.Header,
|
|
upstreamRequestID string,
|
|
errorEvidence *ErrorEvidence,
|
|
) error {
|
|
if !dispatchState.Valid() {
|
|
return validationError("dispatch_state", "unsupported value")
|
|
}
|
|
if statusCode > 0 && !responseStarted {
|
|
return validationError("response_started", "must be true when status_code is present")
|
|
}
|
|
if !responseStarted && (len(header) > 0 || upstreamRequestID != "") {
|
|
return validationError("response_started", "must be true when upstream response metadata is present")
|
|
}
|
|
if responseStarted && !validHTTPStatus(statusCode) {
|
|
return validationError("status_code", "response_started requires a valid HTTP status")
|
|
}
|
|
if dispatchState == DispatchNotSent {
|
|
if responseStarted || statusCode != 0 || len(header) > 0 || upstreamRequestID != "" {
|
|
return validationError("dispatch_state", "not_sent cannot contain upstream response metadata")
|
|
}
|
|
}
|
|
if responseStarted && dispatchState != DispatchMaybeSent && dispatchState != DispatchLocal {
|
|
return validationError("dispatch_state", "response_started requires maybe_sent or local")
|
|
}
|
|
if err := validateHeader(header); err != nil {
|
|
return validationError("header", "contains an invalid name or value")
|
|
}
|
|
if containsControl(upstreamRequestID) {
|
|
return validationError("upstream_request_id", "contains control characters")
|
|
}
|
|
if errorEvidence == nil {
|
|
if !responseStarted || statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
|
|
return validationError("error", "is required unless a started response has a 2xx status")
|
|
}
|
|
return nil
|
|
}
|
|
if err := errorEvidence.Validate(); err != nil {
|
|
return err
|
|
}
|
|
if errorEvidence.StatusCode > 0 && !responseStarted {
|
|
return validationError("error.status_code", "requires response_started")
|
|
}
|
|
if responseStarted && errorEvidence.StatusCode != 0 && errorEvidence.StatusCode != statusCode {
|
|
return validationError("error.status_code", "must match result status_code")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func operationRequiresModel(operation Operation) bool {
|
|
return operation == OperationChatCompletion ||
|
|
operation == OperationResponsesCreate ||
|
|
operation == OperationResponsesCompact ||
|
|
operation == OperationResponsesInputTokens ||
|
|
operation == OperationCountTokens ||
|
|
operation == OperationImagesGenerate ||
|
|
operation == OperationImagesEdit ||
|
|
operation == OperationEmbeddingsCreate ||
|
|
operation == OperationRerank ||
|
|
operation == OperationProbe
|
|
}
|
|
|
|
func validateRequestPath(path string) error {
|
|
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
|
|
return validationError("path", "invalid prefix")
|
|
}
|
|
parsed, err := url.ParseRequestURI(path)
|
|
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
|
|
return validationError("path", "invalid request path")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateValues(values url.Values) error {
|
|
for key, items := range values {
|
|
if key == "" || containsControl(key) {
|
|
return validationError("query", "invalid key")
|
|
}
|
|
for _, item := range items {
|
|
if containsControl(item) {
|
|
return validationError("query", "invalid value")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateHeader(header http.Header) error {
|
|
for name, values := range header {
|
|
if !validHTTPToken(name) {
|
|
return validationError("header", "invalid name")
|
|
}
|
|
for _, value := range values {
|
|
if strings.ContainsAny(value, "\r\n\x00") {
|
|
return validationError("header", "invalid value")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSafeEvidenceHeader(header http.Header) error {
|
|
if err := validateHeader(header); err != nil {
|
|
return err
|
|
}
|
|
for name := range header {
|
|
canonical := http.CanonicalHeaderKey(name)
|
|
if canonical == "Retry-After" ||
|
|
canonical == "Request-Id" ||
|
|
canonical == "X-Request-Id" ||
|
|
canonical == "Traceparent" ||
|
|
strings.HasPrefix(canonical, "Ratelimit-") ||
|
|
strings.HasPrefix(canonical, "X-Ratelimit-") {
|
|
continue
|
|
}
|
|
return validationError("header", "not allowlisted")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validOptionalHTTPStatus(statusCode int) bool {
|
|
return statusCode == 0 || validHTTPStatus(statusCode)
|
|
}
|
|
|
|
func validHTTPStatus(statusCode int) bool {
|
|
return statusCode >= 100 && statusCode <= 599
|
|
}
|
|
|
|
func validHTTPToken(value string) bool {
|
|
if value == "" {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if r <= 31 || r >= 127 || strings.ContainsRune("()<>@,;:\\\"/[]?={} \t", r) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func containsControl(value string) bool {
|
|
for _, r := range value {
|
|
if unicode.IsControl(r) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasReadyMetadata(event StreamEvent) bool {
|
|
return event.StatusCode != 0 || event.Header != nil || event.UpstreamRequestID != ""
|
|
}
|
|
|
|
func validationError(field, reason string) *ValidationError {
|
|
return &ValidationError{Field: field, Reason: reason}
|
|
}
|