amazon-bedrock.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. import { test, expect, mock, describe } from "bun:test"
  2. import path from "path"
  3. import { unlink } from "fs/promises"
  4. // === Mocks ===
  5. // These mocks are required because Provider.list() triggers:
  6. // 1. BunProc.install("@aws-sdk/credential-providers") - in bedrock custom loader
  7. // 2. Plugin.list() which calls BunProc.install() for default plugins
  8. // Without mocks, these would attempt real package installations that timeout in tests.
  9. mock.module("../../src/bun/index", () => ({
  10. BunProc: {
  11. install: async (pkg: string, _version?: string) => {
  12. // Return package name without version for mocking
  13. const lastAtIndex = pkg.lastIndexOf("@")
  14. return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg
  15. },
  16. run: async () => {
  17. throw new Error("BunProc.run should not be called in tests")
  18. },
  19. which: () => process.execPath,
  20. InstallFailedError: class extends Error {},
  21. },
  22. }))
  23. mock.module("@aws-sdk/credential-providers", () => ({
  24. fromNodeProviderChain: () => async () => ({
  25. accessKeyId: "mock-access-key-id",
  26. secretAccessKey: "mock-secret-access-key",
  27. }),
  28. }))
  29. const mockPlugin = () => ({})
  30. mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
  31. mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
  32. mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin }))
  33. // Import after mocks are set up
  34. const { tmpdir } = await import("../fixture/fixture")
  35. const { Instance } = await import("../../src/project/instance")
  36. const { Provider } = await import("../../src/provider/provider")
  37. const { Env } = await import("../../src/env")
  38. const { Global } = await import("../../src/global")
  39. test("Bedrock: config region takes precedence over AWS_REGION env var", async () => {
  40. await using tmp = await tmpdir({
  41. init: async (dir) => {
  42. await Bun.write(
  43. path.join(dir, "opencode.json"),
  44. JSON.stringify({
  45. $schema: "https://opencode.ai/config.json",
  46. provider: {
  47. "amazon-bedrock": {
  48. options: {
  49. region: "eu-west-1",
  50. },
  51. },
  52. },
  53. }),
  54. )
  55. },
  56. })
  57. await Instance.provide({
  58. directory: tmp.path,
  59. init: async () => {
  60. Env.set("AWS_REGION", "us-east-1")
  61. Env.set("AWS_PROFILE", "default")
  62. },
  63. fn: async () => {
  64. const providers = await Provider.list()
  65. expect(providers["amazon-bedrock"]).toBeDefined()
  66. expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
  67. },
  68. })
  69. })
  70. test("Bedrock: falls back to AWS_REGION env var when no config region", async () => {
  71. await using tmp = await tmpdir({
  72. init: async (dir) => {
  73. await Bun.write(
  74. path.join(dir, "opencode.json"),
  75. JSON.stringify({
  76. $schema: "https://opencode.ai/config.json",
  77. }),
  78. )
  79. },
  80. })
  81. await Instance.provide({
  82. directory: tmp.path,
  83. init: async () => {
  84. Env.set("AWS_REGION", "eu-west-1")
  85. Env.set("AWS_PROFILE", "default")
  86. },
  87. fn: async () => {
  88. const providers = await Provider.list()
  89. expect(providers["amazon-bedrock"]).toBeDefined()
  90. expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
  91. },
  92. })
  93. })
  94. test("Bedrock: loads when bearer token from auth.json is present", async () => {
  95. await using tmp = await tmpdir({
  96. init: async (dir) => {
  97. await Bun.write(
  98. path.join(dir, "opencode.json"),
  99. JSON.stringify({
  100. $schema: "https://opencode.ai/config.json",
  101. provider: {
  102. "amazon-bedrock": {
  103. options: {
  104. region: "eu-west-1",
  105. },
  106. },
  107. },
  108. }),
  109. )
  110. },
  111. })
  112. const authPath = path.join(Global.Path.data, "auth.json")
  113. // Save original auth.json if it exists
  114. let originalAuth: string | undefined
  115. try {
  116. originalAuth = await Bun.file(authPath).text()
  117. } catch {
  118. // File doesn't exist, that's fine
  119. }
  120. try {
  121. // Write test auth.json
  122. await Bun.write(
  123. authPath,
  124. JSON.stringify({
  125. "amazon-bedrock": {
  126. type: "api",
  127. key: "test-bearer-token",
  128. },
  129. }),
  130. )
  131. await Instance.provide({
  132. directory: tmp.path,
  133. init: async () => {
  134. Env.set("AWS_PROFILE", "")
  135. Env.set("AWS_ACCESS_KEY_ID", "")
  136. Env.set("AWS_BEARER_TOKEN_BEDROCK", "")
  137. },
  138. fn: async () => {
  139. const providers = await Provider.list()
  140. expect(providers["amazon-bedrock"]).toBeDefined()
  141. expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
  142. },
  143. })
  144. } finally {
  145. // Restore original or delete
  146. if (originalAuth !== undefined) {
  147. await Bun.write(authPath, originalAuth)
  148. } else {
  149. try {
  150. await unlink(authPath)
  151. } catch {
  152. // Ignore errors if file doesn't exist
  153. }
  154. }
  155. }
  156. })
  157. test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async () => {
  158. await using tmp = await tmpdir({
  159. init: async (dir) => {
  160. await Bun.write(
  161. path.join(dir, "opencode.json"),
  162. JSON.stringify({
  163. $schema: "https://opencode.ai/config.json",
  164. provider: {
  165. "amazon-bedrock": {
  166. options: {
  167. profile: "my-custom-profile",
  168. region: "us-east-1",
  169. },
  170. },
  171. },
  172. }),
  173. )
  174. },
  175. })
  176. await Instance.provide({
  177. directory: tmp.path,
  178. init: async () => {
  179. Env.set("AWS_PROFILE", "default")
  180. Env.set("AWS_ACCESS_KEY_ID", "test-key-id")
  181. },
  182. fn: async () => {
  183. const providers = await Provider.list()
  184. expect(providers["amazon-bedrock"]).toBeDefined()
  185. expect(providers["amazon-bedrock"].options?.region).toBe("us-east-1")
  186. },
  187. })
  188. })
  189. test("Bedrock: includes custom endpoint in options when specified", async () => {
  190. await using tmp = await tmpdir({
  191. init: async (dir) => {
  192. await Bun.write(
  193. path.join(dir, "opencode.json"),
  194. JSON.stringify({
  195. $schema: "https://opencode.ai/config.json",
  196. provider: {
  197. "amazon-bedrock": {
  198. options: {
  199. endpoint: "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
  200. },
  201. },
  202. },
  203. }),
  204. )
  205. },
  206. })
  207. await Instance.provide({
  208. directory: tmp.path,
  209. init: async () => {
  210. Env.set("AWS_PROFILE", "default")
  211. },
  212. fn: async () => {
  213. const providers = await Provider.list()
  214. expect(providers["amazon-bedrock"]).toBeDefined()
  215. expect(providers["amazon-bedrock"].options?.endpoint).toBe(
  216. "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
  217. )
  218. },
  219. })
  220. })
  221. test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () => {
  222. await using tmp = await tmpdir({
  223. init: async (dir) => {
  224. await Bun.write(
  225. path.join(dir, "opencode.json"),
  226. JSON.stringify({
  227. $schema: "https://opencode.ai/config.json",
  228. provider: {
  229. "amazon-bedrock": {
  230. options: {
  231. region: "us-east-1",
  232. },
  233. },
  234. },
  235. }),
  236. )
  237. },
  238. })
  239. await Instance.provide({
  240. directory: tmp.path,
  241. init: async () => {
  242. Env.set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
  243. Env.set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
  244. Env.set("AWS_PROFILE", "")
  245. Env.set("AWS_ACCESS_KEY_ID", "")
  246. },
  247. fn: async () => {
  248. const providers = await Provider.list()
  249. expect(providers["amazon-bedrock"]).toBeDefined()
  250. expect(providers["amazon-bedrock"].options?.region).toBe("us-east-1")
  251. },
  252. })
  253. })
  254. // Tests for cross-region inference profile prefix handling
  255. // Models from models.dev may come with prefixes already (e.g., us., eu., global.)
  256. // These should NOT be double-prefixed when passed to the SDK
  257. test("Bedrock: model with us. prefix should not be double-prefixed", async () => {
  258. await using tmp = await tmpdir({
  259. init: async (dir) => {
  260. await Bun.write(
  261. path.join(dir, "opencode.json"),
  262. JSON.stringify({
  263. $schema: "https://opencode.ai/config.json",
  264. provider: {
  265. "amazon-bedrock": {
  266. options: {
  267. region: "us-east-1",
  268. },
  269. models: {
  270. "us.anthropic.claude-opus-4-5-20251101-v1:0": {
  271. name: "Claude Opus 4.5 (US)",
  272. },
  273. },
  274. },
  275. },
  276. }),
  277. )
  278. },
  279. })
  280. await Instance.provide({
  281. directory: tmp.path,
  282. init: async () => {
  283. Env.set("AWS_PROFILE", "default")
  284. },
  285. fn: async () => {
  286. const providers = await Provider.list()
  287. expect(providers["amazon-bedrock"]).toBeDefined()
  288. // The model should exist with the us. prefix
  289. expect(providers["amazon-bedrock"].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
  290. },
  291. })
  292. })
  293. test("Bedrock: model with global. prefix should not be prefixed", async () => {
  294. await using tmp = await tmpdir({
  295. init: async (dir) => {
  296. await Bun.write(
  297. path.join(dir, "opencode.json"),
  298. JSON.stringify({
  299. $schema: "https://opencode.ai/config.json",
  300. provider: {
  301. "amazon-bedrock": {
  302. options: {
  303. region: "us-east-1",
  304. },
  305. models: {
  306. "global.anthropic.claude-opus-4-5-20251101-v1:0": {
  307. name: "Claude Opus 4.5 (Global)",
  308. },
  309. },
  310. },
  311. },
  312. }),
  313. )
  314. },
  315. })
  316. await Instance.provide({
  317. directory: tmp.path,
  318. init: async () => {
  319. Env.set("AWS_PROFILE", "default")
  320. },
  321. fn: async () => {
  322. const providers = await Provider.list()
  323. expect(providers["amazon-bedrock"]).toBeDefined()
  324. expect(providers["amazon-bedrock"].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
  325. },
  326. })
  327. })
  328. test("Bedrock: model with eu. prefix should not be double-prefixed", async () => {
  329. await using tmp = await tmpdir({
  330. init: async (dir) => {
  331. await Bun.write(
  332. path.join(dir, "opencode.json"),
  333. JSON.stringify({
  334. $schema: "https://opencode.ai/config.json",
  335. provider: {
  336. "amazon-bedrock": {
  337. options: {
  338. region: "eu-west-1",
  339. },
  340. models: {
  341. "eu.anthropic.claude-opus-4-5-20251101-v1:0": {
  342. name: "Claude Opus 4.5 (EU)",
  343. },
  344. },
  345. },
  346. },
  347. }),
  348. )
  349. },
  350. })
  351. await Instance.provide({
  352. directory: tmp.path,
  353. init: async () => {
  354. Env.set("AWS_PROFILE", "default")
  355. },
  356. fn: async () => {
  357. const providers = await Provider.list()
  358. expect(providers["amazon-bedrock"]).toBeDefined()
  359. expect(providers["amazon-bedrock"].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
  360. },
  361. })
  362. })
  363. test("Bedrock: model without prefix in US region should get us. prefix added", async () => {
  364. await using tmp = await tmpdir({
  365. init: async (dir) => {
  366. await Bun.write(
  367. path.join(dir, "opencode.json"),
  368. JSON.stringify({
  369. $schema: "https://opencode.ai/config.json",
  370. provider: {
  371. "amazon-bedrock": {
  372. options: {
  373. region: "us-east-1",
  374. },
  375. models: {
  376. "anthropic.claude-opus-4-5-20251101-v1:0": {
  377. name: "Claude Opus 4.5",
  378. },
  379. },
  380. },
  381. },
  382. }),
  383. )
  384. },
  385. })
  386. await Instance.provide({
  387. directory: tmp.path,
  388. init: async () => {
  389. Env.set("AWS_PROFILE", "default")
  390. },
  391. fn: async () => {
  392. const providers = await Provider.list()
  393. expect(providers["amazon-bedrock"]).toBeDefined()
  394. // Non-prefixed model should still be registered
  395. expect(providers["amazon-bedrock"].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
  396. },
  397. })
  398. })
  399. // Direct unit tests for cross-region inference profile prefix handling
  400. // These test the prefix detection logic used in getModel
  401. describe("Bedrock cross-region prefix detection", () => {
  402. const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
  403. test("should detect global. prefix", () => {
  404. const modelID = "global.anthropic.claude-opus-4-5-20251101-v1:0"
  405. const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
  406. expect(hasPrefix).toBe(true)
  407. })
  408. test("should detect us. prefix", () => {
  409. const modelID = "us.anthropic.claude-opus-4-5-20251101-v1:0"
  410. const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
  411. expect(hasPrefix).toBe(true)
  412. })
  413. test("should detect eu. prefix", () => {
  414. const modelID = "eu.anthropic.claude-opus-4-5-20251101-v1:0"
  415. const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
  416. expect(hasPrefix).toBe(true)
  417. })
  418. test("should detect jp. prefix", () => {
  419. const modelID = "jp.anthropic.claude-sonnet-4-20250514-v1:0"
  420. const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
  421. expect(hasPrefix).toBe(true)
  422. })
  423. test("should detect apac. prefix", () => {
  424. const modelID = "apac.anthropic.claude-sonnet-4-20250514-v1:0"
  425. const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
  426. expect(hasPrefix).toBe(true)
  427. })
  428. test("should detect au. prefix", () => {
  429. const modelID = "au.anthropic.claude-sonnet-4-5-20250929-v1:0"
  430. const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
  431. expect(hasPrefix).toBe(true)
  432. })
  433. test("should NOT detect prefix for non-prefixed model", () => {
  434. const modelID = "anthropic.claude-opus-4-5-20251101-v1:0"
  435. const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
  436. expect(hasPrefix).toBe(false)
  437. })
  438. test("should NOT detect prefix for amazon nova models", () => {
  439. const modelID = "amazon.nova-pro-v1:0"
  440. const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
  441. expect(hasPrefix).toBe(false)
  442. })
  443. test("should NOT detect prefix for cohere models", () => {
  444. const modelID = "cohere.command-r-plus-v1:0"
  445. const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
  446. expect(hasPrefix).toBe(false)
  447. })
  448. })