amazon-bedrock.test.ts 13 KB

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