| 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, "a) { |
| 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 |