From 6df1536256475730d2fdba72083d3142c3089a25 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 28 May 2026 15:50:52 +0100 Subject: [PATCH] fix: add missing_key error kind for missing chat api_key_id (#25783) Refs CODAGT-486 - `codersdk/chats.go`: New `ChatErrorKindMissingKey` constant and `AllChatErrorKinds` entry - `coderd/x/chatd/chaterror/message.go`: `terminalMessage` and `retryMessage` cases - `coderd/x/chatd/model_routing_aibridge.go`: Pre-classify error with `WithClassification` - `coderd/x/chatd/model_routing_internal_test.go`: Classification assertion on production path (CRF-2) - `chatStatusHelpers.ts`: Frontend title "Chat interrupted" - `LiveStreamTail.stories.tsx`: Storybook story with `detail` assertion - `docs/ai-coder/ai-gateway/clients/coder-agents.md`: Troubleshooting entry - Tests: classification round-trip, terminal message, metrics kind enumeration > Generated with [Coder Agents](https://coder.com/agents) on behalf of @johnstcn --- coderd/apidoc/docs.go | 6 ++-- coderd/apidoc/swagger.json | 6 ++-- coderd/x/chatd/chaterror/classify_test.go | 22 ++++++++++++ coderd/x/chatd/chaterror/message.go | 6 ++++ coderd/x/chatd/chaterror/message_test.go | 7 ++++ coderd/x/chatd/chatloop/metrics_test.go | 1 + coderd/x/chatd/model_routing_aibridge.go | 11 +++++- coderd/x/chatd/model_routing_internal_test.go | 7 ++++ codersdk/chats.go | 2 ++ .../ai-gateway/clients/coder-agents.md | 7 ++++ docs/reference/api/chats.md | 14 ++++---- docs/reference/api/schemas.md | 6 ++-- site/src/api/typesGenerated.ts | 2 ++ .../LiveStreamTail.stories.tsx | 35 +++++++++++++++++++ .../ChatConversation/chatStatusHelpers.ts | 2 ++ 15 files changed, 119 insertions(+), 15 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 65f2c1927c..15e45d9f46 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16554,7 +16554,8 @@ const docTemplate = `{ "startup_timeout", "auth", "config", - "usage_limit" + "usage_limit", + "missing_key" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -16564,7 +16565,8 @@ const docTemplate = `{ "ChatErrorKindStartupTimeout", "ChatErrorKindAuth", "ChatErrorKindConfig", - "ChatErrorKindUsageLimit" + "ChatErrorKindUsageLimit", + "ChatErrorKindMissingKey" ] }, "codersdk.ChatFileMetadata": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 33ffe3e4b4..b65c8a95fb 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14904,7 +14904,8 @@ "startup_timeout", "auth", "config", - "usage_limit" + "usage_limit", + "missing_key" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -14914,7 +14915,8 @@ "ChatErrorKindStartupTimeout", "ChatErrorKindAuth", "ChatErrorKindConfig", - "ChatErrorKindUsageLimit" + "ChatErrorKindUsageLimit", + "ChatErrorKindMissingKey" ] }, "codersdk.ChatFileMetadata": { diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 457704bd5f..8e1a9783c3 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -1158,6 +1158,28 @@ func TestClassify_ChainBrokenSurvivesWithClassification(t *testing.T) { " can detect it after re-classification") } +func TestClassify_MissingKeyPreClassified(t *testing.T) { + t.Parallel() + + raw := xerrors.New("AI Gateway routing requires the active turn API key ID") + wrapped := chaterror.WithClassification(raw, chaterror.ClassifiedError{ + Kind: codersdk.ChatErrorKindMissingKey, + Retryable: false, + Detail: "If this error persists after resending, please report it as a bug.", + }) + + classified := chaterror.Classify(wrapped) + require.Equal(t, codersdk.ChatErrorKindMissingKey, classified.Kind) + require.False(t, classified.Retryable) + require.Equal(t, "If this error persists after resending, please report it as a bug.", classified.Detail) + require.Equal(t, + "This conversation was started with an API key that is no longer available."+ + " Send your message again to continue.", + classified.Message, + "Message should be filled by terminalMessage when not set explicitly", + ) +} + func testProviderError( message string, statusCode int, diff --git a/coderd/x/chatd/chaterror/message.go b/coderd/x/chatd/chaterror/message.go index a551078349..5257420061 100644 --- a/coderd/x/chatd/chaterror/message.go +++ b/coderd/x/chatd/chaterror/message.go @@ -61,6 +61,10 @@ func terminalMessage(classified ClassifiedError) string { subject, ) + case codersdk.ChatErrorKindMissingKey: + return "This conversation was started with an API key that is no longer available." + + " Send your message again to continue." + default: if !classified.Retryable && classified.StatusCode == 0 { return "The chat request failed unexpectedly." @@ -102,6 +106,8 @@ func retryMessage(classified ClassifiedError) string { return fmt.Sprintf( "%s rejected the model configuration.", subject, ) + case codersdk.ChatErrorKindMissingKey: + return "The API key for this conversation is no longer available." default: return fmt.Sprintf( "%s returned an unexpected error.", subject, diff --git a/coderd/x/chatd/chaterror/message_test.go b/coderd/x/chatd/chaterror/message_test.go index 87cb375cbc..94bf14bd13 100644 --- a/coderd/x/chatd/chaterror/message_test.go +++ b/coderd/x/chatd/chaterror/message_test.go @@ -90,6 +90,13 @@ func TestTerminalMessage(t *testing.T) { retryable: false, want: "The usage quota for the AI provider has been exceeded. Check the billing and quota settings for the provider account.", }, + { + name: "MissingKey", + kind: codersdk.ChatErrorKindMissingKey, + provider: "", + retryable: false, + want: "This conversation was started with an API key that is no longer available. Send your message again to continue.", + }, } for _, tt := range tests { diff --git a/coderd/x/chatd/chatloop/metrics_test.go b/coderd/x/chatd/chatloop/metrics_test.go index 7aa3885750..c0c86deacc 100644 --- a/coderd/x/chatd/chatloop/metrics_test.go +++ b/coderd/x/chatd/chatloop/metrics_test.go @@ -296,6 +296,7 @@ func TestRecordStreamRetry(t *testing.T) { {name: "startup_timeout", kind: codersdk.ChatErrorKindStartupTimeout}, {name: "auth", kind: codersdk.ChatErrorKindAuth}, {name: "config", kind: codersdk.ChatErrorKindConfig}, + {name: "missing_key", kind: codersdk.ChatErrorKindMissingKey}, {name: "generic", kind: codersdk.ChatErrorKindGeneric}, {name: "chain_broken", kind: codersdk.ChatErrorKindGeneric, chainBroken: true}, } diff --git a/coderd/x/chatd/model_routing_aibridge.go b/coderd/x/chatd/model_routing_aibridge.go index 5db1a16e53..07e8fd66b0 100644 --- a/coderd/x/chatd/model_routing_aibridge.go +++ b/coderd/x/chatd/model_routing_aibridge.go @@ -16,7 +16,9 @@ import ( "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/x/chatd/chatdebug" + "github.com/coder/coder/v2/coderd/x/chatd/chaterror" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" + "github.com/coder/coder/v2/codersdk" ) const ( @@ -98,7 +100,14 @@ func (p *Server) newAIGatewayModel( return nil, xerrors.New("AI Gateway routing requires an AI provider name") } if opts.ActiveAPIKeyID == "" { - return nil, xerrors.New("AI Gateway routing requires the active turn API key ID") + return nil, chaterror.WithClassification( + xerrors.New("AI Gateway routing requires the active turn API key ID"), + chaterror.ClassifiedError{ + Kind: codersdk.ChatErrorKindMissingKey, + Retryable: false, + Detail: "If this error persists after resending, please report it as a bug.", + }, + ) } factoryPtr := p.aibridgeTransportFactory diff --git a/coderd/x/chatd/model_routing_internal_test.go b/coderd/x/chatd/model_routing_internal_test.go index 786365d9fb..0d2f317204 100644 --- a/coderd/x/chatd/model_routing_internal_test.go +++ b/coderd/x/chatd/model_routing_internal_test.go @@ -20,8 +20,10 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/x/chatd/chaterror" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" "github.com/coder/coder/v2/coderd/x/chatd/chattool" + "github.com/coder/coder/v2/codersdk" ) type aibridgeTestFactory struct { @@ -530,6 +532,11 @@ func TestAIBridgeRoutingFailClosed(t *testing.T) { } _, err := server.newModel(t.Context(), aibridgeTestRequest(chat, "gpt-4"), aibridgeTestRoute(aiProvider), modelBuildOptions{}) require.ErrorContains(t, err, "active turn API key ID") + + classified := chaterror.Classify(err) + require.Equal(t, codersdk.ChatErrorKindMissingKey, classified.Kind, + "production path must return a pre-classified missing_key error") + require.False(t, classified.Retryable) }) t.Run("StaticModel", func(t *testing.T) { diff --git a/codersdk/chats.go b/codersdk/chats.go index 665ace7aa8..c6deeb35aa 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1533,6 +1533,7 @@ const ( ChatErrorKindAuth ChatErrorKind = "auth" ChatErrorKindConfig ChatErrorKind = "config" ChatErrorKindUsageLimit ChatErrorKind = "usage_limit" + ChatErrorKindMissingKey ChatErrorKind = "missing_key" ) // AllChatErrorKinds contains every ChatErrorKind value. @@ -1546,6 +1547,7 @@ var AllChatErrorKinds = []ChatErrorKind{ ChatErrorKindAuth, ChatErrorKindConfig, ChatErrorKindUsageLimit, + ChatErrorKindMissingKey, } // ChatError represents a terminal chat error in persisted chat state or the diff --git a/docs/ai-coder/ai-gateway/clients/coder-agents.md b/docs/ai-coder/ai-gateway/clients/coder-agents.md index f5187cce58..de0fcad927 100644 --- a/docs/ai-coder/ai-gateway/clients/coder-agents.md +++ b/docs/ai-coder/ai-gateway/clients/coder-agents.md @@ -164,6 +164,13 @@ key is a valid Coder token. one [model](../../agents/models.md#add-a-model) to the provider after saving the Base URL. Providers without an enabled model are hidden from developers. +- **"Chat interrupted" error when resuming a conversation.** + This occurs when the API key that was used to start a chat turn is no + longer available. Common causes: upgrading from a version before + `api_key_id` tracking was introduced, or deleting an API key while a + chat is active. The error is self-healing: send your message again and + the new message will use your current API key. If the error persists + after resending, this indicates a bug. Please report it. ## Known limitations diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 758f6641f5..691b2a2bc5 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`, `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`, `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 912d53f8ce..34a7b57b9d 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2732,9 +2732,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|------------------------------------------------------------------------------------------------------| -| `auth`, `config`, `generic`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------| +| `auth`, `config`, `generic`, `missing_key`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | ## codersdk.ChatFileMetadata diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2c0df043ff..28412b9b09 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1959,6 +1959,7 @@ export type ChatErrorKind = | "auth" | "config" | "generic" + | "missing_key" | "overloaded" | "rate_limit" | "startup_timeout" @@ -1969,6 +1970,7 @@ export const ChatErrorKinds: ChatErrorKind[] = [ "auth", "config", "generic", + "missing_key", "overloaded", "rate_limit", "startup_timeout", diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx index a4ddf3bf6f..08fd59d1bb 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx @@ -193,6 +193,41 @@ export const TerminalTimeoutErrorUnknownProvider: Story = { }, }; +/** Missing API key shows the "Chat interrupted" terminal error. */ +export const TerminalMissingKeyError: Story = { + args: { + ...defaultArgs, + liveStatus: buildLiveStatus({ + streamError: { + kind: "missing_key", + message: + "This conversation was started with an API key that is no longer available. Send your message again to continue.", + retryable: false, + detail: + "If this error persists after resending, please report it as a bug.", + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /chat interrupted/i }), + ).toBeVisible(); + expect( + canvas.getByText( + /this conversation was started with an api key that is no longer available/i, + ), + ).toBeVisible(); + expect( + canvas.getByText(/if this error persists after resending/i), + ).toBeVisible(); + // Guard against the generic fallback. + expect( + canvas.queryByText(/the chat request failed unexpectedly/i), + ).not.toBeInTheDocument(); + }, +}; + /** Retrying a transport timeout shows attempt + countdown. */ export const RetryingTimeoutAnthropic: Story = { args: { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts index c718e2a007..243296ea2c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts @@ -42,6 +42,8 @@ export const getErrorTitle = ( return "Configuration error"; case "usage_limit": return "Usage limit reached"; + case "missing_key": + return "Chat interrupted"; default: return mode === "retry" ? "Retrying request" : "Request failed"; }