| 1 | package provider |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "net/http" |
| 7 | "regexp" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | ) |
| 11 | |
| 12 | // ContextLimitError is a trusted shared-window overflow from a provider HTTP |
| 13 | // 400/413/422. Unwrap returns the original APIError so localization, trace IDs, |
| 14 | // and telemetry keep working. The body is never persisted or replayed. |
| 15 | type ContextLimitError struct { |
| 16 | APIError *APIError |
| 17 | WindowTokens int |
| 18 | RequestedTokens int |
| 19 | PromptTokens int |
| 20 | CompletionTokens int |
| 21 | } |
| 22 | |
| 23 | // OutputLimitError is a provider-reported completion-token ceiling. It is |
| 24 | // separate from ContextLimitError because the request may fit the model |
| 25 | // context window while exceeding the route's output-only limit. |
| 26 | type OutputLimitError struct { |
| 27 | APIError *APIError |
| 28 | RequestedTokens int |
| 29 | MaxOutputTokens int |
| 30 | } |
| 31 | |
| 32 | func (e *OutputLimitError) Error() string { |
| 33 | if e == nil { |
| 34 | return "output token limit exceeded" |
| 35 | } |
| 36 | if e.APIError != nil { |
| 37 | return e.APIError.Error() |
| 38 | } |
| 39 | return "output token limit exceeded" |
| 40 | } |
| 41 | |
| 42 | func (e *OutputLimitError) Unwrap() error { |
| 43 | if e == nil { |
| 44 | return nil |
| 45 | } |
| 46 | return e.APIError |
| 47 | } |
| 48 | |
| 49 | func (e *ContextLimitError) Error() string { |
| 50 | if e == nil { |
| 51 | return "context limit exceeded" |
| 52 | } |
| 53 | if e.APIError != nil { |
| 54 | return e.APIError.Error() |
| 55 | } |
| 56 | return "context limit exceeded" |
| 57 | } |
| 58 | |
| 59 | func (e *ContextLimitError) Unwrap() error { |
| 60 | if e == nil { |
| 61 | return nil |
| 62 | } |
| 63 | return e.APIError |
| 64 | } |
| 65 | |
| 66 | var ( |
| 67 | contextLimitEnglishRe = regexp.MustCompile(`(?i)maximum context length is (\d+) tokens?\.?\s*however,\s*you requested (\d+) tokens? \((\d+) in the (?:messages|prompt), (\d+) in the completion\)`) |
| 68 | contextLimitPromptRe = regexp.MustCompile(`(?i)prompt is too long:\s*(\d+) tokens? > (\d+) maximum`) |
| 69 | contextLimitSumRe = regexp.MustCompile("(?i)input length and [`']?max_tokens[`']? exceed context limit:\\s*(\\d+)\\s*\\+\\s*(\\d+)\\s*>\\s*(\\d+)") |
| 70 | outputLimitRe = regexp.MustCompile(`(?i)max_tokens\s*(?:is\s+too\s+large|too\s+large)\s*[:=]?\s*(\d+).*?(?:supports?|maximum|at\s+most)[^\d]*(\d+)`) |
| 71 | ) |
| 72 | |
| 73 | func contextLimitStatusOK(status int) bool { |
| 74 | return status == http.StatusBadRequest || status == http.StatusRequestEntityTooLarge || status == http.StatusUnprocessableEntity |
| 75 | } |
| 76 | |
| 77 | func positiveToken(n int) bool { return n > 0 } |
| 78 | |
| 79 | func contextLimitInvariant(window, requested, prompt, completion int) bool { |
| 80 | if !positiveToken(window) { |
| 81 | return false |
| 82 | } |
| 83 | if positiveToken(prompt) && positiveToken(completion) { |
| 84 | if prompt+completion <= window { |
| 85 | return false |
| 86 | } |
| 87 | if requested > 0 && requested != prompt+completion { |
| 88 | return false |
| 89 | } |
| 90 | return true |
| 91 | } |
| 92 | if requested > window && (prompt > 0 || completion > 0 || requested > 0) { |
| 93 | return requested > window |
| 94 | } |
| 95 | return false |
| 96 | } |
| 97 | |
| 98 | func completeContextLimit(window, requested, prompt, completion int) (int, int, int, int, bool) { |
| 99 | if window <= 0 { |
| 100 | return 0, 0, 0, 0, false |
| 101 | } |
| 102 | if prompt > 0 && completion > 0 && requested <= 0 { |
| 103 | requested = prompt + completion |
| 104 | } |
| 105 | if requested > 0 && prompt > 0 && completion <= 0 && requested > prompt { |
| 106 | completion = requested - prompt |
| 107 | } |
| 108 | if requested > 0 && completion > 0 && prompt <= 0 && requested > completion { |
| 109 | prompt = requested - completion |
| 110 | } |
| 111 | if !contextLimitInvariant(window, requested, prompt, completion) { |
| 112 | return 0, 0, 0, 0, false |
| 113 | } |
| 114 | if requested <= 0 { |
| 115 | requested = prompt + completion |
| 116 | } |
| 117 | return window, requested, prompt, completion, true |
| 118 | } |
| 119 | |
| 120 | type contextLimitJSON struct { |
| 121 | Error *struct { |
| 122 | Message string `json:"message"` |
| 123 | ContextLength int `json:"context_length"` |
| 124 | MaxContextLength int `json:"max_context_length"` |
| 125 | MaxTokens int `json:"max_tokens"` |
| 126 | RequestedTokens int `json:"requested_tokens"` |
| 127 | Requested int `json:"requested"` |
| 128 | PromptTokens int `json:"prompt_tokens"` |
| 129 | InputTokens int `json:"input_tokens"` |
| 130 | CompletionTokens int `json:"completion_tokens"` |
| 131 | OutputTokens int `json:"output_tokens"` |
| 132 | } `json:"error"` |
| 133 | ContextLength int `json:"context_length"` |
| 134 | MaxContextLength int `json:"max_context_length"` |
| 135 | RequestedTokens int `json:"requested_tokens"` |
| 136 | PromptTokens int `json:"prompt_tokens"` |
| 137 | InputTokens int `json:"input_tokens"` |
| 138 | CompletionTokens int `json:"completion_tokens"` |
| 139 | OutputTokens int `json:"output_tokens"` |
| 140 | Usage *struct { |
| 141 | InputTokens int `json:"input_tokens"` |
| 142 | OutputTokens int `json:"output_tokens"` |
| 143 | PromptTokens int `json:"prompt_tokens"` |
| 144 | CompletionTokens int `json:"completion_tokens"` |
| 145 | } `json:"usage"` |
| 146 | } |
| 147 | |
| 148 | func firstPositive(values ...int) int { |
| 149 | for _, n := range values { |
| 150 | if n > 0 { |
| 151 | return n |
| 152 | } |
| 153 | } |
| 154 | return 0 |
| 155 | } |
| 156 | |
| 157 | func parseContextLimitJSON(body string) (window, requested, prompt, completion int, message string, ok bool) { |
| 158 | var parsed contextLimitJSON |
| 159 | if json.Unmarshal([]byte(body), &parsed) != nil { |
| 160 | return 0, 0, 0, 0, "", false |
| 161 | } |
| 162 | if parsed.Error != nil { |
| 163 | message = parsed.Error.Message |
| 164 | window = firstPositive(parsed.Error.ContextLength, parsed.Error.MaxContextLength) |
| 165 | requested = firstPositive(parsed.Error.RequestedTokens, parsed.Error.Requested) |
| 166 | prompt = firstPositive(parsed.Error.PromptTokens, parsed.Error.InputTokens) |
| 167 | completion = firstPositive(parsed.Error.CompletionTokens, parsed.Error.OutputTokens) |
| 168 | } |
| 169 | window = firstPositive(window, parsed.ContextLength, parsed.MaxContextLength) |
| 170 | requested = firstPositive(requested, parsed.RequestedTokens) |
| 171 | prompt = firstPositive(prompt, parsed.PromptTokens, parsed.InputTokens) |
| 172 | completion = firstPositive(completion, parsed.CompletionTokens, parsed.OutputTokens) |
| 173 | if parsed.Usage != nil { |
| 174 | prompt = firstPositive(prompt, parsed.Usage.PromptTokens, parsed.Usage.InputTokens) |
| 175 | completion = firstPositive(completion, parsed.Usage.CompletionTokens, parsed.Usage.OutputTokens) |
| 176 | } |
| 177 | if window, requested, prompt, completion, ok = completeContextLimit(window, requested, prompt, completion); ok { |
| 178 | return window, requested, prompt, completion, message, true |
| 179 | } |
| 180 | return 0, 0, 0, 0, message, false |
| 181 | } |
| 182 | |
| 183 | func parseContextLimitText(text string) (window, requested, prompt, completion int, ok bool) { |
| 184 | text = strings.TrimSpace(text) |
| 185 | if text == "" { |
| 186 | return 0, 0, 0, 0, false |
| 187 | } |
| 188 | if m := contextLimitEnglishRe.FindStringSubmatch(text); len(m) == 5 { |
| 189 | return completeContextLimit(atoiStrict(m[1]), atoiStrict(m[2]), atoiStrict(m[3]), atoiStrict(m[4])) |
| 190 | } |
| 191 | if m := contextLimitSumRe.FindStringSubmatch(text); len(m) == 4 { |
| 192 | return completeContextLimit(atoiStrict(m[3]), 0, atoiStrict(m[1]), atoiStrict(m[2])) |
| 193 | } |
| 194 | if m := contextLimitPromptRe.FindStringSubmatch(text); len(m) == 3 { |
| 195 | prompt = atoiStrict(m[1]) |
| 196 | window = atoiStrict(m[2]) |
| 197 | if prompt > 0 && window > 0 && prompt > window { |
| 198 | return window, prompt, prompt, 0, true |
| 199 | } |
| 200 | } |
| 201 | return 0, 0, 0, 0, false |
| 202 | } |
| 203 | |
| 204 | func atoiStrict(s string) int { |
| 205 | n, err := strconv.Atoi(strings.TrimSpace(s)) |
| 206 | if err != nil || n <= 0 { |
| 207 | return 0 |
| 208 | } |
| 209 | return n |
| 210 | } |
| 211 | |
| 212 | // ParseContextLimitError extracts a trusted overflow from an APIError. |
| 213 | // Unparseable, non-context, or invariant-breaking bodies return nil. |
| 214 | func ParseContextLimitError(apiErr *APIError) *ContextLimitError { |
| 215 | if apiErr == nil || !contextLimitStatusOK(apiErr.Status) { |
| 216 | return nil |
| 217 | } |
| 218 | window, requested, prompt, completion, message, jsonOK := parseContextLimitJSON(apiErr.Body) |
| 219 | if !jsonOK { |
| 220 | if w, r, p, c, ok := parseContextLimitText(apiErr.Body); ok { |
| 221 | window, requested, prompt, completion = w, r, p, c |
| 222 | } else if w, r, p, c, ok := parseContextLimitText(message); ok { |
| 223 | window, requested, prompt, completion = w, r, p, c |
| 224 | } else { |
| 225 | // A bare overflow with no token numbers (Zhipu GLM 1261) is still |
| 226 | // provider-confirmed: trust it with an unknown window so consumers |
| 227 | // fall back to the configured window instead of resending as-is. |
| 228 | if isUnnumberedPromptTooLong(message, apiErr.Body) { |
| 229 | return &ContextLimitError{APIError: apiErr} |
| 230 | } |
| 231 | return nil |
| 232 | } |
| 233 | } |
| 234 | if !contextLimitInvariant(window, requested, prompt, completion) && |
| 235 | !(window > 0 && requested > window && prompt > 0) { |
| 236 | if isUnnumberedPromptTooLong(message, apiErr.Body) { |
| 237 | return &ContextLimitError{APIError: apiErr} |
| 238 | } |
| 239 | return nil |
| 240 | } |
| 241 | if requested <= 0 { |
| 242 | requested = prompt + completion |
| 243 | } |
| 244 | return &ContextLimitError{ |
| 245 | APIError: apiErr, |
| 246 | WindowTokens: window, |
| 247 | RequestedTokens: requested, |
| 248 | PromptTokens: prompt, |
| 249 | CompletionTokens: completion, |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | // isUnnumberedPromptTooLong matches provider overflow errors that carry no |
| 254 | // token numbers at all. Canonical shape — Zhipu GLM 1261: |
| 255 | // |
| 256 | // {"error":{"code":"1261","message":"Prompt exceeds max length"}} |
| 257 | // |
| 258 | // The message (or the whole body, when the JSON shape differs) is matched |
| 259 | // case-insensitively; code 1261 is not matched directly so sibling GLM codes |
| 260 | // that reuse the message stay covered and numeric codes never false-positive. |
| 261 | func isUnnumberedPromptTooLong(message, body string) bool { |
| 262 | for _, s := range []string{message, body} { |
| 263 | if s == "" { |
| 264 | continue |
| 265 | } |
| 266 | if strings.Contains(strings.ToLower(s), "prompt exceeds max length") { |
| 267 | return true |
| 268 | } |
| 269 | } |
| 270 | return false |
| 271 | } |
| 272 | |
| 273 | // AsContextLimitError unwraps err to a trusted overflow, if any. |
| 274 | func AsContextLimitError(err error) *ContextLimitError { |
| 275 | var limit *ContextLimitError |
| 276 | if err != nil && errors.As(err, &limit) { |
| 277 | return limit |
| 278 | } |
| 279 | return nil |
| 280 | } |
| 281 | |
| 282 | // ParseOutputLimitError extracts a completion-only ceiling from a 400/413/422 |
| 283 | // API error. The parser is intentionally conservative: it only accepts text |
| 284 | // that names both the requested max_tokens and a smaller supported maximum. |
| 285 | func ParseOutputLimitError(apiErr *APIError) *OutputLimitError { |
| 286 | if apiErr == nil || !contextLimitStatusOK(apiErr.Status) { |
| 287 | return nil |
| 288 | } |
| 289 | text := strings.TrimSpace(apiErr.Body) |
| 290 | if text == "" { |
| 291 | return nil |
| 292 | } |
| 293 | m := outputLimitRe.FindStringSubmatch(text) |
| 294 | if len(m) != 3 { |
| 295 | return nil |
| 296 | } |
| 297 | requested, maxOutput := atoiStrict(m[1]), atoiStrict(m[2]) |
| 298 | if requested <= 0 || maxOutput <= 0 || requested <= maxOutput { |
| 299 | return nil |
| 300 | } |
| 301 | return &OutputLimitError{APIError: apiErr, RequestedTokens: requested, MaxOutputTokens: maxOutput} |
| 302 | } |
| 303 | |
| 304 | // AsOutputLimitError unwraps err to a trusted output ceiling, if any. |
| 305 | func AsOutputLimitError(err error) *OutputLimitError { |
| 306 | var limit *OutputLimitError |
| 307 | if err != nil && errors.As(err, &limit) { |
| 308 | return limit |
| 309 | } |
| 310 | return nil |
| 311 | } |
| 312 |