Files
coder/coderd/x/chatd/chaterror/message.go
T
Michael Suchacz f0e6ac64b3 feat: remove native chat usage limits in favor of AI Gateway budgets (#27329)
## Stack Context

This stack makes AI Gateway data and budgets the source of truth for AI
spend controls.

1. Re-back the per-chat cost endpoint with AI Gateway data (#27328,
merged).
2. **This PR:** remove native chat usage limits.
3. Remove native chat cost tracking and its dedicated admin UI (#27330).

## Summary

Removes the native usage-limit API, SDK types, SQL, and chat enforcement
for deployment, user, and group chat limits. Compact AI Gateway budget
indicators remain in the Agents sidebar, user menu, and group settings.
Gateway budget rejections and provider quota failures continue to
classify as usage-limit errors, including a 409 response for synchronous
title generation.

Budget-period labels now use the API's UTC boundaries, so users see the
same dates in every browser timezone. The documentation explains the AI
Gateway replacement, its licensing requirements, and the differences
from native limits.

No schema is dropped in this release. The usage-limit table, index, user
and group columns, constraints, audit mappings, and generated scan
fields remain for mixed-version rolling upgrades. #27600 tracks their
removal after the compatibility window.

## Breaking change

Native day, week, and month chat spend limits are removed and are not
migrated. AI Gateway budgets are month-based, group-scoped with per-user
overrides, and require the AI Gateway entitlement. Deployments without
that entitlement no longer have chat spend enforcement.

> Mux prepared this PR on Mike's behalf.
2026-08-04 11:36:49 +02:00

173 lines
5.5 KiB
Go

package chaterror
import (
"fmt"
"strings"
stringutil "github.com/coder/coder/v2/coderd/util/strings"
"github.com/coder/coder/v2/codersdk"
)
// terminalMessage produces the user-facing error description shown
// when retries are exhausted. HTTP status codes are carried in the
// classified payload's StatusCode field and rendered as a separate
// footer chip by the UI, so they are intentionally omitted here to
// avoid duplicating the same information in two places.
func terminalMessage(classified ClassifiedError) string {
subject := providerSubject(classified.Provider)
switch classified.Kind {
case codersdk.ChatErrorKindOverloaded:
return stringutil.Capitalize(fmt.Sprintf("%s is temporarily overloaded.", subject))
case codersdk.ChatErrorKindRateLimit:
return stringutil.Capitalize(fmt.Sprintf("%s is rate limiting requests.", subject))
case codersdk.ChatErrorKindTimeout:
if !classified.Retryable && classified.StatusCode == 0 {
return "The request timed out before it completed."
}
return stringutil.Capitalize(fmt.Sprintf("%s is temporarily unavailable.", subject))
case codersdk.ChatErrorKindStreamSilenceTimeout:
return stringutil.Capitalize(fmt.Sprintf(
"%s did not send response data in time.", subject,
))
case codersdk.ChatErrorKindUsageLimit:
return "The AI usage limit has been exceeded." +
" Contact an administrator or check the applicable budget and quota settings."
case codersdk.ChatErrorKindAuth:
return fmt.Sprintf(
"Authentication with %s failed."+
" Check the API key and permissions.",
subject,
)
case codersdk.ChatErrorKindConfig:
return stringutil.Capitalize(fmt.Sprintf(
"%s rejected the model configuration."+
" Check the selected model and provider settings.",
subject,
))
case codersdk.ChatErrorKindMissingKey:
return "This conversation was started with an API key that is no longer available." +
" Send your message again to continue."
case codersdk.ChatErrorKindProviderDisabled:
displayName := providerDisplayName(classified.Provider)
return fmt.Sprintf(
"The %s provider has been disabled."+
" Contact your Coder administrator.",
displayName,
)
case codersdk.ChatErrorKindContentFilter:
return ContentFilterMessage(classified.Provider, "")
default:
if !classified.Retryable && classified.StatusCode == 0 {
return "The chat request failed unexpectedly."
}
return stringutil.Capitalize(fmt.Sprintf("%s returned an unexpected error.", subject))
}
}
// retryMessage produces a clean factual description suitable for
// display alongside the retry countdown UI. It omits HTTP status
// codes (surfaced separately in the payload) and remediation
// guidance (not actionable while auto-retrying).
func retryMessage(classified ClassifiedError) string {
if classified.Retryable && classified.Message != "" {
return classified.Message
}
subject := providerSubject(classified.Provider)
switch classified.Kind {
case codersdk.ChatErrorKindOverloaded:
return stringutil.Capitalize(fmt.Sprintf("%s is temporarily overloaded.", subject))
case codersdk.ChatErrorKindRateLimit:
return stringutil.Capitalize(fmt.Sprintf("%s is rate limiting requests.", subject))
case codersdk.ChatErrorKindTimeout:
return stringutil.Capitalize(fmt.Sprintf("%s is temporarily unavailable.", subject))
case codersdk.ChatErrorKindStreamSilenceTimeout:
return stringutil.Capitalize(fmt.Sprintf(
"%s did not send response data in time.", subject,
))
case codersdk.ChatErrorKindAuth:
return fmt.Sprintf(
"Authentication with %s failed.", subject,
)
case codersdk.ChatErrorKindConfig:
return stringutil.Capitalize(fmt.Sprintf(
"%s rejected the model configuration.", subject,
))
case codersdk.ChatErrorKindMissingKey:
return "The API key for this conversation is no longer available."
case codersdk.ChatErrorKindProviderDisabled:
displayName := providerDisplayName(classified.Provider)
return fmt.Sprintf(
"The %s provider has been disabled by an administrator.",
displayName,
)
default:
return stringutil.Capitalize(fmt.Sprintf(
"%s returned an unexpected error.", subject,
))
}
}
// ContentFilterMessage is the user-facing message for a response blocked by
// the provider's content filter.
func ContentFilterMessage(provider, category string) string {
subject := providerSubject(provider)
if category = strings.TrimSpace(category); category != "" {
return stringutil.Capitalize(fmt.Sprintf(
"%s blocked this response under its content policy (%s).", subject, category,
))
}
return stringutil.Capitalize(fmt.Sprintf(
"%s blocked this response under its content policy.", subject,
))
}
func providerSubject(provider string) string {
if displayName := providerDisplayName(provider); displayName != "AI" && displayName != "" {
return displayName
}
return "the AI provider"
}
func providerDisplayName(provider string) string {
switch normalizeProvider(provider) {
case "anthropic":
return "Anthropic"
case "azure":
return "Azure OpenAI"
case "bedrock":
return "AWS Bedrock"
case "google":
return "Google"
case "openai":
return "OpenAI"
case "openai-compat":
return "OpenAI Compatible"
case "openrouter":
return "OpenRouter"
case "vercel":
return "Vercel AI Gateway"
default:
return "AI"
}
}
func normalizeProvider(provider string) string {
normalized := strings.ToLower(strings.TrimSpace(provider))
switch normalized {
case "azure openai", "azure-openai":
return "azure"
case "openai compat", "openai compatible", "openai_compat":
return "openai-compat"
default:
return normalized
}
}