amazon-bedrock.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. import { afterEach, describe, expect, test } from "bun:test"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { Effect } from "effect"
  4. import path from "path"
  5. import { unlink } from "fs/promises"
  6. import { Global } from "@kirincode-ai/core/global"
  7. import { Filesystem } from "@/util/filesystem"
  8. import { Env } from "../../src/env"
  9. import { Provider } from "@/provider/provider"
  10. import { disposeAllInstances } from "../fixture/fixture"
  11. import { testEffect } from "../lib/effect"
  12. import { ProviderV2 } from "@kirincode-ai/core/provider"
  13. import { ModelV2 } from "@kirincode-ai/core/model"
  14. const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node])))
  15. const originalEnv = new Map<string, string | undefined>()
  16. const set = (k: string, v: string) =>
  17. Effect.gen(function* () {
  18. if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
  19. process.env[k] = v
  20. yield* Env.use.set(k, v)
  21. })
  22. afterEach(async () => {
  23. for (const [key, value] of originalEnv) {
  24. if (value === undefined) delete process.env[key]
  25. else process.env[key] = value
  26. }
  27. originalEnv.clear()
  28. await disposeAllInstances()
  29. })
  30. const list = Provider.use.list()
  31. const mantleModelConfig = {
  32. provider: { npm: "@ai-sdk/amazon-bedrock/mantle" },
  33. limit: { context: 272_000, output: 32_000 },
  34. modalities: {
  35. input: ["text", "image", "pdf"] as Array<"text" | "image" | "pdf">,
  36. output: ["text"] as Array<"text">,
  37. },
  38. }
  39. const withAuthJson = (contents: string) =>
  40. Effect.acquireRelease(
  41. Effect.promise(async () => {
  42. const authPath = path.join(Global.Path.data, "auth.json")
  43. let original: string | undefined
  44. try {
  45. original = await Filesystem.readText(authPath)
  46. } catch {
  47. original = undefined
  48. }
  49. await Filesystem.write(authPath, contents)
  50. return { authPath, original }
  51. }),
  52. ({ authPath, original }) =>
  53. Effect.promise(async () => {
  54. if (original !== undefined) {
  55. await Filesystem.write(authPath, original)
  56. return
  57. }
  58. await unlink(authPath).catch(() => undefined)
  59. }),
  60. )
  61. it.instance(
  62. "Bedrock: config region takes precedence over AWS_REGION env var",
  63. () =>
  64. Effect.gen(function* () {
  65. yield* set("AWS_REGION", "us-east-1")
  66. yield* set("AWS_PROFILE", "default")
  67. const providers = yield* list
  68. expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
  69. expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1")
  70. }),
  71. { config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } },
  72. )
  73. it.instance("Bedrock: falls back to AWS_REGION env var when no config region", () =>
  74. Effect.gen(function* () {
  75. yield* set("AWS_REGION", "eu-west-1")
  76. yield* set("AWS_PROFILE", "default")
  77. const providers = yield* list
  78. expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
  79. expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1")
  80. }),
  81. )
  82. it.instance(
  83. "Bedrock: loads when bearer token from auth.json is present",
  84. () =>
  85. Effect.gen(function* () {
  86. yield* withAuthJson(JSON.stringify({ "amazon-bedrock": { type: "api", key: "test-bearer-token" } }))
  87. yield* set("AWS_PROFILE", "")
  88. yield* set("AWS_ACCESS_KEY_ID", "")
  89. yield* set("AWS_BEARER_TOKEN_BEDROCK", "")
  90. const providers = yield* list
  91. expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
  92. expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1")
  93. }),
  94. { config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } },
  95. )
  96. it.instance(
  97. "Bedrock Mantle: GPT-5.5 uses Responses API and OpenAI base path",
  98. () =>
  99. Effect.gen(function* () {
  100. yield* set("AWS_REGION", "")
  101. yield* set("AWS_PROFILE", "")
  102. yield* set("AWS_ACCESS_KEY_ID", "")
  103. yield* set("AWS_BEARER_TOKEN_BEDROCK", "")
  104. const model = yield* Provider.use.getModel(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5"))
  105. const language = yield* Provider.use.getLanguage(model)
  106. expect((language as { provider: string }).provider).toBe("bedrock-mantle.responses")
  107. expect((language as { modelId: string }).modelId).toBe("openai.gpt-5.5")
  108. expect(
  109. (language as unknown as { config: { url: (input: { path: string; modelId: string }) => string } }).config.url({
  110. path: "/responses",
  111. modelId: "openai.gpt-5.5",
  112. }),
  113. ).toBe("https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses")
  114. }),
  115. {
  116. config: {
  117. provider: {
  118. "amazon-bedrock": {
  119. options: { region: "us-east-2", apiKey: "test-bearer-token" },
  120. models: {
  121. "openai.gpt-5.5": {
  122. ...mantleModelConfig,
  123. provider: {
  124. npm: "@ai-sdk/amazon-bedrock/mantle",
  125. api: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
  126. },
  127. },
  128. },
  129. },
  130. },
  131. },
  132. },
  133. )
  134. it.instance(
  135. "Bedrock Mantle: GPT OSS safeguard uses Chat Completions and Mantle base path",
  136. () =>
  137. Effect.gen(function* () {
  138. yield* set("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token")
  139. const model = yield* Provider.use.getModel(
  140. ProviderV2.ID.amazonBedrock,
  141. ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
  142. )
  143. const language = yield* Provider.use.getLanguage(model)
  144. expect((language as { provider: string }).provider).toBe("bedrock-mantle.chat")
  145. expect((language as { modelId: string }).modelId).toBe("openai.gpt-oss-safeguard-120b")
  146. expect(
  147. (language as unknown as { config: { url: (input: { path: string; modelId: string }) => string } }).config.url({
  148. path: "/chat/completions",
  149. modelId: "openai.gpt-oss-safeguard-120b",
  150. }),
  151. ).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions")
  152. }),
  153. {
  154. config: {
  155. provider: {
  156. "amazon-bedrock": {
  157. options: { region: "us-east-1" },
  158. models: { "openai.gpt-oss-safeguard-120b": mantleModelConfig },
  159. },
  160. },
  161. },
  162. },
  163. )
  164. it.instance(
  165. "Bedrock: config profile takes precedence over AWS_PROFILE env var",
  166. () =>
  167. Effect.gen(function* () {
  168. yield* set("AWS_PROFILE", "default")
  169. yield* set("AWS_ACCESS_KEY_ID", "test-key-id")
  170. const providers = yield* list
  171. expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
  172. expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("us-east-1")
  173. }),
  174. {
  175. config: {
  176. provider: { "amazon-bedrock": { options: { profile: "my-custom-profile", region: "us-east-1" } } },
  177. },
  178. },
  179. )
  180. it.instance(
  181. "Bedrock: includes custom endpoint in options when specified",
  182. () =>
  183. Effect.gen(function* () {
  184. yield* set("AWS_PROFILE", "default")
  185. const providers = yield* list
  186. expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
  187. expect(providers[ProviderV2.ID.amazonBedrock].options?.endpoint).toBe(
  188. "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
  189. )
  190. }),
  191. {
  192. config: {
  193. provider: {
  194. "amazon-bedrock": {
  195. options: { endpoint: "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com" },
  196. },
  197. },
  198. },
  199. },
  200. )
  201. it.instance(
  202. "Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present",
  203. () =>
  204. Effect.gen(function* () {
  205. yield* set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
  206. yield* set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
  207. yield* set("AWS_PROFILE", "")
  208. yield* set("AWS_ACCESS_KEY_ID", "")
  209. const providers = yield* list
  210. expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
  211. expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("us-east-1")
  212. }),
  213. { config: { provider: { "amazon-bedrock": { options: { region: "us-east-1" } } } } },
  214. )
  215. // Cross-region inference profile prefix handling.
  216. // Models from models.dev may come with prefixes already (e.g. us., eu., global.).
  217. // These should NOT be double-prefixed when passed to the SDK.
  218. it.instance(
  219. "Bedrock: model with us. prefix should not be double-prefixed",
  220. () =>
  221. Effect.gen(function* () {
  222. yield* set("AWS_PROFILE", "default")
  223. const providers = yield* list
  224. expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
  225. expect(providers[ProviderV2.ID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
  226. }),
  227. {
  228. config: {
  229. provider: {
  230. "amazon-bedrock": {
  231. options: { region: "us-east-1" },
  232. models: { "us.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (US)" } },
  233. },
  234. },
  235. },
  236. },
  237. )
  238. it.instance(
  239. "Bedrock: model with global. prefix should not be prefixed",
  240. () =>
  241. Effect.gen(function* () {
  242. yield* set("AWS_PROFILE", "default")
  243. const providers = yield* list
  244. expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
  245. expect(
  246. providers[ProviderV2.ID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"],
  247. ).toBeDefined()
  248. }),
  249. {
  250. config: {
  251. provider: {
  252. "amazon-bedrock": {
  253. options: { region: "us-east-1" },
  254. models: { "global.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (Global)" } },
  255. },
  256. },
  257. },
  258. },
  259. )
  260. it.instance(
  261. "Bedrock: model with eu. prefix should not be double-prefixed",
  262. () =>
  263. Effect.gen(function* () {
  264. yield* set("AWS_PROFILE", "default")
  265. const providers = yield* list
  266. expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
  267. expect(providers[ProviderV2.ID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
  268. }),
  269. {
  270. config: {
  271. provider: {
  272. "amazon-bedrock": {
  273. options: { region: "eu-west-1" },
  274. models: { "eu.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (EU)" } },
  275. },
  276. },
  277. },
  278. },
  279. )
  280. it.instance(
  281. "Bedrock: model without prefix in US region should get us. prefix added",
  282. () =>
  283. Effect.gen(function* () {
  284. yield* set("AWS_PROFILE", "default")
  285. const providers = yield* list
  286. expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
  287. expect(providers[ProviderV2.ID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
  288. }),
  289. {
  290. config: {
  291. provider: {
  292. "amazon-bedrock": {
  293. options: { region: "us-east-1" },
  294. models: { "anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5" } },
  295. },
  296. },
  297. },
  298. },
  299. )
  300. // Direct unit tests for cross-region inference profile prefix detection.
  301. describe("Bedrock cross-region prefix detection", () => {
  302. const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
  303. test("should detect global. prefix", () => {
  304. expect(crossRegionPrefixes.some((p) => "global.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
  305. })
  306. test("should detect us. prefix", () => {
  307. expect(crossRegionPrefixes.some((p) => "us.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
  308. })
  309. test("should detect eu. prefix", () => {
  310. expect(crossRegionPrefixes.some((p) => "eu.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
  311. })
  312. test("should detect jp. prefix", () => {
  313. expect(crossRegionPrefixes.some((p) => "jp.anthropic.claude-sonnet-4-20250514-v1:0".startsWith(p))).toBe(true)
  314. })
  315. test("should detect apac. prefix", () => {
  316. expect(crossRegionPrefixes.some((p) => "apac.anthropic.claude-sonnet-4-20250514-v1:0".startsWith(p))).toBe(true)
  317. })
  318. test("should detect au. prefix", () => {
  319. expect(crossRegionPrefixes.some((p) => "au.anthropic.claude-sonnet-4-5-20250929-v1:0".startsWith(p))).toBe(true)
  320. })
  321. test("should NOT detect prefix for non-prefixed model", () => {
  322. expect(crossRegionPrefixes.some((p) => "anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(false)
  323. })
  324. test("should NOT detect prefix for amazon nova models", () => {
  325. expect(crossRegionPrefixes.some((p) => "amazon.nova-pro-v1:0".startsWith(p))).toBe(false)
  326. })
  327. test("should NOT detect prefix for cohere models", () => {
  328. expect(crossRegionPrefixes.some((p) => "cohere.command-r-plus-v1:0".startsWith(p))).toBe(false)
  329. })
  330. })