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) {