provider-amazon-bedrock.test.ts 18 KB

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