mirror of
https://github.com/coder/coder.git
synced 2026-11-01 04:47:17 +08:00
## 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.
173 lines
5.5 KiB
Go
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
|
|
}
|
|
}
|