diff --git a/go.mod b/go.mod index c1172937c..6fa3a900c 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/gdamore/tcell/v2 v2.13.8 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/h2non/filetype v1.1.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 github.com/modelcontextprotocol/go-sdk v1.3.0 @@ -37,8 +38,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect - github.com/gdamore/tcell/v2 v2.13.8 // indirect - github.com/h2non/filetype v1.1.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/pkg/providers/enhanced_error.go b/pkg/providers/enhanced_error.go new file mode 100644 index 000000000..607a45e91 --- /dev/null +++ b/pkg/providers/enhanced_error.go @@ -0,0 +1,184 @@ +package providers + +import ( + "fmt" + "time" +) + +// RequestContext holds contextual information about an API request +type RequestContext struct { + RequestID string // Unique identifier for the request + Timestamp time.Time // Time of the request + Model string // Model name used in this call + Provider string // Provider name used in this call + UserID string // Associated user (if available) + SessionID string // Session identifier (if available) + ToolCallID string // Tool call ID if this is part of a tool call + Endpoint string // API endpoint that was called + Correlation string // Additional correlation identifier +} + +// EnhancedError wraps an error with additional contextual information +type EnhancedError struct { + WrappedErr error // Original error + Context *RequestContext // Contextual information + Message string // Custom message for the enhanced error + EventType string // Type of event that caused the error (e.g. "api_request", "rate_limit", etc.) +} + +func NewEnhancedError(wrappedErr error, context *RequestContext, message string, eventType string) *EnhancedError { + return &EnhancedError{ + WrappedErr: wrappedErr, + Context: context, + Message: message, + EventType: eventType, + } +} + +func (e *EnhancedError) Error() string { + if e.Context != nil { + return fmt.Sprintf("error: %s [event=%s, req_id=%s, provider=%s, model=%s, timestamp=%s]: %v", + e.Message, e.EventType, e.Context.RequestID, e.Context.Provider, e.Context.Model, e.Context.Timestamp.Format(time.RFC3339), e.WrappedErr) + } + return fmt.Sprintf("error: %s [event=%s]: %v", e.Message, e.EventType, e.WrappedErr) +} + +func (e *EnhancedError) Unwrap() error { + return e.WrappedErr +} + +// FormatDetailedError formats the error with extensive detail including all context +func (e *EnhancedError) FormatDetailedError() string { + if e.Context == nil { + return e.Error() + } + + return fmt.Sprintf(`Enhanced Error Details: + Error Message: %s + Event Type: %s + Request ID: %s + Model: %s + Provider: %s + Endpoint: %s + Timestamp: %s + User ID: %s + Session ID: %s + Tool Call ID: %s + Correlation ID: %s + Wrapped Error: %v`, + e.Message, + e.EventType, + e.Context.RequestID, + e.Context.Model, + e.Context.Provider, + e.Context.Endpoint, + e.Context.Timestamp.Format(time.RFC3339), + e.Context.UserID, + e.Context.SessionID, + e.Context.ToolCallID, + e.Context.Correlation, + e.WrappedErr, + ) +} + +// ExtendedFailoverError enhances the original FailoverError with additional context and metadata +type ExtendedFailoverError struct { + Reason FailoverReason + Provider string + Model string + Status int + Wrapped error + RequestID string + Timestamp time.Time + Correlation string + EventType string + Metadata map[string]interface{} // Arbitrary additional metadata + Message string +} + +func NewExtendedFailoverError(reason FailoverReason, provider, model string, status int, wrapped error) *ExtendedFailoverError { + return &ExtendedFailoverError{ + Reason: reason, + Provider: provider, + Model: model, + Status: status, + Wrapped: wrapped, + RequestID: "", // Will be set by caller + Timestamp: time.Now(), + Correlation: "", // Will be set by caller + EventType: "provider_api_failure", + Metadata: make(map[string]interface{}), + Message: "", // Optional custom message + } +} + +func (e *ExtendedFailoverError) Error() string { + baseMsg := fmt.Sprintf("extended_failover(%s): provider=%s model=%s status=%d", e.Reason, e.Provider, e.Model, e.Status) + if e.RequestID != "" { + baseMsg += fmt.Sprintf(" request_id=%s", e.RequestID) + } + if e.Correlation != "" { + baseMsg += fmt.Sprintf(" correlation=%s", e.Correlation) + } + if e.Message != "" { + baseMsg += fmt.Sprintf(" message='%s'", e.Message) + } + baseMsg += fmt.Sprintf(" timestamp=%s", e.Timestamp.Format(time.RFC3339)) + + // Include error details from wrapped error if present + if e.Wrapped != nil { + baseMsg += fmt.Sprintf(": %v", e.Wrapped) + } + + return baseMsg +} + +func (e *ExtendedFailoverError) Unwrap() error { + return e.Wrapped +} + +func (e *ExtendedFailoverError) WithRequestID(id string) *ExtendedFailoverError { + e.RequestID = id + return e +} + +func (e *ExtendedFailoverError) WithTimestamp(ts time.Time) *ExtendedFailoverError { + e.Timestamp = ts + return e +} + +func (e *ExtendedFailoverError) WithCorrelationID(id string) *ExtendedFailoverError { + e.Correlation = id + return e +} + +func (e *ExtendedFailoverError) WithEventType(eventType string) *ExtendedFailoverError { + e.EventType = eventType + return e +} + +func (e *ExtendedFailoverError) WithMetadata(key string, value interface{}) *ExtendedFailoverError { + if e.Metadata == nil { + e.Metadata = make(map[string]interface{}) + } + e.Metadata[key] = value + return e +} + +func (e *ExtendedFailoverError) WithMessage(message string) *ExtendedFailoverError { + e.Message = message + return e +} + +// IsRetriable returns true if this error should trigger fallback to next candidate. +// Non-retriable: Format errors (bad request structure, image dimension/size). +func (e *ExtendedFailoverError) IsRetriable() bool { + return e.Reason != FailoverFormat +} + +// Convert a regular FailoverError to an ExtendedFailoverError +func (e *FailoverError) AsExtendedError() *ExtendedFailoverError { + extended := NewExtendedFailoverError(e.Reason, e.Provider, e.Model, e.Status, e.Wrapped) + extended.Timestamp = time.Now() + return extended +} diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index fd9bf1e81..2793da3ac 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -3,7 +3,14 @@ package providers import ( "context" "regexp" + + "crypto/rand" + "math/big" + "strings" + + "time" + ) // Common patterns in Go HTTP error messages @@ -113,14 +120,14 @@ func ClassifyError(err error, provider, model string) *FailoverError { return nil } - // Context deadline exceeded: treat as timeout, always fallback. if err == context.DeadlineExceeded { - return &FailoverError{ - Reason: FailoverTimeout, - Provider: provider, - Model: model, - Wrapped: err, - } + reqID, _ := generateRequestID() + return (&FailoverError{ + Reason: FailoverTimeout, + Provider: provider, + Model: model, + Wrapped: err, + }).SetTimestamp(time.Now()).WithRequestID(reqID) } msg := strings.ToLower(err.Error()) @@ -251,3 +258,18 @@ func parseDigits(s string) int { } return n } + +// generateRequestID creates a unique request ID for tracking API calls +func generateRequestID() (string, error) { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + const length = 16 + b := make([]byte, length) + for i := range b { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + b[i] = charset[n.Int64()] + } + return "req_" + string(b), nil +} diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index 7ba563b66..99e3111e2 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -159,20 +159,33 @@ func (fc *FallbackChain) Execute( return nil, context.Canceled } - // Classify the error. - failErr := ClassifyError(err, candidate.Provider, candidate.Model) + // Classify the error after context check + failErr := ClassifyError(err, candidate.Provider, candidate.Model) + if failErr == nil { + // Generate request ID for tracking + reqID, genErr := generateRequestID() + errWithReqID := &FailoverError{ + Reason: FailoverUnknown, // Will treat as unclassifiable as before + Provider: candidate.Provider, + Model: candidate.Model, + Wrapped: err, + Timestamp: time.Now(), + } + if genErr == nil && reqID != "" { + errWithReqID = errWithReqID.WithRequestID(reqID) + } + + // Unclassifiable error: do not fallback, return immediately. + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Error: errWithReqID, + Duration: elapsed, + }) + return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w", + candidate.Provider, candidate.Model, errWithReqID) + } - if failErr == nil { - // Unclassifiable error: do not fallback, return immediately. - result.Attempts = append(result.Attempts, FallbackAttempt{ - Provider: candidate.Provider, - Model: candidate.Model, - Error: err, - Duration: elapsed, - }) - return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w", - candidate.Provider, candidate.Model, err) - } // Non-retriable error: abort immediately. if !failErr.IsRetriable() { @@ -250,22 +263,31 @@ func (fc *FallbackChain) ExecuteImage( // Image dimension/size errors are non-retriable. errMsg := strings.ToLower(err.Error()) - if IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) { - result.Attempts = append(result.Attempts, FallbackAttempt{ - Provider: candidate.Provider, - Model: candidate.Model, - Error: err, - Reason: FailoverFormat, - Duration: elapsed, - }) - return nil, &FailoverError{ - Reason: FailoverFormat, - Provider: candidate.Provider, - Model: candidate.Model, - Wrapped: err, - } - } + if IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) { + reqID, _ := generateRequestID() + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Error: err, + Reason: FailoverFormat, + Duration: elapsed, + }) + imageDimFailErr := &FailoverError{ + Reason: FailoverFormat, + Provider: candidate.Provider, + Model: candidate.Model, + Wrapped: err, + Timestamp: time.Now(), + } + if reqID != "" { + imageDimFailErr = imageDimFailErr.WithRequestID(reqID) + } + + return nil, imageDimFailErr + + + } // Any other error: record and try next. result.Attempts = append(result.Attempts, FallbackAttempt{ Provider: candidate.Provider, diff --git a/pkg/providers/types.go b/pkg/providers/types.go index f0c168bc6..52b5f9626 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -3,6 +3,7 @@ package providers import ( "context" "fmt" + "time" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -52,14 +53,21 @@ const ( // FailoverError wraps an LLM provider error with classification metadata. type FailoverError struct { - Reason FailoverReason - Provider string - Model string - Status int - Wrapped error + Reason FailoverReason + Provider string + Model string + Status int + Wrapped error + Timestamp time.Time // Timestamp of when the error occurred + RequestID string // Request ID for tracking + Correlation string // Correlation ID } func (e *FailoverError) Error() string { + if e.RequestID != "" { + return fmt.Sprintf("failover(%s): provider=%s model=%s status=%d req_id=%s correlation=%s timestamp=%s: %v", + e.Reason, e.Provider, e.Model, e.Status, e.RequestID, e.Correlation, e.Timestamp.Format(time.RFC3339), e.Wrapped) + } return fmt.Sprintf("failover(%s): provider=%s model=%s status=%d: %v", e.Reason, e.Provider, e.Model, e.Status, e.Wrapped) } @@ -74,6 +82,24 @@ func (e *FailoverError) IsRetriable() bool { return e.Reason != FailoverFormat } +// SetTimestamp stores when the error occurred +func (e *FailoverError) SetTimestamp(t time.Time) *FailoverError { + e.Timestamp = t + return e +} + +// WithRequestID sets the request ID for this error +func (e *FailoverError) WithRequestID(id string) *FailoverError { + e.RequestID = id + return e +} + +// WithCorrelation sets the correlation identifier for this error +func (e *FailoverError) WithCorrelation(correlation string) *FailoverError { + e.Correlation = correlation + return e +} + // ModelConfig holds primary model and fallback list. type ModelConfig struct { Primary string