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) 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" case status == 429 || codeLower == "rate_limit_exceeded": return "AI provider rate limited — retry later" case codeLower == "insufficient_quota" || strings.Contains(lower, "insufficient_quota") || strings.Contains(lower, "exceeded your current quota"): return "OpenAI quota exceeded — check billing" 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", } }