amazon-bedrock.test.ts 14 KB

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