diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 94cbf589..f706bc0f 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -1,6 +1,7 @@ import { BedrockRuntimeClient, type BedrockRuntimeClientConfig, + BedrockRuntimeServiceException, StopReason as BedrockStopReason, type Tool as BedrockTool, CachePointType, @@ -185,15 +186,15 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt } else if (item.metadata) { handleMetadata(item.metadata, model, output); } else if (item.internalServerException) { - throw new Error(`Internal server error: ${item.internalServerException.message}`); + throw item.internalServerException; } else if (item.modelStreamErrorException) { - throw new Error(`Model stream error: ${item.modelStreamErrorException.message}`); + throw item.modelStreamErrorException; } else if (item.validationException) { - throw new Error(`Validation error: ${item.validationException.message}`); + throw item.validationException; } else if (item.throttlingException) { - throw new Error(`Throttling error: ${item.throttlingException.message}`); + throw item.throttlingException; } else if (item.serviceUnavailableException) { - throw new Error(`Service unavailable: ${item.serviceUnavailableException.message}`); + throw item.serviceUnavailableException; } } @@ -213,7 +214,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt delete (block as Block).partialJson; } output.stopReason = options.signal?.aborted ? "aborted" : "error"; - output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + output.errorMessage = formatBedrockError(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } @@ -222,6 +223,36 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt return stream; }; +/** + * Human-readable prefixes for Bedrock SDK exception names. + * The downstream retry logic in agent-session matches patterns like + * `server.?error` and `service.?unavailable`, so we preserve the legacy + * prefix format rather than using the raw SDK exception name. + */ +const BEDROCK_ERROR_PREFIXES: Record = { + InternalServerException: "Internal server error", + ModelStreamErrorException: "Model stream error", + ValidationException: "Validation error", + ThrottlingException: "Throttling error", + ServiceUnavailableException: "Service unavailable", +}; + +/** + * Format a Bedrock error with a human-readable prefix. + * AWS SDK exceptions (both from `client.send()` and from stream event items) + * extend BedrockRuntimeServiceException. We map the `.name` to a stable + * human-readable prefix so downstream consumers (retry logic, context-overflow + * detection) can distinguish error categories via simple string matching. + */ +function formatBedrockError(error: unknown): string { + const message = error instanceof Error ? error.message : JSON.stringify(error); + if (error instanceof BedrockRuntimeServiceException) { + const prefix = BEDROCK_ERROR_PREFIXES[error.name] ?? error.name; + return `${prefix}: ${message}`; + } + return message; +} + export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions> = ( model: Model<"bedrock-converse-stream">, context: Context, diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts index 968d9da8..d39f16dc 100644 --- a/packages/ai/src/utils/overflow.ts +++ b/packages/ai/src/utils/overflow.ts @@ -19,7 +19,7 @@ import type { AssistantMessage } from "../types.js"; * - GitHub Copilot: "prompt token count of X exceeds the limit of Y" * - MiniMax: "invalid params, context window exceeds limit" * - Kimi For Coding: "Your request exceeded model token limit: X (requested: Y)" - * - Cerebras: Returns "400/413 status code (no body)" - handled separately below + * - Cerebras: "400/413 status code (no body)" * - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length" * - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow * - Ollama: Some deployments truncate silently, others return errors like "prompt too long; exceeded max context length by X tokens" @@ -43,6 +43,22 @@ const OVERFLOW_PATTERNS = [ /context[_ ]length[_ ]exceeded/i, // Generic fallback /too many tokens/i, // Generic fallback /token limit exceeded/i, // Generic fallback + /^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i, // Cerebras: 400/413 with no body +]; + +/** + * Patterns that indicate non-overflow errors (e.g. rate limiting, server errors). + * Error messages matching any of these are excluded from overflow detection + * even if they also match an OVERFLOW_PATTERN. + * + * Example: Bedrock formats throttling errors as "ThrottlingException: Too many tokens, + * please wait before trying again." which would match the /too many tokens/i overflow + * pattern without this exclusion. + */ +const NON_OVERFLOW_PATTERNS = [ + /^(Throttling error|Service unavailable):/i, // AWS Bedrock non-overflow errors (human-readable prefixes from formatBedrockError) + /rate limit/i, // Generic rate limiting + /too many requests/i, // Generic HTTP 429 style ]; /** @@ -94,14 +110,9 @@ const OVERFLOW_PATTERNS = [ export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean { // Case 1: Check error message patterns if (message.stopReason === "error" && message.errorMessage) { - // Check known patterns - if (OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) { - return true; - } - - // Cerebras returns 400/413 with no body for context overflow - // Note: 429 is rate limiting (requests/tokens per time), NOT context overflow - if (/^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message.errorMessage)) { + // Skip messages matching known non-overflow patterns (e.g. throttling / rate-limit) + const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!)); + if (!isNonOverflow && OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) { return true; } } diff --git a/packages/ai/test/overflow.test.ts b/packages/ai/test/overflow.test.ts index 214cb123..d1aac258 100644 --- a/packages/ai/test/overflow.test.ts +++ b/packages/ai/test/overflow.test.ts @@ -39,4 +39,26 @@ describe("isContextOverflow", () => { const message = createErrorMessage("500 `model runner crashed unexpectedly`"); expect(isContextOverflow(message, 32768)).toBe(false); }); + + it("does not treat Bedrock throttling 'Too many tokens' as overflow", () => { + // Bedrock returns this for HTTP 429 rate limiting, NOT context overflow. + // formatBedrockError uses a human-readable prefix for ThrottlingException. + const message = createErrorMessage("Throttling error: Too many tokens, please wait before trying again."); + expect(isContextOverflow(message, 200000)).toBe(false); + }); + + it("does not treat Bedrock service unavailable as overflow", () => { + const message = createErrorMessage("Service unavailable: The service is temporarily unavailable."); + expect(isContextOverflow(message, 200000)).toBe(false); + }); + + it("does not treat generic rate limit errors as overflow", () => { + const message = createErrorMessage("Rate limit exceeded, please retry after 30 seconds."); + expect(isContextOverflow(message, 200000)).toBe(false); + }); + + it("does not treat HTTP 429 style errors as overflow", () => { + const message = createErrorMessage("Too many requests. Please slow down."); + expect(isContextOverflow(message, 200000)).toBe(false); + }); });