Files

230 lines
7.2 KiB
Go

package providers
import (
"errors"
"net"
"strings"
)
// FailoverReason categorizes provider errors for failover decisions.
type FailoverReason string
const (
FailoverAuth FailoverReason = "auth"
FailoverAuthPermanent FailoverReason = "auth_permanent"
FailoverFormat FailoverReason = "format"
FailoverRateLimit FailoverReason = "rate_limit"
FailoverOverloaded FailoverReason = "overloaded"
FailoverServerError FailoverReason = "server_error"
FailoverBilling FailoverReason = "billing"
FailoverTimeout FailoverReason = "timeout"
FailoverModelNotFound FailoverReason = "model_not_found"
FailoverContentPolicy FailoverReason = "content_policy"
FailoverUnknown FailoverReason = "unknown"
)
// FailoverClassification is the result of classifying a provider error.
type FailoverClassification struct {
Kind string // "reason" or "context_overflow"
Reason FailoverReason // only when Kind == "reason"
}
// Convenience constructors
func classifyReason(r FailoverReason) FailoverClassification {
return FailoverClassification{Kind: "reason", Reason: r}
}
func classifyContextOverflow() FailoverClassification {
return FailoverClassification{Kind: "context_overflow"}
}
// ErrorClassifier classifies provider errors for failover routing.
type ErrorClassifier interface {
Classify(err error, statusCode int, body string) FailoverClassification
}
// DefaultClassifier handles common HTTP status + body pattern matching.
type DefaultClassifier struct {
providerPatterns map[string][]ErrorPattern
}
// ErrorPattern maps a body substring pattern to a FailoverReason.
type ErrorPattern struct {
Contains string
Reason FailoverReason
}
// NewDefaultClassifier returns a classifier with built-in patterns
// for OpenAI and Anthropic providers pre-registered.
func NewDefaultClassifier() *DefaultClassifier {
c := &DefaultClassifier{
providerPatterns: make(map[string][]ErrorPattern),
}
RegisterOpenAIPatterns(c)
RegisterAnthropicPatterns(c)
return c
}
// RegisterPatterns adds provider-specific error body patterns.
// Must be called during init only (not thread-safe for concurrent writes).
func (c *DefaultClassifier) RegisterPatterns(provider string, patterns []ErrorPattern) {
c.providerPatterns[provider] = append(c.providerPatterns[provider], patterns...)
}
// Classify determines the failover reason for an error.
func (c *DefaultClassifier) Classify(err error, statusCode int, body string) FailoverClassification {
lower := strings.ToLower(body)
// Context overflow — not a failover reason, triggers compaction
if isContextOverflow(lower) {
return classifyContextOverflow()
}
// HTTP status-based classification
switch {
case statusCode == 429:
return classifyReason(FailoverRateLimit)
case statusCode == 402:
return classifyReason(FailoverBilling)
case statusCode == 401 || statusCode == 403:
if containsAny(lower, "revoked", "deleted", "deactivated", "disabled", "expired") {
return classifyReason(FailoverAuthPermanent)
}
return classifyReason(FailoverAuth)
case statusCode == 404:
if containsAny(lower, "model", "not found", "does not exist") {
return classifyReason(FailoverModelNotFound)
}
case statusCode == 529:
// Anthropic-specific overloaded status
return classifyReason(FailoverOverloaded)
case statusCode >= 500:
if containsAny(lower, "overload", "capacity", "too many") {
return classifyReason(FailoverOverloaded)
}
return classifyReason(FailoverServerError)
}
// Body pattern matching for specific error types
if containsAny(lower, "credit balance", "insufficient_quota", "billing") && statusCode != 0 {
return classifyReason(FailoverBilling)
}
if containsAny(lower, "tool_call", "function_call", "invalid_request") && statusCode == 400 {
return classifyReason(FailoverFormat)
}
if isContentPolicyRefusal(lower, statusCode) {
return classifyReason(FailoverContentPolicy)
}
// Provider-specific patterns
for _, patterns := range c.providerPatterns {
for _, p := range patterns {
if strings.Contains(lower, strings.ToLower(p.Contains)) {
return classifyReason(p.Reason)
}
}
}
// Network errors → timeout
if isNetworkError(err) {
return classifyReason(FailoverTimeout)
}
return classifyReason(FailoverUnknown)
}
// ClassifyHTTPError is a convenience that extracts status + body from an HTTPError.
func ClassifyHTTPError(classifier ErrorClassifier, err error) FailoverClassification {
if err == nil {
return classifyReason(FailoverUnknown)
}
var httpErr *HTTPError
if errors.As(err, &httpErr) {
return classifier.Classify(err, httpErr.Status, httpErr.Body)
}
classification := classifier.Classify(err, 0, err.Error())
if classification.Kind == "context_overflow" || classification.Reason != FailoverUnknown {
return classification
}
// Non-HTTP error — check for network errors
if isNetworkError(err) {
return classifyReason(FailoverTimeout)
}
return classifyReason(FailoverUnknown)
}
// isContextOverflow detects context window exceeded errors across providers.
func isContextOverflow(lower string) bool {
return containsAny(lower,
"context length exceeded",
"context window",
"maximum context length",
"token limit",
"too many tokens",
"prompt is too long",
// Chinese patterns (Qwen/DashScope)
"超出最大长度限制",
"上下文长度",
// Issue 958: Additional patterns
"prompt exceeds max length", // ZAI/GLM-5
"request_too_large", // Generic
"input is too long", // DashScope
"请求输入过长", // Chinese generic
)
}
// IsContextOverflowMessage exports overflow detection for use by pipeline.
func IsContextOverflowMessage(lower string) bool {
return isContextOverflow(lower)
}
// isNetworkError checks if an error is a network-level failure.
func isNetworkError(err error) bool {
if err == nil {
return false
}
var netErr net.Error
if errors.As(err, &netErr) {
return true
}
s := err.Error()
return containsAny(s, "connection reset", "broken pipe", "EOF", "timeout", "ECONNREFUSED")
}
func isContentPolicyRefusal(lower string, statusCode int) bool {
if statusCode != 0 && containsAny(lower, "data_inspection_failed", "inappropriate content", "content_policy_violation") {
return true
}
if containsAny(lower, "limited access to this content for safety reasons", "content for safety reasons") {
return true
}
return strings.Contains(lower, "invalid prompt") && strings.Contains(lower, "safety")
}
// containsAny returns true if s contains any of the substrings.
func containsAny(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
// RegisterOpenAIPatterns adds OpenAI-ecosystem specific error patterns.
func RegisterOpenAIPatterns(c *DefaultClassifier) {
c.RegisterPatterns("openai", []ErrorPattern{
{Contains: "model_is_deactivated", Reason: FailoverModelNotFound},
{Contains: "model not found", Reason: FailoverModelNotFound},
{Contains: "does not exist", Reason: FailoverModelNotFound},
})
}
// RegisterAnthropicPatterns adds Anthropic-specific error patterns.
func RegisterAnthropicPatterns(c *DefaultClassifier) {
c.RegisterPatterns("anthropic", []ErrorPattern{
{Contains: "overloaded", Reason: FailoverOverloaded},
{Contains: "credit balance", Reason: FailoverBilling},
})
}