mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: migrate Bedrock provider to AI SDK (#11243)
* feat: migrate Bedrock provider to AI SDK Replace the raw AWS SDK (@aws-sdk/client-bedrock-runtime) Bedrock handler with the Vercel AI SDK (@ai-sdk/amazon-bedrock). Reduces provider from 1,633 lines to 575 lines (65% reduction). Key changes: - Use streamText()/generateText() instead of ConverseStreamCommand/ConverseCommand - Use createAmazonBedrock() with native auth (access key, secret, session, profile via credentialProvider, API key, VPC endpoint as baseURL) - Reasoning config via providerOptions.bedrock.reasoningConfig - Anthropic beta headers via providerOptions.bedrock.anthropicBeta - Thinking signature captured from providerMetadata.bedrock.signature on reasoning-delta stream events - Thinking signature round-tripped via providerOptions.bedrock.signature on reasoning parts in convertToAiSdkMessages() - Redacted thinking captured from providerMetadata.bedrock.redactedData - isAiSdkProvider() returns true for reasoning block preservation - Keep: getModel, ARN parsing, cross-region inference, cost calculation, service tier pricing, 1M context beta Tests: 83 tests skipped (mock old AWS SDK internals, need rewrite for AI SDK mocking). 106 tests pass. 0 tests fail. * fix: address review feedback for Bedrock AI SDK migration - Wire usePromptCache into AI SDK via providerOptions.bedrock.cachePoint on system prompt and last two user messages - Remove debug logger.info that fires on every stream event with providerMetadata - Tighten isThrottlingError to match 'rate limit' instead of broad 'rate'/'limit' substrings that false-positive on context length errors - Use shared handleAiSdkError utility for consistent error handling with status code preservation for retry logic * fix: bedrock AI SDK migration - fix usage metrics, rewrite tests, remove dead code - Fix reasoningTokens always 0 (usage.details?.reasoningTokens → usage.reasoningTokens) - Fix cacheReadInputTokens always 0 (read from usage.inputTokenDetails instead of providerMetadata) - Fix invokedModelId not extracted for prompt router cost calculation - Rewrite all 6 skipped bedrock test suites for AI SDK mocking pattern (140 tests pass) - Remove dead code: bedrock-converse-format.ts, cache-strategy/ (6 files, ~2700 lines) * chore: remove dead @anthropic-ai/bedrock-sdk dep and stale AWS SDK mocks * chore: update pnpm-lock.yaml after removing @anthropic-ai/bedrock-sdk * fix: compute cache point indices from original Anthropic messages before AI SDK conversion The previous approach naively targeted the last 2 user messages in the post-conversion AI SDK array, but convertToAiSdkMessages() splits user messages containing tool_results into separate tool + user messages, causing cache points to land on the wrong messages (tiny text fragments instead of the intended meaty user turns). Now we identify the last 2 user messages in the original Anthropic message array (matching the Anthropic provider's caching strategy) and build a parallel-walk mapping to apply cachePoint to the correct corresponding AI SDK message. * perf: optimize prompt caching with 3-point message strategy + anchor for 20-block window Previous approach only cached the last 2 user messages (using 2 of 4 available cache checkpoints for messages). This left significant cache savings on the table for longer conversations. New strategy uses up to 3 message cache points (+ 1 system = 4 total): - Last user message: write to cache for next request - Second-to-last user message: read from cache for current request - Anchor message at ~1/3 position: ensures the 20-block lookback window from the second-to-last breakpoint hits a stable cache entry, covering all assistant/tool messages in the middle of the conversation Also extracted the parallel-walk mapping logic into a reusable applyCachePointsToAiSdkMessages() helper method. Industry benchmarks show 70-95% token cache rates are achievable; this change should significantly improve our 39% baseline for longer multi-turn conversations. * chore: remove stale bedrock-sdk external, fix arnInfo property name, remove unused exports --------- Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
This commit is contained in:
parent
0e5407aa76
commit
ca7e3b6161
20 changed files with 1930 additions and 5849 deletions
|
|
@ -16,7 +16,6 @@ export default defineConfig({
|
|||
external: [
|
||||
// Keep native modules external
|
||||
"@anthropic-ai/sdk",
|
||||
"@anthropic-ai/bedrock-sdk",
|
||||
"@anthropic-ai/vertex-sdk",
|
||||
// Keep @vscode/ripgrep external - we bundle the binary separately
|
||||
"@vscode/ripgrep",
|
||||
|
|
|
|||
432
pnpm-lock.yaml
generated
432
pnpm-lock.yaml
generated
|
|
@ -746,6 +746,9 @@ importers:
|
|||
|
||||
src:
|
||||
dependencies:
|
||||
'@ai-sdk/amazon-bedrock':
|
||||
specifier: ^4.0.50
|
||||
version: 4.0.50(zod@3.25.76)
|
||||
'@ai-sdk/cerebras':
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.35(zod@3.25.76)
|
||||
|
|
@ -770,9 +773,6 @@ importers:
|
|||
'@ai-sdk/xai':
|
||||
specifier: ^3.0.46
|
||||
version: 3.0.46(zod@3.25.76)
|
||||
'@anthropic-ai/bedrock-sdk':
|
||||
specifier: ^0.10.2
|
||||
version: 0.10.4
|
||||
'@anthropic-ai/sdk':
|
||||
specifier: ^0.37.0
|
||||
version: 0.37.0
|
||||
|
|
@ -1417,12 +1417,24 @@ packages:
|
|||
'@adobe/css-tools@4.4.2':
|
||||
resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==}
|
||||
|
||||
'@ai-sdk/amazon-bedrock@4.0.50':
|
||||
resolution: {integrity: sha512-DsIxaUHPbDUY0DfxYMz6GL9tO/z7ISiwACSiYupcYImqrcdLtIGFujPgszOf92ed3olfhjdkhTwKBHaf6Yh6Qw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/anthropic@2.0.58':
|
||||
resolution: {integrity: sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/anthropic@3.0.37':
|
||||
resolution: {integrity: sha512-tEgcJPw+a6obbF+SHrEiZsx3DNxOHqeY8bK4IpiNsZ8YPZD141R34g3lEAaQnmNN5mGsEJ8SXoEDabuzi8wFJQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/cerebras@1.0.35':
|
||||
resolution: {integrity: sha512-JrNdMYptrOUjNthibgBeAcBjZ/H+fXb49sSrWhOx5Aq8eUcrYvwQ2DtSAi8VraHssZu78NAnBMrgFWSUOTXFxw==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -1575,9 +1587,6 @@ packages:
|
|||
'@antfu/utils@8.1.1':
|
||||
resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
|
||||
|
||||
'@anthropic-ai/bedrock-sdk@0.10.4':
|
||||
resolution: {integrity: sha512-szduEHbMli6XL934xrraYg5cFuKL/1oMyj/iZuEVjtddQ7eD5cXObzWobsv5mTLWijQmSzMfFD+JAUHDPHlQ/Q==}
|
||||
|
||||
'@anthropic-ai/sdk@0.37.0':
|
||||
resolution: {integrity: sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==}
|
||||
|
||||
|
|
@ -1587,9 +1596,6 @@ packages:
|
|||
'@asamuzakjp/css-color@3.2.0':
|
||||
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
||||
|
||||
'@aws-crypto/crc32@3.0.0':
|
||||
resolution: {integrity: sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==}
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
|
@ -1597,9 +1603,6 @@ packages:
|
|||
'@aws-crypto/sha256-browser@5.2.0':
|
||||
resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==}
|
||||
|
||||
'@aws-crypto/sha256-js@4.0.0':
|
||||
resolution: {integrity: sha512-MHGJyjE7TX9aaqXj7zk2ppnFUOhaDs5sP+HtNS0evOxn72c+5njUmyJmpGd7TfyoDznZlHMmdo/xGUdu2NIjNQ==}
|
||||
|
||||
'@aws-crypto/sha256-js@5.2.0':
|
||||
resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
|
@ -1607,12 +1610,6 @@ packages:
|
|||
'@aws-crypto/supports-web-crypto@5.2.0':
|
||||
resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==}
|
||||
|
||||
'@aws-crypto/util@3.0.0':
|
||||
resolution: {integrity: sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==}
|
||||
|
||||
'@aws-crypto/util@4.0.0':
|
||||
resolution: {integrity: sha512-2EnmPy2gsFZ6m8bwUQN4jq+IyXV3quHAcwPOS6ZA3k+geujiqI8aRokO2kFJe+idJ/P3v4qWI186rVMo0+zLDQ==}
|
||||
|
||||
'@aws-crypto/util@5.2.0':
|
||||
resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
|
||||
|
||||
|
|
@ -1708,14 +1705,6 @@ packages:
|
|||
resolution: {integrity: sha512-/inmPnjZE0ZBE16zaCowAvouSx05FJ7p6BQYuzlJ8vxEU0sS0Hf8fvhuiRnN9V9eDUPIBY+/5EjbMWygXL4wlQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.804.0':
|
||||
resolution: {integrity: sha512-A9qnsy9zQ8G89vrPPlNG9d1d8QcKRGqJKqwyGgS0dclJpwy6d1EWgQLIolKPl6vcFpLoe6avLOLxr+h8ur5wpg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.840.0':
|
||||
resolution: {integrity: sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.922.0':
|
||||
resolution: {integrity: sha512-eLA6XjVobAUAMivvM7DBL79mnHyrm+32TkXNWZua5mnxF+6kQCfblKKJvxMZLGosO53/Ex46ogim8IY5Nbqv2w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -1744,9 +1733,6 @@ packages:
|
|||
aws-crt:
|
||||
optional: true
|
||||
|
||||
'@aws-sdk/util-utf8-browser@3.259.0':
|
||||
resolution: {integrity: sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==}
|
||||
|
||||
'@aws-sdk/xml-builder@3.921.0':
|
||||
resolution: {integrity: sha512-LVHg0jgjyicKKvpNIEMXIMr1EBViESxcPkqfOlT+X1FkmUMTNZEEVF18tOJg4m4hV5vxtkWcqtr4IEeWa1C41Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -3879,10 +3865,6 @@ packages:
|
|||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@smithy/abort-controller@2.2.0':
|
||||
resolution: {integrity: sha512-wRlta7GuLWpTqtFfGo+nZyOO1vEvewdNR1R4rTxpC8XU6vG/NDyrFBhwLZsqg1NUoR1noVaXJPC/7ZK47QCySw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/abort-controller@4.2.4':
|
||||
resolution: {integrity: sha512-Z4DUr/AkgyFf1bOThW2HwzREagee0sB5ycl+hDiSZOfRLW8ZgrOjDi6g8mHH19yyU5E2A/64W3z6SMIf5XiUSQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -3899,9 +3881,6 @@ packages:
|
|||
resolution: {integrity: sha512-YVNMjhdz2pVto5bRdux7GMs0x1m0Afz3OcQy/4Yf9DH4fWOtroGH7uLvs7ZmDyoBJzLdegtIPpXrpJOZWvUXdw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/eventstream-codec@2.2.0':
|
||||
resolution: {integrity: sha512-8janZoJw85nJmQZc4L8TuePp2pk1nxLgkxIR0TUjKJ5Dkj5oelB9WtiSSGXCQvNsJl0VSTvK/2ueMXxvpa9GVw==}
|
||||
|
||||
'@smithy/eventstream-codec@4.2.4':
|
||||
resolution: {integrity: sha512-aV8blR9RBDKrOlZVgjOdmOibTC2sBXNiT7WA558b4MPdsLTV6sbyc1WIE9QiIuYMJjYtnPLciefoqSW8Gi+MZQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -3914,25 +3893,14 @@ packages:
|
|||
resolution: {integrity: sha512-lxfDT0UuSc1HqltOGsTEAlZ6H29gpfDSdEPTapD5G63RbnYToZ+ezjzdonCCH90j5tRRCw3aLXVbiZaBW3VRVg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/eventstream-serde-node@2.2.0':
|
||||
resolution: {integrity: sha512-zpQMtJVqCUMn+pCSFcl9K/RPNtQE0NuMh8sKpCdEHafhwRsjP50Oq/4kMmvxSRy6d8Jslqd8BLvDngrUtmN9iA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/eventstream-serde-node@4.2.4':
|
||||
resolution: {integrity: sha512-TPhiGByWnYyzcpU/K3pO5V7QgtXYpE0NaJPEZBCa1Y5jlw5SjqzMSbFiLb+ZkJhqoQc0ImGyVINqnq1ze0ZRcQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/eventstream-serde-universal@2.2.0':
|
||||
resolution: {integrity: sha512-pvoe/vvJY0mOpuF84BEtyZoYfbehiFj8KKWk1ds2AT0mTLYFVs+7sBJZmioOFdBXKd48lfrx1vumdPdmGlCLxA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/eventstream-serde-universal@4.2.4':
|
||||
resolution: {integrity: sha512-GNI/IXaY/XBB1SkGBFmbW033uWA0tj085eCxYih0eccUe/PFR7+UBQv9HNDk2fD9TJu7UVsCWsH99TkpEPSOzQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/fetch-http-handler@2.5.0':
|
||||
resolution: {integrity: sha512-BOWEBeppWhLn/no/JxUL/ghTfANTjT7kg3Ww2rPqTUY9R4yHPXxJ9JhMe3Z03LN3aPwiwlpDIUcVw1xDyHqEhw==}
|
||||
|
||||
'@smithy/fetch-http-handler@5.3.5':
|
||||
resolution: {integrity: sha512-mg83SM3FLI8Sa2ooTJbsh5MFfyMTyNRwxqpKHmE0ICRIa66Aodv80DMsTQI02xBLVJ0hckwqTRr5IGAbbWuFLQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -3949,10 +3917,6 @@ packages:
|
|||
resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/is-array-buffer@3.0.0':
|
||||
resolution: {integrity: sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@smithy/is-array-buffer@4.2.0':
|
||||
resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -3961,10 +3925,6 @@ packages:
|
|||
resolution: {integrity: sha512-hJRZuFS9UsElX4DJSJfoX4M1qXRH+VFiLMUnhsWvtOOUWRNvvOfDaUSdlNbjwv1IkpVjj/Rd/O59Jl3nhAcxow==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-endpoint@2.5.1':
|
||||
resolution: {integrity: sha512-1/8kFp6Fl4OsSIVTWHnNjLnTL8IqpIb/D3sTSczrKFnrE9VMNWxnrRKNvpUHOJ6zpGD5f62TPm7+17ilTJpiCQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/middleware-endpoint@4.3.6':
|
||||
resolution: {integrity: sha512-PXehXofGMFpDqr933rxD8RGOcZ0QBAWtuzTgYRAHAL2BnKawHDEdf/TnGpcmfPJGwonhginaaeJIKluEojiF/w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -3973,66 +3933,34 @@ packages:
|
|||
resolution: {integrity: sha512-OhLx131znrEDxZPAvH/OYufR9d1nB2CQADyYFN4C3V/NQS7Mg4V6uvxHC/Dr96ZQW8IlHJTJ+vAhKt6oxWRndA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-serde@2.3.0':
|
||||
resolution: {integrity: sha512-sIADe7ojwqTyvEQBe1nc/GXB9wdHhi9UwyX0lTyttmUWDJLP655ZYE1WngnNyXREme8I27KCaUhyhZWRXL0q7Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/middleware-serde@4.2.4':
|
||||
resolution: {integrity: sha512-jUr3x2CDhV15TOX2/Uoz4gfgeqLrRoTQbYAuhLS7lcVKNev7FeYSJ1ebEfjk+l9kbb7k7LfzIR/irgxys5ZTOg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/middleware-stack@2.2.0':
|
||||
resolution: {integrity: sha512-Qntc3jrtwwrsAC+X8wms8zhrTr0sFXnyEGhZd9sLtsJ/6gGQKFzNB+wWbOcpJd7BR8ThNCoKt76BuQahfMvpeA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/middleware-stack@4.2.4':
|
||||
resolution: {integrity: sha512-Gy3TKCOnm9JwpFooldwAboazw+EFYlC+Bb+1QBsSi5xI0W5lX81j/P5+CXvD/9ZjtYKRgxq+kkqd/KOHflzvgA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-config-provider@2.3.0':
|
||||
resolution: {integrity: sha512-0elK5/03a1JPWMDPaS726Iw6LpQg80gFut1tNpPfxFuChEEklo2yL823V94SpTZTxmKlXFtFgsP55uh3dErnIg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/node-config-provider@4.3.4':
|
||||
resolution: {integrity: sha512-3X3w7qzmo4XNNdPKNS4nbJcGSwiEMsNsRSunMA92S4DJLLIrH5g1AyuOA2XKM9PAPi8mIWfqC+fnfKNsI4KvHw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@2.5.0':
|
||||
resolution: {integrity: sha512-mVGyPBzkkGQsPoxQUbxlEfRjrj6FPyA3u3u2VXGr9hT8wilsoQdZdvKpMBFMB8Crfhv5dNkKHIW0Yyuc7eABqA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.4.4':
|
||||
resolution: {integrity: sha512-VXHGfzCXLZeKnFp6QXjAdy+U8JF9etfpUXD1FAbzY1GzsFJiDQRQIt2CnMUvUdz3/YaHNqT3RphVWMUpXTIODA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/property-provider@2.2.0':
|
||||
resolution: {integrity: sha512-+xiil2lFhtTRzXkx8F053AV46QnIw6e7MV8od5Mi68E1ICOjCeCHw2XfLnDEUHnT9WGUIkwcqavXjfwuJbGlpg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/property-provider@4.2.4':
|
||||
resolution: {integrity: sha512-g2DHo08IhxV5GdY3Cpt/jr0mkTlAD39EJKN27Jb5N8Fb5qt8KG39wVKTXiTRCmHHou7lbXR8nKVU14/aRUf86w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/protocol-http@3.3.0':
|
||||
resolution: {integrity: sha512-Xy5XK1AFWW2nlY/biWZXu6/krgbaf2dg0q492D8M5qthsnU2H+UgFeZLbM76FnH7s6RO/xhQRkj+T6KBO3JzgQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/protocol-http@5.3.4':
|
||||
resolution: {integrity: sha512-3sfFd2MAzVt0Q/klOmjFi3oIkxczHs0avbwrfn1aBqtc23WqQSmjvk77MBw9WkEQcwbOYIX5/2z4ULj8DuxSsw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/querystring-builder@2.2.0':
|
||||
resolution: {integrity: sha512-L1kSeviUWL+emq3CUVSgdogoM/D9QMFaqxL/dd0X7PCNWmPXqt+ExtrBjqT0V7HLN03Vs9SuiLrG3zy3JGnE5A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/querystring-builder@4.2.4':
|
||||
resolution: {integrity: sha512-KQ1gFXXC+WsbPFnk7pzskzOpn4s+KheWgO3dzkIEmnb6NskAIGp/dGdbKisTPJdtov28qNDohQrgDUKzXZBLig==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/querystring-parser@2.2.0':
|
||||
resolution: {integrity: sha512-BvHCDrKfbG5Yhbpj4vsbuPV2GgcpHiAkLeIlcA1LtfpMz3jrqizP1+OguSNSj1MwBHEiN+jwNisXLGdajGDQJA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/querystring-parser@4.2.4':
|
||||
resolution: {integrity: sha512-aHb5cqXZocdzEkZ/CvhVjdw5l4r1aU/9iMEyoKzH4eXMowT6M0YjBpp7W/+XjkBnY8Xh0kVd55GKjnPKlCwinQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -4041,57 +3969,26 @@ packages:
|
|||
resolution: {integrity: sha512-fdWuhEx4+jHLGeew9/IvqVU/fxT/ot70tpRGuOLxE3HzZOyKeTQfYeV1oaBXpzi93WOk668hjMuuagJ2/Qs7ng==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/shared-ini-file-loader@2.4.0':
|
||||
resolution: {integrity: sha512-WyujUJL8e1B6Z4PBfAqC/aGY1+C7T0w20Gih3yrvJSk97gpiVfB+y7c46T4Nunk+ZngLq0rOIdeVeIklk0R3OA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/shared-ini-file-loader@4.3.4':
|
||||
resolution: {integrity: sha512-y5ozxeQ9omVjbnJo9dtTsdXj9BEvGx2X8xvRgKnV+/7wLBuYJQL6dOa/qMY6omyHi7yjt1OA97jZLoVRYi8lxA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/signature-v4@3.1.2':
|
||||
resolution: {integrity: sha512-3BcPylEsYtD0esM4Hoyml/+s7WP2LFhcM3J2AGdcL2vx9O60TtfpDOL72gjb4lU8NeRPeKAwR77YNyyGvMbuEA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@smithy/signature-v4@5.3.4':
|
||||
resolution: {integrity: sha512-ScDCpasxH7w1HXHYbtk3jcivjvdA1VICyAdgvVqKhKKwxi+MTwZEqFw0minE+oZ7F07oF25xh4FGJxgqgShz0A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/smithy-client@2.5.1':
|
||||
resolution: {integrity: sha512-jrbSQrYCho0yDaaf92qWgd+7nAeap5LtHTI51KXqmpIFCceKU3K9+vIVTUH72bOJngBMqa4kyu1VJhRcSrk/CQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/smithy-client@4.9.2':
|
||||
resolution: {integrity: sha512-gZU4uAFcdrSi3io8U99Qs/FvVdRxPvIMToi+MFfsy/DN9UqtknJ1ais+2M9yR8e0ASQpNmFYEKeIKVcMjQg3rg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/types@2.12.0':
|
||||
resolution: {integrity: sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/types@3.7.2':
|
||||
resolution: {integrity: sha512-bNwBYYmN8Eh9RyjS1p2gW6MIhSO2rl7X9QeLM8iTdcGRP+eDiIWDt66c9IysCc22gefKszZv+ubV9qZc7hdESg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@smithy/types@4.3.1':
|
||||
resolution: {integrity: sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/types@4.8.1':
|
||||
resolution: {integrity: sha512-N0Zn0OT1zc+NA+UVfkYqQzviRh5ucWwO7mBV3TmHHprMnfcJNfhlPicDkBHi0ewbh+y3evR6cNAW0Raxvb01NA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/url-parser@2.2.0':
|
||||
resolution: {integrity: sha512-hoA4zm61q1mNTpksiSWp2nEl1dt3j726HdRhiNgVJQMj7mLp7dprtF57mOB6JvEk/x9d2bsuL5hlqZbBuHQylQ==}
|
||||
|
||||
'@smithy/url-parser@4.2.4':
|
||||
resolution: {integrity: sha512-w/N/Iw0/PTwJ36PDqU9PzAwVElo4qXxCC0eCTlUtIz/Z5V/2j/cViMHi0hPukSBHp4DVwvUlUhLgCzqSJ6plrg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-base64@2.3.0':
|
||||
resolution: {integrity: sha512-s3+eVwNeJuXUwuMbusncZNViuhv2LjVJ1nMwTqSA0XAC7gjKhqqxRdJPhR8+YrkoZ9IiIbFk/yK6ACe/xlF+hw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/util-base64@4.3.0':
|
||||
resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -4108,10 +4005,6 @@ packages:
|
|||
resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/util-buffer-from@3.0.0':
|
||||
resolution: {integrity: sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@smithy/util-buffer-from@4.2.0':
|
||||
resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -4132,26 +4025,10 @@ packages:
|
|||
resolution: {integrity: sha512-f+nBDhgYRCmUEDKEQb6q0aCcOTXRDqH5wWaFHJxt4anB4pKHlgGoYP3xtioKXH64e37ANUkzWf6p4Mnv1M5/Vg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-hex-encoding@2.2.0':
|
||||
resolution: {integrity: sha512-7iKXR+/4TpLK194pVjKiasIyqMtTYJsgKgM242Y9uzt5dhHnUDvMNb+3xIhRJ9QhvqGii/5cRUt4fJn3dtXNHQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/util-hex-encoding@3.0.0':
|
||||
resolution: {integrity: sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@smithy/util-hex-encoding@4.2.0':
|
||||
resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-middleware@2.2.0':
|
||||
resolution: {integrity: sha512-L1qpleXf9QD6LwLCJ5jddGkgWyuSvWBkJwWAZ6kFkdifdso+sk3L3O1HdmPvCdnCK3IS4qWyPxev01QMnfHSBw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/util-middleware@3.0.11':
|
||||
resolution: {integrity: sha512-dWpyc1e1R6VoXrwLoLDd57U1z6CwNSdkM69Ie4+6uYh2GC7Vg51Qtan7ITzczuVpqezdDTKJGJB95fFvvjU/ow==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@smithy/util-middleware@4.2.4':
|
||||
resolution: {integrity: sha512-fKGQAPAn8sgV0plRikRVo6g6aR0KyKvgzNrPuM74RZKy/wWVzx3BMk+ZWEueyN3L5v5EDg+P582mKU+sH5OAsg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -4160,22 +4037,10 @@ packages:
|
|||
resolution: {integrity: sha512-yQncJmj4dtv/isTXxRb4AamZHy4QFr4ew8GxS6XLWt7sCIxkPxPzINWd7WLISEFPsIan14zrKgvyAF+/yzfwoA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-stream@2.2.0':
|
||||
resolution: {integrity: sha512-17faEXbYWIRst1aU9SvPZyMdWmqIrduZjVOqCPMIsWFNxs5yQQgFrJL6b2SdiCzyW9mJoDjFtgi53xx7EH+BXA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/util-stream@4.5.5':
|
||||
resolution: {integrity: sha512-7M5aVFjT+HPilPOKbOmQfCIPchZe4DSBc1wf1+NvHvSoFTiFtauZzT+onZvCj70xhXd0AEmYnZYmdJIuwxOo4w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-uri-escape@2.2.0':
|
||||
resolution: {integrity: sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/util-uri-escape@3.0.0':
|
||||
resolution: {integrity: sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@smithy/util-uri-escape@4.2.0':
|
||||
resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -4184,10 +4049,6 @@ packages:
|
|||
resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/util-utf8@3.0.0':
|
||||
resolution: {integrity: sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@smithy/util-utf8@4.2.0':
|
||||
resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
|
@ -5146,6 +5007,9 @@ packages:
|
|||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
aws4fetch@1.0.20:
|
||||
resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==}
|
||||
|
||||
axios@1.12.0:
|
||||
resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==}
|
||||
|
||||
|
|
@ -11130,12 +10994,28 @@ snapshots:
|
|||
|
||||
'@adobe/css-tools@4.4.2': {}
|
||||
|
||||
'@ai-sdk/amazon-bedrock@4.0.50(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/anthropic': 3.0.37(zod@3.25.76)
|
||||
'@ai-sdk/provider': 3.0.7
|
||||
'@ai-sdk/provider-utils': 4.0.13(zod@3.25.76)
|
||||
'@smithy/eventstream-codec': 4.2.4
|
||||
'@smithy/util-utf8': 4.2.0
|
||||
aws4fetch: 1.0.20
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/anthropic@2.0.58(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.1
|
||||
'@ai-sdk/provider-utils': 3.0.20(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/anthropic@3.0.37(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.7
|
||||
'@ai-sdk/provider-utils': 4.0.13(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/cerebras@1.0.35(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/openai-compatible': 1.0.31(zod@3.25.76)
|
||||
|
|
@ -11304,23 +11184,6 @@ snapshots:
|
|||
|
||||
'@antfu/utils@8.1.1': {}
|
||||
|
||||
'@anthropic-ai/bedrock-sdk@0.10.4':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.37.0
|
||||
'@aws-crypto/sha256-js': 4.0.0
|
||||
'@aws-sdk/client-bedrock-runtime': 3.922.0
|
||||
'@aws-sdk/credential-providers': 3.922.0
|
||||
'@smithy/eventstream-serde-node': 2.2.0
|
||||
'@smithy/fetch-http-handler': 2.5.0
|
||||
'@smithy/protocol-http': 3.3.0
|
||||
'@smithy/signature-v4': 3.1.2
|
||||
'@smithy/smithy-client': 2.5.1
|
||||
'@smithy/types': 2.12.0
|
||||
'@smithy/util-base64': 2.3.0
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
- encoding
|
||||
|
||||
'@anthropic-ai/sdk@0.37.0':
|
||||
dependencies:
|
||||
'@types/node': 18.19.100
|
||||
|
|
@ -11349,12 +11212,6 @@ snapshots:
|
|||
'@csstools/css-tokenizer': 3.0.4
|
||||
lru-cache: 10.4.3
|
||||
|
||||
'@aws-crypto/crc32@3.0.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 3.0.0
|
||||
'@aws-sdk/types': 3.840.0
|
||||
tslib: 1.14.1
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
|
|
@ -11371,12 +11228,6 @@ snapshots:
|
|||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/sha256-js@4.0.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 4.0.0
|
||||
'@aws-sdk/types': 3.804.0
|
||||
tslib: 1.14.1
|
||||
|
||||
'@aws-crypto/sha256-js@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
|
|
@ -11387,18 +11238,6 @@ snapshots:
|
|||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/util@3.0.0':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.840.0
|
||||
'@aws-sdk/util-utf8-browser': 3.259.0
|
||||
tslib: 1.14.1
|
||||
|
||||
'@aws-crypto/util@4.0.0':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.840.0
|
||||
'@aws-sdk/util-utf8-browser': 3.259.0
|
||||
tslib: 1.14.1
|
||||
|
||||
'@aws-crypto/util@5.2.0':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.922.0
|
||||
|
|
@ -11806,16 +11645,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/types@3.804.0':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/types@3.840.0':
|
||||
dependencies:
|
||||
'@smithy/types': 4.3.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/types@3.922.0':
|
||||
dependencies:
|
||||
'@smithy/types': 4.8.1
|
||||
|
|
@ -11855,10 +11684,6 @@ snapshots:
|
|||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/util-utf8-browser@3.259.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/xml-builder@3.921.0':
|
||||
dependencies:
|
||||
'@smithy/types': 4.8.1
|
||||
|
|
@ -14041,11 +13866,6 @@ snapshots:
|
|||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@smithy/abort-controller@2.2.0':
|
||||
dependencies:
|
||||
'@smithy/types': 2.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/abort-controller@4.2.4':
|
||||
dependencies:
|
||||
'@smithy/types': 4.8.1
|
||||
|
|
@ -14081,13 +13901,6 @@ snapshots:
|
|||
'@smithy/url-parser': 4.2.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/eventstream-codec@2.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/crc32': 3.0.0
|
||||
'@smithy/types': 2.12.0
|
||||
'@smithy/util-hex-encoding': 2.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/eventstream-codec@4.2.4':
|
||||
dependencies:
|
||||
'@aws-crypto/crc32': 5.2.0
|
||||
|
|
@ -14106,38 +13919,18 @@ snapshots:
|
|||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/eventstream-serde-node@2.2.0':
|
||||
dependencies:
|
||||
'@smithy/eventstream-serde-universal': 2.2.0
|
||||
'@smithy/types': 2.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/eventstream-serde-node@4.2.4':
|
||||
dependencies:
|
||||
'@smithy/eventstream-serde-universal': 4.2.4
|
||||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/eventstream-serde-universal@2.2.0':
|
||||
dependencies:
|
||||
'@smithy/eventstream-codec': 2.2.0
|
||||
'@smithy/types': 2.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/eventstream-serde-universal@4.2.4':
|
||||
dependencies:
|
||||
'@smithy/eventstream-codec': 4.2.4
|
||||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/fetch-http-handler@2.5.0':
|
||||
dependencies:
|
||||
'@smithy/protocol-http': 3.3.0
|
||||
'@smithy/querystring-builder': 2.2.0
|
||||
'@smithy/types': 2.12.0
|
||||
'@smithy/util-base64': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/fetch-http-handler@5.3.5':
|
||||
dependencies:
|
||||
'@smithy/protocol-http': 5.3.4
|
||||
|
|
@ -14162,10 +13955,6 @@ snapshots:
|
|||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/is-array-buffer@3.0.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/is-array-buffer@4.2.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
|
@ -14176,16 +13965,6 @@ snapshots:
|
|||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-endpoint@2.5.1':
|
||||
dependencies:
|
||||
'@smithy/middleware-serde': 2.3.0
|
||||
'@smithy/node-config-provider': 2.3.0
|
||||
'@smithy/shared-ini-file-loader': 2.4.0
|
||||
'@smithy/types': 2.12.0
|
||||
'@smithy/url-parser': 2.2.0
|
||||
'@smithy/util-middleware': 2.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-endpoint@4.3.6':
|
||||
dependencies:
|
||||
'@smithy/core': 3.17.2
|
||||
|
|
@ -14209,34 +13988,17 @@ snapshots:
|
|||
'@smithy/uuid': 1.1.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-serde@2.3.0':
|
||||
dependencies:
|
||||
'@smithy/types': 2.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-serde@4.2.4':
|
||||
dependencies:
|
||||
'@smithy/protocol-http': 5.3.4
|
||||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-stack@2.2.0':
|
||||
dependencies:
|
||||
'@smithy/types': 2.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/middleware-stack@4.2.4':
|
||||
dependencies:
|
||||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-config-provider@2.3.0':
|
||||
dependencies:
|
||||
'@smithy/property-provider': 2.2.0
|
||||
'@smithy/shared-ini-file-loader': 2.4.0
|
||||
'@smithy/types': 2.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-config-provider@4.3.4':
|
||||
dependencies:
|
||||
'@smithy/property-provider': 4.2.4
|
||||
|
|
@ -14244,14 +14006,6 @@ snapshots:
|
|||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@2.5.0':
|
||||
dependencies:
|
||||
'@smithy/abort-controller': 2.2.0
|
||||
'@smithy/protocol-http': 3.3.0
|
||||
'@smithy/querystring-builder': 2.2.0
|
||||
'@smithy/types': 2.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.4.4':
|
||||
dependencies:
|
||||
'@smithy/abort-controller': 4.2.4
|
||||
|
|
@ -14260,43 +14014,22 @@ snapshots:
|
|||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/property-provider@2.2.0':
|
||||
dependencies:
|
||||
'@smithy/types': 2.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/property-provider@4.2.4':
|
||||
dependencies:
|
||||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/protocol-http@3.3.0':
|
||||
dependencies:
|
||||
'@smithy/types': 2.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/protocol-http@5.3.4':
|
||||
dependencies:
|
||||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/querystring-builder@2.2.0':
|
||||
dependencies:
|
||||
'@smithy/types': 2.12.0
|
||||
'@smithy/util-uri-escape': 2.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/querystring-builder@4.2.4':
|
||||
dependencies:
|
||||
'@smithy/types': 4.8.1
|
||||
'@smithy/util-uri-escape': 4.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/querystring-parser@2.2.0':
|
||||
dependencies:
|
||||
'@smithy/types': 2.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/querystring-parser@4.2.4':
|
||||
dependencies:
|
||||
'@smithy/types': 4.8.1
|
||||
|
|
@ -14306,26 +14039,11 @@ snapshots:
|
|||
dependencies:
|
||||
'@smithy/types': 4.8.1
|
||||
|
||||
'@smithy/shared-ini-file-loader@2.4.0':
|
||||
dependencies:
|
||||
'@smithy/types': 2.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/shared-ini-file-loader@4.3.4':
|
||||
dependencies:
|
||||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@3.1.2':
|
||||
dependencies:
|
||||
'@smithy/is-array-buffer': 3.0.0
|
||||
'@smithy/types': 3.7.2
|
||||
'@smithy/util-hex-encoding': 3.0.0
|
||||
'@smithy/util-middleware': 3.0.11
|
||||
'@smithy/util-uri-escape': 3.0.0
|
||||
'@smithy/util-utf8': 3.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.3.4':
|
||||
dependencies:
|
||||
'@smithy/is-array-buffer': 4.2.0
|
||||
|
|
@ -14337,15 +14055,6 @@ snapshots:
|
|||
'@smithy/util-utf8': 4.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/smithy-client@2.5.1':
|
||||
dependencies:
|
||||
'@smithy/middleware-endpoint': 2.5.1
|
||||
'@smithy/middleware-stack': 2.2.0
|
||||
'@smithy/protocol-http': 3.3.0
|
||||
'@smithy/types': 2.12.0
|
||||
'@smithy/util-stream': 2.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/smithy-client@4.9.2':
|
||||
dependencies:
|
||||
'@smithy/core': 3.17.2
|
||||
|
|
@ -14356,40 +14065,16 @@ snapshots:
|
|||
'@smithy/util-stream': 4.5.5
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/types@2.12.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/types@3.7.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/types@4.3.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/types@4.8.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/url-parser@2.2.0':
|
||||
dependencies:
|
||||
'@smithy/querystring-parser': 2.2.0
|
||||
'@smithy/types': 2.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/url-parser@4.2.4':
|
||||
dependencies:
|
||||
'@smithy/querystring-parser': 4.2.4
|
||||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-base64@2.3.0':
|
||||
dependencies:
|
||||
'@smithy/util-buffer-from': 2.2.0
|
||||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-base64@4.3.0':
|
||||
dependencies:
|
||||
'@smithy/util-buffer-from': 4.2.0
|
||||
|
|
@ -14409,11 +14094,6 @@ snapshots:
|
|||
'@smithy/is-array-buffer': 2.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-buffer-from@3.0.0':
|
||||
dependencies:
|
||||
'@smithy/is-array-buffer': 3.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-buffer-from@4.2.0':
|
||||
dependencies:
|
||||
'@smithy/is-array-buffer': 4.2.0
|
||||
|
|
@ -14446,28 +14126,10 @@ snapshots:
|
|||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-hex-encoding@2.2.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-hex-encoding@3.0.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-hex-encoding@4.2.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-middleware@2.2.0':
|
||||
dependencies:
|
||||
'@smithy/types': 2.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-middleware@3.0.11':
|
||||
dependencies:
|
||||
'@smithy/types': 3.7.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-middleware@4.2.4':
|
||||
dependencies:
|
||||
'@smithy/types': 4.8.1
|
||||
|
|
@ -14479,17 +14141,6 @@ snapshots:
|
|||
'@smithy/types': 4.8.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-stream@2.2.0':
|
||||
dependencies:
|
||||
'@smithy/fetch-http-handler': 2.5.0
|
||||
'@smithy/node-http-handler': 2.5.0
|
||||
'@smithy/types': 2.12.0
|
||||
'@smithy/util-base64': 2.3.0
|
||||
'@smithy/util-buffer-from': 2.2.0
|
||||
'@smithy/util-hex-encoding': 2.2.0
|
||||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-stream@4.5.5':
|
||||
dependencies:
|
||||
'@smithy/fetch-http-handler': 5.3.5
|
||||
|
|
@ -14501,14 +14152,6 @@ snapshots:
|
|||
'@smithy/util-utf8': 4.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-uri-escape@2.2.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-uri-escape@3.0.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-uri-escape@4.2.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
|
@ -14518,11 +14161,6 @@ snapshots:
|
|||
'@smithy/util-buffer-from': 2.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-utf8@3.0.0':
|
||||
dependencies:
|
||||
'@smithy/util-buffer-from': 3.0.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-utf8@4.2.0':
|
||||
dependencies:
|
||||
'@smithy/util-buffer-from': 4.2.0
|
||||
|
|
@ -15627,6 +15265,8 @@ snapshots:
|
|||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
|
||||
aws4fetch@1.0.20: {}
|
||||
|
||||
axios@1.12.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
|
|
|
|||
|
|
@ -22,38 +22,6 @@ vitest.mock("../../../utils/logging", () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
// Mock AWS SDK
|
||||
vitest.mock("@aws-sdk/client-bedrock-runtime", () => {
|
||||
const mockModule = {
|
||||
lastCommandInput: null as Record<string, any> | null,
|
||||
mockSend: vitest.fn().mockImplementation(async function () {
|
||||
return {
|
||||
output: new TextEncoder().encode(JSON.stringify({ content: "Test response" })),
|
||||
}
|
||||
}),
|
||||
mockConverseCommand: vitest.fn(function (input) {
|
||||
mockModule.lastCommandInput = input
|
||||
return { input }
|
||||
}),
|
||||
MockBedrockRuntimeClient: class {
|
||||
public config: any
|
||||
public send: any
|
||||
|
||||
constructor(config: { region?: string }) {
|
||||
this.config = config
|
||||
this.send = mockModule.mockSend
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
BedrockRuntimeClient: mockModule.MockBedrockRuntimeClient,
|
||||
ConverseCommand: mockModule.mockConverseCommand,
|
||||
ConverseStreamCommand: vitest.fn(),
|
||||
__mock: mockModule, // Expose mock internals for testing
|
||||
}
|
||||
})
|
||||
|
||||
describe("Bedrock ARN Handling", () => {
|
||||
// Helper function to create a handler with specific options
|
||||
const createHandler = (options: Partial<ApiHandlerOptions> = {}) => {
|
||||
|
|
@ -224,8 +192,8 @@ describe("Bedrock ARN Handling", () => {
|
|||
"arn:aws:bedrock:eu-west-1:123456789012:inference-profile/anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
})
|
||||
|
||||
// Verify the client was created with the ARN region, not the provided region
|
||||
expect((handler as any).client.config.region).toBe("eu-west-1")
|
||||
// Verify the handler's options were updated with the ARN region
|
||||
expect((handler as any).options.awsRegion).toBe("eu-west-1")
|
||||
})
|
||||
|
||||
it("should log region mismatch warning when ARN region differs from provided region", () => {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@ vi.mock("@roo-code/telemetry", () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
// Mock BedrockRuntimeClient and commands
|
||||
const mockSend = vi.fn()
|
||||
|
||||
// Mock AWS SDK credential providers
|
||||
vi.mock("@aws-sdk/credential-providers", () => {
|
||||
return {
|
||||
|
|
@ -21,16 +18,27 @@ vi.mock("@aws-sdk/credential-providers", () => {
|
|||
}
|
||||
})
|
||||
|
||||
vi.mock("@aws-sdk/client-bedrock-runtime", () => ({
|
||||
BedrockRuntimeClient: vi.fn().mockImplementation(() => ({
|
||||
send: mockSend,
|
||||
})),
|
||||
ConverseStreamCommand: vi.fn(),
|
||||
ConverseCommand: vi.fn(),
|
||||
// Use vi.hoisted to define mock functions for AI SDK
|
||||
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/amazon-bedrock", () => ({
|
||||
createAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))),
|
||||
}))
|
||||
|
||||
import { AwsBedrockHandler } from "../bedrock"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
describe("AwsBedrockHandler Error Handling", () => {
|
||||
let handler: AwsBedrockHandler
|
||||
|
|
@ -46,6 +54,10 @@ describe("AwsBedrockHandler Error Handling", () => {
|
|||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper: create an Error with optional extra properties that
|
||||
* the production code inspects (status, name, $metadata, __type).
|
||||
*/
|
||||
const createMockError = (options: {
|
||||
message?: string
|
||||
name?: string
|
||||
|
|
@ -56,505 +68,481 @@ describe("AwsBedrockHandler Error Handling", () => {
|
|||
requestId?: string
|
||||
extendedRequestId?: string
|
||||
cfId?: string
|
||||
[key: string]: any // Allow additional properties
|
||||
[key: string]: unknown
|
||||
}
|
||||
}): Error => {
|
||||
const error = new Error(options.message || "Test error") as any
|
||||
if (options.name) error.name = options.name
|
||||
if (options.status) error.status = options.status
|
||||
if (options.status !== undefined) error.status = options.status
|
||||
if (options.__type) error.__type = options.__type
|
||||
if (options.$metadata) error.$metadata = options.$metadata
|
||||
return error
|
||||
}
|
||||
|
||||
describe("Throttling Error Detection", () => {
|
||||
it("should detect throttling from HTTP 429 status code", async () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// Throttling Detection — completePrompt path
|
||||
//
|
||||
// Production flow: generateText throws → catch → isThrottlingError() is
|
||||
// NOT called in completePrompt (only in createMessage), so it falls
|
||||
// through to handleAiSdkError which wraps with "Bedrock: <msg>".
|
||||
//
|
||||
// For createMessage: streamText throws → catch → isThrottlingError()
|
||||
// returns true → re-throws original error.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("Throttling Error Detection (createMessage)", () => {
|
||||
it("should re-throw throttling errors with status 429 for retry", async () => {
|
||||
const throttleError = createMockError({
|
||||
message: "Request failed",
|
||||
status: 429,
|
||||
})
|
||||
|
||||
mockSend.mockRejectedValueOnce(throttleError)
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw throttleError
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await handler.completePrompt("test")
|
||||
expect(result).toContain("throttled or rate limited")
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("throttled or rate limited")
|
||||
}
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
}).rejects.toThrow("Request failed")
|
||||
})
|
||||
|
||||
it("should detect throttling from AWS SDK $metadata.httpStatusCode", async () => {
|
||||
it("should re-throw throttling errors detected via $metadata.httpStatusCode", async () => {
|
||||
const throttleError = createMockError({
|
||||
message: "Request failed",
|
||||
$metadata: { httpStatusCode: 429 },
|
||||
})
|
||||
|
||||
mockSend.mockRejectedValueOnce(throttleError)
|
||||
|
||||
try {
|
||||
const result = await handler.completePrompt("test")
|
||||
expect(result).toContain("throttled or rate limited")
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("throttled or rate limited")
|
||||
}
|
||||
})
|
||||
|
||||
it("should detect throttling from ThrottlingException name", async () => {
|
||||
const throttleError = createMockError({
|
||||
message: "Request failed",
|
||||
name: "ThrottlingException",
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw throttleError
|
||||
})
|
||||
|
||||
mockSend.mockRejectedValueOnce(throttleError)
|
||||
|
||||
try {
|
||||
const result = await handler.completePrompt("test")
|
||||
expect(result).toContain("throttled or rate limited")
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("throttled or rate limited")
|
||||
}
|
||||
})
|
||||
|
||||
it("should detect throttling from __type field", async () => {
|
||||
const throttleError = createMockError({
|
||||
message: "Request failed",
|
||||
__type: "ThrottlingException",
|
||||
})
|
||||
|
||||
mockSend.mockRejectedValueOnce(throttleError)
|
||||
|
||||
try {
|
||||
const result = await handler.completePrompt("test")
|
||||
expect(result).toContain("throttled or rate limited")
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("throttled or rate limited")
|
||||
}
|
||||
})
|
||||
|
||||
it("should detect throttling from 'Bedrock is unable to process your request' message", async () => {
|
||||
const throttleError = createMockError({
|
||||
message: "Bedrock is unable to process your request",
|
||||
})
|
||||
|
||||
mockSend.mockRejectedValueOnce(throttleError)
|
||||
|
||||
try {
|
||||
const result = await handler.completePrompt("test")
|
||||
expect(result).toContain("throttled or rate limited")
|
||||
} catch (error) {
|
||||
expect(error.message).toMatch(/throttled or rate limited/)
|
||||
}
|
||||
})
|
||||
|
||||
it("should detect throttling from various message patterns", async () => {
|
||||
const throttlingMessages = [
|
||||
"Request throttled",
|
||||
"Rate limit exceeded",
|
||||
"Too many requests",
|
||||
"Service unavailable due to high demand",
|
||||
"Server is overloaded",
|
||||
"System is busy",
|
||||
"Please wait and try again",
|
||||
]
|
||||
|
||||
for (const message of throttlingMessages) {
|
||||
const throttleError = createMockError({ message })
|
||||
mockSend.mockRejectedValueOnce(throttleError)
|
||||
|
||||
try {
|
||||
await handler.completePrompt("test")
|
||||
// Should not reach here as completePrompt should throw
|
||||
throw new Error("Expected error to be thrown")
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("throttled or rate limited")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should display appropriate error information for throttling errors", async () => {
|
||||
const throttlingError = createMockError({
|
||||
message: "Bedrock is unable to process your request",
|
||||
name: "ThrottlingException",
|
||||
status: 429,
|
||||
$metadata: {
|
||||
httpStatusCode: 429,
|
||||
requestId: "12345-abcde-67890",
|
||||
extendedRequestId: "extended-12345",
|
||||
cfId: "cf-12345",
|
||||
},
|
||||
})
|
||||
|
||||
mockSend.mockRejectedValueOnce(throttlingError)
|
||||
|
||||
try {
|
||||
await handler.completePrompt("test")
|
||||
throw new Error("Expected error to be thrown")
|
||||
} catch (error) {
|
||||
// Should contain the main error message
|
||||
expect(error.message).toContain("throttled or rate limited")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Service Quota Exceeded Detection", () => {
|
||||
it("should detect service quota exceeded errors", async () => {
|
||||
const quotaError = createMockError({
|
||||
message: "Service quota exceeded for model requests",
|
||||
})
|
||||
|
||||
mockSend.mockRejectedValueOnce(quotaError)
|
||||
|
||||
try {
|
||||
const result = await handler.completePrompt("test")
|
||||
expect(result).toContain("Service quota exceeded")
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("Service quota exceeded")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Model Not Ready Detection", () => {
|
||||
it("should detect model not ready errors", async () => {
|
||||
const modelError = createMockError({
|
||||
message: "Model is not ready, please try again later",
|
||||
})
|
||||
|
||||
mockSend.mockRejectedValueOnce(modelError)
|
||||
|
||||
try {
|
||||
const result = await handler.completePrompt("test")
|
||||
expect(result).toContain("Model is not ready")
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("Model is not ready")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Internal Server Error Detection", () => {
|
||||
it("should detect internal server errors", async () => {
|
||||
const serverError = createMockError({
|
||||
message: "Internal server error occurred",
|
||||
})
|
||||
|
||||
mockSend.mockRejectedValueOnce(serverError)
|
||||
|
||||
try {
|
||||
const result = await handler.completePrompt("test")
|
||||
expect(result).toContain("internal server error")
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("internal server error")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Token Limit Detection", () => {
|
||||
it("should detect enhanced token limit errors", async () => {
|
||||
const tokenErrors = [
|
||||
"Too many tokens in request",
|
||||
"Token limit exceeded",
|
||||
"Maximum context length reached",
|
||||
"Context length exceeds limit",
|
||||
]
|
||||
|
||||
for (const message of tokenErrors) {
|
||||
const tokenError = createMockError({ message })
|
||||
mockSend.mockRejectedValueOnce(tokenError)
|
||||
|
||||
try {
|
||||
await handler.completePrompt("test")
|
||||
// Should not reach here as completePrompt should throw
|
||||
throw new Error("Expected error to be thrown")
|
||||
} catch (error) {
|
||||
// Either "Too many tokens" for token-specific errors or "throttled" for limit-related errors
|
||||
expect(error.message).toMatch(/Too many tokens|throttled or rate limited/)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Streaming Context Error Handling", () => {
|
||||
it("should handle throttling errors in streaming context", async () => {
|
||||
const throttleError = createMockError({
|
||||
message: "Bedrock is unable to process your request",
|
||||
status: 429,
|
||||
})
|
||||
|
||||
const mockStream = {
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
async next() {
|
||||
throw throttleError
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
mockSend.mockResolvedValueOnce({ stream: mockStream })
|
||||
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
// For throttling errors, it should throw immediately without yielding chunks
|
||||
// This allows the retry mechanism to catch and handle it
|
||||
await expect(async () => {
|
||||
for await (const chunk of generator) {
|
||||
// Should not yield any chunks for throttling errors
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
}).rejects.toThrow("Request failed")
|
||||
})
|
||||
|
||||
it("should re-throw ThrottlingException by name", async () => {
|
||||
const throttleError = createMockError({
|
||||
message: "Request failed",
|
||||
name: "ThrottlingException",
|
||||
})
|
||||
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw throttleError
|
||||
})
|
||||
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
}).rejects.toThrow("Request failed")
|
||||
})
|
||||
|
||||
it("should re-throw 'Bedrock is unable to process your request' as throttling", async () => {
|
||||
const throttleError = createMockError({
|
||||
message: "Bedrock is unable to process your request",
|
||||
})
|
||||
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw throttleError
|
||||
})
|
||||
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
}).rejects.toThrow("Bedrock is unable to process your request")
|
||||
})
|
||||
|
||||
it("should yield error chunks for non-throttling errors in streaming context", async () => {
|
||||
const genericError = createMockError({
|
||||
message: "Some other error",
|
||||
status: 500,
|
||||
})
|
||||
it("should detect throttling from various message patterns", async () => {
|
||||
const throttlingMessages = ["Request throttled", "Rate limit exceeded", "Too many requests"]
|
||||
|
||||
const mockStream = {
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
async next() {
|
||||
throw genericError
|
||||
},
|
||||
for (const message of throttlingMessages) {
|
||||
vi.clearAllMocks()
|
||||
const throttleError = createMockError({ message })
|
||||
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw throttleError
|
||||
})
|
||||
|
||||
const localHandler = new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
awsSecretKey: "test-secret-key",
|
||||
awsRegion: "us-east-1",
|
||||
})
|
||||
|
||||
const generator = localHandler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
// Throttling errors are re-thrown with original message for retry
|
||||
await expect(async () => {
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
},
|
||||
}).rejects.toThrow(message)
|
||||
}
|
||||
|
||||
mockSend.mockResolvedValueOnce({ stream: mockStream })
|
||||
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
const chunks: any[] = []
|
||||
try {
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
} catch (error) {
|
||||
// Expected to throw after yielding chunks
|
||||
}
|
||||
|
||||
// Should have yielded error chunks before throwing for non-throttling errors
|
||||
expect(
|
||||
chunks.some((chunk) => chunk.type === "text" && chunk.text && chunk.text.includes("Some other error")),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error Priority and Specificity", () => {
|
||||
it("should prioritize HTTP status codes over message patterns", async () => {
|
||||
// Error with both 429 status and generic message should be detected as throttling
|
||||
it("should prioritize HTTP status 429 over message content for throttling", async () => {
|
||||
const mixedError = createMockError({
|
||||
message: "Some generic error message",
|
||||
status: 429,
|
||||
})
|
||||
|
||||
mockSend.mockRejectedValueOnce(mixedError)
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw mixedError
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await handler.completePrompt("test")
|
||||
expect(result).toContain("throttled or rate limited")
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("throttled or rate limited")
|
||||
}
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
// Because status=429, it's throttling → re-throws original error
|
||||
await expect(async () => {
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
}).rejects.toThrow("Some generic error message")
|
||||
})
|
||||
|
||||
it("should prioritize AWS error types over message patterns", async () => {
|
||||
// Error with ThrottlingException name but different message should still be throttling
|
||||
it("should prioritize ThrottlingException name over message for throttling", async () => {
|
||||
const specificError = createMockError({
|
||||
message: "Some other error occurred",
|
||||
name: "ThrottlingException",
|
||||
})
|
||||
|
||||
mockSend.mockRejectedValueOnce(specificError)
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw specificError
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await handler.completePrompt("test")
|
||||
expect(result).toContain("throttled or rate limited")
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("throttled or rate limited")
|
||||
}
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
// ThrottlingException → re-throws original for retry
|
||||
await expect(async () => {
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
}).rejects.toThrow("Some other error occurred")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Unknown Error Fallback", () => {
|
||||
it("should still show unknown error for truly unrecognized errors", async () => {
|
||||
const unknownError = createMockError({
|
||||
// -----------------------------------------------------------------------
|
||||
// Non-throttling errors (createMessage) are wrapped by handleAiSdkError
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("Non-throttling errors (createMessage)", () => {
|
||||
it("should wrap non-throttling errors with provider name via handleAiSdkError", async () => {
|
||||
const genericError = createMockError({
|
||||
message: "Something completely unexpected happened",
|
||||
})
|
||||
|
||||
mockSend.mockRejectedValueOnce(unknownError)
|
||||
|
||||
try {
|
||||
const result = await handler.completePrompt("test")
|
||||
expect(result).toContain("Unknown Error")
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("Unknown Error")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Enhanced Error Throw for Retry System", () => {
|
||||
it("should throw enhanced error messages for completePrompt to display in retry system", async () => {
|
||||
const throttlingError = createMockError({
|
||||
message: "Too many tokens, rate limited",
|
||||
status: 429,
|
||||
$metadata: {
|
||||
httpStatusCode: 429,
|
||||
requestId: "test-request-id-12345",
|
||||
},
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw genericError
|
||||
})
|
||||
mockSend.mockRejectedValueOnce(throttlingError)
|
||||
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
}).rejects.toThrow("Bedrock: Something completely unexpected happened")
|
||||
})
|
||||
|
||||
it("should preserve status code from non-throttling API errors", async () => {
|
||||
const apiError = createMockError({
|
||||
message: "Internal server error occurred",
|
||||
status: 500,
|
||||
})
|
||||
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw apiError
|
||||
})
|
||||
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
try {
|
||||
await handler.completePrompt("test")
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
throw new Error("Expected error to be thrown")
|
||||
} catch (error) {
|
||||
// Should contain the verbose message template
|
||||
expect(error.message).toContain("Request was throttled or rate limited")
|
||||
// Should preserve original error properties
|
||||
expect((error as any).status).toBe(429)
|
||||
expect((error as any).$metadata.requestId).toBe("test-request-id-12345")
|
||||
} catch (error: any) {
|
||||
expect(error.message).toContain("Bedrock:")
|
||||
expect(error.message).toContain("Internal server error occurred")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw enhanced error messages for createMessage streaming to display in retry system", async () => {
|
||||
it("should handle validation errors (token limits) as non-throttling", async () => {
|
||||
const tokenError = createMockError({
|
||||
message: "Too many tokens in request",
|
||||
name: "ValidationException",
|
||||
$metadata: {
|
||||
httpStatusCode: 400,
|
||||
requestId: "token-error-id-67890",
|
||||
extendedRequestId: "extended-12345",
|
||||
},
|
||||
})
|
||||
|
||||
const mockStream = {
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
async next() {
|
||||
throw tokenError
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw tokenError
|
||||
})
|
||||
|
||||
mockSend.mockResolvedValueOnce({ stream: mockStream })
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
try {
|
||||
const stream = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
for await (const chunk of stream) {
|
||||
// Should not reach here as it should throw an error
|
||||
await expect(async () => {
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
throw new Error("Expected error to be thrown")
|
||||
} catch (error) {
|
||||
// Should contain error codes (note: this will be caught by the non-throttling error path)
|
||||
expect(error.message).toContain("Too many tokens")
|
||||
// Should preserve original error properties
|
||||
expect(error.name).toBe("ValidationException")
|
||||
expect((error as any).$metadata.requestId).toBe("token-error-id-67890")
|
||||
}
|
||||
}).rejects.toThrow("Bedrock: Too many tokens in request")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge Case Test Coverage", () => {
|
||||
it("should handle concurrent throttling errors correctly", async () => {
|
||||
const throttlingError = createMockError({
|
||||
// -----------------------------------------------------------------------
|
||||
// Streaming context: errors mid-stream
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("Streaming Context Error Handling", () => {
|
||||
it("should re-throw throttling errors that occur mid-stream", async () => {
|
||||
const throttleError = createMockError({
|
||||
message: "Bedrock is unable to process your request",
|
||||
status: 429,
|
||||
})
|
||||
|
||||
// Setup multiple concurrent requests that will all fail with throttling
|
||||
mockSend.mockRejectedValue(throttlingError)
|
||||
// Mock streamText to return an object whose fullStream throws mid-iteration
|
||||
async function* failingStream() {
|
||||
yield { type: "text-delta" as const, textDelta: "partial" }
|
||||
throw throttleError
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: failingStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of generator) {
|
||||
// may yield partial text before throwing
|
||||
}
|
||||
}).rejects.toThrow("Bedrock is unable to process your request")
|
||||
})
|
||||
|
||||
it("should wrap non-throttling errors that occur mid-stream via handleAiSdkError", async () => {
|
||||
const genericError = createMockError({
|
||||
message: "Some other error",
|
||||
status: 500,
|
||||
})
|
||||
|
||||
async function* failingStream() {
|
||||
yield { type: "text-delta" as const, textDelta: "partial" }
|
||||
throw genericError
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: failingStream(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
}).rejects.toThrow("Bedrock: Some other error")
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// completePrompt errors — all go through handleAiSdkError (no throttle check)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("completePrompt error handling", () => {
|
||||
it("should wrap errors with provider name for completePrompt", async () => {
|
||||
mockGenerateText.mockRejectedValueOnce(new Error("Bedrock API failure"))
|
||||
|
||||
await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock: Bedrock API failure")
|
||||
})
|
||||
|
||||
it("should wrap throttling-pattern errors with provider name for completePrompt", async () => {
|
||||
const throttleError = createMockError({
|
||||
message: "Bedrock is unable to process your request",
|
||||
status: 429,
|
||||
})
|
||||
|
||||
mockGenerateText.mockRejectedValueOnce(throttleError)
|
||||
|
||||
// completePrompt does NOT have the throttle-rethrow path; it always uses handleAiSdkError
|
||||
await expect(handler.completePrompt("test")).rejects.toThrow(
|
||||
"Bedrock: Bedrock is unable to process your request",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle concurrent generateText failures", async () => {
|
||||
const error = new Error("API failure")
|
||||
mockGenerateText.mockRejectedValue(error)
|
||||
|
||||
// Execute multiple concurrent requests
|
||||
const promises = Array.from({ length: 5 }, () => handler.completePrompt("test"))
|
||||
|
||||
// All should throw with throttling error
|
||||
const results = await Promise.allSettled(promises)
|
||||
|
||||
results.forEach((result) => {
|
||||
expect(result.status).toBe("rejected")
|
||||
if (result.status === "rejected") {
|
||||
expect(result.reason.message).toContain("throttled or rate limited")
|
||||
expect(result.reason.message).toContain("Bedrock:")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle mixed error scenarios with both throttling and other indicators", async () => {
|
||||
// Error with both 429 status (throttling) and validation error message
|
||||
const mixedError = createMockError({
|
||||
message: "ValidationException: Your input is invalid, but also rate limited",
|
||||
name: "ValidationException",
|
||||
status: 429,
|
||||
$metadata: {
|
||||
httpStatusCode: 429,
|
||||
requestId: "mixed-error-id",
|
||||
},
|
||||
it("should preserve status code from API call errors in completePrompt", async () => {
|
||||
const apiError = createMockError({
|
||||
message: "Service unavailable",
|
||||
status: 503,
|
||||
})
|
||||
|
||||
mockSend.mockRejectedValueOnce(mixedError)
|
||||
mockGenerateText.mockRejectedValueOnce(apiError)
|
||||
|
||||
try {
|
||||
await handler.completePrompt("test")
|
||||
} catch (error) {
|
||||
// Should be treated as throttling due to 429 status taking priority
|
||||
expect(error.message).toContain("throttled or rate limited")
|
||||
// Should still preserve metadata
|
||||
expect((error as any).$metadata?.requestId).toBe("mixed-error-id")
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle rapid successive retries in streaming context", async () => {
|
||||
const throttlingError = createMockError({
|
||||
message: "ThrottlingException",
|
||||
name: "ThrottlingException",
|
||||
})
|
||||
|
||||
// Mock stream that throws immediately
|
||||
const mockStream = {
|
||||
// eslint-disable-next-line require-yield
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
throw throttlingError
|
||||
},
|
||||
}
|
||||
|
||||
mockSend.mockResolvedValueOnce({ stream: mockStream })
|
||||
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "test" }]
|
||||
|
||||
try {
|
||||
// Should throw immediately without yielding any chunks
|
||||
const stream = handler.createMessage("", messages)
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
// Should not reach here
|
||||
expect(chunks).toHaveLength(0)
|
||||
} catch (error) {
|
||||
// Error should be thrown immediately for retry mechanism
|
||||
// The error might be a TypeError if the stream iterator fails
|
||||
expect(error).toBeDefined()
|
||||
// The important thing is that it throws immediately without yielding chunks
|
||||
}
|
||||
})
|
||||
|
||||
it("should validate error properties exist before accessing them", async () => {
|
||||
// Error with unusual structure
|
||||
const unusualError = {
|
||||
message: "Error with unusual structure",
|
||||
// Missing typical properties like name, status, etc.
|
||||
}
|
||||
|
||||
mockSend.mockRejectedValueOnce(unusualError)
|
||||
|
||||
try {
|
||||
await handler.completePrompt("test")
|
||||
} catch (error) {
|
||||
// Should handle gracefully without accessing undefined properties
|
||||
expect(error.message).toContain("Unknown Error")
|
||||
// Should not have undefined values in the error message
|
||||
expect(error.message).not.toContain("undefined")
|
||||
throw new Error("Expected error to be thrown")
|
||||
} catch (error: any) {
|
||||
expect(error.message).toContain("Bedrock:")
|
||||
expect(error.message).toContain("Service unavailable")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Telemetry
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("Error telemetry", () => {
|
||||
it("should capture telemetry for createMessage errors", async () => {
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw new Error("Stream failure")
|
||||
})
|
||||
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
}).rejects.toThrow()
|
||||
|
||||
expect(mockCaptureException).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should capture telemetry for completePrompt errors", async () => {
|
||||
mockGenerateText.mockRejectedValueOnce(new Error("Generate failure"))
|
||||
|
||||
await expect(handler.completePrompt("test")).rejects.toThrow()
|
||||
|
||||
expect(mockCaptureException).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should capture telemetry for throttling errors too", async () => {
|
||||
const throttleError = createMockError({
|
||||
message: "Rate limit exceeded",
|
||||
status: 429,
|
||||
})
|
||||
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw throttleError
|
||||
})
|
||||
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
await expect(async () => {
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
}).rejects.toThrow()
|
||||
|
||||
// Telemetry is captured even for throttling errors
|
||||
expect(mockCaptureException).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("Edge Case Test Coverage", () => {
|
||||
it("should handle non-Error objects thrown by generateText", async () => {
|
||||
mockGenerateText.mockRejectedValueOnce("string error")
|
||||
|
||||
await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock: string error")
|
||||
})
|
||||
|
||||
it("should handle non-Error objects thrown by streamText", async () => {
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw "string error"
|
||||
})
|
||||
|
||||
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
|
||||
// Non-Error values are not detected as throttling → handleAiSdkError path
|
||||
await expect(async () => {
|
||||
for await (const _chunk of generator) {
|
||||
// should throw
|
||||
}
|
||||
}).rejects.toThrow("Bedrock: string error")
|
||||
})
|
||||
|
||||
it("should handle errors with unusual structure gracefully", async () => {
|
||||
const unusualError = { message: "Error with unusual structure" }
|
||||
mockGenerateText.mockRejectedValueOnce(unusualError)
|
||||
|
||||
try {
|
||||
await handler.completePrompt("test")
|
||||
throw new Error("Expected error to be thrown")
|
||||
} catch (error: any) {
|
||||
// handleAiSdkError wraps with "Bedrock: ..."
|
||||
expect(error.message).toContain("Bedrock:")
|
||||
expect(error.message).not.toContain("undefined")
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle concurrent throttling errors in streaming context", async () => {
|
||||
const throttlingError = createMockError({
|
||||
message: "Bedrock is unable to process your request",
|
||||
status: 429,
|
||||
})
|
||||
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw throttlingError
|
||||
})
|
||||
|
||||
// Execute multiple concurrent streaming requests
|
||||
const promises = Array.from({ length: 3 }, async () => {
|
||||
const localHandler = new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
awsSecretKey: "test-secret-key",
|
||||
awsRegion: "us-east-1",
|
||||
})
|
||||
const gen = localHandler.createMessage("system", [{ role: "user", content: "test" }])
|
||||
for await (const _chunk of gen) {
|
||||
// should throw
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(promises)
|
||||
results.forEach((result) => {
|
||||
expect(result.status).toBe("rejected")
|
||||
if (result.status === "rejected") {
|
||||
// Throttling errors are re-thrown with original message
|
||||
expect(result.reason.message).toBe("Bedrock is unable to process your request")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,18 +4,6 @@ import { AWS_INFERENCE_PROFILE_MAPPING } from "@roo-code/types"
|
|||
import { AwsBedrockHandler } from "../bedrock"
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
// Mock AWS SDK
|
||||
vitest.mock("@aws-sdk/client-bedrock-runtime", () => {
|
||||
return {
|
||||
BedrockRuntimeClient: vitest.fn().mockImplementation(() => ({
|
||||
send: vitest.fn(),
|
||||
config: { region: "us-east-1" },
|
||||
})),
|
||||
ConverseCommand: vitest.fn(),
|
||||
ConverseStreamCommand: vitest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe("Amazon Bedrock Inference Profiles", () => {
|
||||
// Helper function to create a handler with specific options
|
||||
const createHandler = (options: Partial<ApiHandlerOptions> = {}) => {
|
||||
|
|
|
|||
|
|
@ -1,350 +1,198 @@
|
|||
// npx vitest run src/api/providers/__tests__/bedrock-invokedModelId.spec.ts
|
||||
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
// Mock TelemetryService before other imports
|
||||
vi.mock("@roo-code/telemetry", () => ({
|
||||
TelemetryService: {
|
||||
instance: {
|
||||
captureException: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { AwsBedrockHandler, StreamEvent } from "../bedrock"
|
||||
|
||||
// Mock AWS SDK credential providers and Bedrock client
|
||||
vitest.mock("@aws-sdk/credential-providers", () => ({
|
||||
fromIni: vitest.fn().mockReturnValue({
|
||||
// Mock AWS SDK credential providers
|
||||
vi.mock("@aws-sdk/credential-providers", () => ({
|
||||
fromIni: vi.fn().mockReturnValue({
|
||||
accessKeyId: "profile-access-key",
|
||||
secretAccessKey: "profile-secret-key",
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Smithy client
|
||||
vitest.mock("@smithy/smithy-client", () => ({
|
||||
throwDefaultError: vitest.fn(),
|
||||
// Use vi.hoisted to define mock functions for AI SDK
|
||||
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
}))
|
||||
|
||||
// Create a mock send function that we can reference
|
||||
const mockSend = vitest.fn().mockImplementation(async () => {
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
$metadata: {
|
||||
httpStatusCode: 200,
|
||||
requestId: "mock-request-id",
|
||||
},
|
||||
stream: {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
metadata: {
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock AWS SDK modules
|
||||
vitest.mock("@aws-sdk/client-bedrock-runtime", () => {
|
||||
return {
|
||||
BedrockRuntimeClient: vitest.fn().mockImplementation(() => ({
|
||||
send: mockSend,
|
||||
config: { region: "us-east-1" },
|
||||
middlewareStack: {
|
||||
clone: () => ({ resolve: () => {} }),
|
||||
use: () => {},
|
||||
},
|
||||
})),
|
||||
ConverseStreamCommand: vitest.fn((params) => ({
|
||||
...params,
|
||||
input: params,
|
||||
middlewareStack: {
|
||||
clone: () => ({ resolve: () => {} }),
|
||||
use: () => {},
|
||||
},
|
||||
})),
|
||||
ConverseCommand: vitest.fn((params) => ({
|
||||
...params,
|
||||
input: params,
|
||||
middlewareStack: {
|
||||
clone: () => ({ resolve: () => {} }),
|
||||
use: () => {},
|
||||
},
|
||||
})),
|
||||
}
|
||||
})
|
||||
vi.mock("@ai-sdk/amazon-bedrock", () => ({
|
||||
createAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))),
|
||||
}))
|
||||
|
||||
import { AwsBedrockHandler } from "../bedrock"
|
||||
import { bedrockModels } from "@roo-code/types"
|
||||
|
||||
describe("AwsBedrockHandler with invokedModelId", () => {
|
||||
beforeEach(() => {
|
||||
vitest.clearAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Helper function to create a mock async iterable stream
|
||||
function createMockStream(events: StreamEvent[]) {
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
for (const event of events) {
|
||||
yield event
|
||||
}
|
||||
// Always yield a metadata event at the end
|
||||
yield {
|
||||
metadata: {
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
/**
|
||||
* Helper: set up mockStreamText to return a stream whose resolved
|
||||
* `providerMetadata` contains the given `invokedModelId` in the
|
||||
* `bedrock.trace.promptRouter` path.
|
||||
*/
|
||||
function setupMockStreamWithInvokedModelId(invokedModelId?: string) {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Hello" }
|
||||
yield { type: "text-delta", text: ", world!" }
|
||||
}
|
||||
|
||||
const providerMetadata = invokedModelId
|
||||
? {
|
||||
bedrock: {
|
||||
trace: {
|
||||
promptRouter: {
|
||||
invokedModelId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
: {}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 100, outputTokens: 200 }),
|
||||
providerMetadata: Promise.resolve(providerMetadata),
|
||||
})
|
||||
}
|
||||
|
||||
it("should update costModelConfig when invokedModelId is present in the stream", async () => {
|
||||
// Create a handler with a custom ARN
|
||||
const mockOptions: ApiHandlerOptions = {
|
||||
it("should update costModelConfig when invokedModelId is present in providerMetadata", async () => {
|
||||
// Create a handler with a custom ARN (prompt router)
|
||||
const handler = new AwsBedrockHandler({
|
||||
awsAccessKey: "test-access-key",
|
||||
awsSecretKey: "test-secret-key",
|
||||
awsRegion: "us-east-1",
|
||||
awsCustomArn: "arn:aws:bedrock:us-west-2:123456789:default-prompt-router/anthropic.claude:1",
|
||||
}
|
||||
|
||||
const handler = new AwsBedrockHandler(mockOptions)
|
||||
|
||||
// Verify that getModel returns the updated model info
|
||||
const initialModel = handler.getModel()
|
||||
//the default prompt router model has an input price of 3. After the stream is handled it should be updated to 8
|
||||
expect(initialModel.info.inputPrice).toBe(3)
|
||||
|
||||
// Create a spy on the getModel
|
||||
const getModelByIdSpy = vitest.spyOn(handler, "getModelById")
|
||||
|
||||
// Mock the stream to include an event with invokedModelId and usage metadata
|
||||
mockSend.mockImplementationOnce(async () => {
|
||||
return {
|
||||
stream: createMockStream([
|
||||
// First event with invokedModelId and usage metadata
|
||||
{
|
||||
trace: {
|
||||
promptRouter: {
|
||||
invokedModelId:
|
||||
"arn:aws:bedrock:us-west-2:699475926481:inference-profile/us.anthropic.claude-3-opus-20240229-v1:0",
|
||||
usage: {
|
||||
inputTokens: 150,
|
||||
outputTokens: 250,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
contentBlockStart: {
|
||||
start: {
|
||||
text: "Hello",
|
||||
},
|
||||
contentBlockIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
contentBlockDelta: {
|
||||
delta: {
|
||||
text: ", world!",
|
||||
},
|
||||
contentBlockIndex: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
}
|
||||
})
|
||||
|
||||
// Create a message generator
|
||||
const messageGenerator = handler.createMessage("system prompt", [{ role: "user", content: "user message" }])
|
||||
// The default prompt router model should use sonnet pricing (inputPrice: 3)
|
||||
const initialModel = handler.getModel()
|
||||
expect(initialModel.info.inputPrice).toBe(3)
|
||||
|
||||
// Collect all yielded events to verify usage events
|
||||
// Spy on getModelById to verify the invoked model is looked up
|
||||
const getModelByIdSpy = vi.spyOn(handler, "getModelById")
|
||||
|
||||
// Set up stream to include an invokedModelId pointing to Claude 3 Opus
|
||||
setupMockStreamWithInvokedModelId(
|
||||
"arn:aws:bedrock:us-west-2:699475926481:inference-profile/us.anthropic.claude-3-opus-20240229-v1:0",
|
||||
)
|
||||
|
||||
// Consume the generator
|
||||
const events = []
|
||||
for await (const event of messageGenerator) {
|
||||
for await (const event of handler.createMessage("system prompt", [{ role: "user", content: "user message" }])) {
|
||||
events.push(event)
|
||||
}
|
||||
|
||||
// Verify that getModelById was called with the id, not the full arn
|
||||
// Verify that getModelById was called with the parsed model id and type
|
||||
expect(getModelByIdSpy).toHaveBeenCalledWith("anthropic.claude-3-opus-20240229-v1:0", "inference-profile")
|
||||
|
||||
// Verify that getModel returns the updated model info
|
||||
// After processing, getModel should return the invoked model's pricing (Opus: inputPrice 15)
|
||||
const costModel = handler.getModel()
|
||||
//expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
expect(costModel.info.inputPrice).toBe(15)
|
||||
|
||||
// Verify that a usage event was emitted after updating the costModelConfig
|
||||
const usageEvents = events.filter((event) => event.type === "usage")
|
||||
// Verify that a usage event was emitted
|
||||
const usageEvents = events.filter((e: any) => e.type === "usage")
|
||||
expect(usageEvents.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// The last usage event should have the token counts from the metadata
|
||||
const lastUsageEvent = usageEvents[usageEvents.length - 1]
|
||||
// Expect the usage event to include all token information
|
||||
// The usage event should contain the token counts
|
||||
const lastUsageEvent = usageEvents[usageEvents.length - 1] as any
|
||||
expect(lastUsageEvent).toMatchObject({
|
||||
type: "usage",
|
||||
inputTokens: 100,
|
||||
outputTokens: 200,
|
||||
// Cache tokens may be present with default values
|
||||
cacheReadTokens: expect.any(Number),
|
||||
cacheWriteTokens: expect.any(Number),
|
||||
})
|
||||
})
|
||||
|
||||
it("should not update costModelConfig when invokedModelId is not present", async () => {
|
||||
// Create a handler with default settings
|
||||
const mockOptions: ApiHandlerOptions = {
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
awsSecretKey: "test-secret-key",
|
||||
awsRegion: "us-east-1",
|
||||
}
|
||||
})
|
||||
|
||||
const handler = new AwsBedrockHandler(mockOptions)
|
||||
|
||||
// Store the initial model configuration
|
||||
const initialModelConfig = handler.getModel()
|
||||
expect(initialModelConfig.id).toBe("anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
|
||||
// Mock the stream without an invokedModelId event
|
||||
mockSend.mockImplementationOnce(async () => {
|
||||
return {
|
||||
stream: createMockStream([
|
||||
// Some content events but no invokedModelId
|
||||
{
|
||||
contentBlockStart: {
|
||||
start: {
|
||||
text: "Hello",
|
||||
},
|
||||
contentBlockIndex: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
contentBlockDelta: {
|
||||
delta: {
|
||||
text: ", world!",
|
||||
},
|
||||
contentBlockIndex: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
}
|
||||
})
|
||||
|
||||
// Create a message generator
|
||||
const messageGenerator = handler.createMessage("system prompt", [{ role: "user", content: "user message" }])
|
||||
// Set up stream WITHOUT an invokedModelId
|
||||
setupMockStreamWithInvokedModelId(undefined)
|
||||
|
||||
// Consume the generator
|
||||
for await (const _ of messageGenerator) {
|
||||
// Just consume the messages
|
||||
for await (const _ of handler.createMessage("system prompt", [{ role: "user", content: "user message" }])) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
// Verify that getModel returns the original model info (unchanged)
|
||||
// Model should remain unchanged
|
||||
const costModel = handler.getModel()
|
||||
expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
expect(costModel).toEqual(initialModelConfig)
|
||||
expect(costModel.info.inputPrice).toBe(initialModelConfig.info.inputPrice)
|
||||
})
|
||||
|
||||
it("should handle invalid invokedModelId format gracefully", async () => {
|
||||
// Create a handler with default settings
|
||||
const mockOptions: ApiHandlerOptions = {
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
awsSecretKey: "test-secret-key",
|
||||
awsRegion: "us-east-1",
|
||||
}
|
||||
|
||||
const handler = new AwsBedrockHandler(mockOptions)
|
||||
|
||||
// Mock the stream with an invalid invokedModelId
|
||||
mockSend.mockImplementationOnce(async () => {
|
||||
return {
|
||||
stream: createMockStream([
|
||||
// Event with invalid invokedModelId format
|
||||
{
|
||||
trace: {
|
||||
promptRouter: {
|
||||
invokedModelId: "invalid-format-not-an-arn",
|
||||
},
|
||||
},
|
||||
},
|
||||
// Some content events
|
||||
{
|
||||
contentBlockStart: {
|
||||
start: {
|
||||
text: "Hello",
|
||||
},
|
||||
contentBlockIndex: 0,
|
||||
},
|
||||
},
|
||||
]),
|
||||
}
|
||||
})
|
||||
|
||||
// Create a message generator
|
||||
const messageGenerator = handler.createMessage("system prompt", [{ role: "user", content: "user message" }])
|
||||
// Set up stream with an invalid (non-ARN) invokedModelId
|
||||
setupMockStreamWithInvokedModelId("invalid-format-not-an-arn")
|
||||
|
||||
// Consume the generator
|
||||
for await (const _ of messageGenerator) {
|
||||
// Just consume the messages
|
||||
// Consume the generator — should not throw
|
||||
for await (const _ of handler.createMessage("system prompt", [{ role: "user", content: "user message" }])) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
// Verify that getModel returns the original model info
|
||||
// Model should remain unchanged (the parseArn call should fail gracefully)
|
||||
const costModel = handler.getModel()
|
||||
expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
})
|
||||
|
||||
it("should handle errors during invokedModelId processing", async () => {
|
||||
// Create a handler with default settings
|
||||
const mockOptions: ApiHandlerOptions = {
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
it("should use the invoked model's pricing for totalCost calculation", async () => {
|
||||
const handler = new AwsBedrockHandler({
|
||||
awsAccessKey: "test-access-key",
|
||||
awsSecretKey: "test-secret-key",
|
||||
awsRegion: "us-east-1",
|
||||
}
|
||||
|
||||
const handler = new AwsBedrockHandler(mockOptions)
|
||||
|
||||
// Mock the stream with a valid invokedModelId
|
||||
mockSend.mockImplementationOnce(async () => {
|
||||
return {
|
||||
stream: createMockStream([
|
||||
// Event with valid invokedModelId
|
||||
{
|
||||
trace: {
|
||||
promptRouter: {
|
||||
invokedModelId:
|
||||
"arn:aws:bedrock:us-east-1:123456789:foundation-model/anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
}
|
||||
awsCustomArn: "arn:aws:bedrock:us-west-2:123456789:default-prompt-router/anthropic.claude:1",
|
||||
})
|
||||
|
||||
// Mock getModel to throw an error when called with the model name
|
||||
vitest.spyOn(handler, "getModel").mockImplementation((modelName?: string) => {
|
||||
if (modelName === "anthropic.claude-3-sonnet-20240229-v1:0") {
|
||||
throw new Error("Test error during model lookup")
|
||||
}
|
||||
// Set up stream to include Opus as the invoked model
|
||||
setupMockStreamWithInvokedModelId(
|
||||
"arn:aws:bedrock:us-west-2:699475926481:foundation-model/anthropic.claude-3-opus-20240229-v1:0",
|
||||
)
|
||||
|
||||
// Default return value for initial call
|
||||
return {
|
||||
id: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
info: {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 128_000,
|
||||
supportsPromptCache: false,
|
||||
supportsImages: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Create a message generator
|
||||
const messageGenerator = handler.createMessage("system prompt", [{ role: "user", content: "user message" }])
|
||||
|
||||
// Consume the generator
|
||||
for await (const _ of messageGenerator) {
|
||||
// Just consume the messages
|
||||
const events = []
|
||||
for await (const event of handler.createMessage("system prompt", [{ role: "user", content: "user message" }])) {
|
||||
events.push(event)
|
||||
}
|
||||
|
||||
// Verify that getModel returns the original model info
|
||||
const costModel = handler.getModel()
|
||||
expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
const usageEvent = events.find((e: any) => e.type === "usage") as any
|
||||
expect(usageEvent).toBeDefined()
|
||||
|
||||
// Calculate expected cost based on Opus pricing ($15 / 1M input, $75 / 1M output)
|
||||
const opusInfo = bedrockModels["anthropic.claude-3-opus-20240229-v1:0"]
|
||||
const expectedCost =
|
||||
(100 * (opusInfo.inputPrice ?? 0)) / 1_000_000 + (200 * (opusInfo.outputPrice ?? 0)) / 1_000_000
|
||||
|
||||
expect(usageEvent.totalCost).toBeCloseTo(expectedCost, 10)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,37 +1,48 @@
|
|||
// npx vitest api/providers/__tests__/bedrock-reasoning.test.ts
|
||||
// npx vitest run api/providers/__tests__/bedrock-reasoning.spec.ts
|
||||
|
||||
// Use vi.hoisted to define mock functions for AI SDK
|
||||
const { mockStreamText, mockGenerateText, mockCreateAmazonBedrock } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
mockCreateAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/amazon-bedrock", () => ({
|
||||
createAmazonBedrock: mockCreateAmazonBedrock,
|
||||
}))
|
||||
|
||||
// Mock AWS SDK credential providers
|
||||
vi.mock("@aws-sdk/credential-providers", () => ({
|
||||
fromIni: vi.fn().mockReturnValue(async () => ({
|
||||
accessKeyId: "profile-access-key",
|
||||
secretAccessKey: "profile-secret-key",
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("../../../utils/logging", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { AwsBedrockHandler } from "../bedrock"
|
||||
import { BedrockRuntimeClient, ConverseStreamCommand } from "@aws-sdk/client-bedrock-runtime"
|
||||
import { logger } from "../../../utils/logging"
|
||||
|
||||
// Mock the AWS SDK
|
||||
vi.mock("@aws-sdk/client-bedrock-runtime")
|
||||
vi.mock("../../../utils/logging")
|
||||
|
||||
// Store the command payload for verification
|
||||
let capturedPayload: any = null
|
||||
|
||||
describe("AwsBedrockHandler - Extended Thinking", () => {
|
||||
let handler: AwsBedrockHandler
|
||||
let mockSend: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPayload = null
|
||||
mockSend = vi.fn()
|
||||
|
||||
// Mock ConverseStreamCommand to capture the payload
|
||||
;(ConverseStreamCommand as unknown as ReturnType<typeof vi.fn>).mockImplementation((payload) => {
|
||||
capturedPayload = payload
|
||||
return {
|
||||
input: payload,
|
||||
}
|
||||
})
|
||||
;(BedrockRuntimeClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
send: mockSend,
|
||||
config: { region: "us-east-1" },
|
||||
}))
|
||||
;(logger.info as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => {})
|
||||
;(logger.error as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => {})
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -39,8 +50,8 @@ describe("AwsBedrockHandler - Extended Thinking", () => {
|
|||
})
|
||||
|
||||
describe("Extended Thinking Support", () => {
|
||||
it("should include thinking parameter for Claude Sonnet 4 when reasoning is enabled", async () => {
|
||||
handler = new AwsBedrockHandler({
|
||||
it("should include reasoningConfig in providerOptions when reasoning is enabled", async () => {
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiProvider: "bedrock",
|
||||
apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
awsRegion: "us-east-1",
|
||||
|
|
@ -49,35 +60,17 @@ describe("AwsBedrockHandler - Extended Thinking", () => {
|
|||
modelMaxThinkingTokens: 4096,
|
||||
})
|
||||
|
||||
// Mock the stream response
|
||||
mockSend.mockResolvedValue({
|
||||
stream: (async function* () {
|
||||
yield {
|
||||
messageStart: { role: "assistant" },
|
||||
}
|
||||
yield {
|
||||
contentBlockStart: {
|
||||
content_block: { type: "thinking", thinking: "Let me think..." },
|
||||
contentBlockIndex: 0,
|
||||
},
|
||||
}
|
||||
yield {
|
||||
contentBlockDelta: {
|
||||
delta: { type: "thinking_delta", thinking: " about this problem." },
|
||||
},
|
||||
}
|
||||
yield {
|
||||
contentBlockStart: {
|
||||
start: { text: "Here's the answer:" },
|
||||
contentBlockIndex: 1,
|
||||
},
|
||||
}
|
||||
yield {
|
||||
metadata: {
|
||||
usage: { inputTokens: 100, outputTokens: 50 },
|
||||
},
|
||||
}
|
||||
})(),
|
||||
// Mock stream with reasoning content
|
||||
async function* mockFullStream() {
|
||||
yield { type: "reasoning", text: "Let me think..." }
|
||||
yield { type: "reasoning", text: " about this problem." }
|
||||
yield { type: "text-delta", text: "Here's the answer:" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const messages = [{ role: "user" as const, content: "Test message" }]
|
||||
|
|
@ -88,13 +81,14 @@ describe("AwsBedrockHandler - Extended Thinking", () => {
|
|||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify the command was called with the correct payload
|
||||
expect(mockSend).toHaveBeenCalledTimes(1)
|
||||
expect(capturedPayload).toBeDefined()
|
||||
expect(capturedPayload.additionalModelRequestFields).toBeDefined()
|
||||
expect(capturedPayload.additionalModelRequestFields.thinking).toEqual({
|
||||
// Verify streamText was called with providerOptions containing reasoningConfig
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
expect(callArgs.providerOptions).toBeDefined()
|
||||
expect(callArgs.providerOptions.bedrock).toBeDefined()
|
||||
expect(callArgs.providerOptions.bedrock.reasoningConfig).toEqual({
|
||||
type: "enabled",
|
||||
budget_tokens: 4096, // Uses the full modelMaxThinkingTokens value
|
||||
budgetTokens: 4096,
|
||||
})
|
||||
|
||||
// Verify reasoning chunks were yielded
|
||||
|
|
@ -102,110 +96,24 @@ describe("AwsBedrockHandler - Extended Thinking", () => {
|
|||
expect(reasoningChunks).toHaveLength(2)
|
||||
expect(reasoningChunks[0].text).toBe("Let me think...")
|
||||
expect(reasoningChunks[1].text).toBe(" about this problem.")
|
||||
|
||||
// Verify that topP is NOT present when thinking is enabled
|
||||
expect(capturedPayload.inferenceConfig).not.toHaveProperty("topP")
|
||||
})
|
||||
|
||||
it("should pass thinking parameters from metadata", async () => {
|
||||
handler = new AwsBedrockHandler({
|
||||
apiProvider: "bedrock",
|
||||
apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
awsRegion: "us-east-1",
|
||||
})
|
||||
|
||||
mockSend.mockResolvedValue({
|
||||
stream: (async function* () {
|
||||
yield { messageStart: { role: "assistant" } }
|
||||
yield { metadata: { usage: { inputTokens: 100, outputTokens: 50 } } }
|
||||
})(),
|
||||
})
|
||||
|
||||
const messages = [{ role: "user" as const, content: "Test message" }]
|
||||
const metadata = {
|
||||
taskId: "test-task",
|
||||
thinking: {
|
||||
enabled: true,
|
||||
maxTokens: 16384,
|
||||
maxThinkingTokens: 8192,
|
||||
},
|
||||
}
|
||||
|
||||
const stream = handler.createMessage("System prompt", messages, metadata)
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify the thinking parameter was passed correctly
|
||||
expect(mockSend).toHaveBeenCalledTimes(1)
|
||||
expect(capturedPayload).toBeDefined()
|
||||
expect(capturedPayload.additionalModelRequestFields).toBeDefined()
|
||||
expect(capturedPayload.additionalModelRequestFields.thinking).toEqual({
|
||||
type: "enabled",
|
||||
budget_tokens: 8192,
|
||||
})
|
||||
|
||||
// Verify that topP is NOT present when thinking is enabled via metadata
|
||||
expect(capturedPayload.inferenceConfig).not.toHaveProperty("topP")
|
||||
})
|
||||
|
||||
it("should log when extended thinking is enabled", async () => {
|
||||
handler = new AwsBedrockHandler({
|
||||
apiProvider: "bedrock",
|
||||
apiModelId: "anthropic.claude-opus-4-20250514-v1:0",
|
||||
awsRegion: "us-east-1",
|
||||
enableReasoningEffort: true,
|
||||
modelMaxThinkingTokens: 5000,
|
||||
})
|
||||
|
||||
mockSend.mockResolvedValue({
|
||||
stream: (async function* () {
|
||||
yield { messageStart: { role: "assistant" } }
|
||||
})(),
|
||||
})
|
||||
|
||||
const messages = [{ role: "user" as const, content: "Test" }]
|
||||
const stream = handler.createMessage("System prompt", messages)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
// Verify logging
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Extended thinking enabled"),
|
||||
expect.objectContaining({
|
||||
ctx: "bedrock",
|
||||
modelId: "anthropic.claude-opus-4-20250514-v1:0",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should not include topP when thinking is disabled (global removal)", async () => {
|
||||
handler = new AwsBedrockHandler({
|
||||
it("should not include reasoningConfig when reasoning is disabled", async () => {
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiProvider: "bedrock",
|
||||
apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
awsRegion: "us-east-1",
|
||||
// Note: no enableReasoningEffort = true, so thinking is disabled
|
||||
})
|
||||
|
||||
mockSend.mockResolvedValue({
|
||||
stream: (async function* () {
|
||||
yield { messageStart: { role: "assistant" } }
|
||||
yield {
|
||||
contentBlockStart: {
|
||||
start: { text: "Hello" },
|
||||
contentBlockIndex: 0,
|
||||
},
|
||||
}
|
||||
yield {
|
||||
contentBlockDelta: {
|
||||
delta: { text: " world" },
|
||||
},
|
||||
}
|
||||
yield { metadata: { usage: { inputTokens: 100, outputTokens: 50 } } }
|
||||
})(),
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Hello world" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const messages = [{ role: "user" as const, content: "Test message" }]
|
||||
|
|
@ -216,43 +124,117 @@ describe("AwsBedrockHandler - Extended Thinking", () => {
|
|||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify that topP is NOT present for any model (removed globally)
|
||||
expect(mockSend).toHaveBeenCalledTimes(1)
|
||||
expect(capturedPayload).toBeDefined()
|
||||
expect(capturedPayload.inferenceConfig).not.toHaveProperty("topP")
|
||||
|
||||
// Verify that additionalModelRequestFields contains fine-grained-tool-streaming for Claude models
|
||||
expect(capturedPayload.additionalModelRequestFields).toBeDefined()
|
||||
expect(capturedPayload.additionalModelRequestFields.anthropic_beta).toContain(
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
)
|
||||
// Verify streamText was called — providerOptions should not contain reasoningConfig
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
const bedrockOpts = callArgs.providerOptions?.bedrock
|
||||
expect(bedrockOpts?.reasoningConfig).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should enable reasoning when enableReasoningEffort is true in settings", async () => {
|
||||
handler = new AwsBedrockHandler({
|
||||
it("should capture thinking signature from stream providerMetadata", async () => {
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiProvider: "bedrock",
|
||||
apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
awsRegion: "us-east-1",
|
||||
enableReasoningEffort: true, // This should trigger reasoning
|
||||
enableReasoningEffort: true,
|
||||
modelMaxThinkingTokens: 4096,
|
||||
})
|
||||
|
||||
mockSend.mockResolvedValue({
|
||||
stream: (async function* () {
|
||||
yield { messageStart: { role: "assistant" } }
|
||||
yield {
|
||||
contentBlockStart: {
|
||||
content_block: { type: "thinking", thinking: "Let me think..." },
|
||||
contentBlockIndex: 0,
|
||||
},
|
||||
}
|
||||
yield {
|
||||
contentBlockDelta: {
|
||||
delta: { type: "thinking_delta", thinking: " about this problem." },
|
||||
},
|
||||
}
|
||||
yield { metadata: { usage: { inputTokens: 100, outputTokens: 50 } } }
|
||||
})(),
|
||||
const testSignature = "test-thinking-signature-abc123"
|
||||
|
||||
// Mock stream with reasoning content that includes a signature in providerMetadata
|
||||
async function* mockFullStream() {
|
||||
yield { type: "reasoning", text: "Let me think..." }
|
||||
// The SDK emits signature as a reasoning-delta with providerMetadata.bedrock.signature
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
providerMetadata: { bedrock: { signature: testSignature } },
|
||||
}
|
||||
yield { type: "text-delta", text: "Answer" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const messages = [{ role: "user" as const, content: "Test message" }]
|
||||
const stream = handler.createMessage("System prompt", messages)
|
||||
|
||||
for await (const _chunk of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
// Verify thinking signature was captured
|
||||
expect(handler.getThoughtSignature()).toBe(testSignature)
|
||||
})
|
||||
|
||||
it("should capture redacted thinking blocks from stream providerMetadata", async () => {
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiProvider: "bedrock",
|
||||
apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
awsRegion: "us-east-1",
|
||||
enableReasoningEffort: true,
|
||||
modelMaxThinkingTokens: 4096,
|
||||
})
|
||||
|
||||
const redactedData = "base64-encoded-redacted-data"
|
||||
|
||||
// Mock stream with redacted reasoning content
|
||||
async function* mockFullStream() {
|
||||
yield { type: "reasoning", text: "Some thinking..." }
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
providerMetadata: { bedrock: { redactedData } },
|
||||
}
|
||||
yield { type: "text-delta", text: "Answer" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const messages = [{ role: "user" as const, content: "Test message" }]
|
||||
const stream = handler.createMessage("System prompt", messages)
|
||||
|
||||
for await (const _chunk of stream) {
|
||||
// consume stream
|
||||
}
|
||||
|
||||
// Verify redacted thinking blocks were captured
|
||||
const redactedBlocks = handler.getRedactedThinkingBlocks()
|
||||
expect(redactedBlocks).toBeDefined()
|
||||
expect(redactedBlocks).toHaveLength(1)
|
||||
expect(redactedBlocks![0]).toEqual({
|
||||
type: "redacted_thinking",
|
||||
data: redactedData,
|
||||
})
|
||||
})
|
||||
|
||||
it("should enable reasoning when enableReasoningEffort is true in settings", async () => {
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiProvider: "bedrock",
|
||||
apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
awsRegion: "us-east-1",
|
||||
enableReasoningEffort: true,
|
||||
modelMaxThinkingTokens: 4096,
|
||||
})
|
||||
|
||||
async function* mockFullStream() {
|
||||
yield { type: "reasoning", text: "Let me think..." }
|
||||
yield { type: "reasoning", text: " about this problem." }
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
|
||||
const messages = [{ role: "user" as const, content: "Test message" }]
|
||||
|
|
@ -264,17 +246,13 @@ describe("AwsBedrockHandler - Extended Thinking", () => {
|
|||
}
|
||||
|
||||
// Verify thinking was enabled via settings
|
||||
expect(mockSend).toHaveBeenCalledTimes(1)
|
||||
expect(capturedPayload).toBeDefined()
|
||||
expect(capturedPayload.additionalModelRequestFields).toBeDefined()
|
||||
expect(capturedPayload.additionalModelRequestFields.thinking).toEqual({
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
expect(callArgs.providerOptions?.bedrock?.reasoningConfig).toEqual({
|
||||
type: "enabled",
|
||||
budget_tokens: 4096,
|
||||
budgetTokens: 4096,
|
||||
})
|
||||
|
||||
// Verify that topP is NOT present when thinking is enabled via settings
|
||||
expect(capturedPayload.inferenceConfig).not.toHaveProperty("topP")
|
||||
|
||||
// Verify reasoning chunks were yielded
|
||||
const reasoningChunks = chunks.filter((c) => c.type === "reasoning")
|
||||
expect(reasoningChunks).toHaveLength(2)
|
||||
|
|
@ -282,8 +260,8 @@ describe("AwsBedrockHandler - Extended Thinking", () => {
|
|||
expect(reasoningChunks[1].text).toBe(" about this problem.")
|
||||
})
|
||||
|
||||
it("should support API key authentication", async () => {
|
||||
handler = new AwsBedrockHandler({
|
||||
it("should support API key authentication via createAmazonBedrock", () => {
|
||||
new AwsBedrockHandler({
|
||||
apiProvider: "bedrock",
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsRegion: "us-east-1",
|
||||
|
|
@ -291,41 +269,13 @@ describe("AwsBedrockHandler - Extended Thinking", () => {
|
|||
awsApiKey: "test-api-key-token",
|
||||
})
|
||||
|
||||
mockSend.mockResolvedValue({
|
||||
stream: (async function* () {
|
||||
yield { messageStart: { role: "assistant" } }
|
||||
yield {
|
||||
contentBlockStart: {
|
||||
start: { text: "Hello from API key auth" },
|
||||
contentBlockIndex: 0,
|
||||
},
|
||||
}
|
||||
yield { metadata: { usage: { inputTokens: 100, outputTokens: 50 } } }
|
||||
})(),
|
||||
})
|
||||
|
||||
const messages = [{ role: "user" as const, content: "Test message" }]
|
||||
const stream = handler.createMessage("System prompt", messages)
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify the client was created with API key token
|
||||
expect(BedrockRuntimeClient).toHaveBeenCalledWith(
|
||||
// Verify the provider was created with API key
|
||||
expect(mockCreateAmazonBedrock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
region: "us-east-1",
|
||||
token: { token: "test-api-key-token" },
|
||||
authSchemePreference: ["httpBearerAuth"],
|
||||
apiKey: "test-api-key-token",
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify the stream worked correctly
|
||||
expect(mockSend).toHaveBeenCalledTimes(1)
|
||||
const textChunks = chunks.filter((c) => c.type === "text")
|
||||
expect(textChunks).toHaveLength(1)
|
||||
expect(textChunks[0].text).toBe("Hello from API key auth")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,38 +7,40 @@ vi.mock("@aws-sdk/credential-providers", () => {
|
|||
return { fromIni: mockFromIni }
|
||||
})
|
||||
|
||||
// Mock BedrockRuntimeClient and ConverseStreamCommand
|
||||
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
||||
const mockSend = vi.fn().mockResolvedValue({
|
||||
stream: [],
|
||||
})
|
||||
const mockBedrockRuntimeClient = vi.fn().mockImplementation(() => ({
|
||||
send: mockSend,
|
||||
}))
|
||||
// Use vi.hoisted to define mock functions for AI SDK
|
||||
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
BedrockRuntimeClient: mockBedrockRuntimeClient,
|
||||
ConverseStreamCommand: vi.fn(),
|
||||
ConverseCommand: vi.fn(),
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
import { AwsBedrockHandler } from "../bedrock"
|
||||
import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime"
|
||||
// Mock createAmazonBedrock so we can inspect how it was called
|
||||
const { mockCreateAmazonBedrock } = vi.hoisted(() => ({
|
||||
mockCreateAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))),
|
||||
}))
|
||||
|
||||
// Get access to the mocked functions
|
||||
const mockBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient)
|
||||
vi.mock("@ai-sdk/amazon-bedrock", () => ({
|
||||
createAmazonBedrock: mockCreateAmazonBedrock,
|
||||
}))
|
||||
|
||||
import { AwsBedrockHandler } from "../bedrock"
|
||||
|
||||
describe("Amazon Bedrock VPC Endpoint Functionality", () => {
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Test Scenario 1: Input Validation Test
|
||||
describe("VPC Endpoint URL Validation", () => {
|
||||
it("should configure client with endpoint URL when both URL and enabled flag are provided", () => {
|
||||
// Create handler with endpoint URL and enabled flag
|
||||
it("should configure provider with baseURL when both URL and enabled flag are provided", () => {
|
||||
new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
|
|
@ -48,17 +50,15 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => {
|
|||
awsBedrockEndpointEnabled: true,
|
||||
})
|
||||
|
||||
// Verify the client was created with the correct endpoint
|
||||
expect(mockBedrockRuntimeClient).toHaveBeenCalledWith(
|
||||
expect(mockCreateAmazonBedrock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
region: "us-east-1",
|
||||
endpoint: "https://bedrock-vpc.example.com",
|
||||
baseURL: "https://bedrock-vpc.example.com",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should not configure client with endpoint URL when URL is provided but enabled flag is false", () => {
|
||||
// Create handler with endpoint URL but disabled flag
|
||||
it("should not configure provider with baseURL when URL is provided but enabled flag is false", () => {
|
||||
new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
|
|
@ -68,23 +68,23 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => {
|
|||
awsBedrockEndpointEnabled: false,
|
||||
})
|
||||
|
||||
// Verify the client was created without the endpoint
|
||||
expect(mockBedrockRuntimeClient).toHaveBeenCalledWith(
|
||||
expect(mockCreateAmazonBedrock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
region: "us-east-1",
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify the endpoint property is not present
|
||||
const clientConfig = mockBedrockRuntimeClient.mock.calls[0][0]
|
||||
expect(clientConfig).not.toHaveProperty("endpoint")
|
||||
const providerSettings = (mockCreateAmazonBedrock.mock.calls as unknown[][])[0][0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(providerSettings).not.toHaveProperty("baseURL")
|
||||
})
|
||||
})
|
||||
|
||||
// Test Scenario 2: Edge Case Tests
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle empty endpoint URL gracefully", () => {
|
||||
// Create handler with empty endpoint URL but enabled flag
|
||||
new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
|
|
@ -94,20 +94,21 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => {
|
|||
awsBedrockEndpointEnabled: true,
|
||||
})
|
||||
|
||||
// Verify the client was created without the endpoint (since it's empty)
|
||||
expect(mockBedrockRuntimeClient).toHaveBeenCalledWith(
|
||||
expect(mockCreateAmazonBedrock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
region: "us-east-1",
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify the endpoint property is not present
|
||||
const clientConfig = mockBedrockRuntimeClient.mock.calls[0][0]
|
||||
expect(clientConfig).not.toHaveProperty("endpoint")
|
||||
// Empty string is falsy, so baseURL should not be set
|
||||
const providerSettings = (mockCreateAmazonBedrock.mock.calls as unknown[][])[0][0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(providerSettings).not.toHaveProperty("baseURL")
|
||||
})
|
||||
|
||||
it("should handle undefined endpoint URL gracefully", () => {
|
||||
// Create handler with undefined endpoint URL but enabled flag
|
||||
new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
|
|
@ -117,23 +118,23 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => {
|
|||
awsBedrockEndpointEnabled: true,
|
||||
})
|
||||
|
||||
// Verify the client was created without the endpoint
|
||||
expect(mockBedrockRuntimeClient).toHaveBeenCalledWith(
|
||||
expect(mockCreateAmazonBedrock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
region: "us-east-1",
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify the endpoint property is not present
|
||||
const clientConfig = mockBedrockRuntimeClient.mock.calls[0][0]
|
||||
expect(clientConfig).not.toHaveProperty("endpoint")
|
||||
const providerSettings = (mockCreateAmazonBedrock.mock.calls as unknown[][])[0][0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(providerSettings).not.toHaveProperty("baseURL")
|
||||
})
|
||||
})
|
||||
|
||||
// Test Scenario 4: Error Handling Tests
|
||||
// Test Scenario 3: Error Handling Tests
|
||||
describe("Error Handling", () => {
|
||||
it("should handle invalid endpoint URLs by passing them directly to AWS SDK", () => {
|
||||
// Create handler with an invalid URL format
|
||||
it("should handle invalid endpoint URLs by passing them directly to the provider", () => {
|
||||
new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
|
|
@ -143,21 +144,24 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => {
|
|||
awsBedrockEndpointEnabled: true,
|
||||
})
|
||||
|
||||
// Verify the client was created with the invalid endpoint
|
||||
// (AWS SDK will handle the validation/errors)
|
||||
expect(mockBedrockRuntimeClient).toHaveBeenCalledWith(
|
||||
// The invalid URL is passed directly; the provider/SDK will handle validation
|
||||
expect(mockCreateAmazonBedrock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
region: "us-east-1",
|
||||
endpoint: "invalid-url-format",
|
||||
baseURL: "invalid-url-format",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Test Scenario 5: Persistence Tests
|
||||
// Test Scenario 4: Persistence Tests
|
||||
describe("Persistence", () => {
|
||||
it("should maintain consistent behavior across multiple requests", async () => {
|
||||
// Create handler with endpoint URL and enabled flag
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "test response",
|
||||
usage: { promptTokens: 10, completionTokens: 5 },
|
||||
})
|
||||
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
|
|
@ -167,23 +171,23 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => {
|
|||
awsBedrockEndpointEnabled: true,
|
||||
})
|
||||
|
||||
// Verify the client was configured with the endpoint
|
||||
expect(mockBedrockRuntimeClient).toHaveBeenCalledWith(
|
||||
// Verify the provider was configured with the endpoint
|
||||
expect(mockCreateAmazonBedrock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
region: "us-east-1",
|
||||
endpoint: "https://bedrock-vpc.example.com",
|
||||
baseURL: "https://bedrock-vpc.example.com",
|
||||
}),
|
||||
)
|
||||
|
||||
// Make a request to ensure the endpoint configuration persists
|
||||
try {
|
||||
await handler.completePrompt("Test prompt")
|
||||
} catch (error) {
|
||||
// Ignore errors, we're just testing the client configuration persistence
|
||||
} catch {
|
||||
// Ignore errors — we're just testing the provider configuration persistence
|
||||
}
|
||||
|
||||
// Verify the client instance was created and used
|
||||
expect(mockBedrockRuntimeClient).toHaveBeenCalled()
|
||||
// The provider factory should have been called exactly once (during construction)
|
||||
expect(mockCreateAmazonBedrock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,24 +18,26 @@ vi.mock("@aws-sdk/credential-providers", () => {
|
|||
return { fromIni: mockFromIni }
|
||||
})
|
||||
|
||||
// Mock BedrockRuntimeClient and ConverseStreamCommand
|
||||
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
||||
const mockSend = vi.fn().mockResolvedValue({
|
||||
stream: [],
|
||||
})
|
||||
const mockConverseStreamCommand = vi.fn()
|
||||
// Use vi.hoisted to define mock functions for AI SDK
|
||||
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
BedrockRuntimeClient: vi.fn().mockImplementation(() => ({
|
||||
send: mockSend,
|
||||
})),
|
||||
ConverseStreamCommand: mockConverseStreamCommand,
|
||||
ConverseCommand: vi.fn(),
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@ai-sdk/amazon-bedrock", () => ({
|
||||
createAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))),
|
||||
}))
|
||||
|
||||
import { AwsBedrockHandler } from "../bedrock"
|
||||
import { ConverseStreamCommand, BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime"
|
||||
import {
|
||||
BEDROCK_1M_CONTEXT_MODEL_IDS,
|
||||
BEDROCK_SERVICE_TIER_MODEL_IDS,
|
||||
|
|
@ -45,10 +47,6 @@ import {
|
|||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
// Get access to the mocked functions
|
||||
const mockConverseStreamCommand = vi.mocked(ConverseStreamCommand)
|
||||
const mockBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient)
|
||||
|
||||
describe("AwsBedrockHandler", () => {
|
||||
let handler: AwsBedrockHandler
|
||||
|
||||
|
|
@ -478,12 +476,20 @@ describe("AwsBedrockHandler", () => {
|
|||
describe("image handling", () => {
|
||||
const mockImageData = Buffer.from("test-image-data").toString("base64")
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the mocks before each test
|
||||
mockConverseStreamCommand.mockReset()
|
||||
})
|
||||
function setupMockStreamText() {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "I see an image" }
|
||||
}
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
}
|
||||
|
||||
it("should properly pass image content through to streamText via AI SDK messages", async () => {
|
||||
setupMockStreamText()
|
||||
|
||||
it("should properly convert image content to Bedrock format", async () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
|
|
@ -505,42 +511,39 @@ describe("AwsBedrockHandler", () => {
|
|||
]
|
||||
|
||||
const generator = handler.createMessage("", messages)
|
||||
await generator.next() // Start the generator
|
||||
const chunks: unknown[] = []
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify the command was created with the right payload
|
||||
expect(mockConverseStreamCommand).toHaveBeenCalled()
|
||||
const commandArg = mockConverseStreamCommand.mock.calls[0][0]
|
||||
// Verify streamText was called
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
|
||||
// Verify the image was properly formatted
|
||||
const imageBlock = commandArg.messages![0].content![0]
|
||||
expect(imageBlock).toHaveProperty("image")
|
||||
expect(imageBlock.image).toHaveProperty("format", "jpeg")
|
||||
expect(imageBlock.image!.source).toHaveProperty("bytes")
|
||||
expect(imageBlock.image!.source!.bytes).toBeInstanceOf(Uint8Array)
|
||||
})
|
||||
// Verify messages were converted to AI SDK format with image parts
|
||||
const aiSdkMessages = callArgs.messages
|
||||
expect(aiSdkMessages).toBeDefined()
|
||||
expect(aiSdkMessages.length).toBeGreaterThan(0)
|
||||
|
||||
it("should reject unsupported image formats", async () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
data: mockImageData,
|
||||
media_type: "image/tiff" as "image/jpeg", // Type assertion to bypass TS
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
// Find the user message containing image content
|
||||
const userMsg = aiSdkMessages.find((m: { role: string }) => m.role === "user")
|
||||
expect(userMsg).toBeDefined()
|
||||
expect(Array.isArray(userMsg.content)).toBe(true)
|
||||
|
||||
const generator = handler.createMessage("", messages)
|
||||
await expect(generator.next()).rejects.toThrow("Unsupported image format: tiff")
|
||||
// The AI SDK convertToAiSdkMessages converts images to { type: "image", image: "data:...", mimeType: "..." }
|
||||
const imagePart = userMsg.content.find((p: { type: string }) => p.type === "image")
|
||||
expect(imagePart).toBeDefined()
|
||||
expect(imagePart.image).toContain("data:image/jpeg;base64,")
|
||||
expect(imagePart.mimeType).toBe("image/jpeg")
|
||||
|
||||
const textPart = userMsg.content.find((p: { type: string }) => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart.text).toBe("What's in this image?")
|
||||
})
|
||||
|
||||
it("should handle multiple images in a single message", async () => {
|
||||
setupMockStreamText()
|
||||
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
|
|
@ -574,20 +577,25 @@ describe("AwsBedrockHandler", () => {
|
|||
]
|
||||
|
||||
const generator = handler.createMessage("", messages)
|
||||
await generator.next() // Start the generator
|
||||
const chunks: unknown[] = []
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify the command was created with the right payload
|
||||
expect(mockConverseStreamCommand).toHaveBeenCalled()
|
||||
const commandArg = mockConverseStreamCommand.mock.calls[0][0]
|
||||
// Verify streamText was called
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
|
||||
// Verify both images were properly formatted
|
||||
const firstImage = commandArg.messages![0].content![0]
|
||||
const secondImage = commandArg.messages![0].content![2]
|
||||
// Verify messages contain both images
|
||||
const userMsg = callArgs.messages.find((m: { role: string }) => m.role === "user")
|
||||
expect(userMsg).toBeDefined()
|
||||
|
||||
expect(firstImage).toHaveProperty("image")
|
||||
expect(firstImage.image).toHaveProperty("format", "jpeg")
|
||||
expect(secondImage).toHaveProperty("image")
|
||||
expect(secondImage.image).toHaveProperty("format", "png")
|
||||
const imageParts = userMsg.content.filter((p: { type: string }) => p.type === "image")
|
||||
expect(imageParts).toHaveLength(2)
|
||||
expect(imageParts[0].image).toContain("data:image/jpeg;base64,")
|
||||
expect(imageParts[0].mimeType).toBe("image/jpeg")
|
||||
expect(imageParts[1].image).toContain("data:image/png;base64,")
|
||||
expect(imageParts[1].mimeType).toBe("image/png")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -686,6 +694,17 @@ describe("AwsBedrockHandler", () => {
|
|||
})
|
||||
|
||||
describe("1M context beta feature", () => {
|
||||
function setupMockStreamText() {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Response" }
|
||||
}
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
}
|
||||
|
||||
it("should enable 1M context window when awsBedrock1MContext is true for Claude Sonnet 4", () => {
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0],
|
||||
|
|
@ -731,7 +750,9 @@ describe("AwsBedrockHandler", () => {
|
|||
expect(model.info.contextWindow).toBe(200_000)
|
||||
})
|
||||
|
||||
it("should include anthropic_beta parameter when 1M context is enabled", async () => {
|
||||
it("should include anthropicBeta in providerOptions when 1M context is enabled", async () => {
|
||||
setupMockStreamText()
|
||||
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0],
|
||||
awsAccessKey: "test",
|
||||
|
|
@ -748,23 +769,23 @@ describe("AwsBedrockHandler", () => {
|
|||
]
|
||||
|
||||
const generator = handler.createMessage("", messages)
|
||||
await generator.next() // Start the generator
|
||||
const chunks: unknown[] = []
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify the command was created with the right payload
|
||||
expect(mockConverseStreamCommand).toHaveBeenCalled()
|
||||
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
|
||||
// Should include anthropic_beta in additionalModelRequestFields with both 1M context and fine-grained-tool-streaming
|
||||
expect(commandArg.additionalModelRequestFields).toBeDefined()
|
||||
expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain("context-1m-2025-08-07")
|
||||
expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain(
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
)
|
||||
// Should not include anthropic_version since thinking is not enabled
|
||||
expect(commandArg.additionalModelRequestFields.anthropic_version).toBeUndefined()
|
||||
// Should include anthropicBeta in providerOptions.bedrock with 1M context
|
||||
const bedrockOpts = callArgs.providerOptions?.bedrock as Record<string, unknown> | undefined
|
||||
expect(bedrockOpts).toBeDefined()
|
||||
expect(bedrockOpts!.anthropicBeta).toContain("context-1m-2025-08-07")
|
||||
})
|
||||
|
||||
it("should not include 1M context beta when 1M context is disabled but still include fine-grained-tool-streaming", async () => {
|
||||
it("should not include 1M context beta when 1M context is disabled", async () => {
|
||||
setupMockStreamText()
|
||||
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0],
|
||||
awsAccessKey: "test",
|
||||
|
|
@ -781,22 +802,24 @@ describe("AwsBedrockHandler", () => {
|
|||
]
|
||||
|
||||
const generator = handler.createMessage("", messages)
|
||||
await generator.next() // Start the generator
|
||||
const chunks: unknown[] = []
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify the command was created with the right payload
|
||||
expect(mockConverseStreamCommand).toHaveBeenCalled()
|
||||
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
|
||||
// Should include anthropic_beta with fine-grained-tool-streaming for Claude models
|
||||
expect(commandArg.additionalModelRequestFields).toBeDefined()
|
||||
expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain(
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
)
|
||||
// Should NOT include 1M context beta
|
||||
expect(commandArg.additionalModelRequestFields.anthropic_beta).not.toContain("context-1m-2025-08-07")
|
||||
// Should NOT include anthropicBeta with 1M context
|
||||
const bedrockOpts = callArgs.providerOptions?.bedrock as Record<string, unknown> | undefined
|
||||
if (bedrockOpts?.anthropicBeta) {
|
||||
expect(bedrockOpts.anthropicBeta).not.toContain("context-1m-2025-08-07")
|
||||
}
|
||||
})
|
||||
|
||||
it("should not include 1M context beta for non-Claude Sonnet 4 models but still include fine-grained-tool-streaming", async () => {
|
||||
it("should not include 1M context beta for non-Claude Sonnet 4 models", async () => {
|
||||
setupMockStreamText()
|
||||
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test",
|
||||
|
|
@ -813,19 +836,19 @@ describe("AwsBedrockHandler", () => {
|
|||
]
|
||||
|
||||
const generator = handler.createMessage("", messages)
|
||||
await generator.next() // Start the generator
|
||||
const chunks: unknown[] = []
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify the command was created with the right payload
|
||||
expect(mockConverseStreamCommand).toHaveBeenCalled()
|
||||
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
|
||||
// Should include anthropic_beta with fine-grained-tool-streaming for Claude models (even non-Sonnet 4)
|
||||
expect(commandArg.additionalModelRequestFields).toBeDefined()
|
||||
expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain(
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
)
|
||||
// Should NOT include 1M context beta for non-Sonnet 4 models
|
||||
expect(commandArg.additionalModelRequestFields.anthropic_beta).not.toContain("context-1m-2025-08-07")
|
||||
// Should NOT include anthropicBeta with 1M context for non-Sonnet 4 models
|
||||
const bedrockOpts = callArgs.providerOptions?.bedrock as Record<string, unknown> | undefined
|
||||
if (bedrockOpts?.anthropicBeta) {
|
||||
expect(bedrockOpts.anthropicBeta).not.toContain("context-1m-2025-08-07")
|
||||
}
|
||||
})
|
||||
|
||||
it("should enable 1M context window with cross-region inference for Claude Sonnet 4", () => {
|
||||
|
|
@ -846,7 +869,9 @@ describe("AwsBedrockHandler", () => {
|
|||
expect(model.id).toBe(`us.${BEDROCK_1M_CONTEXT_MODEL_IDS[0]}`)
|
||||
})
|
||||
|
||||
it("should include anthropic_beta parameter with cross-region inference for Claude Sonnet 4", async () => {
|
||||
it("should include anthropicBeta with cross-region inference for Claude Sonnet 4", async () => {
|
||||
setupMockStreamText()
|
||||
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0],
|
||||
awsAccessKey: "test",
|
||||
|
|
@ -864,33 +889,34 @@ describe("AwsBedrockHandler", () => {
|
|||
]
|
||||
|
||||
const generator = handler.createMessage("", messages)
|
||||
await generator.next() // Start the generator
|
||||
const chunks: unknown[] = []
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify the command was created with the right payload
|
||||
expect(mockConverseStreamCommand).toHaveBeenCalled()
|
||||
const commandArg = mockConverseStreamCommand.mock.calls[
|
||||
mockConverseStreamCommand.mock.calls.length - 1
|
||||
][0] as any
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
|
||||
// Should include anthropic_beta in additionalModelRequestFields with both 1M context and fine-grained-tool-streaming
|
||||
expect(commandArg.additionalModelRequestFields).toBeDefined()
|
||||
expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain("context-1m-2025-08-07")
|
||||
expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain(
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
)
|
||||
// Should not include anthropic_version since thinking is not enabled
|
||||
expect(commandArg.additionalModelRequestFields.anthropic_version).toBeUndefined()
|
||||
// Model ID should have cross-region prefix
|
||||
expect(commandArg.modelId).toBe(`us.${BEDROCK_1M_CONTEXT_MODEL_IDS[0]}`)
|
||||
// Should include anthropicBeta in providerOptions.bedrock with 1M context
|
||||
const bedrockOpts = callArgs.providerOptions?.bedrock as Record<string, unknown> | undefined
|
||||
expect(bedrockOpts).toBeDefined()
|
||||
expect(bedrockOpts!.anthropicBeta).toContain("context-1m-2025-08-07")
|
||||
})
|
||||
})
|
||||
|
||||
describe("service tier feature", () => {
|
||||
const supportedModelId = BEDROCK_SERVICE_TIER_MODEL_IDS[0] // amazon.nova-lite-v1:0
|
||||
|
||||
beforeEach(() => {
|
||||
mockConverseStreamCommand.mockReset()
|
||||
})
|
||||
function setupMockStreamText() {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Response" }
|
||||
}
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
|
||||
providerMetadata: Promise.resolve({}),
|
||||
})
|
||||
}
|
||||
|
||||
describe("pricing multipliers in getModel()", () => {
|
||||
it("should apply FLEX tier pricing with 50% discount", () => {
|
||||
|
|
@ -976,7 +1002,9 @@ describe("AwsBedrockHandler", () => {
|
|||
})
|
||||
|
||||
describe("service_tier parameter in API requests", () => {
|
||||
it("should include service_tier as top-level parameter for supported models", async () => {
|
||||
it("should include service_tier in providerOptions.bedrock.additionalModelRequestFields for supported models", async () => {
|
||||
setupMockStreamText()
|
||||
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiModelId: supportedModelId,
|
||||
awsAccessKey: "test",
|
||||
|
|
@ -993,23 +1021,27 @@ describe("AwsBedrockHandler", () => {
|
|||
]
|
||||
|
||||
const generator = handler.createMessage("", messages)
|
||||
await generator.next() // Start the generator
|
||||
|
||||
// Verify the command was created with service_tier at top level
|
||||
// Per AWS documentation, service_tier must be a top-level parameter, not inside additionalModelRequestFields
|
||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html
|
||||
expect(mockConverseStreamCommand).toHaveBeenCalled()
|
||||
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any
|
||||
|
||||
// service_tier should be at the top level of the payload
|
||||
expect(commandArg.service_tier).toBe("PRIORITY")
|
||||
// service_tier should NOT be in additionalModelRequestFields
|
||||
if (commandArg.additionalModelRequestFields) {
|
||||
expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined()
|
||||
const chunks: unknown[] = []
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
|
||||
// service_tier should be passed through providerOptions.bedrock.additionalModelRequestFields
|
||||
const bedrockOpts = callArgs.providerOptions?.bedrock as Record<string, unknown> | undefined
|
||||
expect(bedrockOpts).toBeDefined()
|
||||
const additionalFields = bedrockOpts!.additionalModelRequestFields as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
expect(additionalFields).toBeDefined()
|
||||
expect(additionalFields!.service_tier).toBe("PRIORITY")
|
||||
})
|
||||
|
||||
it("should include service_tier FLEX as top-level parameter", async () => {
|
||||
it("should include service_tier FLEX in providerOptions", async () => {
|
||||
setupMockStreamText()
|
||||
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiModelId: supportedModelId,
|
||||
awsAccessKey: "test",
|
||||
|
|
@ -1026,20 +1058,26 @@ describe("AwsBedrockHandler", () => {
|
|||
]
|
||||
|
||||
const generator = handler.createMessage("", messages)
|
||||
await generator.next() // Start the generator
|
||||
|
||||
expect(mockConverseStreamCommand).toHaveBeenCalled()
|
||||
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any
|
||||
|
||||
// service_tier should be at the top level of the payload
|
||||
expect(commandArg.service_tier).toBe("FLEX")
|
||||
// service_tier should NOT be in additionalModelRequestFields
|
||||
if (commandArg.additionalModelRequestFields) {
|
||||
expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined()
|
||||
const chunks: unknown[] = []
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
|
||||
const bedrockOpts = callArgs.providerOptions?.bedrock as Record<string, unknown> | undefined
|
||||
expect(bedrockOpts).toBeDefined()
|
||||
const additionalFields = bedrockOpts!.additionalModelRequestFields as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
expect(additionalFields).toBeDefined()
|
||||
expect(additionalFields!.service_tier).toBe("FLEX")
|
||||
})
|
||||
|
||||
it("should NOT include service_tier for unsupported models", async () => {
|
||||
setupMockStreamText()
|
||||
|
||||
const unsupportedModelId = "anthropic.claude-3-5-sonnet-20241022-v2:0"
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiModelId: unsupportedModelId,
|
||||
|
|
@ -1057,19 +1095,25 @@ describe("AwsBedrockHandler", () => {
|
|||
]
|
||||
|
||||
const generator = handler.createMessage("", messages)
|
||||
await generator.next() // Start the generator
|
||||
const chunks: unknown[] = []
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(mockConverseStreamCommand).toHaveBeenCalled()
|
||||
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
|
||||
// Service tier should NOT be included for unsupported models (at top level or in additionalModelRequestFields)
|
||||
expect(commandArg.service_tier).toBeUndefined()
|
||||
if (commandArg.additionalModelRequestFields) {
|
||||
expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined()
|
||||
// Service tier should NOT be included for unsupported models
|
||||
const bedrockOpts = callArgs.providerOptions?.bedrock as Record<string, unknown> | undefined
|
||||
if (bedrockOpts?.additionalModelRequestFields) {
|
||||
const additionalFields = bedrockOpts.additionalModelRequestFields as Record<string, unknown>
|
||||
expect(additionalFields.service_tier).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("should NOT include service_tier when not specified", async () => {
|
||||
setupMockStreamText()
|
||||
|
||||
const handler = new AwsBedrockHandler({
|
||||
apiModelId: supportedModelId,
|
||||
awsAccessKey: "test",
|
||||
|
|
@ -1086,15 +1130,19 @@ describe("AwsBedrockHandler", () => {
|
|||
]
|
||||
|
||||
const generator = handler.createMessage("", messages)
|
||||
await generator.next() // Start the generator
|
||||
const chunks: unknown[] = []
|
||||
for await (const chunk of generator) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(mockConverseStreamCommand).toHaveBeenCalled()
|
||||
const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any
|
||||
expect(mockStreamText).toHaveBeenCalledTimes(1)
|
||||
const callArgs = mockStreamText.mock.calls[0][0]
|
||||
|
||||
// Service tier should NOT be included when not specified (at top level or in additionalModelRequestFields)
|
||||
expect(commandArg.service_tier).toBeUndefined()
|
||||
if (commandArg.additionalModelRequestFields) {
|
||||
expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined()
|
||||
// Service tier should NOT be included when not specified
|
||||
const bedrockOpts = callArgs.providerOptions?.bedrock as Record<string, unknown> | undefined
|
||||
if (bedrockOpts?.additionalModelRequestFields) {
|
||||
const additionalFields = bedrockOpts.additionalModelRequestFields as Record<string, unknown>
|
||||
expect(additionalFields.service_tier).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1127,16 +1175,16 @@ describe("AwsBedrockHandler", () => {
|
|||
})
|
||||
|
||||
describe("error telemetry", () => {
|
||||
let mockSend: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockCaptureException.mockClear()
|
||||
// Get access to the mock send function from the mocked client
|
||||
mockSend = vi.mocked(BedrockRuntimeClient).mock.results[0]?.value?.send
|
||||
})
|
||||
|
||||
it("should capture telemetry on createMessage error", async () => {
|
||||
// Create a handler with a fresh mock
|
||||
// Mock streamText to throw an error
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw new Error("Bedrock API error")
|
||||
})
|
||||
|
||||
const errorHandler = new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
|
|
@ -1144,15 +1192,6 @@ describe("AwsBedrockHandler", () => {
|
|||
awsRegion: "us-east-1",
|
||||
})
|
||||
|
||||
// Get the mock send from the new handler instance
|
||||
const clientInstance =
|
||||
vi.mocked(BedrockRuntimeClient).mock.results[vi.mocked(BedrockRuntimeClient).mock.results.length - 1]
|
||||
?.value
|
||||
const mockSendFn = clientInstance?.send as ReturnType<typeof vi.fn>
|
||||
|
||||
// Mock the send to throw an error
|
||||
mockSendFn.mockRejectedValueOnce(new Error("Bedrock API error"))
|
||||
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
|
|
@ -1186,7 +1225,9 @@ describe("AwsBedrockHandler", () => {
|
|||
})
|
||||
|
||||
it("should capture telemetry on completePrompt error", async () => {
|
||||
// Create a handler with a fresh mock
|
||||
// Mock generateText to throw an error
|
||||
mockGenerateText.mockRejectedValueOnce(new Error("Bedrock completion error"))
|
||||
|
||||
const errorHandler = new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
|
|
@ -1194,15 +1235,6 @@ describe("AwsBedrockHandler", () => {
|
|||
awsRegion: "us-east-1",
|
||||
})
|
||||
|
||||
// Get the mock send from the new handler instance
|
||||
const clientInstance =
|
||||
vi.mocked(BedrockRuntimeClient).mock.results[vi.mocked(BedrockRuntimeClient).mock.results.length - 1]
|
||||
?.value
|
||||
const mockSendFn = clientInstance?.send as ReturnType<typeof vi.fn>
|
||||
|
||||
// Mock the send to throw an error for ConverseCommand
|
||||
mockSendFn.mockRejectedValueOnce(new Error("Bedrock completion error"))
|
||||
|
||||
// Call completePrompt - it should throw
|
||||
await expect(errorHandler.completePrompt("Test prompt")).rejects.toThrow()
|
||||
|
||||
|
|
@ -1223,7 +1255,11 @@ describe("AwsBedrockHandler", () => {
|
|||
})
|
||||
|
||||
it("should still throw the error after capturing telemetry", async () => {
|
||||
// Create a handler with a fresh mock
|
||||
// Mock streamText to throw an error
|
||||
mockStreamText.mockImplementation(() => {
|
||||
throw new Error("Test error for throw verification")
|
||||
})
|
||||
|
||||
const errorHandler = new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
|
|
@ -1231,15 +1267,6 @@ describe("AwsBedrockHandler", () => {
|
|||
awsRegion: "us-east-1",
|
||||
})
|
||||
|
||||
// Get the mock send from the new handler instance
|
||||
const clientInstance =
|
||||
vi.mocked(BedrockRuntimeClient).mock.results[vi.mocked(BedrockRuntimeClient).mock.results.length - 1]
|
||||
?.value
|
||||
const mockSendFn = clientInstance?.send as ReturnType<typeof vi.fn>
|
||||
|
||||
// Mock the send to throw an error
|
||||
mockSendFn.mockRejectedValueOnce(new Error("Test error for throw verification"))
|
||||
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -349,7 +349,14 @@ describe("AI SDK conversion utilities", () => {
|
|||
expect(result[0]).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "Deep thought" },
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Deep thought",
|
||||
providerOptions: {
|
||||
bedrock: { signature: "sig" },
|
||||
anthropic: { signature: "sig" },
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "OK" },
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,694 +0,0 @@
|
|||
// npx vitest run src/api/transform/__tests__/bedrock-converse-format.spec.ts
|
||||
|
||||
import { convertToBedrockConverseMessages } from "../bedrock-converse-format"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ContentBlock, ToolResultContentBlock } from "@aws-sdk/client-bedrock-runtime"
|
||||
import { OPENAI_CALL_ID_MAX_LENGTH } from "../../../utils/tool-id"
|
||||
|
||||
describe("convertToBedrockConverseMessages", () => {
|
||||
it("converts simple text messages correctly", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi there" },
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ text: "Hello" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ text: "Hi there" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("converts messages with images correctly", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Look at this image:",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
data: "SGVsbG8=", // "Hello" in base64
|
||||
media_type: "image/jpeg" as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
if (!result[0] || !result[0].content) {
|
||||
expect.fail("Expected result to have content")
|
||||
return
|
||||
}
|
||||
|
||||
expect(result[0].role).toBe("user")
|
||||
expect(result[0].content).toHaveLength(2)
|
||||
expect(result[0].content[0]).toEqual({ text: "Look at this image:" })
|
||||
|
||||
const imageBlock = result[0].content[1] as ContentBlock
|
||||
if ("image" in imageBlock && imageBlock.image && imageBlock.image.source) {
|
||||
expect(imageBlock.image.format).toBe("jpeg")
|
||||
expect(imageBlock.image.source).toBeDefined()
|
||||
expect(imageBlock.image.source.bytes).toBeDefined()
|
||||
} else {
|
||||
expect.fail("Expected image block not found")
|
||||
}
|
||||
})
|
||||
|
||||
it("converts tool use messages correctly (native tools format; default)", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "test-id",
|
||||
name: "read_file",
|
||||
input: {
|
||||
path: "test.txt",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
if (!result[0] || !result[0].content) {
|
||||
expect.fail("Expected result to have content")
|
||||
return
|
||||
}
|
||||
|
||||
expect(result[0].role).toBe("assistant")
|
||||
const toolBlock = result[0].content[0] as ContentBlock
|
||||
if ("toolUse" in toolBlock && toolBlock.toolUse) {
|
||||
expect(toolBlock.toolUse).toEqual({
|
||||
toolUseId: "test-id",
|
||||
name: "read_file",
|
||||
input: { path: "test.txt" },
|
||||
})
|
||||
} else {
|
||||
expect.fail("Expected tool use block not found")
|
||||
}
|
||||
})
|
||||
|
||||
it("converts tool use messages correctly (native tools format)", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "test-id",
|
||||
name: "read_file",
|
||||
input: {
|
||||
path: "test.txt",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
if (!result[0] || !result[0].content) {
|
||||
expect.fail("Expected result to have content")
|
||||
return
|
||||
}
|
||||
|
||||
expect(result[0].role).toBe("assistant")
|
||||
const toolBlock = result[0].content[0] as ContentBlock
|
||||
if ("toolUse" in toolBlock && toolBlock.toolUse) {
|
||||
expect(toolBlock.toolUse).toEqual({
|
||||
toolUseId: "test-id",
|
||||
name: "read_file",
|
||||
input: { path: "test.txt" },
|
||||
})
|
||||
} else {
|
||||
expect.fail("Expected tool use block not found")
|
||||
}
|
||||
})
|
||||
|
||||
it("converts tool result messages to native format (default)", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "test-id",
|
||||
content: [{ type: "text", text: "File contents here" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
if (!result[0] || !result[0].content) {
|
||||
expect.fail("Expected result to have content")
|
||||
return
|
||||
}
|
||||
|
||||
expect(result[0].role).toBe("user")
|
||||
const resultBlock = result[0].content[0] as ContentBlock
|
||||
if ("toolResult" in resultBlock && resultBlock.toolResult) {
|
||||
const expectedContent: ToolResultContentBlock[] = [{ text: "File contents here" }]
|
||||
expect(resultBlock.toolResult).toEqual({
|
||||
toolUseId: "test-id",
|
||||
content: expectedContent,
|
||||
status: "success",
|
||||
})
|
||||
} else {
|
||||
expect.fail("Expected tool result block not found")
|
||||
}
|
||||
})
|
||||
|
||||
it("converts tool result messages to native format", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "test-id",
|
||||
content: [{ type: "text", text: "File contents here" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
if (!result[0] || !result[0].content) {
|
||||
expect.fail("Expected result to have content")
|
||||
return
|
||||
}
|
||||
|
||||
expect(result[0].role).toBe("user")
|
||||
const resultBlock = result[0].content[0] as ContentBlock
|
||||
if ("toolResult" in resultBlock && resultBlock.toolResult) {
|
||||
const expectedContent: ToolResultContentBlock[] = [{ text: "File contents here" }]
|
||||
expect(resultBlock.toolResult).toEqual({
|
||||
toolUseId: "test-id",
|
||||
content: expectedContent,
|
||||
status: "success",
|
||||
})
|
||||
} else {
|
||||
expect.fail("Expected tool result block not found")
|
||||
}
|
||||
})
|
||||
|
||||
it("converts tool result messages with string content to native format (default)", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "test-id",
|
||||
content: "File: test.txt\nLines 1-5:\nHello World",
|
||||
} as any, // Anthropic types don't allow string content but runtime can have it
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
if (!result[0] || !result[0].content) {
|
||||
expect.fail("Expected result to have content")
|
||||
return
|
||||
}
|
||||
|
||||
expect(result[0].role).toBe("user")
|
||||
const resultBlock = result[0].content[0] as ContentBlock
|
||||
if ("toolResult" in resultBlock && resultBlock.toolResult) {
|
||||
expect(resultBlock.toolResult).toEqual({
|
||||
toolUseId: "test-id",
|
||||
content: [{ text: "File: test.txt\nLines 1-5:\nHello World" }],
|
||||
status: "success",
|
||||
})
|
||||
} else {
|
||||
expect.fail("Expected tool result block not found")
|
||||
}
|
||||
})
|
||||
|
||||
it("converts tool result messages with string content to native format", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "test-id",
|
||||
content: "File: test.txt\nLines 1-5:\nHello World",
|
||||
} as any, // Anthropic types don't allow string content but runtime can have it
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
if (!result[0] || !result[0].content) {
|
||||
expect.fail("Expected result to have content")
|
||||
return
|
||||
}
|
||||
|
||||
expect(result[0].role).toBe("user")
|
||||
const resultBlock = result[0].content[0] as ContentBlock
|
||||
if ("toolResult" in resultBlock && resultBlock.toolResult) {
|
||||
expect(resultBlock.toolResult).toEqual({
|
||||
toolUseId: "test-id",
|
||||
content: [{ text: "File: test.txt\nLines 1-5:\nHello World" }],
|
||||
status: "success",
|
||||
})
|
||||
} else {
|
||||
expect.fail("Expected tool result block not found")
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps both tool_use and tool_result in native format by default", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "call-123",
|
||||
name: "read_file",
|
||||
input: { path: "test.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call-123",
|
||||
content: "File contents here",
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
// Both should be native toolUse/toolResult blocks
|
||||
const assistantContent = result[0]?.content?.[0] as ContentBlock
|
||||
const userContent = result[1]?.content?.[0] as ContentBlock
|
||||
|
||||
expect("toolUse" in assistantContent).toBe(true)
|
||||
expect("toolResult" in userContent).toBe(true)
|
||||
expect("text" in assistantContent).toBe(false)
|
||||
expect("text" in userContent).toBe(false)
|
||||
})
|
||||
|
||||
it("handles text content correctly", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello world",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
if (!result[0] || !result[0].content) {
|
||||
expect.fail("Expected result to have content")
|
||||
return
|
||||
}
|
||||
|
||||
expect(result[0].role).toBe("user")
|
||||
expect(result[0].content).toHaveLength(1)
|
||||
const textBlock = result[0].content[0] as ContentBlock
|
||||
expect(textBlock).toEqual({ text: "Hello world" })
|
||||
})
|
||||
|
||||
describe("toolUseId sanitization for Bedrock 64-char limit", () => {
|
||||
it("truncates toolUseId longer than 64 characters in tool_use blocks", () => {
|
||||
const longId = "a".repeat(100)
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: longId,
|
||||
name: "read_file",
|
||||
input: { path: "test.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
const toolBlock = result[0]?.content?.[0] as ContentBlock
|
||||
|
||||
if ("toolUse" in toolBlock && toolBlock.toolUse && toolBlock.toolUse.toolUseId) {
|
||||
expect(toolBlock.toolUse.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH)
|
||||
expect(toolBlock.toolUse.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH)
|
||||
expect(toolBlock.toolUse.toolUseId).toContain("_")
|
||||
} else {
|
||||
expect.fail("Expected tool use block not found")
|
||||
}
|
||||
})
|
||||
|
||||
it("truncates toolUseId longer than 64 characters in tool_result blocks with string content", () => {
|
||||
const longId = "b".repeat(100)
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: longId,
|
||||
content: "Result content",
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
const resultBlock = result[0]?.content?.[0] as ContentBlock
|
||||
|
||||
if ("toolResult" in resultBlock && resultBlock.toolResult && resultBlock.toolResult.toolUseId) {
|
||||
expect(resultBlock.toolResult.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH)
|
||||
expect(resultBlock.toolResult.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH)
|
||||
expect(resultBlock.toolResult.toolUseId).toContain("_")
|
||||
} else {
|
||||
expect.fail("Expected tool result block not found")
|
||||
}
|
||||
})
|
||||
|
||||
it("truncates toolUseId longer than 64 characters in tool_result blocks with array content", () => {
|
||||
const longId = "c".repeat(100)
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: longId,
|
||||
content: [{ type: "text", text: "Result content" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
const resultBlock = result[0]?.content?.[0] as ContentBlock
|
||||
|
||||
if ("toolResult" in resultBlock && resultBlock.toolResult && resultBlock.toolResult.toolUseId) {
|
||||
expect(resultBlock.toolResult.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH)
|
||||
expect(resultBlock.toolResult.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH)
|
||||
} else {
|
||||
expect.fail("Expected tool result block not found")
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps toolUseId unchanged when under 64 characters", () => {
|
||||
const shortId = "short-id-123"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: shortId,
|
||||
name: "read_file",
|
||||
input: { path: "test.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
const toolBlock = result[0]?.content?.[0] as ContentBlock
|
||||
|
||||
if ("toolUse" in toolBlock && toolBlock.toolUse) {
|
||||
expect(toolBlock.toolUse.toolUseId).toBe(shortId)
|
||||
} else {
|
||||
expect.fail("Expected tool use block not found")
|
||||
}
|
||||
})
|
||||
|
||||
it("produces consistent truncated IDs for the same input", () => {
|
||||
const longId = "d".repeat(100)
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: longId,
|
||||
name: "read_file",
|
||||
input: { path: "test.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result1 = convertToBedrockConverseMessages(messages)
|
||||
const result2 = convertToBedrockConverseMessages(messages)
|
||||
|
||||
const toolBlock1 = result1[0]?.content?.[0] as ContentBlock
|
||||
const toolBlock2 = result2[0]?.content?.[0] as ContentBlock
|
||||
|
||||
if ("toolUse" in toolBlock1 && toolBlock1.toolUse && "toolUse" in toolBlock2 && toolBlock2.toolUse) {
|
||||
expect(toolBlock1.toolUse.toolUseId).toBe(toolBlock2.toolUse.toolUseId)
|
||||
} else {
|
||||
expect.fail("Expected tool use blocks not found")
|
||||
}
|
||||
})
|
||||
|
||||
it("produces different truncated IDs for different long inputs", () => {
|
||||
const longId1 = "e".repeat(100)
|
||||
const longId2 = "f".repeat(100)
|
||||
|
||||
const messages1: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: longId1, name: "read_file", input: {} }],
|
||||
},
|
||||
]
|
||||
const messages2: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: longId2, name: "read_file", input: {} }],
|
||||
},
|
||||
]
|
||||
|
||||
const result1 = convertToBedrockConverseMessages(messages1)
|
||||
const result2 = convertToBedrockConverseMessages(messages2)
|
||||
|
||||
const toolBlock1 = result1[0]?.content?.[0] as ContentBlock
|
||||
const toolBlock2 = result2[0]?.content?.[0] as ContentBlock
|
||||
|
||||
if ("toolUse" in toolBlock1 && toolBlock1.toolUse && "toolUse" in toolBlock2 && toolBlock2.toolUse) {
|
||||
expect(toolBlock1.toolUse.toolUseId).not.toBe(toolBlock2.toolUse.toolUseId)
|
||||
} else {
|
||||
expect.fail("Expected tool use blocks not found")
|
||||
}
|
||||
})
|
||||
|
||||
it("matching tool_use and tool_result IDs are both truncated consistently", () => {
|
||||
const longId = "g".repeat(100)
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: longId,
|
||||
name: "read_file",
|
||||
input: { path: "test.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: longId,
|
||||
content: "File contents",
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
const toolUseBlock = result[0]?.content?.[0] as ContentBlock
|
||||
const toolResultBlock = result[1]?.content?.[0] as ContentBlock
|
||||
|
||||
if (
|
||||
"toolUse" in toolUseBlock &&
|
||||
toolUseBlock.toolUse &&
|
||||
toolUseBlock.toolUse.toolUseId &&
|
||||
"toolResult" in toolResultBlock &&
|
||||
toolResultBlock.toolResult &&
|
||||
toolResultBlock.toolResult.toolUseId
|
||||
) {
|
||||
expect(toolUseBlock.toolUse.toolUseId).toBe(toolResultBlock.toolResult.toolUseId)
|
||||
expect(toolUseBlock.toolUse.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH)
|
||||
} else {
|
||||
expect.fail("Expected tool use and result blocks not found")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("thinking and reasoning block handling", () => {
|
||||
it("should convert thinking blocks to reasoningContent format", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Let me think about this...", signature: "sig-abc123" } as any,
|
||||
{ type: "text", text: "Here is my answer." },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].role).toBe("assistant")
|
||||
expect(result[0].content).toHaveLength(2)
|
||||
|
||||
const reasoningBlock = result[0].content![0] as any
|
||||
expect(reasoningBlock.reasoningContent).toBeDefined()
|
||||
expect(reasoningBlock.reasoningContent.reasoningText.text).toBe("Let me think about this...")
|
||||
expect(reasoningBlock.reasoningContent.reasoningText.signature).toBe("sig-abc123")
|
||||
|
||||
const textBlock = result[0].content![1] as any
|
||||
expect(textBlock.text).toBe("Here is my answer.")
|
||||
})
|
||||
|
||||
it("should convert redacted_thinking blocks with data to reasoningContent.redactedContent", () => {
|
||||
const testData = Buffer.from("encrypted-redacted-content").toString("base64")
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "redacted_thinking", data: testData } as any, { type: "text", text: "Response" }],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toHaveLength(2)
|
||||
|
||||
const redactedBlock = result[0].content![0] as any
|
||||
expect(redactedBlock.reasoningContent).toBeDefined()
|
||||
expect(redactedBlock.reasoningContent.redactedContent).toBeInstanceOf(Uint8Array)
|
||||
// Verify round-trip: decode back and compare
|
||||
const decoded = Buffer.from(redactedBlock.reasoningContent.redactedContent).toString("utf-8")
|
||||
expect(decoded).toBe("encrypted-redacted-content")
|
||||
})
|
||||
|
||||
it("should skip redacted_thinking blocks without data", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "redacted_thinking" } as any, { type: "text", text: "Response" }],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
// Only the text block should remain (redacted_thinking without data is filtered out)
|
||||
expect(result[0].content).toHaveLength(1)
|
||||
expect((result[0].content![0] as any).text).toBe("Response")
|
||||
})
|
||||
|
||||
it("should skip reasoning blocks (internal Roo Code format)", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "Internal reasoning" } as any,
|
||||
{ type: "text", text: "Response" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toHaveLength(1)
|
||||
expect((result[0].content![0] as any).text).toBe("Response")
|
||||
})
|
||||
|
||||
it("should skip thoughtSignature blocks (Gemini format)", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Response" },
|
||||
{ type: "thoughtSignature", thoughtSignature: "gemini-sig" } as any,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toHaveLength(1)
|
||||
expect((result[0].content![0] as any).text).toBe("Response")
|
||||
})
|
||||
|
||||
it("should handle full thinking + redacted_thinking + text + tool_use message", () => {
|
||||
const redactedData = Buffer.from("redacted-binary").toString("base64")
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Deep thought", signature: "sig-xyz" } as any,
|
||||
{ type: "redacted_thinking", data: redactedData } as any,
|
||||
{ type: "text", text: "I'll use a tool." },
|
||||
{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "test.txt" } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToBedrockConverseMessages(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toHaveLength(4)
|
||||
|
||||
// thinking → reasoningContent.reasoningText
|
||||
expect((result[0].content![0] as any).reasoningContent.reasoningText.text).toBe("Deep thought")
|
||||
expect((result[0].content![0] as any).reasoningContent.reasoningText.signature).toBe("sig-xyz")
|
||||
|
||||
// redacted_thinking → reasoningContent.redactedContent
|
||||
expect((result[0].content![1] as any).reasoningContent.redactedContent).toBeInstanceOf(Uint8Array)
|
||||
|
||||
// text
|
||||
expect((result[0].content![2] as any).text).toBe("I'll use a tool.")
|
||||
|
||||
// tool_use → toolUse
|
||||
expect((result[0].content![3] as any).toolUse.name).toBe("read_file")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -139,6 +139,11 @@ export function convertToAiSdkMessages(
|
|||
providerOptions?: Record<string, Record<string, unknown>>
|
||||
}> = []
|
||||
|
||||
// Capture thinking signature for Anthropic-protocol providers (Bedrock, Anthropic).
|
||||
// Task.ts stores thinking blocks as { type: "thinking", thinking: "...", signature: "..." }.
|
||||
// The signature must be passed back via providerOptions on reasoning parts.
|
||||
let thinkingSignature: string | undefined
|
||||
|
||||
// Extract thoughtSignature from content blocks (Gemini 3 thought signature round-tripping).
|
||||
// Task.ts stores these as { type: "thoughtSignature", thoughtSignature: "..." } blocks.
|
||||
let thoughtSignature: string | undefined
|
||||
|
|
@ -196,16 +201,20 @@ export function convertToAiSdkMessages(
|
|||
if ((part as unknown as { type?: string }).type === "thinking") {
|
||||
if (reasoningContent) continue
|
||||
|
||||
const thinking = (part as unknown as { thinking?: string }).thinking
|
||||
if (typeof thinking === "string" && thinking.length > 0) {
|
||||
reasoningParts.push(thinking)
|
||||
const thinkingPart = part as unknown as { thinking?: string; signature?: string }
|
||||
if (typeof thinkingPart.thinking === "string" && thinkingPart.thinking.length > 0) {
|
||||
reasoningParts.push(thinkingPart.thinking)
|
||||
}
|
||||
// Capture the signature for round-tripping (Anthropic/Bedrock thinking)
|
||||
if (thinkingPart.signature) {
|
||||
thinkingSignature = thinkingPart.signature
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const content: Array<
|
||||
| { type: "reasoning"; text: string }
|
||||
| { type: "reasoning"; text: string; providerOptions?: Record<string, Record<string, unknown>> }
|
||||
| { type: "text"; text: string }
|
||||
| {
|
||||
type: "tool-call"
|
||||
|
|
@ -219,7 +228,20 @@ export function convertToAiSdkMessages(
|
|||
if (reasoningContent) {
|
||||
content.push({ type: "reasoning", text: reasoningContent })
|
||||
} else if (reasoningParts.length > 0) {
|
||||
content.push({ type: "reasoning", text: reasoningParts.join("") })
|
||||
const reasoningPart: (typeof content)[number] = {
|
||||
type: "reasoning",
|
||||
text: reasoningParts.join(""),
|
||||
}
|
||||
// Attach thinking signature for Anthropic/Bedrock round-tripping.
|
||||
// The AI SDK's @ai-sdk/amazon-bedrock reads providerOptions.bedrock.signature
|
||||
// and attaches it to reasoningContent.reasoningText.signature in the Bedrock request.
|
||||
if (thinkingSignature) {
|
||||
reasoningPart.providerOptions = {
|
||||
bedrock: { signature: thinkingSignature },
|
||||
anthropic: { signature: thinkingSignature },
|
||||
}
|
||||
}
|
||||
content.push(reasoningPart)
|
||||
}
|
||||
|
||||
if (textParts.length > 0) {
|
||||
|
|
|
|||
|
|
@ -1,249 +0,0 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ConversationRole, Message, ContentBlock } from "@aws-sdk/client-bedrock-runtime"
|
||||
import { sanitizeOpenAiCallId } from "../../utils/tool-id"
|
||||
|
||||
interface BedrockMessageContent {
|
||||
type: "text" | "image" | "video" | "tool_use" | "tool_result"
|
||||
text?: string
|
||||
source?: {
|
||||
type: "base64"
|
||||
data: string | Uint8Array // string for Anthropic, Uint8Array for Bedrock
|
||||
media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp"
|
||||
}
|
||||
// Video specific fields
|
||||
format?: string
|
||||
s3Location?: {
|
||||
uri: string
|
||||
bucketOwner?: string
|
||||
}
|
||||
// Tool use and result fields
|
||||
toolUseId?: string
|
||||
name?: string
|
||||
input?: any
|
||||
output?: any // Used for tool_result type
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Anthropic messages to Bedrock Converse format
|
||||
* @param anthropicMessages Messages in Anthropic format
|
||||
*/
|
||||
export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] {
|
||||
return anthropicMessages.map((anthropicMessage) => {
|
||||
// Map Anthropic roles to Bedrock roles
|
||||
const role: ConversationRole = anthropicMessage.role === "assistant" ? "assistant" : "user"
|
||||
|
||||
if (typeof anthropicMessage.content === "string") {
|
||||
return {
|
||||
role,
|
||||
content: [
|
||||
{
|
||||
text: anthropicMessage.content,
|
||||
},
|
||||
] as ContentBlock[],
|
||||
}
|
||||
}
|
||||
|
||||
// Process complex content types
|
||||
const content = anthropicMessage.content.map((block) => {
|
||||
const messageBlock = block as BedrockMessageContent & {
|
||||
id?: string
|
||||
tool_use_id?: string
|
||||
content?: string | Array<{ type: string; text: string }>
|
||||
output?: string | Array<{ type: string; text: string }>
|
||||
}
|
||||
|
||||
if (messageBlock.type === "text") {
|
||||
return {
|
||||
text: messageBlock.text || "",
|
||||
} as ContentBlock
|
||||
}
|
||||
|
||||
if (messageBlock.type === "image" && messageBlock.source) {
|
||||
// Convert base64 string to byte array if needed
|
||||
let byteArray: Uint8Array
|
||||
if (typeof messageBlock.source.data === "string") {
|
||||
const binaryString = atob(messageBlock.source.data)
|
||||
byteArray = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
byteArray[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
} else {
|
||||
byteArray = messageBlock.source.data
|
||||
}
|
||||
|
||||
// Extract format from media_type (e.g., "image/jpeg" -> "jpeg")
|
||||
const format = messageBlock.source.media_type.split("/")[1]
|
||||
if (!["png", "jpeg", "gif", "webp"].includes(format)) {
|
||||
throw new Error(`Unsupported image format: ${format}`)
|
||||
}
|
||||
|
||||
return {
|
||||
image: {
|
||||
format: format as "png" | "jpeg" | "gif" | "webp",
|
||||
source: {
|
||||
bytes: byteArray,
|
||||
},
|
||||
},
|
||||
} as ContentBlock
|
||||
}
|
||||
|
||||
if (messageBlock.type === "tool_use") {
|
||||
// Native-only: keep input as JSON object for Bedrock's toolUse format
|
||||
return {
|
||||
toolUse: {
|
||||
toolUseId: sanitizeOpenAiCallId(messageBlock.id || ""),
|
||||
name: messageBlock.name || "",
|
||||
input: messageBlock.input || {},
|
||||
},
|
||||
} as ContentBlock
|
||||
}
|
||||
|
||||
if (messageBlock.type === "tool_result") {
|
||||
// Handle content field - can be string or array (native tool format)
|
||||
if (messageBlock.content) {
|
||||
// Content is a string
|
||||
if (typeof messageBlock.content === "string") {
|
||||
return {
|
||||
toolResult: {
|
||||
toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
|
||||
content: [
|
||||
{
|
||||
text: messageBlock.content,
|
||||
},
|
||||
],
|
||||
status: "success",
|
||||
},
|
||||
} as ContentBlock
|
||||
}
|
||||
// Content is an array of content blocks
|
||||
if (Array.isArray(messageBlock.content)) {
|
||||
return {
|
||||
toolResult: {
|
||||
toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
|
||||
content: messageBlock.content.map((item) => ({
|
||||
text: typeof item === "string" ? item : item.text || String(item),
|
||||
})),
|
||||
status: "success",
|
||||
},
|
||||
} as ContentBlock
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to output handling if content is not available
|
||||
if (messageBlock.output && typeof messageBlock.output === "string") {
|
||||
return {
|
||||
toolResult: {
|
||||
toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
|
||||
content: [
|
||||
{
|
||||
text: messageBlock.output,
|
||||
},
|
||||
],
|
||||
status: "success",
|
||||
},
|
||||
} as ContentBlock
|
||||
}
|
||||
// Handle array of content blocks if output is an array
|
||||
if (Array.isArray(messageBlock.output)) {
|
||||
return {
|
||||
toolResult: {
|
||||
toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
|
||||
content: messageBlock.output.map((part) => {
|
||||
if (typeof part === "object" && "text" in part) {
|
||||
return { text: part.text }
|
||||
}
|
||||
// Skip images in tool results as they're handled separately
|
||||
if (typeof part === "object" && "type" in part && part.type === "image") {
|
||||
return { text: "(see following message for image)" }
|
||||
}
|
||||
return { text: String(part) }
|
||||
}),
|
||||
status: "success",
|
||||
},
|
||||
} as ContentBlock
|
||||
}
|
||||
|
||||
// Default case
|
||||
return {
|
||||
toolResult: {
|
||||
toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""),
|
||||
content: [
|
||||
{
|
||||
text: String(messageBlock.output || ""),
|
||||
},
|
||||
],
|
||||
status: "success",
|
||||
},
|
||||
} as ContentBlock
|
||||
}
|
||||
|
||||
if (messageBlock.type === "video") {
|
||||
const videoContent = messageBlock.s3Location
|
||||
? {
|
||||
s3Location: {
|
||||
uri: messageBlock.s3Location.uri,
|
||||
bucketOwner: messageBlock.s3Location.bucketOwner,
|
||||
},
|
||||
}
|
||||
: messageBlock.source
|
||||
|
||||
return {
|
||||
video: {
|
||||
format: "mp4", // Default to mp4, adjust based on actual format if needed
|
||||
source: videoContent,
|
||||
},
|
||||
} as ContentBlock
|
||||
}
|
||||
|
||||
// Handle Anthropic thinking blocks (stored by Task.ts for extended thinking)
|
||||
// Convert to Bedrock Converse API's reasoningContent format
|
||||
const blockAny = block as { type: string; thinking?: string; signature?: string }
|
||||
if (blockAny.type === "thinking" && blockAny.thinking) {
|
||||
return {
|
||||
reasoningContent: {
|
||||
reasoningText: {
|
||||
text: blockAny.thinking,
|
||||
signature: blockAny.signature,
|
||||
},
|
||||
},
|
||||
} as ContentBlock
|
||||
}
|
||||
|
||||
// Handle redacted thinking blocks (Anthropic sends these when content is filtered).
|
||||
// Convert base64-encoded data back to Uint8Array for Bedrock Converse API's
|
||||
// reasoningContent.redactedContent format.
|
||||
if (blockAny.type === "redacted_thinking" && (blockAny as unknown as { data?: string }).data) {
|
||||
const base64Data = (blockAny as unknown as { data: string }).data
|
||||
const binaryData = Buffer.from(base64Data, "base64")
|
||||
return {
|
||||
reasoningContent: {
|
||||
redactedContent: new Uint8Array(binaryData),
|
||||
},
|
||||
} as ContentBlock
|
||||
}
|
||||
|
||||
// Skip redacted_thinking blocks without data (shouldn't happen, but be safe)
|
||||
if (blockAny.type === "redacted_thinking") {
|
||||
return undefined as unknown as ContentBlock
|
||||
}
|
||||
|
||||
// Skip reasoning blocks (internal Roo Code format, not for the API)
|
||||
if (blockAny.type === "reasoning" || blockAny.type === "thoughtSignature") {
|
||||
return undefined as unknown as ContentBlock
|
||||
}
|
||||
|
||||
// Default case for unknown block types
|
||||
return {
|
||||
text: "[Unknown Block Type]",
|
||||
} as ContentBlock
|
||||
})
|
||||
|
||||
// Filter out undefined entries (from skipped block types like redacted_thinking, reasoning)
|
||||
const filteredContent = content.filter((block): block is ContentBlock => block != null)
|
||||
|
||||
return {
|
||||
role,
|
||||
content: filteredContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,172 +0,0 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ContentBlock, SystemContentBlock, Message, ConversationRole } from "@aws-sdk/client-bedrock-runtime"
|
||||
import { CacheStrategyConfig, CacheResult, CachePointPlacement } from "./types"
|
||||
|
||||
export abstract class CacheStrategy {
|
||||
/**
|
||||
* Determine optimal cache point placements and return the formatted result
|
||||
*/
|
||||
public abstract determineOptimalCachePoints(): CacheResult
|
||||
|
||||
protected config: CacheStrategyConfig
|
||||
protected systemTokenCount: number = 0
|
||||
|
||||
constructor(config: CacheStrategyConfig) {
|
||||
this.config = config
|
||||
this.initializeMessageGroups()
|
||||
this.calculateSystemTokens()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize message groups from the input messages
|
||||
*/
|
||||
protected initializeMessageGroups(): void {
|
||||
if (!this.config.messages.length) return
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate token count for system prompt using a more accurate approach
|
||||
*/
|
||||
protected calculateSystemTokens(): void {
|
||||
if (this.config.systemPrompt) {
|
||||
const text = this.config.systemPrompt
|
||||
|
||||
// Use a more accurate token estimation than simple character count
|
||||
// Count words and add overhead for punctuation and special tokens
|
||||
const words = text.split(/\s+/).filter((word) => word.length > 0)
|
||||
// Average English word is ~1.3 tokens
|
||||
let tokenCount = words.length * 1.3
|
||||
// Add overhead for punctuation and special characters
|
||||
tokenCount += (text.match(/[.,!?;:()[\]{}""''`]/g) || []).length * 0.3
|
||||
// Add overhead for newlines
|
||||
tokenCount += (text.match(/\n/g) || []).length * 0.5
|
||||
// Add a small overhead for system prompt structure
|
||||
tokenCount += 5
|
||||
|
||||
this.systemTokenCount = Math.ceil(tokenCount)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cache point content block
|
||||
*/
|
||||
protected createCachePoint(): ContentBlock {
|
||||
return { cachePoint: { type: "default" } } as unknown as ContentBlock
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert messages to content blocks
|
||||
*/
|
||||
protected messagesToContentBlocks(messages: Anthropic.Messages.MessageParam[]): Message[] {
|
||||
return messages.map((message) => {
|
||||
const role: ConversationRole = message.role === "assistant" ? "assistant" : "user"
|
||||
|
||||
const content: ContentBlock[] = Array.isArray(message.content)
|
||||
? message.content.map((block) => {
|
||||
if (typeof block === "string") {
|
||||
return { text: block } as unknown as ContentBlock
|
||||
}
|
||||
if ("text" in block) {
|
||||
return { text: block.text } as unknown as ContentBlock
|
||||
}
|
||||
// Handle other content types if needed
|
||||
return { text: "[Unsupported Content]" } as unknown as ContentBlock
|
||||
})
|
||||
: [{ text: message.content } as unknown as ContentBlock]
|
||||
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token count meets the minimum threshold for caching
|
||||
*/
|
||||
protected meetsMinTokenThreshold(tokenCount: number): boolean {
|
||||
const minTokens = this.config.modelInfo.minTokensPerCachePoint
|
||||
if (!minTokens) {
|
||||
return false
|
||||
}
|
||||
return tokenCount >= minTokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate token count for a message using a more accurate approach
|
||||
* This implementation is based on the BaseProvider's countTokens method
|
||||
* but adapted to work without requiring an instance of BaseProvider
|
||||
*/
|
||||
protected estimateTokenCount(message: Anthropic.Messages.MessageParam): number {
|
||||
// Use a more sophisticated token counting approach
|
||||
if (!message.content) return 0
|
||||
|
||||
let totalTokens = 0
|
||||
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") {
|
||||
// Use a more accurate token estimation than simple character count
|
||||
// This is still an approximation but better than character/4
|
||||
const text = block.text || ""
|
||||
if (text.length > 0) {
|
||||
// Count words and add overhead for punctuation and special tokens
|
||||
const words = text.split(/\s+/).filter((word) => word.length > 0)
|
||||
// Average English word is ~1.3 tokens
|
||||
totalTokens += words.length * 1.3
|
||||
// Add overhead for punctuation and special characters
|
||||
totalTokens += (text.match(/[.,!?;:()[\]{}""''`]/g) || []).length * 0.3
|
||||
// Add overhead for newlines
|
||||
totalTokens += (text.match(/\n/g) || []).length * 0.5
|
||||
}
|
||||
} else if (block.type === "image") {
|
||||
// For images, use a conservative estimate
|
||||
totalTokens += 300
|
||||
}
|
||||
}
|
||||
} else if (typeof message.content === "string") {
|
||||
const text = message.content
|
||||
// Count words and add overhead for punctuation and special tokens
|
||||
const words = text.split(/\s+/).filter((word) => word.length > 0)
|
||||
// Average English word is ~1.3 tokens
|
||||
totalTokens += words.length * 1.3
|
||||
// Add overhead for punctuation and special characters
|
||||
totalTokens += (text.match(/[.,!?;:()[\]{}""''`]/g) || []).length * 0.3
|
||||
// Add overhead for newlines
|
||||
totalTokens += (text.match(/\n/g) || []).length * 0.5
|
||||
}
|
||||
|
||||
// Add a small overhead for message structure
|
||||
totalTokens += 10
|
||||
|
||||
return Math.ceil(totalTokens)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply cache points to content blocks based on placements
|
||||
*/
|
||||
protected applyCachePoints(messages: Message[], placements: CachePointPlacement[]): Message[] {
|
||||
const result: Message[] = []
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const placement = placements.find((p) => p.index === i)
|
||||
|
||||
if (placement) {
|
||||
messages[i].content?.push(this.createCachePoint())
|
||||
}
|
||||
result.push(messages[i])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the final result with cache points applied
|
||||
*/
|
||||
protected formatResult(systemBlocks: SystemContentBlock[] = [], messages: Message[]): CacheResult {
|
||||
const result = {
|
||||
system: systemBlocks,
|
||||
messages,
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
@ -1,314 +0,0 @@
|
|||
import { SystemContentBlock } from "@aws-sdk/client-bedrock-runtime"
|
||||
import { CacheStrategy } from "./base-strategy"
|
||||
import { CacheResult, CachePointPlacement } from "./types"
|
||||
import { logger } from "../../../utils/logging"
|
||||
|
||||
/**
|
||||
* Strategy for handling multiple cache points.
|
||||
* Creates cache points after messages as soon as uncached tokens exceed minimumTokenCount.
|
||||
*/
|
||||
export class MultiPointStrategy extends CacheStrategy {
|
||||
/**
|
||||
* Determine optimal cache point placements and return the formatted result
|
||||
*/
|
||||
public determineOptimalCachePoints(): CacheResult {
|
||||
// If prompt caching is disabled or no messages, return without cache points
|
||||
if (!this.config.usePromptCache || this.config.messages.length === 0) {
|
||||
return this.formatWithoutCachePoints()
|
||||
}
|
||||
|
||||
const supportsSystemCache = this.config.modelInfo.cachableFields.includes("system")
|
||||
const supportsMessageCache = this.config.modelInfo.cachableFields.includes("messages")
|
||||
const minTokensPerPoint = this.config.modelInfo.minTokensPerCachePoint
|
||||
let remainingCachePoints: number = this.config.modelInfo.maxCachePoints
|
||||
|
||||
// First, determine if we'll use a system cache point
|
||||
const useSystemCache =
|
||||
supportsSystemCache && this.config.systemPrompt && this.meetsMinTokenThreshold(this.systemTokenCount)
|
||||
|
||||
// Handle system blocks
|
||||
let systemBlocks: SystemContentBlock[] = []
|
||||
if (this.config.systemPrompt) {
|
||||
systemBlocks = [{ text: this.config.systemPrompt } as unknown as SystemContentBlock]
|
||||
if (useSystemCache) {
|
||||
systemBlocks.push(this.createCachePoint() as unknown as SystemContentBlock)
|
||||
remainingCachePoints--
|
||||
}
|
||||
}
|
||||
|
||||
// If message caching isn't supported, return with just system caching
|
||||
if (!supportsMessageCache) {
|
||||
return this.formatResult(systemBlocks, this.messagesToContentBlocks(this.config.messages))
|
||||
}
|
||||
|
||||
const placements = this.determineMessageCachePoints(minTokensPerPoint, remainingCachePoints)
|
||||
const messages = this.messagesToContentBlocks(this.config.messages)
|
||||
let cacheResult = this.formatResult(systemBlocks, this.applyCachePoints(messages, placements))
|
||||
|
||||
// Store the placements for future use (to maintain consistency across consecutive messages)
|
||||
// This needs to be handled by the caller by passing these placements back in the next call
|
||||
cacheResult.messageCachePointPlacements = placements
|
||||
|
||||
return cacheResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine optimal cache point placements for messages
|
||||
* This method handles both new conversations and growing conversations
|
||||
*
|
||||
* @param minTokensPerPoint Minimum tokens required per cache point
|
||||
* @param remainingCachePoints Number of cache points available
|
||||
* @returns Array of cache point placements
|
||||
*/
|
||||
private determineMessageCachePoints(
|
||||
minTokensPerPoint: number,
|
||||
remainingCachePoints: number,
|
||||
): CachePointPlacement[] {
|
||||
if (this.config.messages.length <= 1) {
|
||||
return []
|
||||
}
|
||||
|
||||
const placements: CachePointPlacement[] = []
|
||||
const totalMessages = this.config.messages.length
|
||||
const previousPlacements = this.config.previousCachePointPlacements || []
|
||||
|
||||
// Special case: If previousPlacements is empty, place initial cache points
|
||||
if (previousPlacements.length === 0) {
|
||||
let currentIndex = 0
|
||||
|
||||
while (currentIndex < totalMessages && remainingCachePoints > 0) {
|
||||
const newPlacement = this.findOptimalPlacementForRange(
|
||||
currentIndex,
|
||||
totalMessages - 1,
|
||||
minTokensPerPoint,
|
||||
)
|
||||
|
||||
if (newPlacement) {
|
||||
placements.push(newPlacement)
|
||||
currentIndex = newPlacement.index + 1
|
||||
remainingCachePoints--
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return placements
|
||||
}
|
||||
|
||||
// Calculate tokens in new messages (added since last cache point placement)
|
||||
const lastPreviousIndex = previousPlacements[previousPlacements.length - 1].index
|
||||
const newMessagesTokens = this.config.messages
|
||||
.slice(lastPreviousIndex + 1)
|
||||
.reduce((acc, curr) => acc + this.estimateTokenCount(curr), 0)
|
||||
|
||||
// If new messages have enough tokens for a cache point, we need to decide
|
||||
// whether to keep all previous cache points or combine some
|
||||
if (newMessagesTokens >= minTokensPerPoint) {
|
||||
// If we have enough cache points for all previous placements plus a new one, keep them all
|
||||
if (remainingCachePoints > previousPlacements.length) {
|
||||
// Keep all previous placements
|
||||
for (const placement of previousPlacements) {
|
||||
if (placement.index < totalMessages) {
|
||||
placements.push(placement)
|
||||
}
|
||||
}
|
||||
|
||||
// Add a new placement for the new messages
|
||||
const newPlacement = this.findOptimalPlacementForRange(
|
||||
lastPreviousIndex + 1,
|
||||
totalMessages - 1,
|
||||
minTokensPerPoint,
|
||||
)
|
||||
|
||||
if (newPlacement) {
|
||||
placements.push(newPlacement)
|
||||
}
|
||||
} else {
|
||||
// We need to decide which previous cache points to keep and which to combine
|
||||
// Strategy: Compare the token count of new messages with the smallest combined token gap
|
||||
|
||||
// First, analyze the token distribution between previous cache points
|
||||
const tokensBetweenPlacements: number[] = []
|
||||
let startIdx = 0
|
||||
|
||||
for (const placement of previousPlacements) {
|
||||
const tokens = this.config.messages
|
||||
.slice(startIdx, placement.index + 1)
|
||||
.reduce((acc, curr) => acc + this.estimateTokenCount(curr), 0)
|
||||
|
||||
tokensBetweenPlacements.push(tokens)
|
||||
startIdx = placement.index + 1
|
||||
}
|
||||
|
||||
// Find the two consecutive placements with the smallest token gap
|
||||
let smallestGapIndex = 0
|
||||
let smallestGap = Number.MAX_VALUE
|
||||
|
||||
for (let i = 0; i < tokensBetweenPlacements.length - 1; i++) {
|
||||
const gap = tokensBetweenPlacements[i] + tokensBetweenPlacements[i + 1]
|
||||
if (gap < smallestGap) {
|
||||
smallestGap = gap
|
||||
smallestGapIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
// Only combine cache points if it's beneficial
|
||||
// Compare the token count of new messages with the smallest combined token gap
|
||||
// Apply a required percentage increase to ensure reallocation is worth it
|
||||
const requiredPercentageIncrease = 1.2 // 20% increase required
|
||||
const requiredTokenThreshold = smallestGap * requiredPercentageIncrease
|
||||
|
||||
if (newMessagesTokens >= requiredTokenThreshold) {
|
||||
// It's beneficial to combine cache points since new messages have significantly more tokens
|
||||
logger.info("Combining cache points is beneficial", {
|
||||
ctx: "cache-strategy",
|
||||
newMessagesTokens,
|
||||
smallestGap,
|
||||
requiredTokenThreshold,
|
||||
action: "combining_cache_points",
|
||||
})
|
||||
|
||||
// Combine the two placements with the smallest gap
|
||||
for (let i = 0; i < previousPlacements.length; i++) {
|
||||
if (i !== smallestGapIndex && i !== smallestGapIndex + 1) {
|
||||
// Keep this placement
|
||||
if (previousPlacements[i].index < totalMessages) {
|
||||
placements.push(previousPlacements[i])
|
||||
}
|
||||
} else if (i === smallestGapIndex) {
|
||||
// Replace with a combined placement
|
||||
const combinedEndIndex = previousPlacements[i + 1].index
|
||||
|
||||
// Find the optimal placement within this combined range
|
||||
const startOfRange = i === 0 ? 0 : previousPlacements[i - 1].index + 1
|
||||
const combinedPlacement = this.findOptimalPlacementForRange(
|
||||
startOfRange,
|
||||
combinedEndIndex,
|
||||
minTokensPerPoint,
|
||||
)
|
||||
|
||||
if (combinedPlacement) {
|
||||
placements.push(combinedPlacement)
|
||||
}
|
||||
|
||||
// Skip the next placement as we've combined it
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// If we freed up a cache point, use it for the new messages
|
||||
if (placements.length < remainingCachePoints) {
|
||||
const newPlacement = this.findOptimalPlacementForRange(
|
||||
lastPreviousIndex + 1,
|
||||
totalMessages - 1,
|
||||
minTokensPerPoint,
|
||||
)
|
||||
|
||||
if (newPlacement) {
|
||||
placements.push(newPlacement)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's not beneficial to combine cache points
|
||||
// Keep all previous placements and don't add a new one for the new messages
|
||||
logger.info("Combining cache points is not beneficial", {
|
||||
ctx: "cache-strategy",
|
||||
newMessagesTokens,
|
||||
smallestGap,
|
||||
action: "keeping_existing_cache_points",
|
||||
})
|
||||
|
||||
// Keep all previous placements that are still valid
|
||||
for (const placement of previousPlacements) {
|
||||
if (placement.index < totalMessages) {
|
||||
placements.push(placement)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return placements
|
||||
} else {
|
||||
// New messages don't have enough tokens for a cache point
|
||||
// Keep all previous placements that are still valid
|
||||
for (const placement of previousPlacements) {
|
||||
if (placement.index < totalMessages) {
|
||||
placements.push(placement)
|
||||
}
|
||||
}
|
||||
|
||||
return placements
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the optimal placement for a cache point within a specified range of messages
|
||||
* Simply finds the last user message in the range
|
||||
*/
|
||||
private findOptimalPlacementForRange(
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
minTokensPerPoint: number,
|
||||
): CachePointPlacement | null {
|
||||
if (startIndex >= endIndex) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Find the last user message in the range
|
||||
let lastUserMessageIndex = -1
|
||||
for (let i = endIndex; i >= startIndex; i--) {
|
||||
if (this.config.messages[i].role === "user") {
|
||||
lastUserMessageIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUserMessageIndex >= 0) {
|
||||
// Calculate the total tokens covered from the previous cache point (or start of conversation)
|
||||
// to this cache point. This ensures tokensCovered represents the full span of tokens
|
||||
// that will be cached by this cache point.
|
||||
let totalTokensCovered = 0
|
||||
|
||||
// Find the previous cache point index
|
||||
const previousPlacements = this.config.previousCachePointPlacements || []
|
||||
let previousCachePointIndex = -1
|
||||
|
||||
for (const placement of previousPlacements) {
|
||||
if (placement.index < startIndex && placement.index > previousCachePointIndex) {
|
||||
previousCachePointIndex = placement.index
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate tokens from previous cache point (or start) to this cache point
|
||||
const tokenStartIndex = previousCachePointIndex + 1
|
||||
totalTokensCovered = this.config.messages
|
||||
.slice(tokenStartIndex, lastUserMessageIndex + 1)
|
||||
.reduce((acc, curr) => acc + this.estimateTokenCount(curr), 0)
|
||||
|
||||
// Guard clause: ensure we have enough tokens to justify a cache point
|
||||
if (totalTokensCovered < minTokensPerPoint) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
index: lastUserMessageIndex,
|
||||
type: "message",
|
||||
tokensCovered: totalTokensCovered,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Format result without cache points
|
||||
*
|
||||
* @returns Cache result without cache points
|
||||
*/
|
||||
private formatWithoutCachePoints(): CacheResult {
|
||||
const systemBlocks: SystemContentBlock[] = this.config.systemPrompt
|
||||
? [{ text: this.config.systemPrompt } as unknown as SystemContentBlock]
|
||||
: []
|
||||
|
||||
return this.formatResult(systemBlocks, this.messagesToContentBlocks(this.config.messages))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { SystemContentBlock, Message } from "@aws-sdk/client-bedrock-runtime"
|
||||
|
||||
/**
|
||||
* Information about a model's capabilities and constraints
|
||||
*/
|
||||
export interface ModelInfo {
|
||||
/** Maximum number of tokens the model can generate */
|
||||
maxTokens: number
|
||||
/** Maximum context window size in tokens */
|
||||
contextWindow: number
|
||||
/** Whether the model supports prompt caching */
|
||||
supportsPromptCache: boolean
|
||||
/** Maximum number of cache points supported by the model */
|
||||
maxCachePoints: number
|
||||
/** Minimum number of tokens required for a cache point */
|
||||
minTokensPerCachePoint: number
|
||||
/** Fields that can be cached */
|
||||
cachableFields: Array<"system" | "messages" | "tools">
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache point definition
|
||||
*/
|
||||
export interface CachePoint {
|
||||
/** Type of cache point */
|
||||
type: "default"
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of cache strategy application
|
||||
*/
|
||||
export interface CacheResult {
|
||||
/** System content blocks */
|
||||
system: SystemContentBlock[]
|
||||
/** Message content blocks */
|
||||
messages: Message[]
|
||||
/** Cache point placements for messages (for maintaining consistency across consecutive messages) */
|
||||
messageCachePointPlacements?: CachePointPlacement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the position and metadata for a cache point
|
||||
*/
|
||||
export interface CachePointPlacement {
|
||||
/** Where to insert the cache point */
|
||||
index: number
|
||||
/** Type of cache point */
|
||||
type: "system" | "message"
|
||||
/** Number of tokens this cache point covers */
|
||||
tokensCovered: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the caching strategy
|
||||
*/
|
||||
export interface CacheStrategyConfig {
|
||||
/** Model information */
|
||||
modelInfo: ModelInfo
|
||||
/** System prompt text */
|
||||
systemPrompt?: string
|
||||
/** Messages to process */
|
||||
messages: Anthropic.Messages.MessageParam[]
|
||||
/** Whether to use prompt caching */
|
||||
usePromptCache: boolean
|
||||
/** Previous cache point placements (for maintaining consistency across consecutive messages) */
|
||||
previousCachePointPlacements?: CachePointPlacement[]
|
||||
}
|
||||
|
|
@ -450,6 +450,7 @@
|
|||
"clean": "rimraf README.md CHANGELOG.md LICENSE dist logs mock .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.50",
|
||||
"@ai-sdk/cerebras": "^1.0.0",
|
||||
"@ai-sdk/deepseek": "^2.0.14",
|
||||
"@ai-sdk/fireworks": "^2.0.26",
|
||||
|
|
@ -458,8 +459,6 @@
|
|||
"@ai-sdk/groq": "^3.0.19",
|
||||
"@ai-sdk/mistral": "^3.0.0",
|
||||
"@ai-sdk/xai": "^3.0.46",
|
||||
"sambanova-ai-provider": "^1.2.2",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.10.2",
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.7.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
|
||||
|
|
@ -518,6 +517,7 @@
|
|||
"puppeteer-core": "^23.4.0",
|
||||
"reconnecting-eventsource": "^1.6.4",
|
||||
"safe-stable-stringify": "^2.5.0",
|
||||
"sambanova-ai-provider": "^1.2.2",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"say": "^0.16.0",
|
||||
"semver-compare": "^1.0.0",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue