mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
Generated
+4
-2
@@ -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": {
|
||||
|
||||
Generated
+4
-2
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+7
-7
@@ -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).
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -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
|
||||
|
||||
|
||||
Generated
+2
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user