package processing import ( "fmt" "strings" ) // openAIErrorBody is the error object returned by OpenAI-compatible APIs. type openAIErrorBody struct { Message string `json:"message"` Type string `json:"type"` Code any `json:"code"` // string or number depending on provider Param string `json:"param"` } func (e *openAIErrorBody) codeString() string { if e == nil { return "" } switch v := e.Code.(type) { case string: return strings.TrimSpace(v) case float64: if v == float64(int64(v)) { return fmt.Sprintf("%d", int64(v)) } return fmt.Sprintf("%g", v) case int: return fmt.Sprintf("%d", v) default: return "" } } // formatOpenAIHTTPError turns an upstream HTTP error into a short, secret-safe // operator message. Intentionally avoids embedding model ids (gpt-…) so // TruncateError / mapPublicErrorCode do not collapse useful validation text to // "processing_failed". func formatOpenAIHTTPError(status int, errObj *openAIErrorBody) string { msg := "" code := "" param := "" typ := "" if errObj != nil { msg = strings.TrimSpace(errObj.Message) code = errObj.codeString() param = strings.TrimSpace(errObj.Param) typ = strings.TrimSpace(errObj.Type) } lower := strings.ToLower(msg) codeLower := strings.ToLower(code) paramLower := strings.ToLower(param) typLower := strings.ToLower(typ) switch { case status == 401 || codeLower == "invalid_api_key" || strings.Contains(lower, "invalid api key") || strings.Contains(lower, "incorrect api key"): return "AI provider rejected the API key" case status == 403: return "AI provider forbidden the request" // OpenAI returns HTTP 429 for BOTH true rate limits and missing prepaid // credits (insufficient_quota) — check quota BEFORE treating 429 as RPM. case codeLower == "insufficient_quota" || typLower == "insufficient_quota" || strings.Contains(lower, "insufficient_quota") || strings.Contains(lower, "exceeded your current quota") || strings.Contains(lower, "check your plan and billing"): return "API billing/quota empty — add prepaid API credits in Platform billing (not ChatGPT Plus)" case codeLower == "rate_limit_exceeded" || strings.Contains(lower, "rate limit reached") || strings.Contains(lower, "rate_limit_exceeded"): return "AI provider rate limited — retry later" case status == 429: // Bare 429 with no body fields — still often quota on new accounts. return "API HTTP 429 — usually empty Platform billing/credits (not ChatGPT Plus); add prepaid credits then retry" case strings.Contains(lower, "does not exist") || strings.Contains(lower, "do not have access") || codeLower == "model_not_found": return "model not found or API key lacks access — check model name and project permissions" case strings.Contains(lower, "max_tokens") && strings.Contains(lower, "max_completion"): return "model requires completion-token budget (legacy token cap unsupported)" case paramLower == "temperature" || strings.Contains(lower, "temperature"): return "model rejects custom temperature — omit temperature for reasoning chat models" case paramLower == "max_tokens" || (strings.Contains(lower, "unsupported parameter") && strings.Contains(lower, "max_tokens")): return "model rejects legacy token cap — use completion-token budget" case paramLower == "reasoning_effort" || strings.Contains(lower, "reasoning_effort"): return "model rejected reasoning effort — try none/low or omit" case strings.Contains(lower, "unsupported parameter") || strings.Contains(lower, "unsupported value"): if param != "" { return fmt.Sprintf("unsupported chat parameter %q", param) } return "unsupported chat parameter for this model" case status >= 500: return "AI provider temporarily unavailable (server error)" } // Prefer structured fields over raw message (raw often embeds model ids / urls). if code != "" && param != "" { return fmt.Sprintf("provider http %d (%s param=%s)", status, code, param) } if code != "" { return fmt.Sprintf("provider http %d (%s)", status, code) } if typ != "" { return fmt.Sprintf("provider http %d (%s)", status, typ) } if status > 0 { return fmt.Sprintf("provider http %d", status) } return "provider request failed" } // ProbeCompleteOptions is the Chat Completions budget for admin connection tests. // Explicit max_completion_tokens + reasoning_effort=none avoids GPT-5.6 defaults // spending the budget on hidden reasoning for a one-token "ok" reply. func ProbeCompleteOptions() CompleteOptions { return CompleteOptions{ MaxTokens: 64, ReasoningEffort: "none", } } // openAIErrorIsRetryableRateLimit is true only for genuine RPM/TPM limits. // HTTP 429 + insufficient_quota (empty prepaid billing) must not be retried. func openAIErrorIsRetryableRateLimit(errObj *openAIErrorBody) bool { if errObj == nil { return false } code := strings.ToLower(errObj.codeString()) typ := strings.ToLower(strings.TrimSpace(errObj.Type)) msg := strings.ToLower(strings.TrimSpace(errObj.Message)) if code == "insufficient_quota" || typ == "insufficient_quota" || strings.Contains(msg, "insufficient_quota") || strings.Contains(msg, "exceeded your current quota") || strings.Contains(msg, "check your plan and billing") { return false } return code == "rate_limit_exceeded" || strings.Contains(msg, "rate limit reached") || strings.Contains(msg, "rate_limit_exceeded") }