diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 94eae63b69..325471f805 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -28,7 +28,6 @@ import ( "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" - "github.com/coder/coder/v2/coderd/x/chatd/chaterror" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk/proto" @@ -1619,16 +1618,16 @@ func decodeChatLastError(raw pqtype.NullRawMessage) *codersdk.ChatError { if err := json.Unmarshal(raw.RawMessage, &payload); err != nil { return &codersdk.ChatError{ Message: fallbackChatLastErrorMessage, - Kind: chaterror.KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, } } payload.Message = strings.TrimSpace(payload.Message) payload.Detail = strings.TrimSpace(payload.Detail) - payload.Kind = strings.TrimSpace(payload.Kind) + payload.Kind = codersdk.ChatErrorKind(strings.TrimSpace(string(payload.Kind))) payload.Provider = strings.TrimSpace(payload.Provider) if payload.Kind == "" { - payload.Kind = chaterror.KindGeneric + payload.Kind = codersdk.ChatErrorKindGeneric } if payload.Message == "" { payload.Message = fallbackChatLastErrorMessage diff --git a/coderd/database/db2sdk/db2sdk_test.go b/coderd/database/db2sdk/db2sdk_test.go index 41a0d0e3d5..7a0fa09483 100644 --- a/coderd/database/db2sdk/db2sdk_test.go +++ b/coderd/database/db2sdk/db2sdk_test.go @@ -19,7 +19,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/coderd/x/chatd/chaterror" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk/proto" ) @@ -920,7 +919,7 @@ func TestChat_AllFieldsPopulated(t *testing.T) { lastErrorPayload := codersdk.ChatError{ Message: "boom", Detail: "provider detail", - Kind: chaterror.KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, Provider: "openai", Retryable: true, StatusCode: 503, @@ -1082,7 +1081,7 @@ func TestChat_LastErrorFallback(t *testing.T) { raw: json.RawMessage(`{`), expectPayload: &codersdk.ChatError{ Message: fallbackMessage, - Kind: chaterror.KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, Retryable: false, }, }, @@ -1091,7 +1090,7 @@ func TestChat_LastErrorFallback(t *testing.T) { raw: json.RawMessage(`{"kind":"timeout","provider":"openai","status_code":504}`), expectPayload: &codersdk.ChatError{ Message: fallbackMessage, - Kind: "timeout", + Kind: codersdk.ChatErrorKindTimeout, Provider: "openai", Retryable: false, StatusCode: 504, @@ -1102,7 +1101,7 @@ func TestChat_LastErrorFallback(t *testing.T) { raw: json.RawMessage(`{"message":" ","provider":"openai"}`), expectPayload: &codersdk.ChatError{ Message: fallbackMessage, - Kind: chaterror.KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, Provider: "openai", Retryable: false, }, @@ -1112,12 +1111,21 @@ func TestChat_LastErrorFallback(t *testing.T) { raw: json.RawMessage(`{"message":"OpenAI returned an unexpected error.","provider":"openai","status_code":502}`), expectPayload: &codersdk.ChatError{ Message: "OpenAI returned an unexpected error.", - Kind: chaterror.KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, Provider: "openai", Retryable: false, StatusCode: 502, }, }, + { + name: "UsageLimitKindRoundTrips", + raw: json.RawMessage(`{"message":"Usage limit reached.","kind":"usage_limit"}`), + expectPayload: &codersdk.ChatError{ + Message: "Usage limit reached.", + Kind: codersdk.ChatErrorKindUsageLimit, + Retryable: false, + }, + }, } for _, tc := range tests { diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index adebbc834b..822909b1bf 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -5362,7 +5362,7 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) { logger.Error(cleanupCtx, "panic during chat processing", slog.F("panic", r)) classified := chaterror.ClassifiedError{ Message: panicFailureReason(r), - Kind: chaterror.KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, } lastErrorPayload = chaterror.TerminalErrorPayload(classified) p.publishError(chat.ID, classified) @@ -8014,7 +8014,7 @@ func (p *Server) recoverStaleChats(ctx context.Context) { lastErrorPayload, marshalErr := encodeChatLastErrorPayload( chaterror.TerminalErrorPayload(chaterror.ClassifiedError{ Message: "Dynamic tool execution timed out", - Kind: chaterror.KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, }), ) if marshalErr != nil { diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index dd3180eae2..d58828afd6 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -2323,7 +2323,7 @@ func TestSubscribeDoesNotReplayRetryAfterTerminalError(t *testing.T) { server.publishRetry(chatID, newTestRetryPayload()) server.publishError(chatID, chaterror.ClassifiedError{ Message: "OpenAI is rate limiting requests.", - Kind: chaterror.KindRateLimit, + Kind: codersdk.ChatErrorKindRateLimit, Provider: "openai", Retryable: true, StatusCode: 429, @@ -2399,7 +2399,7 @@ func TestSubscribePrefersStructuredErrorPayloadViaPubsub(t *testing.T) { classified := chaterror.ClassifiedError{ Message: "OpenAI is rate limiting requests.", - Kind: chaterror.KindRateLimit, + Kind: codersdk.ChatErrorKindRateLimit, Provider: "openai", Retryable: true, StatusCode: 429, @@ -2449,7 +2449,7 @@ func TestSubscribeFallsBackToLegacyErrorStringViaPubsub(t *testing.T) { func newTestRetryPayload() *codersdk.ChatStreamRetry { payload := chaterror.StreamRetryPayload(1, 1500*time.Millisecond, chaterror.ClassifiedError{ Message: "OpenAI is rate limiting requests.", - Kind: chaterror.KindRateLimit, + Kind: codersdk.ChatErrorKindRateLimit, Provider: "openai", Retryable: true, StatusCode: 429, diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index f64a013fa3..0283a95030 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -46,7 +46,6 @@ import ( "github.com/coder/coder/v2/coderd/workspacestats" "github.com/coder/coder/v2/coderd/x/chatd" "github.com/coder/coder/v2/coderd/x/chatd/chatadvisor" - "github.com/coder/coder/v2/coderd/x/chatd/chaterror" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chattest" "github.com/coder/coder/v2/coderd/x/chatd/chattool" @@ -3690,7 +3689,7 @@ func TestRecoverStaleRequiresActionChat(t *testing.T) { persistedError := requireChatLastErrorPayload(t, chatResult.LastError) require.Equal(t, codersdk.ChatError{ Message: "Dynamic tool execution timed out", - Kind: chaterror.KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, }, persistedError) require.False(t, chatResult.WorkerID.Valid) } @@ -3803,7 +3802,7 @@ func TestUpdateChatStatusPersistsLastError(t *testing.T) { errorMessage := "stream response: status 500: internal server error" wantPayload := codersdk.ChatError{ Message: errorMessage, - Kind: chaterror.KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, } chat, err := db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{ ID: chat.ID, @@ -7077,7 +7076,7 @@ func TestProcessChat_UserProviderKey_MissingKeyError(t *testing.T) { persistedError := requireChatLastErrorPayload(t, chatResult.LastError) require.NotEmpty(t, persistedError.Message) require.NotContains(t, persistedError.Message, "panicked") - require.Equal(t, chaterror.KindGeneric, persistedError.Kind) + require.Equal(t, codersdk.ChatErrorKindGeneric, persistedError.Kind) require.NotEqual(t, database.ChatStatusRunning, chatResult.Status) require.Zero(t, llmCalls.Load(), "missing user key should fail before any LLM request") } @@ -7142,7 +7141,7 @@ func TestProcessChatPanicRecovery(t *testing.T) { persistedError := requireChatLastErrorPayload(t, chatResult.LastError) require.Contains(t, persistedError.Message, "chat processing panicked") require.Contains(t, persistedError.Message, "intentional test panic") - require.Equal(t, chaterror.KindGeneric, persistedError.Kind) + require.Equal(t, codersdk.ChatErrorKindGeneric, persistedError.Kind) } // panicOnInTxDB wraps a database.Store and panics on the first InTx diff --git a/coderd/x/chatd/chaterror/classify.go b/coderd/x/chatd/chaterror/classify.go index 2a37fc767f..3fae9ed344 100644 --- a/coderd/x/chatd/chaterror/classify.go +++ b/coderd/x/chatd/chaterror/classify.go @@ -5,6 +5,8 @@ import ( "errors" "strings" "time" + + "github.com/coder/coder/v2/codersdk" ) // ClassifiedError is the normalized, user-facing view of an @@ -12,7 +14,7 @@ import ( type ClassifiedError struct { Message string Detail string - Kind string + Kind codersdk.ChatErrorKind Provider string Retryable bool StatusCode int @@ -117,7 +119,7 @@ func Classify(err error) ClassifiedError { return normalizeClassification(ClassifiedError{ Message: "The request was canceled before it completed.", Detail: structured.detail, - Kind: KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, Provider: provider, StatusCode: statusCode, RetryAfter: structured.retryAfter, @@ -128,7 +130,7 @@ func Classify(err error) ClassifiedError { return normalizeClassification(ClassifiedError{ Message: responsesAPIDiagnosticMessage, Detail: detail, - Kind: KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, Provider: provider, StatusCode: statusCode, RetryAfter: structured.retryAfter, @@ -154,42 +156,42 @@ func Classify(err error) ClassifiedError { // the root cause when both signals appear. rules := []struct { match bool - kind string + kind codersdk.ChatErrorKind retryable bool }{ { match: overloadedMatch, - kind: KindOverloaded, + kind: codersdk.ChatErrorKindOverloaded, retryable: true, }, { match: authStrong, - kind: KindAuth, + kind: codersdk.ChatErrorKindAuth, retryable: false, }, { match: authWeak && !configMatch, - kind: KindAuth, + kind: codersdk.ChatErrorKindAuth, retryable: false, }, { match: rateLimitMatch && !configMatch, - kind: KindRateLimit, + kind: codersdk.ChatErrorKindRateLimit, retryable: true, }, { match: timeoutMatch && !configMatch, - kind: KindTimeout, + kind: codersdk.ChatErrorKindTimeout, retryable: !deadline, }, { match: configMatch, - kind: KindConfig, + kind: codersdk.ChatErrorKindConfig, retryable: false, }, { match: genericRetryableMatch, - kind: KindGeneric, + kind: codersdk.ChatErrorKindGeneric, retryable: true, }, } @@ -209,7 +211,7 @@ func Classify(err error) ClassifiedError { return normalizeClassification(ClassifiedError{ Detail: structured.detail, - Kind: KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, Provider: provider, StatusCode: statusCode, RetryAfter: structured.retryAfter, @@ -229,7 +231,7 @@ func responsesAPIDiagnostic(lowerMessage, detail string) (string, bool) { func normalizeClassification(classified ClassifiedError) ClassifiedError { classified.Message = strings.TrimSpace(classified.Message) classified.Detail = normalizeClassificationDetail(classified.Detail) - classified.Kind = strings.TrimSpace(classified.Kind) + classified.Kind = codersdk.ChatErrorKind(strings.TrimSpace(string(classified.Kind))) classified.Provider = normalizeProvider(classified.Provider) if classified.RetryAfter < 0 { classified.RetryAfter = 0 @@ -239,10 +241,10 @@ func normalizeClassification(classified ClassifiedError) ClassifiedError { classified.RetryAfter <= 0 { return ClassifiedError{} } - classified.Kind = KindGeneric + classified.Kind = codersdk.ChatErrorKindGeneric } if classified.Kind == "" { - classified.Kind = KindGeneric + classified.Kind = codersdk.ChatErrorKindGeneric } if classified.Message == "" { classified.Message = terminalMessage(classified) diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 353c758134..d987685f29 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -12,6 +12,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" + "github.com/coder/coder/v2/codersdk" ) func TestClassify(t *testing.T) { @@ -27,7 +28,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("status 529 from upstream"), want: chaterror.ClassifiedError{ Message: "The AI provider is temporarily overloaded.", - Kind: chaterror.KindOverloaded, + Kind: codersdk.ChatErrorKindOverloaded, Provider: "", Retryable: true, StatusCode: 529, @@ -38,7 +39,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("anthropic overloaded_error"), want: chaterror.ClassifiedError{ Message: "Anthropic is temporarily overloaded.", - Kind: chaterror.KindOverloaded, + Kind: codersdk.ChatErrorKindOverloaded, Provider: "anthropic", Retryable: true, StatusCode: 0, @@ -49,7 +50,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("authentication failed: invalid model"), want: chaterror.ClassifiedError{ Message: "Authentication with the AI provider failed. Check the API key, permissions, and billing settings.", - Kind: chaterror.KindAuth, + Kind: codersdk.ChatErrorKindAuth, Provider: "", Retryable: false, StatusCode: 0, @@ -60,7 +61,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("invalid model"), want: chaterror.ClassifiedError{ Message: "The AI provider rejected the model configuration. Check the selected model and provider settings.", - Kind: chaterror.KindConfig, + Kind: codersdk.ChatErrorKindConfig, Provider: "", Retryable: false, StatusCode: 0, @@ -71,7 +72,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("forbidden"), want: chaterror.ClassifiedError{ Message: "Authentication with the AI provider failed. Check the API key, permissions, and billing settings.", - Kind: chaterror.KindAuth, + Kind: codersdk.ChatErrorKindAuth, Provider: "", Retryable: false, StatusCode: 0, @@ -82,7 +83,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("status 401 from upstream"), want: chaterror.ClassifiedError{ Message: "Authentication with the AI provider failed. Check the API key, permissions, and billing settings.", - Kind: chaterror.KindAuth, + Kind: codersdk.ChatErrorKindAuth, Provider: "", Retryable: false, StatusCode: 401, @@ -93,7 +94,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("status 403 from upstream"), want: chaterror.ClassifiedError{ Message: "Authentication with the AI provider failed. Check the API key, permissions, and billing settings.", - Kind: chaterror.KindAuth, + Kind: codersdk.ChatErrorKindAuth, Provider: "", Retryable: false, StatusCode: 403, @@ -104,7 +105,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("forbidden: context length exceeded"), want: chaterror.ClassifiedError{ Message: "The AI provider rejected the model configuration. Check the selected model and provider settings.", - Kind: chaterror.KindConfig, + Kind: codersdk.ChatErrorKindConfig, Provider: "", Retryable: false, StatusCode: 0, @@ -115,7 +116,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("status 429 from upstream"), want: chaterror.ClassifiedError{ Message: "The AI provider is rate limiting requests.", - Kind: chaterror.KindRateLimit, + Kind: codersdk.ChatErrorKindRateLimit, Provider: "", Retryable: true, StatusCode: 429, @@ -126,7 +127,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("status 429: invalid model"), want: chaterror.ClassifiedError{ Message: "The AI provider rejected the model configuration. Check the selected model and provider settings.", - Kind: chaterror.KindConfig, + Kind: codersdk.ChatErrorKindConfig, Provider: "", Retryable: false, StatusCode: 429, @@ -137,7 +138,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("service unavailable"), want: chaterror.ClassifiedError{ Message: "The AI provider is temporarily unavailable.", - Kind: chaterror.KindTimeout, + Kind: codersdk.ChatErrorKindTimeout, Provider: "", Retryable: true, StatusCode: 0, @@ -148,7 +149,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("status 503: invalid model"), want: chaterror.ClassifiedError{ Message: "The AI provider rejected the model configuration. Check the selected model and provider settings.", - Kind: chaterror.KindConfig, + Kind: codersdk.ChatErrorKindConfig, Provider: "", Retryable: false, StatusCode: 503, @@ -159,7 +160,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("service unavailable: model not found"), want: chaterror.ClassifiedError{ Message: "The AI provider rejected the model configuration. Check the selected model and provider settings.", - Kind: chaterror.KindConfig, + Kind: codersdk.ChatErrorKindConfig, Provider: "", Retryable: false, StatusCode: 0, @@ -170,7 +171,7 @@ func TestClassify(t *testing.T) { err: xerrors.New("connection refused: unsupported model"), want: chaterror.ClassifiedError{ Message: "The AI provider rejected the model configuration. Check the selected model and provider settings.", - Kind: chaterror.KindConfig, + Kind: codersdk.ChatErrorKindConfig, Provider: "", Retryable: false, StatusCode: 0, @@ -181,7 +182,7 @@ func TestClassify(t *testing.T) { err: context.DeadlineExceeded, want: chaterror.ClassifiedError{ Message: "The request timed out before it completed.", - Kind: chaterror.KindTimeout, + Kind: codersdk.ChatErrorKindTimeout, Provider: "", Retryable: false, StatusCode: 0, @@ -243,7 +244,7 @@ func TestClassify_OpenAIResponsesAPIDiagnostics(t *testing.T) { t.Parallel() classified := chaterror.Classify(xerrors.New(tt.err)) - require.Equal(t, chaterror.KindGeneric, classified.Kind) + require.Equal(t, codersdk.ChatErrorKindGeneric, classified.Kind) require.False(t, classified.Retryable) require.Zero(t, classified.StatusCode) assertDirectionalMessage(t, classified.Message) @@ -263,7 +264,7 @@ func TestClassify_OpenAIResponsesAPIDiagnostics(t *testing.T) { testProviderResponseDump(tt.responseBody), ), )) - require.Equal(t, chaterror.KindGeneric, classified.Kind) + require.Equal(t, codersdk.ChatErrorKindGeneric, classified.Kind) require.False(t, classified.Retryable) require.Equal(t, 400, classified.StatusCode) assertDirectionalMessage(t, classified.Message) @@ -279,54 +280,54 @@ func TestClassify_PatternCoverage(t *testing.T) { tests := []struct { name string err string - wantKind string + wantKind codersdk.ChatErrorKind wantRetry bool }{ - {name: "OverloadedLiteral", err: "overloaded", wantKind: chaterror.KindOverloaded, wantRetry: true}, - {name: "RateLimitLiteral", err: "rate limit", wantKind: chaterror.KindRateLimit, wantRetry: true}, - {name: "RateLimitUnderscoreLiteral", err: "rate_limit", wantKind: chaterror.KindRateLimit, wantRetry: true}, - {name: "RateLimitedLiteral", err: "rate limited", wantKind: chaterror.KindRateLimit, wantRetry: true}, - {name: "RateLimitedHyphenLiteral", err: "rate-limited", wantKind: chaterror.KindRateLimit, wantRetry: true}, - {name: "TooManyRequestsLiteral", err: "too many requests", wantKind: chaterror.KindRateLimit, wantRetry: true}, - {name: "TimeoutLiteral", err: "timeout", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "TimedOutLiteral", err: "timed out", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "ServiceUnavailableLiteral", err: "service unavailable", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "UnavailableLiteral", err: "unavailable", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "ConnectionResetLiteral", err: "connection reset", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "ConnectionRefusedLiteral", err: "connection refused", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "EOFLiteral", err: "eof", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "BrokenPipeLiteral", err: "broken pipe", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "BadGatewayLiteral", err: "bad gateway", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "GatewayTimeoutLiteral", err: "gateway timeout", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "ClientConnLiteral", err: "client conn", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "GOAWAYLiteral", err: "goaway", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "HTTP2StreamClosedLiteral", err: "http2: stream closed", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "UseOfClosedNetworkConnectionLiteral", err: "use of closed network connection", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "AuthenticationLiteral", err: "authentication", wantKind: chaterror.KindAuth, wantRetry: false}, - {name: "UnauthorizedLiteral", err: "unauthorized", wantKind: chaterror.KindAuth, wantRetry: false}, - {name: "InvalidAPIKeyLiteral", err: "invalid api key", wantKind: chaterror.KindAuth, wantRetry: false}, - {name: "InvalidAPIKeyUnderscoreLiteral", err: "invalid_api_key", wantKind: chaterror.KindAuth, wantRetry: false}, - {name: "QuotaLiteral", err: "quota", wantKind: chaterror.KindAuth, wantRetry: false}, - {name: "BillingLiteral", err: "billing", wantKind: chaterror.KindAuth, wantRetry: false}, - {name: "InsufficientQuotaLiteral", err: "insufficient_quota", wantKind: chaterror.KindAuth, wantRetry: false}, - {name: "PaymentRequiredLiteral", err: "payment required", wantKind: chaterror.KindAuth, wantRetry: false}, - {name: "ForbiddenLiteral", err: "forbidden", wantKind: chaterror.KindAuth, wantRetry: false}, - {name: "InvalidModelLiteral", err: "invalid model", wantKind: chaterror.KindConfig, wantRetry: false}, - {name: "ModelNotFoundLiteral", err: "model not found", wantKind: chaterror.KindConfig, wantRetry: false}, - {name: "ModelNotFoundUnderscoreLiteral", err: "model_not_found", wantKind: chaterror.KindConfig, wantRetry: false}, - {name: "UnsupportedModelLiteral", err: "unsupported model", wantKind: chaterror.KindConfig, wantRetry: false}, - {name: "ContextLengthExceededLiteral", err: "context length exceeded", wantKind: chaterror.KindConfig, wantRetry: false}, - {name: "ContextExceededLiteral", err: "context_exceeded", wantKind: chaterror.KindConfig, wantRetry: false}, - {name: "MaximumContextLengthLiteral", err: "maximum context length", wantKind: chaterror.KindConfig, wantRetry: false}, - {name: "MalformedConfigLiteral", err: "malformed config", wantKind: chaterror.KindConfig, wantRetry: false}, - {name: "MalformedConfigurationLiteral", err: "malformed configuration", wantKind: chaterror.KindConfig, wantRetry: false}, - {name: "ServerErrorLiteral", err: "server error", wantKind: chaterror.KindGeneric, wantRetry: true}, - {name: "InternalServerErrorLiteral", err: "internal server error", wantKind: chaterror.KindGeneric, wantRetry: true}, - {name: "ChatInterruptedLiteral", err: "chat interrupted", wantKind: chaterror.KindGeneric, wantRetry: false}, - {name: "RequestInterruptedLiteral", err: "request interrupted", wantKind: chaterror.KindGeneric, wantRetry: false}, - {name: "OperationInterruptedLiteral", err: "operation interrupted", wantKind: chaterror.KindGeneric, wantRetry: false}, - {name: "Status408", err: "status 408", wantKind: chaterror.KindTimeout, wantRetry: true}, - {name: "Status500", err: "status 500", wantKind: chaterror.KindGeneric, wantRetry: true}, + {name: "OverloadedLiteral", err: "overloaded", wantKind: codersdk.ChatErrorKindOverloaded, wantRetry: true}, + {name: "RateLimitLiteral", err: "rate limit", wantKind: codersdk.ChatErrorKindRateLimit, wantRetry: true}, + {name: "RateLimitUnderscoreLiteral", err: "rate_limit", wantKind: codersdk.ChatErrorKindRateLimit, wantRetry: true}, + {name: "RateLimitedLiteral", err: "rate limited", wantKind: codersdk.ChatErrorKindRateLimit, wantRetry: true}, + {name: "RateLimitedHyphenLiteral", err: "rate-limited", wantKind: codersdk.ChatErrorKindRateLimit, wantRetry: true}, + {name: "TooManyRequestsLiteral", err: "too many requests", wantKind: codersdk.ChatErrorKindRateLimit, wantRetry: true}, + {name: "TimeoutLiteral", err: "timeout", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "TimedOutLiteral", err: "timed out", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "ServiceUnavailableLiteral", err: "service unavailable", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "UnavailableLiteral", err: "unavailable", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "ConnectionResetLiteral", err: "connection reset", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "ConnectionRefusedLiteral", err: "connection refused", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "EOFLiteral", err: "eof", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "BrokenPipeLiteral", err: "broken pipe", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "BadGatewayLiteral", err: "bad gateway", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "GatewayTimeoutLiteral", err: "gateway timeout", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "ClientConnLiteral", err: "client conn", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "GOAWAYLiteral", err: "goaway", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "HTTP2StreamClosedLiteral", err: "http2: stream closed", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "UseOfClosedNetworkConnectionLiteral", err: "use of closed network connection", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, + {name: "AuthenticationLiteral", err: "authentication", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false}, + {name: "UnauthorizedLiteral", err: "unauthorized", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false}, + {name: "InvalidAPIKeyLiteral", err: "invalid api key", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false}, + {name: "InvalidAPIKeyUnderscoreLiteral", err: "invalid_api_key", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false}, + {name: "QuotaLiteral", err: "quota", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false}, + {name: "BillingLiteral", err: "billing", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false}, + {name: "InsufficientQuotaLiteral", err: "insufficient_quota", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false}, + {name: "PaymentRequiredLiteral", err: "payment required", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false}, + {name: "ForbiddenLiteral", err: "forbidden", wantKind: codersdk.ChatErrorKindAuth, wantRetry: false}, + {name: "InvalidModelLiteral", err: "invalid model", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false}, + {name: "ModelNotFoundLiteral", err: "model not found", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false}, + {name: "ModelNotFoundUnderscoreLiteral", err: "model_not_found", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false}, + {name: "UnsupportedModelLiteral", err: "unsupported model", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false}, + {name: "ContextLengthExceededLiteral", err: "context length exceeded", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false}, + {name: "ContextExceededLiteral", err: "context_exceeded", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false}, + {name: "MaximumContextLengthLiteral", err: "maximum context length", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false}, + {name: "MalformedConfigLiteral", err: "malformed config", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false}, + {name: "MalformedConfigurationLiteral", err: "malformed configuration", wantKind: codersdk.ChatErrorKindConfig, wantRetry: false}, + {name: "ServerErrorLiteral", err: "server error", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: true}, + {name: "InternalServerErrorLiteral", err: "internal server error", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: true}, + {name: "ChatInterruptedLiteral", err: "chat interrupted", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: false}, + {name: "RequestInterruptedLiteral", err: "request interrupted", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: false}, + {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}, } for _, tt := range tests { @@ -359,7 +360,7 @@ func TestClassify_TransportFailuresUseBroaderRetryMessage(t *testing.T) { t.Parallel() classified := chaterror.Classify(xerrors.New(tt.err)) - require.Equal(t, chaterror.KindTimeout, classified.Kind) + require.Equal(t, codersdk.ChatErrorKindTimeout, classified.Kind) require.True(t, classified.Retryable) require.Equal( t, @@ -371,7 +372,7 @@ func TestClassify_TransportFailuresUseBroaderRetryMessage(t *testing.T) { } // TestClassify_HTTP2TransportErrors checks HTTP/2 transport errors -// classify as retryable KindTimeout. Split into two sub-tables so a +// classify as retryable ChatErrorKindTimeout. Split into two sub-tables so a // bug in transport matching cannot be masked by provider detection // (and vice versa). func TestClassify_HTTP2TransportErrors(t *testing.T) { @@ -426,7 +427,7 @@ func TestClassify_HTTP2TransportErrors(t *testing.T) { t.Parallel() classified := chaterror.Classify(xerrors.New(tt.err)) - require.Equal(t, chaterror.KindTimeout, classified.Kind, "Kind") + require.Equal(t, codersdk.ChatErrorKindTimeout, classified.Kind, "Kind") require.True(t, classified.Retryable, "Retryable") require.Equal(t, "", classified.Provider, "Provider") require.Equal(t, @@ -470,7 +471,7 @@ func TestClassify_HTTP2TransportErrors(t *testing.T) { t.Parallel() classified := chaterror.Classify(xerrors.New(tt.err)) - require.Equal(t, chaterror.KindTimeout, classified.Kind, "Kind") + require.Equal(t, codersdk.ChatErrorKindTimeout, classified.Kind, "Kind") require.True(t, classified.Retryable, "Retryable") require.Equal(t, tt.provider, classified.Provider, "Provider") require.Equal(t, tt.wantMessage, classified.Message, "Message") @@ -486,35 +487,35 @@ func TestClassify_StatusCodeBeatsHTTP2Transport(t *testing.T) { tests := []struct { name string err string - wantKind string + wantKind codersdk.ChatErrorKind wantRetryable bool wantStatus int }{ { name: "HTTP2With429", err: "http2: server error 429 Too Many Requests", - wantKind: chaterror.KindRateLimit, + wantKind: codersdk.ChatErrorKindRateLimit, wantRetryable: true, wantStatus: 429, }, { name: "HTTP2With401", err: "http2: 401 unauthorized", - wantKind: chaterror.KindAuth, + wantKind: codersdk.ChatErrorKindAuth, wantRetryable: false, wantStatus: 401, }, { name: "ClientConnWith429RateLimitWins", err: "http2: client conn is closed: status 429 Too Many Requests", - wantKind: chaterror.KindRateLimit, + wantKind: codersdk.ChatErrorKindRateLimit, wantRetryable: true, wantStatus: 429, }, { name: "GOAWAYWith401AuthWins", err: "http2: server sent GOAWAY: status 401 unauthorized", - wantKind: chaterror.KindAuth, + wantKind: codersdk.ChatErrorKindAuth, wantRetryable: false, wantStatus: 401, }, @@ -538,7 +539,7 @@ func TestClassify_StartupTimeoutWrappedClassificationWins(t *testing.T) { wrapped := chaterror.WithClassification( xerrors.New("context canceled"), chaterror.ClassifiedError{ - Kind: chaterror.KindStartupTimeout, + Kind: codersdk.ChatErrorKindStartupTimeout, Provider: "openai", Retryable: true, }, @@ -546,7 +547,7 @@ func TestClassify_StartupTimeoutWrappedClassificationWins(t *testing.T) { require.Equal(t, chaterror.ClassifiedError{ Message: "OpenAI did not start responding in time.", - Kind: chaterror.KindStartupTimeout, + Kind: codersdk.ChatErrorKindStartupTimeout, Provider: "openai", Retryable: true, StatusCode: 0, @@ -562,7 +563,7 @@ func TestWithProviderUsesExplicitHint(t *testing.T) { enriched := classified.WithProvider("azure openai") require.Equal(t, chaterror.ClassifiedError{ Message: "Azure OpenAI is rate limiting requests.", - Kind: chaterror.KindRateLimit, + Kind: codersdk.ChatErrorKindRateLimit, Provider: "azure", Retryable: true, StatusCode: 429, @@ -578,7 +579,7 @@ func TestWithProviderAddsProviderWhenUnknown(t *testing.T) { enriched := classified.WithProvider("openai") require.Equal(t, chaterror.ClassifiedError{ Message: "OpenAI is rate limiting requests.", - Kind: chaterror.KindRateLimit, + Kind: codersdk.ChatErrorKindRateLimit, Provider: "openai", Retryable: true, StatusCode: 429, @@ -596,7 +597,7 @@ func TestClassify_UsesStructuredProviderStatusAndRetryAfter(t *testing.T) { require.Equal(t, chaterror.ClassifiedError{ Message: "The AI provider is rate limiting requests.", - Kind: chaterror.KindRateLimit, + Kind: codersdk.ChatErrorKindRateLimit, Provider: "", Retryable: true, StatusCode: 429, @@ -660,7 +661,7 @@ func TestWithProviderPreservesRetryAfter(t *testing.T) { require.Equal(t, 30*time.Second, enriched.RetryAfter) require.Equal(t, chaterror.ClassifiedError{ Message: "OpenAI is rate limiting requests.", - Kind: chaterror.KindRateLimit, + Kind: codersdk.ChatErrorKindRateLimit, Provider: "openai", Retryable: true, StatusCode: 429, @@ -681,7 +682,7 @@ func TestClassify_UsesStructuredProviderDetailFromResponseDump(t *testing.T) { require.Equal(t, chaterror.ClassifiedError{ Message: "The AI provider returned an unexpected error.", Detail: "Image exceeds 5 MB maximum.", - Kind: chaterror.KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, Provider: "", Retryable: false, StatusCode: 400, diff --git a/coderd/x/chatd/chaterror/kind.go b/coderd/x/chatd/chaterror/kind.go deleted file mode 100644 index 502155a6f9..0000000000 --- a/coderd/x/chatd/chaterror/kind.go +++ /dev/null @@ -1,13 +0,0 @@ -// Package chaterror classifies provider/runtime failures into stable, -// user-facing chat error payloads. -package chaterror - -const ( - KindOverloaded = "overloaded" - KindRateLimit = "rate_limit" - KindTimeout = "timeout" - KindStartupTimeout = "startup_timeout" - KindAuth = "auth" - KindConfig = "config" - KindGeneric = "generic" -) diff --git a/coderd/x/chatd/chaterror/message.go b/coderd/x/chatd/chaterror/message.go index a3e361773a..4cd311908c 100644 --- a/coderd/x/chatd/chaterror/message.go +++ b/coderd/x/chatd/chaterror/message.go @@ -3,6 +3,8 @@ package chaterror import ( "fmt" "strings" + + "github.com/coder/coder/v2/codersdk" ) // terminalMessage produces the user-facing error description shown @@ -13,24 +15,24 @@ import ( func terminalMessage(classified ClassifiedError) string { subject := providerSubject(classified.Provider) switch classified.Kind { - case KindOverloaded: + case codersdk.ChatErrorKindOverloaded: return fmt.Sprintf("%s is temporarily overloaded.", subject) - case KindRateLimit: + case codersdk.ChatErrorKindRateLimit: return fmt.Sprintf("%s is rate limiting requests.", subject) - case KindTimeout: + 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) - case KindStartupTimeout: + case codersdk.ChatErrorKindStartupTimeout: return fmt.Sprintf( "%s did not start responding in time.", subject, ) - case KindAuth: + case codersdk.ChatErrorKindAuth: displayName := providerDisplayName(classified.Provider) if displayName == "" { displayName = "the AI provider" @@ -41,7 +43,7 @@ func terminalMessage(classified ClassifiedError) string { displayName, ) - case KindConfig: + case codersdk.ChatErrorKindConfig: return fmt.Sprintf( "%s rejected the model configuration."+ " Check the selected model and provider settings.", @@ -63,17 +65,17 @@ func terminalMessage(classified ClassifiedError) string { func retryMessage(classified ClassifiedError) string { subject := providerSubject(classified.Provider) switch classified.Kind { - case KindOverloaded: + case codersdk.ChatErrorKindOverloaded: return fmt.Sprintf("%s is temporarily overloaded.", subject) - case KindRateLimit: + case codersdk.ChatErrorKindRateLimit: return fmt.Sprintf("%s is rate limiting requests.", subject) - case KindTimeout: + case codersdk.ChatErrorKindTimeout: return fmt.Sprintf("%s is temporarily unavailable.", subject) - case KindStartupTimeout: + case codersdk.ChatErrorKindStartupTimeout: return fmt.Sprintf( "%s did not start responding in time.", subject, ) - case KindAuth: + case codersdk.ChatErrorKindAuth: displayName := providerDisplayName(classified.Provider) if displayName == "" { displayName = "the AI provider" @@ -81,7 +83,7 @@ func retryMessage(classified ClassifiedError) string { return fmt.Sprintf( "Authentication with %s failed.", displayName, ) - case KindConfig: + case codersdk.ChatErrorKindConfig: return fmt.Sprintf( "%s rejected the model configuration.", subject, ) diff --git a/coderd/x/chatd/chaterror/message_test.go b/coderd/x/chatd/chaterror/message_test.go index 07551d5d4c..81f1dfff8d 100644 --- a/coderd/x/chatd/chaterror/message_test.go +++ b/coderd/x/chatd/chaterror/message_test.go @@ -7,6 +7,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/x/chatd/chaterror" + "github.com/coder/coder/v2/codersdk" ) // TestTerminalMessage covers the per-provider "temporarily @@ -18,7 +19,7 @@ func TestTerminalMessage(t *testing.T) { tests := []struct { name string - kind string + kind codersdk.ChatErrorKind provider string retryable bool statusCode int @@ -26,42 +27,42 @@ func TestTerminalMessage(t *testing.T) { }{ { name: "Timeout_Retryable_Anthropic", - kind: chaterror.KindTimeout, + kind: codersdk.ChatErrorKindTimeout, provider: "anthropic", retryable: true, want: "Anthropic is temporarily unavailable.", }, { name: "Timeout_Retryable_OpenAI", - kind: chaterror.KindTimeout, + kind: codersdk.ChatErrorKindTimeout, provider: "openai", retryable: true, want: "OpenAI is temporarily unavailable.", }, { name: "Timeout_Retryable_UnknownProvider", - kind: chaterror.KindTimeout, + kind: codersdk.ChatErrorKindTimeout, provider: "", retryable: true, want: "The AI provider is temporarily unavailable.", }, { name: "Timeout_NotRetryable_NoStatus", - kind: chaterror.KindTimeout, + kind: codersdk.ChatErrorKindTimeout, provider: "", retryable: false, want: "The request timed out before it completed.", }, { name: "StartupTimeout_Anthropic", - kind: chaterror.KindStartupTimeout, + kind: codersdk.ChatErrorKindStartupTimeout, provider: "anthropic", retryable: true, want: "Anthropic did not start responding in time.", }, { name: "StartupTimeout_OpenAI", - kind: chaterror.KindStartupTimeout, + kind: codersdk.ChatErrorKindStartupTimeout, provider: "openai", retryable: true, want: "OpenAI did not start responding in time.", @@ -70,7 +71,7 @@ func TestTerminalMessage(t *testing.T) { // Generic fallback reserved for genuinely // unclassified non-retryable failures. name: "Generic_NotRetryable_NoStatus", - kind: chaterror.KindGeneric, + kind: codersdk.ChatErrorKindGeneric, provider: "", retryable: false, want: "The chat request failed unexpectedly.", diff --git a/coderd/x/chatd/chaterror/payload_test.go b/coderd/x/chatd/chaterror/payload_test.go index 7aa21e6500..be7cd14ace 100644 --- a/coderd/x/chatd/chaterror/payload_test.go +++ b/coderd/x/chatd/chaterror/payload_test.go @@ -21,7 +21,7 @@ func TestTerminalErrorPayloadUsesNormalizedClassification(t *testing.T) { require.Equal(t, &codersdk.ChatError{ Message: "Azure OpenAI is rate limiting requests.", - Kind: chaterror.KindRateLimit, + Kind: codersdk.ChatErrorKindRateLimit, Provider: "azure", Retryable: true, StatusCode: 429, @@ -54,7 +54,7 @@ func TestStreamRetryPayloadUsesNormalizedClassification(t *testing.T) { startedAt := time.Now() payload := chaterror.StreamRetryPayload(2, delay, chaterror.ClassifiedError{ Message: "OpenAI returned an unexpected error.", - Kind: chaterror.KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, Provider: "openai", Retryable: true, StatusCode: 503, @@ -66,7 +66,7 @@ func TestStreamRetryPayloadUsesNormalizedClassification(t *testing.T) { // Retry messages omit the HTTP status code; the status code is // surfaced separately in the payload's StatusCode field. require.Equal(t, "OpenAI returned an unexpected error.", payload.Error) - require.Equal(t, chaterror.KindGeneric, payload.Kind) + require.Equal(t, codersdk.ChatErrorKindGeneric, payload.Kind) require.Equal(t, "openai", payload.Provider) require.Equal(t, 503, payload.StatusCode) require.WithinDuration(t, startedAt.Add(delay), payload.RetryingAt, time.Second) diff --git a/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index d82032015b..205f8bfe77 100644 --- a/coderd/x/chatd/chatloop/chatloop.go +++ b/coderd/x/chatd/chatloop/chatloop.go @@ -710,7 +710,7 @@ func classifyStartupTimeout( err = errStartupTimeout } return chaterror.WithClassification(err, chaterror.ClassifiedError{ - Kind: chaterror.KindStartupTimeout, + Kind: codersdk.ChatErrorKindStartupTimeout, Provider: provider, Retryable: true, }) diff --git a/coderd/x/chatd/chatloop/chatloop_test.go b/coderd/x/chatd/chatloop/chatloop_test.go index 862aa9ed05..8af379e645 100644 --- a/coderd/x/chatd/chatloop/chatloop_test.go +++ b/coderd/x/chatd/chatloop/chatloop_test.go @@ -571,7 +571,7 @@ func TestRun_OnRetryEnrichesProvider(t *testing.T) { require.Equal(t, "received status 429 from upstream", records[0].errMsg) require.Equal(t, chatretry.Delay(0), records[0].delay) require.Equal(t, "openai", records[0].classified.Provider) - require.Equal(t, chaterror.KindRateLimit, records[0].classified.Kind) + require.Equal(t, codersdk.ChatErrorKindRateLimit, records[0].classified.Kind) require.True(t, records[0].classified.Retryable) require.Equal(t, 429, records[0].classified.StatusCode) require.Equal( @@ -633,7 +633,7 @@ func TestStartupGuard_DisarmPreservesPermanentError(t *testing.T) { "openai", xerrors.New("invalid model"), )) - require.Equal(t, chaterror.KindConfig, classified.Kind) + require.Equal(t, codersdk.ChatErrorKindConfig, classified.Kind) require.False(t, classified.Retryable) require.Nil(t, context.Cause(attemptCtx)) } @@ -700,7 +700,7 @@ func TestRun_RetriesStartupTimeoutWhileOpeningStream(t *testing.T) { require.NoError(t, awaitRunResult(ctx, t, done)) require.Equal(t, 2, attempts) require.Len(t, retries, 1) - require.Equal(t, chaterror.KindStartupTimeout, retries[0].Kind) + require.Equal(t, codersdk.ChatErrorKindStartupTimeout, retries[0].Kind) require.True(t, retries[0].Retryable) require.Equal(t, "openai", retries[0].Provider) require.Equal( @@ -788,7 +788,7 @@ func TestRun_HTTP2TransportErrorClassifiedAsRetryableTimeout(t *testing.T) { require.NoError(t, awaitRunResult(ctx, t, done)) require.Equal(t, 2, attempts) require.Len(t, retries, 1) - require.Equal(t, chaterror.KindTimeout, retries[0].Kind, "Kind") + require.Equal(t, codersdk.ChatErrorKindTimeout, retries[0].Kind, "Kind") require.True(t, retries[0].Retryable, "Retryable") require.Equal(t, provider, retries[0].Provider, "Provider") }) @@ -862,7 +862,7 @@ func TestRun_RetriesStartupTimeoutBeforeFirstPart(t *testing.T) { require.NoError(t, awaitRunResult(ctx, t, done)) require.Equal(t, 2, attempts) require.Len(t, retries, 1) - require.Equal(t, chaterror.KindStartupTimeout, retries[0].Kind) + require.Equal(t, codersdk.ChatErrorKindStartupTimeout, retries[0].Kind) require.True(t, retries[0].Retryable) require.Equal(t, "openai", retries[0].Provider) require.Equal( @@ -1077,7 +1077,7 @@ func TestRun_RetriesStartupTimeoutWhenStreamClosesSilently(t *testing.T) { require.NoError(t, awaitRunResult(ctx, t, done)) require.Equal(t, 2, attempts) require.Len(t, retries, 1) - require.Equal(t, chaterror.KindStartupTimeout, retries[0].Kind) + require.Equal(t, codersdk.ChatErrorKindStartupTimeout, retries[0].Kind) require.True(t, retries[0].Retryable) require.Equal(t, "openai", retries[0].Provider) require.Equal( diff --git a/coderd/x/chatd/chatloop/metrics.go b/coderd/x/chatd/chatloop/metrics.go index cc96eb379b..2db4f1ac13 100644 --- a/coderd/x/chatd/chatloop/metrics.go +++ b/coderd/x/chatd/chatloop/metrics.go @@ -145,7 +145,7 @@ func (m *Metrics) RecordStreamRetry(provider, model string, classified chaterror if m == nil { return } - m.StreamRetriesTotal.WithLabelValues(provider, model, classified.Kind).Inc() + m.StreamRetriesTotal.WithLabelValues(provider, model, string(classified.Kind)).Inc() } // RecordToolError increments tool_errors_total for the given diff --git a/coderd/x/chatd/chatloop/metrics_test.go b/coderd/x/chatd/chatloop/metrics_test.go index 5c4c296d43..14d641b353 100644 --- a/coderd/x/chatd/chatloop/metrics_test.go +++ b/coderd/x/chatd/chatloop/metrics_test.go @@ -16,6 +16,7 @@ import ( "github.com/coder/coder/v2/coderd/x/chatd/chatloop" "github.com/coder/coder/v2/coderd/x/chatd/chatretry" "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/codersdk" ) func TestNewMetrics_RegistersAllMetrics(t *testing.T) { @@ -33,7 +34,7 @@ func TestNewMetrics_RegistersAllMetrics(t *testing.T) { m.PromptSizeBytes.WithLabelValues("anthropic", "claude-sonnet-4-5") m.TTFTSeconds.WithLabelValues("anthropic", "claude-sonnet-4-5") m.StepsTotal.WithLabelValues("anthropic", "claude-sonnet-4-5") - m.StreamRetriesTotal.WithLabelValues("anthropic", "claude-sonnet-4-5", chaterror.KindTimeout) + m.StreamRetriesTotal.WithLabelValues("anthropic", "claude-sonnet-4-5", string(codersdk.ChatErrorKindTimeout)) // StreamBufferDroppedTotal is a plain Counter, so it's always present // in Gather output once registered; no exerciser call is // needed. @@ -87,14 +88,14 @@ func TestNopMetrics_DoesNotPanic(t *testing.T) { m.CompactionTotal.WithLabelValues("openai", "gpt-5", "error").Inc() m.CompactionTotal.WithLabelValues("google", "gemini-2.5-pro", "timeout").Inc() m.StepsTotal.WithLabelValues("anthropic", "claude-sonnet-4-5").Inc() - m.StreamRetriesTotal.WithLabelValues("anthropic", "claude-sonnet-4-5", chaterror.KindTimeout).Inc() + m.StreamRetriesTotal.WithLabelValues("anthropic", "claude-sonnet-4-5", string(codersdk.ChatErrorKindTimeout)).Inc() m.StreamBufferDroppedTotal.Inc() // Nil-receiver guard for RecordStreamRetry and // RecordStreamBufferDropped mirrors the existing RecordCompaction nil // guard. var nilMetrics *chatloop.Metrics - nilMetrics.RecordStreamRetry("anthropic", "claude-sonnet-4-5", chaterror.ClassifiedError{Kind: chaterror.KindTimeout}) + nilMetrics.RecordStreamRetry("anthropic", "claude-sonnet-4-5", chaterror.ClassifiedError{Kind: codersdk.ChatErrorKindTimeout}) nilMetrics.RecordStreamBufferDropped() nilMetrics.RecordToolError("anthropic", "claude-sonnet-4-5", "test") } @@ -279,21 +280,21 @@ func TestRecordCompaction(t *testing.T) { func TestRecordStreamRetry(t *testing.T) { t.Parallel() - // One row per chaterror.Kind* constant. Production callers always + // One row per ChatErrorKind constant. Production callers always // reach RecordStreamRetry through chaterror.Classify, which // guarantees Kind is non-empty, so no empty-string case is // needed. tests := []struct { name string - kind string + kind codersdk.ChatErrorKind }{ - {name: "overloaded", kind: chaterror.KindOverloaded}, - {name: "rate_limit", kind: chaterror.KindRateLimit}, - {name: "timeout", kind: chaterror.KindTimeout}, - {name: "startup_timeout", kind: chaterror.KindStartupTimeout}, - {name: "auth", kind: chaterror.KindAuth}, - {name: "config", kind: chaterror.KindConfig}, - {name: "generic", kind: chaterror.KindGeneric}, + {name: "overloaded", kind: codersdk.ChatErrorKindOverloaded}, + {name: "rate_limit", kind: codersdk.ChatErrorKindRateLimit}, + {name: "timeout", kind: codersdk.ChatErrorKindTimeout}, + {name: "startup_timeout", kind: codersdk.ChatErrorKindStartupTimeout}, + {name: "auth", kind: codersdk.ChatErrorKindAuth}, + {name: "config", kind: codersdk.ChatErrorKindConfig}, + {name: "generic", kind: codersdk.ChatErrorKindGeneric}, } for _, tt := range tests { @@ -309,7 +310,7 @@ func TestRecordStreamRetry(t *testing.T) { requireCounter(t, reg, "coderd_chatd_stream_retries_total", 1, map[string]string{ "provider": "test-provider", "model": "test-model", - "kind": tt.kind, + "kind": string(tt.kind), }) }) } @@ -558,14 +559,14 @@ func TestRun_StreamRetry_RecordsMetric(t *testing.T) { // Back-compat: OnRetry still fires with classified error. require.Len(t, retries, 1) assert.Equal(t, 1, retries[0].attempt) - assert.Equal(t, chaterror.KindRateLimit, retries[0].classified.Kind) + assert.Equal(t, codersdk.ChatErrorKindRateLimit, retries[0].classified.Kind) assert.Equal(t, "test-provider", retries[0].classified.Provider) // Metric assertion. requireCounter(t, reg, "coderd_chatd_stream_retries_total", 1, map[string]string{ "provider": "test-provider", "model": "test-model", - "kind": chaterror.KindRateLimit, + "kind": string(codersdk.ChatErrorKindRateLimit), }) } diff --git a/coderd/x/chatd/subagent_internal_test.go b/coderd/x/chatd/subagent_internal_test.go index f536f44d33..1112864689 100644 --- a/coderd/x/chatd/subagent_internal_test.go +++ b/coderd/x/chatd/subagent_internal_test.go @@ -21,7 +21,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/pubsub" coderdpubsub "github.com/coder/coder/v2/coderd/pubsub" - "github.com/coder/coder/v2/coderd/x/chatd/chaterror" "github.com/coder/coder/v2/coderd/x/chatd/chatloop" "github.com/coder/coder/v2/coderd/x/chatd/chatprompt" "github.com/coder/coder/v2/coderd/x/chatd/chatprovider" @@ -2719,7 +2718,7 @@ func setChatStatus( if lastError != "" { encodedLastError, err := json.Marshal(codersdk.ChatError{ Message: lastError, - Kind: chaterror.KindGeneric, + Kind: codersdk.ChatErrorKindGeneric, }) require.NoError(t, err) params.LastError = pqtype.NullRawMessage{RawMessage: encodedLastError, Valid: true} diff --git a/codersdk/chats.go b/codersdk/chats.go index 035eef6c9b..43d9e57b77 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1442,6 +1442,33 @@ type ChatStreamStatus struct { Status ChatStatus `json:"status"` } +// ChatErrorKind classifies chat errors for consistent client rendering. +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" +) + +// AllChatErrorKinds contains every ChatErrorKind value. +// Update this when adding new constants above. +var AllChatErrorKinds = []ChatErrorKind{ + ChatErrorKindGeneric, + ChatErrorKindOverloaded, + ChatErrorKindRateLimit, + ChatErrorKindTimeout, + ChatErrorKindStartupTimeout, + ChatErrorKindAuth, + ChatErrorKindConfig, + ChatErrorKindUsageLimit, +} + // ChatError represents a terminal chat error in persisted chat state or the // live stream. type ChatError struct { @@ -1451,7 +1478,7 @@ type ChatError struct { // normalized error message when available. Detail string `json:"detail,omitempty"` // Kind classifies the error for consistent client rendering. - Kind string `json:"kind,omitempty"` + Kind ChatErrorKind `json:"kind,omitempty"` // Provider identifies the upstream model provider when known. Provider string `json:"provider,omitempty"` // Retryable reports whether the underlying error is transient. @@ -1470,7 +1497,7 @@ type ChatStreamRetry struct { // Error is the normalized error message from the failed attempt. Error string `json:"error"` // Kind classifies the retry reason for consistent client rendering. - Kind string `json:"kind,omitempty"` + Kind ChatErrorKind `json:"kind,omitempty"` // Provider identifies the upstream model provider when known. Provider string `json:"provider,omitempty"` // StatusCode is the best-effort upstream HTTP status code. diff --git a/codersdk/chats_test.go b/codersdk/chats_test.go index 68d6e0ecce..3456c6ebb5 100644 --- a/codersdk/chats_test.go +++ b/codersdk/chats_test.go @@ -137,6 +137,35 @@ func TestChatUsageLimitExceededFrom(t *testing.T) { }) } +func TestChatErrorKind_JSONRoundTrip(t *testing.T) { + t.Parallel() + + terminal := codersdk.ChatError{ + Message: "limit reached", + Kind: codersdk.ChatErrorKindUsageLimit, + } + data, err := json.Marshal(terminal) + require.NoError(t, err) + require.Contains(t, string(data), `"kind":"usage_limit"`) + + var decodedTerminal codersdk.ChatError + require.NoError(t, json.Unmarshal(data, &decodedTerminal)) + require.Equal(t, codersdk.ChatErrorKindUsageLimit, decodedTerminal.Kind) + + retry := codersdk.ChatStreamRetry{ + Attempt: 1, + Error: "retrying", + Kind: codersdk.ChatErrorKindUsageLimit, + } + data, err = json.Marshal(retry) + require.NoError(t, err) + require.Contains(t, string(data), `"kind":"usage_limit"`) + + var decodedRetry codersdk.ChatStreamRetry + require.NoError(t, json.Unmarshal(data, &decodedRetry)) + require.Equal(t, codersdk.ChatErrorKindUsageLimit, decodedRetry.Kind) +} + func TestChatMessagePart_StripInternal(t *testing.T) { t.Parallel() @@ -450,7 +479,7 @@ func TestChat_JSONRoundTrip(t *testing.T) { lastError := &codersdk.ChatError{ Message: "boom", Detail: "provider detail", - Kind: "generic", + Kind: codersdk.ChatErrorKindGeneric, Provider: "openai", Retryable: true, StatusCode: 503, diff --git a/enterprise/coderd/x/chatd/chatd_test.go b/enterprise/coderd/x/chatd/chatd_test.go index 3345819696..2cfa3da157 100644 --- a/enterprise/coderd/x/chatd/chatd_test.go +++ b/enterprise/coderd/x/chatd/chatd_test.go @@ -587,7 +587,7 @@ func TestSubscribeRetryEventAcrossInstances(t *testing.T) { require.NotNil(t, retryEvent) require.Equal(t, 1, retryEvent.Attempt) require.Greater(t, retryEvent.DelayMs, int64(0)) - require.Equal(t, "rate_limit", retryEvent.Kind) + require.Equal(t, codersdk.ChatErrorKindRateLimit, retryEvent.Kind) require.Equal(t, "openai", retryEvent.Provider) require.Equal(t, 429, retryEvent.StatusCode) require.Contains(t, retryEvent.Error, "rate limiting requests") diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 3a327a5ec0..7e0667e00c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1706,7 +1706,7 @@ export interface ChatError { /** * Kind classifies the error for consistent client rendering. */ - readonly kind?: string; + readonly kind?: ChatErrorKind; /** * Provider identifies the upstream model provider when known. */ @@ -1721,6 +1721,28 @@ export interface ChatError { readonly status_code?: number; } +// From codersdk/chats.go +export type ChatErrorKind = + | "auth" + | "config" + | "generic" + | "overloaded" + | "rate_limit" + | "startup_timeout" + | "timeout" + | "usage_limit"; + +export const ChatErrorKinds: ChatErrorKind[] = [ + "auth", + "config", + "generic", + "overloaded", + "rate_limit", + "startup_timeout", + "timeout", + "usage_limit", +]; + // From codersdk/chats.go /** * ChatFileMetadata contains lightweight metadata about a file @@ -2489,7 +2511,7 @@ export interface ChatStreamRetry { /** * Kind classifies the retry reason for consistent client rendering. */ - readonly kind?: string; + readonly kind?: ChatErrorKind; /** * Provider identifies the upstream model provider when known. */ diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index ea48eb389a..b1cbc45bbd 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -93,7 +93,7 @@ import { parsePullRequestUrl } from "./utils/pullRequest"; import { type ChatDetailError, formatUsageLimitMessage, - isUsageLimitData, + isChatUsageLimitExceededResponse, } from "./utils/usageLimitMessage"; /** localStorage key controlling whether the right panel is visible. */ @@ -1088,7 +1088,7 @@ const AgentChatPage: FC = () => { if ( isApiError(error) && error.response?.status === 409 && - isUsageLimitData(error.response.data) + isChatUsageLimitExceededResponse(error.response.data) ) { const reason: ChatDetailError = { kind: "usage_limit", diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 3f99da8111..b6e7e60600 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -27,7 +27,7 @@ import { } from "../utils/modelOptions"; import { formatUsageLimitMessage, - isUsageLimitData, + isChatUsageLimitExceededResponse, } from "../utils/usageLimitMessage"; import { AgentChatInput } from "./AgentChatInput"; import { ChatAccessDeniedAlert } from "./ChatAccessDeniedAlert"; @@ -464,7 +464,7 @@ export const AgentCreateForm: FC = ({ ) : createError ? ( isApiError(createError) && createError.response?.status === 409 && - isUsageLimitData(createError.response.data) ? ( + isChatUsageLimitExceededResponse(createError.response.data) ? ( = { anthropic: "https://status.anthropic.com", @@ -24,7 +24,7 @@ const normalizeProvider = (provider?: string): string | undefined => { }; export const getErrorTitle = ( - kind: ChatProviderFailureKind | (string & {}), + kind: TypesGen.ChatErrorKind, mode: "retry" | "error", ): string => { switch (kind) { @@ -40,13 +40,15 @@ export const getErrorTitle = ( return "Authentication failed"; case "config": return "Configuration error"; + case "usage_limit": + return "Usage limit reached"; default: return mode === "retry" ? "Retrying request" : "Request failed"; } }; export const getProviderStatusURL = ( - kind: ChatProviderFailureKind | (string & {}), + kind: TypesGen.ChatErrorKind, provider?: string, ): string | undefined => { if (kind !== "overloaded") { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts index ad2ae0eba9..88588b7aba 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/liveStatusModel.ts @@ -1,3 +1,4 @@ +import type * as TypesGen from "#/api/typesGenerated"; import type { ChatDetailError } from "../../utils/usageLimitMessage"; import { getErrorTitle } from "./chatStatusHelpers"; import type { ReconnectState, RetryState, StreamState } from "./types"; @@ -16,7 +17,7 @@ export type LiveStatusModel = | ({ phase: "retrying"; title: string; - kind: string; + kind: TypesGen.ChatErrorKind; message: string; attempt: number; provider?: string; @@ -34,7 +35,7 @@ export type LiveStatusModel = | ({ phase: "failed"; title: string; - kind: string; + kind: TypesGen.ChatErrorKind; message: string; detail?: string; provider?: string; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/types.ts b/site/src/pages/AgentsPage/components/ChatConversation/types.ts index ac4827f3bd..424215b75a 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/types.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/types.ts @@ -1,6 +1,5 @@ import type * as TypesGen from "#/api/typesGenerated"; import type { ReconnectSchedule } from "#/utils/reconnectingWebSocket"; -import type { ChatProviderFailureKind } from "../../utils/usageLimitMessage"; export type ParsedToolCall = { id: string; @@ -70,7 +69,7 @@ export type ReconnectState = ReconnectSchedule; export type RetryState = { attempt: number; error: string; - kind: ChatProviderFailureKind | (string & {}); + kind: TypesGen.ChatErrorKind; provider?: string; delayMs?: number; retryingAt?: string; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index 7fbaf98c4c..79d7de28b8 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -26,7 +26,7 @@ import type { RetryState } from "./types"; const normalizeRetryState = (retry: TypesGen.ChatStreamRetry): RetryState => ({ attempt: Math.max(1, retry.attempt), error: retry.error.trim() || "Retrying request shortly.", - kind: retry.kind?.trim() || "generic", + kind: retry.kind ?? "generic", provider: retry.provider?.trim() || undefined, delayMs: retry.delay_ms, retryingAt: retry.retrying_at.trim() || undefined, diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.test.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.test.ts index 0b4bdae584..91ef15e437 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.test.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.test.ts @@ -2,7 +2,7 @@ import { type ChatDetailError, chatDetailErrorsEqual, formatUsageLimitMessage, - isUsageLimitData, + isChatUsageLimitExceededResponse, } from "./usageLimitMessage"; describe("formatUsageLimitMessage", () => { @@ -103,8 +103,8 @@ describe("chatDetailErrorsEqual", () => { }); }); -describe("isUsageLimitData", () => { - it("accepts a fully populated valid payload", () => { +describe("isChatUsageLimitExceededResponse", () => { + it("accepts a payload with usage fields", () => { const error: ChatDetailError = { message: "Your usage limit has been reached.", kind: "usage_limit", @@ -112,7 +112,7 @@ describe("isUsageLimitData", () => { expect(error.kind).toBe("usage_limit"); expect( - isUsageLimitData({ + isChatUsageLimitExceededResponse({ spent_micros: 900_000, limit_micros: 500_000, resets_at: "2026-03-16T00:00:00Z", @@ -121,20 +121,21 @@ describe("isUsageLimitData", () => { }); it("rejects null", () => { - expect(isUsageLimitData(null)).toBe(false); + expect(isChatUsageLimitExceededResponse(null)).toBe(false); }); it("rejects undefined", () => { - expect(isUsageLimitData(undefined)).toBe(false); + expect(isChatUsageLimitExceededResponse(undefined)).toBe(false); }); it("rejects an empty object (missing all fields)", () => { - expect(isUsageLimitData({})).toBe(false); + expect(isChatUsageLimitExceededResponse({})).toBe(false); }); it("rejects when spent_micros is missing", () => { expect( - isUsageLimitData({ + isChatUsageLimitExceededResponse({ + message: "Chat usage limit exceeded.", limit_micros: 500_000, resets_at: "2026-03-16T00:00:00Z", }), @@ -143,7 +144,8 @@ describe("isUsageLimitData", () => { it("rejects when limit_micros is missing", () => { expect( - isUsageLimitData({ + isChatUsageLimitExceededResponse({ + message: "Chat usage limit exceeded.", spent_micros: 900_000, resets_at: "2026-03-16T00:00:00Z", }), @@ -152,13 +154,18 @@ describe("isUsageLimitData", () => { it("rejects when resets_at is missing", () => { expect( - isUsageLimitData({ spent_micros: 900_000, limit_micros: 500_000 }), + isChatUsageLimitExceededResponse({ + message: "Chat usage limit exceeded.", + spent_micros: 900_000, + limit_micros: 500_000, + }), ).toBe(false); }); it("rejects wrong field types (string for spent_micros)", () => { expect( - isUsageLimitData({ + isChatUsageLimitExceededResponse({ + message: "Chat usage limit exceeded.", spent_micros: "900000", limit_micros: 500_000, resets_at: "2026-03-16T00:00:00Z", @@ -168,7 +175,8 @@ describe("isUsageLimitData", () => { it("accepts payload with extra fields", () => { expect( - isUsageLimitData({ + isChatUsageLimitExceededResponse({ + message: "Chat usage limit exceeded.", spent_micros: 900_000, limit_micros: 500_000, resets_at: "2026-03-16T00:00:00Z", diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts index c2d41d5134..1986da0227 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts @@ -1,27 +1,12 @@ +import type * as TypesGen from "#/api/typesGenerated"; import { formatCostMicros } from "#/utils/currency"; -/** - * Shape of structured usage-limit fields added to 409 responses - * from chat endpoints. - */ -interface UsageLimitData { - spent_micros?: number; - limit_micros?: number; - resets_at?: string; // RFC3339 -} - -/** - * Known provider failure kinds surfaced in chat retry/error events. - */ -export type ChatProviderFailureKind = - | "generic" - | "overloaded" - | "rate_limit" - | "timeout" - | "startup_timeout" - | "auth" - | "config" - | "usage_limit"; +type UsageLimitData = Partial< + Pick< + TypesGen.ChatUsageLimitExceededResponse, + "spent_micros" | "limit_micros" | "resets_at" + > +>; /** * Typed classification for errors surfaced in the agent detail view. @@ -33,7 +18,7 @@ export type ChatProviderFailureKind = export type ChatDetailError = { message: string; detail?: string; - kind: ChatProviderFailureKind | (string & {}); + kind: TypesGen.ChatErrorKind; provider?: string; retryable?: boolean; statusCode?: number; @@ -81,11 +66,11 @@ function formatResetDate(isoString: string): string { } /** - * Runtime guard that validates whether an unknown value has the shape - * of structured usage-limit fields from a 409 response. - * All three fields must be present with correct types. + * Runtime guard for the structured 409 usage-limit response. */ -export function isUsageLimitData(value: unknown): value is UsageLimitData { +export function isChatUsageLimitExceededResponse( + value: unknown, +): value is TypesGen.ChatUsageLimitExceededResponse { if (value == null || typeof value !== "object") { return false; }