// API 错误分类系统 -- 一次分类,多处消费. // // 升华改进(ELEVATED): 早期方案有三套平行的 if-else 链(classifyAPIError / getAssistantMessageFromError / // categorizeRetryableAPIError)各自独立做字符串匹配,改一个忘另一个就是 bug. // 早期方案注释甚至承认 "Patterns MUST stay in sync"--需要注释来保证同步说明结构就是错的. // // 本设计:解析 HTTP 响应时一次性创建结构化 APIError(含 Category + RetryInfo + TokenGap + Hint), // 所有消费者(分析,用户消息,重试决策)直接读字段,不再各自做 string matching. // // 替代方案:<原方案三套独立分类器 + 字符串匹配> package api import ( "bytes" "encoding/json" "fmt" "net/http" "regexp" "strconv" "strings" "time" "git.flytoex.net/yuanwei/flyto-agent/internal/apierror" ) // ============================================================ // ErrorCategory - 错误的语义分类枚举 // ============================================================ // ErrorCategory 是 API 错误的语义分类(使用 apierror 包的定义). type ErrorCategory = apierror.ErrorCategory // 以下常量与 apierror.Err* 对应,保持向后兼容. const ( ErrUnknown ErrorCategory = apierror.ErrUnknown ErrAborted ErrorCategory = apierror.ErrAborted ErrTimeout ErrorCategory = apierror.ErrTimeout ErrRateLimit ErrorCategory = apierror.ErrRateLimit ErrOverloaded ErrorCategory = apierror.ErrOverloaded ErrPromptTooLong ErrorCategory = apierror.ErrPromptTooLong ErrMediaTooLarge ErrorCategory = apierror.ErrMediaTooLarge ErrRequestTooLarge ErrorCategory = apierror.ErrRequestTooLarge ErrInvalidRequest ErrorCategory = apierror.ErrInvalidRequest ErrAuthentication ErrorCategory = apierror.ErrAuthentication ErrModelNotFound ErrorCategory = apierror.ErrModelNotFound ErrBilling ErrorCategory = apierror.ErrBilling ErrServerError ErrorCategory = apierror.ErrServerError ErrConnection ErrorCategory = apierror.ErrConnection ErrSSL ErrorCategory = apierror.ErrSSL ErrToolMismatch ErrorCategory = apierror.ErrToolMismatch ErrUnexpectedTool ErrorCategory = apierror.ErrUnexpectedTool ErrDuplicateToolID ErrorCategory = apierror.ErrDuplicateToolID ErrInvalidModel ErrorCategory = apierror.ErrInvalidModel ErrContentPolicy ErrorCategory = apierror.ErrContentPolicy ) // String 和 IsRetryableByDefault 方法继承自 apierror.ErrorCategory // ============================================================ // RetryInfo - 重试元信息 // ============================================================ // RetryInfo 封装重试建议(使用 apierror 包的定义). type RetryInfo = apierror.RetryInfo // ============================================================ // APIError - 结构化 API 错误 // ============================================================ // APIError 是结构化的 API 错误,实现 error 接口. // // 升华改进(ELEVATED): 所有错误信息在创建时一次性解析填充-- // Category(分类),RetryInfo(重试),TokenGap(压缩跳步),Hint(用户提示). // 消费者只读字段,不做二次解析. // // 替代方案:<原方案每个消费者各自 instanceof + string includes 做分类> type APIError struct { // ErrCategory 是错误的语义分类 ErrCategory ErrorCategory // StatusCode 是 HTTP 状态码(连接错误时为 0) StatusCode int // Msg 是原始错误消息(可能来自 API JSON body 或网络错误) Msg string // Hint 是用户可操作的诊断提示(如 "检查企业代理的 SSL 证书") Hint string // Retry 是重试建议(nil 表示使用 ErrCategory 默认行为) Retry *RetryInfo // TokenGap 是 prompt_too_long 时溢出的 token 数(用于响应式压缩跳步). // 精妙之处(CLEVER): 从 "137500 tokens > 135000 maximum" 中提取差值 2500, // 让压缩模块一步跳过 2500 token 的消息组,而非逐个尝试. // 仅在 ErrCategory == ErrPromptTooLong 时有意义. TokenGap int // RespHeaders 是保留的响应头(用于下游消费者提取供应商特定信息) RespHeaders http.Header // Body 是原始响应体(用于调试和日志) Body string // Cause 是底层错误(网络错误时保留原始 error 链) Cause error } // Error implements the error interface. The output line includes every // structured field populated at construction time -- Hint, TokenGap, and // a truncated Body preview. Previous revisions read only StatusCode / // ErrCategory / Msg; the other three were filled by the classifier but // silently dropped, so each log line showed half the diagnostic that the // struct carried. Body is truncated to 256 bytes to keep lines // log-friendly; the full body stays on the APIError and can be read // directly by debug tooling when needed. // // Previous implementation retained for reference: // // if e.StatusCode > 0 { // return fmt.Sprintf("api: HTTP %d [%s]: %s", e.StatusCode, e.ErrCategory, e.Msg) // } // return fmt.Sprintf("api: [%s]: %s", e.ErrCategory, e.Msg) // // Error 实现 error 接口. 输出行包含构造时填充的所有结构字段 -- Hint / // TokenGap / Body 截断预览. 旧版本只读 StatusCode / ErrCategory / Msg, // 其他三个字段由 classifier 填了但被静默丢弃, 每条日志只能看到结构体 // 携带一半的诊断信息. Body 截到 256 字节保持日志可读; 完整 body 仍挂 // 在 APIError 上, debug 工具需要时直接读字段. // // 原实现保留供参照, 见上方 godoc. func (e *APIError) Error() string { var b strings.Builder if e.StatusCode > 0 { fmt.Fprintf(&b, "api: HTTP %d [%s]: %s", e.StatusCode, e.ErrCategory, e.Msg) } else { fmt.Fprintf(&b, "api: [%s]: %s", e.ErrCategory, e.Msg) } if e.TokenGap > 0 { fmt.Fprintf(&b, " (token_gap=%d)", e.TokenGap) } if e.Hint != "" { fmt.Fprintf(&b, " hint: %s", e.Hint) } if e.Body != "" { preview := e.Body if len(preview) > 256 { preview = preview[:256] + "..." } fmt.Fprintf(&b, " body[%d]: %s", len(e.Body), preview) } return b.String() } // Unwrap 支持 errors.Is / errors.As 链式解包. func (e *APIError) Unwrap() error { return e.Cause } // IsRetryable 返回是否建议重试. // 优先使用 Retry 的精确判断,fallback 到 ErrCategory 默认行为. func (e *APIError) IsRetryable() bool { if e.Retry != nil { return e.Retry.Retryable } return e.ErrCategory.IsRetryableByDefault() } // RetryDelay 返回建议的重试等待时间. func (e *APIError) RetryDelay() time.Duration { if e.Retry != nil { return e.Retry.After } return 0 } // AnalyticsTag 返回用于分析系统的标准化标签字符串. func (e *APIError) AnalyticsTag() string { return e.ErrCategory.String() } // Category 返回错误的字符串分类(实现 retry.RetryError 接口). func (e *APIError) Category() string { return e.ErrCategory.String() } // Message 返回错误消息字符串(实现 retry.RetryError 接口). func (e *APIError) Message() string { return e.Msg } // Headers 返回响应头(实现 retry.RetryError 接口). func (e *APIError) Headers() http.Header { return e.RespHeaders } // RetryInfo 返回重试信息(实现 retry.RetryError 接口). func (e *APIError) RetryInfo() *RetryInfo { return e.Retry } // ============================================================ // Token 差值解析 - 从 prompt_too_long 消息中提取溢出量 // ============================================================ // promptTooLongRe 匹配 "prompt is too long: 137500 tokens > 135000 maximum" 格式. // 精妙之处(CLEVER): 大小写不敏感 + 宽松匹配-- // Anthropic 直连返回小写,Vertex 返回首字母大写,其他网关可能有不同格式. // 只要包含 "数字 tokens > 数字" 就能提取. var promptTooLongRe = regexp.MustCompile(`(?i)prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)`) // ParseTokenGap 从错误消息中提取 prompt_too_long 的 token 溢出量. // 返回 0 表示无法解析. func ParseTokenGap(message string) int { matches := promptTooLongRe.FindStringSubmatch(message) if len(matches) < 3 { return 0 } actual, err1 := strconv.Atoi(matches[1]) limit, err2 := strconv.Atoi(matches[2]) if err1 != nil || err2 != nil { return 0 } gap := actual - limit if gap > 0 { return gap } return 0 } // ============================================================ // HTML 消毒 - 清理 CloudFlare 等网关返回的 HTML 错误页 // ============================================================ // SanitizeErrorHTML 清理可能包含 HTML 的错误消息. // // 精妙之处(CLEVER): CloudFlare,AWS ALB 等网关在故障时返回 HTML 错误页(而非 JSON). // 这些 HTML 如果原样传给 LLM 会浪费几千 token.提取