返回 DeepSeek-Reasonix
failure_diagnostic.go
根目录 / internal / provider / failure_diagnostic.go
1 package provider
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9 )
10
11 // RequestIdentity keeps the stable connection key separate from the
12 // user-editable label and the selected wire protocol.
13 type RequestIdentity struct {
14 Provider string
15 DisplayName string
16 Protocol string
17 }
18
19 // RequestFailure preserves connection identity for failures that happen before
20 // an HTTP status exists, while retaining the original error for classification.
21 type RequestFailure struct {
22 Identity RequestIdentity
23 Operation string
24 Err error
25 }
26
27 func (e *RequestFailure) Error() string {
28 return fmt.Sprintf("%s: %s: %v", ProviderDisplayLabel(e.Identity.Provider, e.Identity.DisplayName, e.Identity.Protocol), e.Operation, e.Err)
29 }
30
31 func (e *RequestFailure) Unwrap() error { return e.Err }
32
33 func ProtocolDisplayName(kind string) string {
34 switch strings.ToLower(strings.TrimSpace(kind)) {
35 case "openai":
36 return "Chat Completions"
37 case "anthropic":
38 return "Anthropic Messages"
39 case "responses":
40 return "Responses"
41 case "dashscope-responses":
42 return "DashScope Responses"
43 default:
44 return strings.TrimSpace(kind)
45 }
46 }
47
48 func ProviderDisplayLabel(providerID, displayName, protocol string) string {
49 name := strings.TrimSpace(displayName)
50 if name == "" {
51 name = strings.TrimSpace(providerID)
52 }
53 protocolName := ProtocolDisplayName(protocol)
54 if name == "" {
55 return protocolName
56 }
57 if protocolName == "" {
58 return name
59 }
60 return name + " · " + protocolName
61 }
62
63 // FailureDiagnostic contains safe classification only, never response bodies.
64 type FailureDiagnostic struct {
65 Kind string `json:"kind"`
66 Status int `json:"status,omitempty"`
67 TraceID string `json:"traceId,omitempty"`
68 ProviderID string `json:"providerId,omitempty"`
69 ProviderDisplayName string `json:"providerDisplayName,omitempty"`
70 Protocol string `json:"protocol,omitempty"`
71 RequestPath string `json:"requestPath,omitempty"`
72 }
73
74 // FailureDiagnosticDetail renders the safe operator fields shared by live and
75 // persisted failure notices. It intentionally excludes display identity and
76 // any request query or credentials.
77 func FailureDiagnosticDetail(d *FailureDiagnostic) string {
78 if d == nil {
79 return ""
80 }
81 detail := ""
82 if d.ProviderID != "" {
83 detail = "Connection ID: " + d.ProviderID
84 }
85 if d.RequestPath != "" {
86 if detail != "" {
87 detail += "\n"
88 }
89 detail += "Request path: " + d.RequestPath
90 }
91 return detail
92 }
93
94 func DiagnoseFailure(err error) *FailureDiagnostic {
95 if err == nil {
96 return nil
97 }
98 d := &FailureDiagnostic{Kind: "unknown"}
99 var request *RequestFailure
100 if errors.As(err, &request) {
101 d.ProviderID = request.Identity.Provider
102 d.ProviderDisplayName = request.Identity.DisplayName
103 d.Protocol = request.Identity.Protocol
104 }
105 var quota *QuotaError
106 if errors.As(err, &quota) {
107 d.ProviderID = quota.Provider
108 d.ProviderDisplayName = quota.ProviderDisplayName
109 d.Protocol = quota.Protocol
110 }
111 var api *APIError
112 if errors.As(err, &api) {
113 d.Status = api.Status
114 d.ProviderID = api.Provider
115 d.ProviderDisplayName = api.ProviderDisplayName
116 d.Protocol = api.Protocol
117 if len(api.RequestPath) <= 512 && strings.HasPrefix(api.RequestPath, "/") {
118 d.RequestPath = api.RequestPath
119 }
120 // Trace identifiers are opaque tokens, not arbitrary header text.
121 if len(api.TraceID) <= 128 && strings.IndexFunc(api.TraceID, func(r rune) bool {
122 return !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("-_.:", r))
123 }) < 0 {
124 d.TraceID = api.TraceID
125 }
126 }
127 switch {
128 case errors.Is(err, context.Canceled):
129 d.Kind = "cancelled"
130 case AsRecoveryWaitExhausted(err) != nil:
131 d.Kind = "recovery_wait_exhausted"
132 case AsQuotaError(err) != nil:
133 d.Kind = "quota"
134 d.Status = AsQuotaError(err).Status
135 case errors.As(err, new(*AuthError)):
136 d.Kind = "auth"
137 var auth *AuthError
138 if errors.As(err, &auth) {
139 d.Status = auth.Status
140 d.ProviderID = auth.Provider
141 d.ProviderDisplayName = auth.ProviderDisplayName
142 d.Protocol = auth.Protocol
143 }
144 case AsContextLimitError(err) != nil || AsOutputLimitError(err) != nil:
145 d.Kind = "limit"
146 case AsReasoningReplayError(err) != nil:
147 d.Kind = "protocol"
148 case IsOpaqueBadRequest(err):
149 d.Kind = "upstream_reason_missing"
150 case ClassifyRecovery(err).Retryable:
151 d.Kind = "temporary"
152 case api != nil && api.Status >= 400 && api.Status < 500:
153 d.Kind = "request"
154 }
155 return d
156 }
157
158 // IsOpaqueBadRequest deliberately recognizes only empty/model-only 400 bodies.
159 // An unknown structured error may carry a useful reason and must not be guessed.
160 func IsOpaqueBadRequest(err error) bool {
161 var api *APIError
162 if !errors.As(err, &api) || api.Status != 400 {
163 return false
164 }
165 if strings.TrimSpace(api.Body) == "" {
166 return true
167 }
168 var object map[string]json.RawMessage
169 if json.Unmarshal([]byte(api.Body), &object) != nil || object == nil {
170 return false
171 }
172 for key := range object {
173 if key != "model" {
174 return false
175 }
176 }
177 return true
178 }
179
179 lines GO