provider-amazon-bedrock.test.ts 18 KB

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