diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 835823ecf4..7bca7d7988 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16498,7 +16498,8 @@ const docTemplate = `{ "auth", "config", "usage_limit", - "missing_key" + "missing_key", + "provider_disabled" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -16509,7 +16510,8 @@ const docTemplate = `{ "ChatErrorKindAuth", "ChatErrorKindConfig", "ChatErrorKindUsageLimit", - "ChatErrorKindMissingKey" + "ChatErrorKindMissingKey", + "ChatErrorKindProviderDisabled" ] }, "codersdk.ChatFileMetadata": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index db7cbf6809..d641744117 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14848,7 +14848,8 @@ "auth", "config", "usage_limit", - "missing_key" + "missing_key", + "provider_disabled" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -14859,7 +14860,8 @@ "ChatErrorKindAuth", "ChatErrorKindConfig", "ChatErrorKindUsageLimit", - "ChatErrorKindMissingKey" + "ChatErrorKindMissingKey", + "ChatErrorKindProviderDisabled" ] }, "codersdk.ChatFileMetadata": { diff --git a/coderd/x/chatd/chaterror/classify.go b/coderd/x/chatd/chaterror/classify.go index 73e50f083b..4bf28efd4f 100644 --- a/coderd/x/chatd/chaterror/classify.go +++ b/coderd/x/chatd/chaterror/classify.go @@ -195,6 +195,7 @@ func Classify(err error) ClassifiedError { } retryableHTTP2StreamReset, hasHTTP2StreamReset := classifyHTTP2StreamReset(err) + providerDisabledMatch := containsAny(lower, providerDisabledPatterns...) deadline := errors.Is(err, context.DeadlineExceeded) || strings.Contains(lower, "context deadline exceeded") overloadedMatch := statusCode == 529 || containsAny(lower, overloadedPatterns...) usageLimitMatch := containsAny(lower, usageLimitPatterns...) @@ -221,6 +222,8 @@ func Classify(err error) ClassifiedError { // over whatever HTTP status code the provider happened to use. // Strong auth still stays above config because bad credentials are // the root cause when both signals appear. + // Provider-disabled must precede timeout because disabled providers + // return 503, which matches the timeout rule. rules := []struct { match bool kind codersdk.ChatErrorKind @@ -251,6 +254,11 @@ func Classify(err error) ClassifiedError { kind: codersdk.ChatErrorKindRateLimit, retryable: true, }, + { + match: providerDisabledMatch, + kind: codersdk.ChatErrorKindProviderDisabled, + retryable: false, + }, { match: timeoutMatch && !configMatch, kind: codersdk.ChatErrorKindTimeout, diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 8e1a9783c3..0e2e008bb8 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -2,6 +2,7 @@ package chaterror_test import ( "context" + "fmt" "io" "net/http" "strings" @@ -218,6 +219,85 @@ func TestClassify(t *testing.T) { StatusCode: 0, }, }, + // The next cases model the error that fantasy produces + // when aibridge's disabledProviderHandler returns a 503 + // plain-text sentinel. Fantasy sets Title from the HTTP + // status text and Message from the response body (including + // the trailing newline written by http.Error). + { + name: "ProviderDisabled503ClassifiesAsProviderDisabled", + err: &fantasy.ProviderError{ + Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable), + Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "openai"), + StatusCode: http.StatusServiceUnavailable, + }, + want: chaterror.ClassifiedError{ + Message: "The OpenAI provider has been disabled. Contact your Coder administrator.", + Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "openai"), + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "openai", + Retryable: false, + StatusCode: 503, + }, + }, + { + name: "ProviderDisabled503UnknownProvider", + err: &fantasy.ProviderError{ + Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable), + Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "mycustomprovider"), + StatusCode: http.StatusServiceUnavailable, + }, + want: chaterror.ClassifiedError{ + Message: "The AI provider has been disabled. Contact your Coder administrator.", + Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "mycustomprovider"), + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "", + Retryable: false, + StatusCode: 503, + }, + }, + { + name: "ProviderDisabledPlainErrorString", + err: xerrors.New(fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "anthropic")), + want: chaterror.ClassifiedError{ + Message: "The Anthropic provider has been disabled. Contact your Coder administrator.", + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "anthropic", + Retryable: false, + StatusCode: 0, + }, + }, + { + name: "ProviderDisabledBeatsTimeout503", + err: &fantasy.ProviderError{ + Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable), + Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "google"), + StatusCode: http.StatusServiceUnavailable, + }, + want: chaterror.ClassifiedError{ + Message: "The Google provider has been disabled. Contact your Coder administrator.", + Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "google"), + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "google", + Retryable: false, + StatusCode: 503, + }, + }, + { + name: "Generic503StillClassifiesAsTimeout", + err: &fantasy.ProviderError{ + Message: "service unavailable", + StatusCode: 503, + }, + want: chaterror.ClassifiedError{ + Message: "The AI provider is temporarily unavailable.", + Detail: "service unavailable", + Kind: codersdk.ChatErrorKindTimeout, + Provider: "", + Retryable: true, + StatusCode: 503, + }, + }, } for _, tt := range tests { @@ -363,6 +443,7 @@ func TestClassify_PatternCoverage(t *testing.T) { {name: "OperationInterruptedLiteral", err: "operation interrupted", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: false}, {name: "Status408", err: "status 408", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, {name: "Status500", err: "status 500", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: true}, + {name: "ProviderDisabledLiteral", err: "provider_disabled", wantKind: codersdk.ChatErrorKindProviderDisabled, wantRetry: false}, } for _, tt := range tests { diff --git a/coderd/x/chatd/chaterror/message.go b/coderd/x/chatd/chaterror/message.go index 5257420061..fef3ba78fa 100644 --- a/coderd/x/chatd/chaterror/message.go +++ b/coderd/x/chatd/chaterror/message.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + stringutil "github.com/coder/coder/v2/coderd/util/strings" "github.com/coder/coder/v2/codersdk" ) @@ -16,60 +17,58 @@ func terminalMessage(classified ClassifiedError) string { subject := providerSubject(classified.Provider) switch classified.Kind { case codersdk.ChatErrorKindOverloaded: - return fmt.Sprintf("%s is temporarily overloaded.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily overloaded.", subject)) case codersdk.ChatErrorKindRateLimit: - return fmt.Sprintf("%s is rate limiting requests.", subject) + 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 fmt.Sprintf("%s is temporarily unavailable.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily unavailable.", subject)) case codersdk.ChatErrorKindStartupTimeout: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s did not start responding in time.", subject, - ) + )) case codersdk.ChatErrorKindUsageLimit: - displayName := providerDisplayName(classified.Provider) - if displayName == "" { - displayName = "the AI provider" - } - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "The usage quota for %s has been exceeded."+ " Check the billing and quota settings for the provider account.", - displayName, - ) + subject, + )) case codersdk.ChatErrorKindAuth: - displayName := providerDisplayName(classified.Provider) - if displayName == "" { - displayName = "the AI provider" - } return fmt.Sprintf( "Authentication with %s failed."+ " Check the API key and permissions.", - displayName, + subject, ) case codersdk.ChatErrorKindConfig: - return fmt.Sprintf( + 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, + ) default: if !classified.Retryable && classified.StatusCode == 0 { return "The chat request failed unexpectedly." } - return fmt.Sprintf("%s returned an unexpected error.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s returned an unexpected error.", subject)) } } @@ -85,41 +84,43 @@ func retryMessage(classified ClassifiedError) string { subject := providerSubject(classified.Provider) switch classified.Kind { case codersdk.ChatErrorKindOverloaded: - return fmt.Sprintf("%s is temporarily overloaded.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily overloaded.", subject)) case codersdk.ChatErrorKindRateLimit: - return fmt.Sprintf("%s is rate limiting requests.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is rate limiting requests.", subject)) case codersdk.ChatErrorKindTimeout: - return fmt.Sprintf("%s is temporarily unavailable.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily unavailable.", subject)) case codersdk.ChatErrorKindStartupTimeout: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s did not start responding in time.", subject, - ) + )) case codersdk.ChatErrorKindAuth: - displayName := providerDisplayName(classified.Provider) - if displayName == "" { - displayName = "the AI provider" - } return fmt.Sprintf( - "Authentication with %s failed.", displayName, + "Authentication with %s failed.", subject, ) case codersdk.ChatErrorKindConfig: - return fmt.Sprintf( + 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." - default: + case codersdk.ChatErrorKindProviderDisabled: + displayName := providerDisplayName(classified.Provider) return fmt.Sprintf( - "%s returned an unexpected error.", subject, + "The %s provider has been disabled by an administrator.", + displayName, ) + default: + return stringutil.Capitalize(fmt.Sprintf( + "%s returned an unexpected error.", subject, + )) } } func providerSubject(provider string) string { - if displayName := providerDisplayName(provider); displayName != "" { + if displayName := providerDisplayName(provider); displayName != "AI" && displayName != "" { return displayName } - return "The AI provider" + return "the AI provider" } func providerDisplayName(provider string) string { @@ -141,7 +142,7 @@ func providerDisplayName(provider string) string { case "vercel": return "Vercel AI Gateway" default: - return "" + return "AI" } } diff --git a/coderd/x/chatd/chaterror/signals.go b/coderd/x/chatd/chaterror/signals.go index ebe6ff939b..8dad919127 100644 --- a/coderd/x/chatd/chaterror/signals.go +++ b/coderd/x/chatd/chaterror/signals.go @@ -4,6 +4,8 @@ import ( "regexp" "strconv" "strings" + + "github.com/coder/coder/v2/aibridge" ) type providerHint struct { @@ -83,6 +85,7 @@ var ( } genericRetryablePatterns = []string{"server error", "internal server error"} interruptedPatterns = []string{"chat interrupted", "request interrupted", "operation interrupted"} + providerDisabledPatterns = []string{aibridge.ErrorCodeProviderDisabled} ) func extractStatusCode(lower string) int { diff --git a/codersdk/chats.go b/codersdk/chats.go index c6deeb35aa..bcf235f590 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1525,15 +1525,16 @@ type ChatStreamStatus struct { type ChatErrorKind string const ( - ChatErrorKindGeneric ChatErrorKind = "generic" - ChatErrorKindOverloaded ChatErrorKind = "overloaded" - ChatErrorKindRateLimit ChatErrorKind = "rate_limit" - ChatErrorKindTimeout ChatErrorKind = "timeout" - ChatErrorKindStartupTimeout ChatErrorKind = "startup_timeout" - ChatErrorKindAuth ChatErrorKind = "auth" - ChatErrorKindConfig ChatErrorKind = "config" - ChatErrorKindUsageLimit ChatErrorKind = "usage_limit" - ChatErrorKindMissingKey ChatErrorKind = "missing_key" + ChatErrorKindGeneric ChatErrorKind = "generic" + ChatErrorKindOverloaded ChatErrorKind = "overloaded" + ChatErrorKindRateLimit ChatErrorKind = "rate_limit" + ChatErrorKindTimeout ChatErrorKind = "timeout" + ChatErrorKindStartupTimeout ChatErrorKind = "startup_timeout" + ChatErrorKindAuth ChatErrorKind = "auth" + ChatErrorKindConfig ChatErrorKind = "config" + ChatErrorKindUsageLimit ChatErrorKind = "usage_limit" + ChatErrorKindMissingKey ChatErrorKind = "missing_key" + ChatErrorKindProviderDisabled ChatErrorKind = "provider_disabled" ) // AllChatErrorKinds contains every ChatErrorKind value. @@ -1548,6 +1549,7 @@ var AllChatErrorKinds = []ChatErrorKind{ ChatErrorKindConfig, ChatErrorKindUsageLimit, ChatErrorKindMissingKey, + ChatErrorKindProviderDisabled, } // ChatError represents a terminal chat error in persisted chat state or the diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 691b2a2bc5..f475d8482d 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -292,13 +292,13 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|---------------|---------------------------------------------------------------------------------------------------------------------| -| `client_type` | `api`, `ui` | -| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | -| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | -| `plan_mode` | `plan` | -| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` | +| Property | Value(s) | +|---------------|------------------------------------------------------------------------------------------------------------------------------------------| +| `client_type` | `api`, `ui` | +| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | +| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | +| `plan_mode` | `plan` | +| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index cd5f603559..771657d3c0 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2681,9 +2681,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|---------------------------------------------------------------------------------------------------------------------| -| `auth`, `config`, `generic`, `missing_key`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | +| Value(s) | +|------------------------------------------------------------------------------------------------------------------------------------------| +| `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | ## codersdk.ChatFileMetadata diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 74b6041f3b..8a465a3073 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1969,6 +1969,7 @@ export type ChatErrorKind = | "generic" | "missing_key" | "overloaded" + | "provider_disabled" | "rate_limit" | "startup_timeout" | "timeout" @@ -1980,6 +1981,7 @@ export const ChatErrorKinds: ChatErrorKind[] = [ "generic", "missing_key", "overloaded", + "provider_disabled", "rate_limit", "startup_timeout", "timeout", diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx index b58a6b8ebe..32484ed15c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx @@ -288,6 +288,40 @@ export const TerminalStartupTimeoutError: Story = { }, }; +/** Disabled provider errors render an admin-oriented message without retry. */ +export const TerminalProviderDisabledError: Story = { + args: { + ...defaultArgs, + liveStatus: buildLiveStatus({ + streamError: { + kind: "provider_disabled", + message: + "The OpenAI provider has been disabled. Contact your Coder administrator.", + provider: "openai", + retryable: false, + statusCode: 503, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /provider disabled/i }), + ).toBeVisible(); + expect( + canvas.getByText( + /the openai provider has been disabled.*contact your coder administrator/i, + ), + ).toBeVisible(); + expect(canvas.getByText(/^HTTP 503$/)).toBeVisible(); + // No retry or status link for administrative disablement. + expect(canvas.queryByText(/retrying/i)).not.toBeInTheDocument(); + expect( + canvas.queryByRole("link", { name: /status/i }), + ).not.toBeInTheDocument(); + }, +}; + /** Generic failures do not show usage or provider CTAs. */ export const GenericErrorDoesNotShowUsageAction: Story = { args: { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts index 243296ea2c..d9ea6f6e59 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts @@ -44,6 +44,8 @@ export const getErrorTitle = ( return "Usage limit reached"; case "missing_key": return "Chat interrupted"; + case "provider_disabled": + return "Provider disabled"; default: return mode === "retry" ? "Retrying request" : "Request failed"; } diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts index 1986da0227..1c7f10f00f 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts @@ -11,9 +11,8 @@ type UsageLimitData = Partial< /** * Typed classification for errors surfaced in the agent detail view. * - "usage_limit": the user hit a spending cap (409 + valid usage data). - * - other kinds come from normalized stream/provider failures such as - * "generic", "overloaded", "rate_limit", "timeout", - * "startup_timeout", "auth", and "config". + * - other kinds come from normalized stream/provider failures. + * See ChatErrorKind for the full set. */ export type ChatDetailError = { message: string;