provider-amazon-bedrock.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import { describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import { PluginV2 } from "@opencode-ai/core/plugin"
  4. import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
  5. import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
  6. function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
  7. const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
  8. return (language as { config: { baseUrl: () => string } }).config.baseUrl()
  9. }
  10. function bedrockFetch(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
  11. const language = (sdk as { languageModel: (id: string) => unknown }).languageModel(modelID)
  12. return (
  13. language as { config: { fetch: (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response> } }
  14. ).config.fetch
  15. }
  16. describe("AmazonBedrockPlugin", () => {
  17. it.effect("moves endpoint option to endpoint URL", () =>
  18. Effect.gen(function* () {
  19. const plugin = yield* PluginV2.Service
  20. yield* plugin.add(AmazonBedrockPlugin)
  21. const result = yield* plugin.trigger(
  22. "provider.update",
  23. {},
  24. {
  25. provider: provider("amazon-bedrock", {
  26. options: {
  27. headers: {},
  28. body: {},
  29. aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} },
  30. },
  31. }),
  32. cancel: false,
  33. },
  34. )
  35. expect(result.provider.endpoint).toEqual({
  36. type: "aisdk",
  37. package: "test-provider",
  38. url: "https://bedrock.example",
  39. })
  40. expect(result.provider.options.aisdk.provider.endpoint).toBeUndefined()
  41. }),
  42. )
  43. it.effect("prefers endpoint over baseURL for SDK base URL", () =>
  44. withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
  45. Effect.gen(function* () {
  46. const plugin = yield* PluginV2.Service
  47. yield* plugin.add(AmazonBedrockPlugin)
  48. const result = yield* plugin.trigger(
  49. "aisdk.sdk",
  50. {
  51. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  52. package: "@ai-sdk/amazon-bedrock",
  53. options: {
  54. name: "amazon-bedrock",
  55. bearerToken: "token",
  56. baseURL: "https://base.example",
  57. endpoint: "https://endpoint.example",
  58. region: "us-east-1",
  59. },
  60. },
  61. {},
  62. )
  63. expect(bedrockBaseURL(result.sdk)).toBe("https://endpoint.example")
  64. }),
  65. ),
  66. )
  67. it.effect("uses baseURL as SDK base URL", () =>
  68. withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () =>
  69. Effect.gen(function* () {
  70. const plugin = yield* PluginV2.Service
  71. yield* plugin.add(AmazonBedrockPlugin)
  72. const result = yield* plugin.trigger(
  73. "aisdk.sdk",
  74. {
  75. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  76. package: "@ai-sdk/amazon-bedrock",
  77. options: {
  78. name: "amazon-bedrock",
  79. bearerToken: "token",
  80. baseURL: "https://base.example",
  81. region: "us-east-1",
  82. },
  83. },
  84. {},
  85. )
  86. expect(bedrockBaseURL(result.sdk)).toBe("https://base.example")
  87. }),
  88. ),
  89. )
  90. it.effect("creates SDK without explicit credential env so the default AWS chain can resolve credentials", () =>
  91. withEnv(
  92. {
  93. AWS_ACCESS_KEY_ID: undefined,
  94. AWS_BEARER_TOKEN_BEDROCK: undefined,
  95. AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined,
  96. AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined,
  97. AWS_PROFILE: undefined,
  98. AWS_REGION: undefined,
  99. AWS_WEB_IDENTITY_TOKEN_FILE: undefined,
  100. },
  101. () =>
  102. Effect.gen(function* () {
  103. const plugin = yield* PluginV2.Service
  104. yield* plugin.add(AmazonBedrockPlugin)
  105. const result = yield* plugin.trigger(
  106. "aisdk.sdk",
  107. {
  108. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  109. package: "@ai-sdk/amazon-bedrock",
  110. options: { name: "amazon-bedrock" },
  111. },
  112. {},
  113. )
  114. expect(result.sdk).toBeDefined()
  115. expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com")
  116. }),
  117. ),
  118. )
  119. it.effect("uses config region over AWS_REGION for SDK base URL", () =>
  120. withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () =>
  121. Effect.gen(function* () {
  122. const plugin = yield* PluginV2.Service
  123. yield* plugin.add(AmazonBedrockPlugin)
  124. const result = yield* plugin.trigger(
  125. "aisdk.sdk",
  126. {
  127. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  128. package: "@ai-sdk/amazon-bedrock",
  129. options: { name: "amazon-bedrock", region: "eu-west-1" },
  130. },
  131. {},
  132. )
  133. expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com")
  134. }),
  135. ),
  136. )
  137. it.effect("uses AWS_REGION for SDK base URL when config region is absent", () =>
  138. withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () =>
  139. Effect.gen(function* () {
  140. const plugin = yield* PluginV2.Service
  141. yield* plugin.add(AmazonBedrockPlugin)
  142. const result = yield* plugin.trigger(
  143. "aisdk.sdk",
  144. {
  145. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  146. package: "@ai-sdk/amazon-bedrock",
  147. options: { name: "amazon-bedrock" },
  148. },
  149. {},
  150. )
  151. expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com")
  152. }),
  153. ),
  154. )
  155. it.effect("defaults SDK region to us-east-1", () =>
  156. withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () =>
  157. Effect.gen(function* () {
  158. const plugin = yield* PluginV2.Service
  159. yield* plugin.add(AmazonBedrockPlugin)
  160. const result = yield* plugin.trigger(
  161. "aisdk.sdk",
  162. {
  163. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  164. package: "@ai-sdk/amazon-bedrock",
  165. options: { name: "amazon-bedrock" },
  166. },
  167. {},
  168. )
  169. expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com")
  170. }),
  171. ),
  172. )
  173. it.effect("loads bearer token option into env and uses bearer auth", () =>
  174. withEnv({ AWS_ACCESS_KEY_ID: undefined, AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined }, () =>
  175. Effect.gen(function* () {
  176. const plugin = yield* PluginV2.Service
  177. const headers: Array<string | null> = []
  178. yield* plugin.add(AmazonBedrockPlugin)
  179. const result = yield* plugin.trigger(
  180. "aisdk.sdk",
  181. {
  182. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  183. package: "@ai-sdk/amazon-bedrock",
  184. options: {
  185. name: "amazon-bedrock",
  186. bearerToken: "option-token",
  187. fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
  188. headers.push(new Headers(init?.headers).get("Authorization"))
  189. return new Response("{}")
  190. },
  191. },
  192. },
  193. {},
  194. )
  195. yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" }))
  196. expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("option-token")
  197. expect(headers).toEqual(["Bearer option-token"])
  198. }),
  199. ),
  200. )
  201. it.effect("prefers bearer token env over bearer token option", () =>
  202. withEnv({ AWS_BEARER_TOKEN_BEDROCK: "env-token" }, () =>
  203. Effect.gen(function* () {
  204. const plugin = yield* PluginV2.Service
  205. const headers: Array<string | null> = []
  206. yield* plugin.add(AmazonBedrockPlugin)
  207. const result = yield* plugin.trigger(
  208. "aisdk.sdk",
  209. {
  210. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  211. package: "@ai-sdk/amazon-bedrock",
  212. options: {
  213. name: "amazon-bedrock",
  214. bearerToken: "option-token",
  215. fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
  216. headers.push(new Headers(init?.headers).get("Authorization"))
  217. return new Response("{}")
  218. },
  219. },
  220. },
  221. {},
  222. )
  223. yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" }))
  224. expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("env-token")
  225. expect(headers).toEqual(["Bearer env-token"])
  226. }),
  227. ),
  228. )
  229. it.effect("uses SigV4 credential env when bearer token is absent", () =>
  230. withEnv(
  231. {
  232. AWS_ACCESS_KEY_ID: "test-access-key",
  233. AWS_BEARER_TOKEN_BEDROCK: undefined,
  234. AWS_REGION: "us-east-1",
  235. AWS_SECRET_ACCESS_KEY: "test-secret-key",
  236. AWS_SESSION_TOKEN: "test-session-token",
  237. },
  238. () =>
  239. Effect.gen(function* () {
  240. const plugin = yield* PluginV2.Service
  241. const headers: Array<string | null> = []
  242. yield* plugin.add(AmazonBedrockPlugin)
  243. const result = yield* plugin.trigger(
  244. "aisdk.sdk",
  245. {
  246. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  247. package: "@ai-sdk/amazon-bedrock",
  248. options: {
  249. name: "amazon-bedrock",
  250. fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
  251. headers.push(new Headers(init?.headers).get("Authorization"))
  252. return new Response("{}")
  253. },
  254. },
  255. },
  256. {},
  257. )
  258. yield* Effect.promise(() =>
  259. bedrockFetch(result.sdk)("https://bedrock-runtime.us-east-1.amazonaws.com/model/test/invoke", {
  260. body: "{}",
  261. method: "POST",
  262. }),
  263. )
  264. expect(headers[0]?.startsWith("AWS4-HMAC-SHA256 ")).toBe(true)
  265. }),
  266. ),
  267. )
  268. it.effect("applies legacy cross-region inference prefixes", () =>
  269. Effect.gen(function* () {
  270. const plugin = yield* PluginV2.Service
  271. const calls: string[] = []
  272. yield* plugin.add(AmazonBedrockPlugin)
  273. yield* plugin.trigger(
  274. "aisdk.language",
  275. {
  276. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  277. sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
  278. options: {},
  279. },
  280. {},
  281. )
  282. yield* plugin.trigger(
  283. "aisdk.language",
  284. {
  285. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  286. sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
  287. options: { region: "eu-west-1" },
  288. },
  289. {},
  290. )
  291. yield* plugin.trigger(
  292. "aisdk.language",
  293. {
  294. model: model("amazon-bedrock", "global.anthropic.claude-sonnet-4-5"),
  295. sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
  296. options: { region: "eu-west-1" },
  297. },
  298. {},
  299. )
  300. yield* plugin.trigger(
  301. "aisdk.language",
  302. {
  303. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  304. sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
  305. options: { region: "ap-northeast-1" },
  306. },
  307. {},
  308. )
  309. yield* plugin.trigger(
  310. "aisdk.language",
  311. {
  312. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  313. sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
  314. options: { region: "ap-southeast-2" },
  315. },
  316. {},
  317. )
  318. expect(calls).toEqual([
  319. "languageModel:us.anthropic.claude-sonnet-4-5",
  320. "languageModel:eu.anthropic.claude-sonnet-4-5",
  321. "languageModel:global.anthropic.claude-sonnet-4-5",
  322. "languageModel:jp.anthropic.claude-sonnet-4-5",
  323. "languageModel:au.anthropic.claude-sonnet-4-5",
  324. ])
  325. }),
  326. )
  327. it.effect("uses AWS_REGION for language prefixes when region option is absent", () =>
  328. withEnv({ AWS_REGION: "eu-west-1" }, () =>
  329. Effect.gen(function* () {
  330. const plugin = yield* PluginV2.Service
  331. const calls: string[] = []
  332. yield* plugin.add(AmazonBedrockPlugin)
  333. yield* plugin.trigger(
  334. "aisdk.language",
  335. {
  336. model: model("amazon-bedrock", "anthropic.claude-sonnet-4-5"),
  337. sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
  338. options: {},
  339. },
  340. {},
  341. )
  342. expect(calls).toEqual(["languageModel:eu.anthropic.claude-sonnet-4-5"])
  343. }),
  344. ),
  345. )
  346. it.effect("applies the full legacy cross-region prefix matrix", () =>
  347. Effect.gen(function* () {
  348. const plugin = yield* PluginV2.Service
  349. const calls: string[] = []
  350. const cases = [
  351. { region: "us-east-1", modelID: "amazon.nova-micro-v1:0", expected: "us.amazon.nova-micro-v1:0" },
  352. { region: "us-east-1", modelID: "amazon.nova-lite-v1:0", expected: "us.amazon.nova-lite-v1:0" },
  353. { region: "us-east-1", modelID: "amazon.nova-pro-v1:0", expected: "us.amazon.nova-pro-v1:0" },
  354. { region: "us-east-1", modelID: "amazon.nova-premier-v1:0", expected: "us.amazon.nova-premier-v1:0" },
  355. { region: "us-east-1", modelID: "amazon.nova-2-lite-v1:0", expected: "us.amazon.nova-2-lite-v1:0" },
  356. { region: "us-east-1", modelID: "anthropic.claude-sonnet-4-5", expected: "us.anthropic.claude-sonnet-4-5" },
  357. { region: "us-east-1", modelID: "deepseek.r1-v1:0", expected: "us.deepseek.r1-v1:0" },
  358. { region: "us-gov-west-1", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" },
  359. { region: "us-east-1", modelID: "cohere.command-r-plus-v1:0", expected: "cohere.command-r-plus-v1:0" },
  360. { region: "eu-west-1", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
  361. { region: "eu-west-2", modelID: "amazon.nova-lite-v1:0", expected: "eu.amazon.nova-lite-v1:0" },
  362. { region: "eu-west-3", modelID: "amazon.nova-micro-v1:0", expected: "eu.amazon.nova-micro-v1:0" },
  363. {
  364. region: "eu-north-1",
  365. modelID: "meta.llama3-70b-instruct-v1:0",
  366. expected: "eu.meta.llama3-70b-instruct-v1:0",
  367. },
  368. { region: "eu-central-1", modelID: "mistral.pixtral-large-v1:0", expected: "eu.mistral.pixtral-large-v1:0" },
  369. { region: "eu-south-1", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
  370. { region: "eu-south-2", modelID: "anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
  371. { region: "eu-central-2", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" },
  372. { region: "eu-west-1", modelID: "cohere.command-r-plus-v1:0", expected: "cohere.command-r-plus-v1:0" },
  373. {
  374. region: "ap-southeast-2",
  375. modelID: "anthropic.claude-sonnet-4-5",
  376. expected: "au.anthropic.claude-sonnet-4-5",
  377. },
  378. {
  379. region: "ap-southeast-4",
  380. modelID: "anthropic.claude-haiku-v1:0",
  381. expected: "au.anthropic.claude-haiku-v1:0",
  382. },
  383. { region: "ap-southeast-2", modelID: "anthropic.claude-opus-4", expected: "apac.anthropic.claude-opus-4" },
  384. {
  385. region: "ap-northeast-1",
  386. modelID: "anthropic.claude-sonnet-4-5",
  387. expected: "jp.anthropic.claude-sonnet-4-5",
  388. },
  389. { region: "ap-northeast-1", modelID: "amazon.nova-pro-v1:0", expected: "jp.amazon.nova-pro-v1:0" },
  390. { region: "ap-south-1", modelID: "anthropic.claude-sonnet-4-5", expected: "apac.anthropic.claude-sonnet-4-5" },
  391. { region: "ap-south-1", modelID: "amazon.nova-lite-v1:0", expected: "apac.amazon.nova-lite-v1:0" },
  392. { region: "ca-central-1", modelID: "anthropic.claude-sonnet-4-5", expected: "anthropic.claude-sonnet-4-5" },
  393. {
  394. region: "us-east-1",
  395. modelID: "global.anthropic.claude-sonnet-4-5",
  396. expected: "global.anthropic.claude-sonnet-4-5",
  397. },
  398. { region: "us-east-1", modelID: "us.anthropic.claude-sonnet-4-5", expected: "us.anthropic.claude-sonnet-4-5" },
  399. { region: "eu-west-1", modelID: "eu.anthropic.claude-sonnet-4-5", expected: "eu.anthropic.claude-sonnet-4-5" },
  400. {
  401. region: "ap-northeast-1",
  402. modelID: "jp.anthropic.claude-sonnet-4-5",
  403. expected: "jp.anthropic.claude-sonnet-4-5",
  404. },
  405. {
  406. region: "ap-south-1",
  407. modelID: "apac.anthropic.claude-sonnet-4-5",
  408. expected: "apac.anthropic.claude-sonnet-4-5",
  409. },
  410. {
  411. region: "ap-southeast-2",
  412. modelID: "au.anthropic.claude-sonnet-4-5",
  413. expected: "au.anthropic.claude-sonnet-4-5",
  414. },
  415. ]
  416. yield* plugin.add(AmazonBedrockPlugin)
  417. for (const item of cases) {
  418. yield* plugin.trigger(
  419. "aisdk.language",
  420. {
  421. model: model("amazon-bedrock", item.modelID),
  422. sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
  423. options: { region: item.region },
  424. },
  425. {},
  426. )
  427. }
  428. expect(calls).toEqual(cases.map((item) => `languageModel:${item.expected}`))
  429. }),
  430. )
  431. it.effect("ignores non-Bedrock providers for language selection", () =>
  432. Effect.gen(function* () {
  433. const plugin = yield* PluginV2.Service
  434. const calls: string[] = []
  435. yield* plugin.add(AmazonBedrockPlugin)
  436. const result = yield* plugin.trigger(
  437. "aisdk.language",
  438. {
  439. model: model("openai", "anthropic.claude-sonnet-4-5"),
  440. sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
  441. options: { region: "eu-west-1" },
  442. },
  443. {},
  444. )
  445. expect(calls).toEqual([])
  446. expect(result.language).toBeUndefined()
  447. }),
  448. )
  449. })