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
This commit is contained in:
Cian Johnston
2026-05-28 15:50:52 +01:00
committed by GitHub
parent ea280c5a90
commit 6df1536256
15 changed files with 119 additions and 15 deletions
+4 -2
View File
@@ -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": {
+4 -2
View File
@@ -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": {
+22
View File
@@ -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,
+6
View File
@@ -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,
+7
View File
@@ -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 {
+1
View File
@@ -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},
}
+10 -1
View File
@@ -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
@@ -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) {
+2
View File
@@ -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
@@ -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
+7 -7
View File
@@ -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).
+3 -3
View File
@@ -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
+2
View File
@@ -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",
@@ -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: {
@@ -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";
}