provider-snowflake-cortex.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { AISDK } from "@opencode-ai/core/aisdk"
  2. import { describe, expect, it as bun_it } from "bun:test"
  3. import { Effect } from "effect"
  4. import { ModelV2 } from "@opencode-ai/core/model"
  5. import { PluginV2 } from "@opencode-ai/core/plugin"
  6. import { PluginHost } from "@opencode-ai/core/plugin/host"
  7. import { SnowflakeCortexPlugin, cortexFetch } from "@opencode-ai/core/plugin/provider/snowflake-cortex"
  8. import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
  9. import { ProviderV2 } from "@opencode-ai/core/provider"
  10. import { testEffect } from "../lib/effect"
  11. import { PluginTestLayer } from "./fixture"
  12. const it = testEffect(PluginTestLayer)
  13. const addPlugin = Effect.fn(function* () {
  14. const plugin = yield* PluginV2.Service
  15. const aisdk = yield* AISDK.Service
  16. const host = yield* PluginHost.make(plugin)
  17. yield* SnowflakeCortexPlugin.effect(host)
  18. })
  19. function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
  20. return Effect.acquireUseRelease(
  21. Effect.sync(() => {
  22. const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
  23. Object.entries(vars).forEach(([key, value]) => {
  24. if (value === undefined) delete process.env[key]
  25. else process.env[key] = value
  26. })
  27. return previous
  28. }),
  29. effect,
  30. (previous) =>
  31. Effect.sync(() => {
  32. Object.entries(previous).forEach(([key, value]) => {
  33. if (value === undefined) delete process.env[key]
  34. else process.env[key] = value
  35. })
  36. }),
  37. )
  38. }
  39. describe("SnowflakeCortexPlugin", () => {
  40. it.effect("is registered in ProviderPlugins before OpenAICompatiblePlugin", () =>
  41. Effect.sync(() => {
  42. expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("snowflake-cortex"))
  43. const ids = ProviderPlugins.map((p) => p.id)
  44. expect(ids.indexOf("snowflake-cortex")).toBeLessThan(ids.indexOf("openai-compatible"))
  45. }),
  46. )
  47. it.effect("ignores non-snowflake-cortex providers", () =>
  48. Effect.gen(function* () {
  49. const plugin = yield* PluginV2.Service
  50. const aisdk = yield* AISDK.Service
  51. yield* addPlugin()
  52. const result = yield* aisdk.runSDK({
  53. model: ModelV2.Info.make({
  54. ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")),
  55. api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" },
  56. }),
  57. package: "@ai-sdk/openai",
  58. options: { name: "openai" },
  59. })
  60. expect(result.sdk).toBeUndefined()
  61. }),
  62. )
  63. it.effect("creates SDK for snowflake-cortex using SNOWFLAKE_CORTEX_PAT env var", () =>
  64. withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
  65. Effect.gen(function* () {
  66. const plugin = yield* PluginV2.Service
  67. const aisdk = yield* AISDK.Service
  68. yield* addPlugin()
  69. const result = yield* aisdk.runSDK({
  70. model: ModelV2.Info.make({
  71. ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
  72. api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
  73. }),
  74. package: "@ai-sdk/openai-compatible",
  75. options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
  76. })
  77. expect(result.sdk).toBeDefined()
  78. }),
  79. ),
  80. )
  81. it.effect("falls back to options.apiKey when SNOWFLAKE_CORTEX_PAT env var is absent", () =>
  82. withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () =>
  83. Effect.gen(function* () {
  84. const plugin = yield* PluginV2.Service
  85. const aisdk = yield* AISDK.Service
  86. yield* addPlugin()
  87. const result = yield* aisdk.runSDK({
  88. model: ModelV2.Info.make({
  89. ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
  90. api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
  91. }),
  92. package: "@ai-sdk/openai-compatible",
  93. options: {
  94. name: "snowflake-cortex",
  95. baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1",
  96. apiKey: "options-pat",
  97. },
  98. })
  99. expect(result.sdk).toBeDefined()
  100. }),
  101. ),
  102. )
  103. it.effect("uses SNOWFLAKE_CORTEX_TOKEN env var", () =>
  104. withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () =>
  105. Effect.gen(function* () {
  106. const plugin = yield* PluginV2.Service
  107. const aisdk = yield* AISDK.Service
  108. yield* addPlugin()
  109. const result = yield* aisdk.runSDK({
  110. model: ModelV2.Info.make({
  111. ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
  112. api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
  113. }),
  114. package: "@ai-sdk/openai-compatible",
  115. options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
  116. })
  117. expect(result.sdk).toBeDefined()
  118. }),
  119. ),
  120. )
  121. it.effect("falls back to options.token when no Snowflake env token is set", () =>
  122. withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () =>
  123. Effect.gen(function* () {
  124. const plugin = yield* PluginV2.Service
  125. const aisdk = yield* AISDK.Service
  126. yield* addPlugin()
  127. const result = yield* aisdk.runSDK({
  128. model: ModelV2.Info.make({
  129. ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
  130. api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
  131. }),
  132. package: "@ai-sdk/openai-compatible",
  133. options: {
  134. name: "snowflake-cortex",
  135. baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1",
  136. token: "options-token",
  137. },
  138. })
  139. expect(result.sdk).toBeDefined()
  140. }),
  141. ),
  142. )
  143. it.effect("sets includeUsage on the SDK options", () =>
  144. withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () =>
  145. Effect.gen(function* () {
  146. const plugin = yield* PluginV2.Service
  147. const aisdk = yield* AISDK.Service
  148. yield* addPlugin()
  149. const result = yield* aisdk.runSDK({
  150. model: ModelV2.Info.make({
  151. ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
  152. api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" },
  153. }),
  154. package: "@ai-sdk/openai-compatible",
  155. options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" },
  156. })
  157. expect(result.options.includeUsage).toBe(true)
  158. }),
  159. ),
  160. )
  161. })
  162. type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise<Response>
  163. describe("cortexFetch", () => {
  164. bun_it("rewrites max_tokens to max_completion_tokens", async () => {
  165. const captured: RequestInit[] = []
  166. const upstream: FetchLike = async (_url, init) => {
  167. captured.push(init ?? {})
  168. return new Response("{}", { status: 200 })
  169. }
  170. await cortexFetch(upstream)("https://test", {
  171. method: "POST",
  172. body: JSON.stringify({ model: "claude-sonnet-4-6", max_tokens: 1024 }),
  173. })
  174. const body = JSON.parse(captured[0].body as string)
  175. expect(body.max_completion_tokens).toBe(1024)
  176. expect(body.max_tokens).toBeUndefined()
  177. })
  178. bun_it("preserves body when max_tokens is absent", async () => {
  179. const captured: RequestInit[] = []
  180. const upstream: FetchLike = async (_url, init) => {
  181. captured.push(init ?? {})
  182. return new Response("{}", { status: 200 })
  183. }
  184. const original = JSON.stringify({ model: "claude-sonnet-4-6", temperature: 0.7 })
  185. await cortexFetch(upstream)("https://test", { method: "POST", body: original })
  186. expect(captured[0].body).toBe(original)
  187. })
  188. bun_it("treats 400 'conversation complete' as a stop response", async () => {
  189. const upstream: FetchLike = async () =>
  190. new Response(JSON.stringify({ message: "Conversation complete" }), {
  191. status: 400,
  192. headers: { "content-type": "application/json" },
  193. })
  194. const response = await cortexFetch(upstream)("https://test", {})
  195. expect(response.status).toBe(200)
  196. const data = (await response.json()) as { choices: { finish_reason: string }[] }
  197. expect(data.choices[0].finish_reason).toBe("stop")
  198. })
  199. bun_it("passes through other 400 errors unchanged", async () => {
  200. const upstream: FetchLike = async () =>
  201. new Response(JSON.stringify({ message: "Invalid model" }), {
  202. status: 400,
  203. headers: { "content-type": "application/json" },
  204. })
  205. const response = await cortexFetch(upstream)("https://test", {})
  206. expect(response.status).toBe(400)
  207. })
  208. bun_it("passes through non-400 errors unchanged", async () => {
  209. const upstream: FetchLike = async () => new Response("Unauthorized", { status: 401 })
  210. const response = await cortexFetch(upstream)("https://test", {})
  211. expect(response.status).toBe(401)
  212. })
  213. bun_it("handles invalid JSON body gracefully without throwing", async () => {
  214. const captured: RequestInit[] = []
  215. const upstream: FetchLike = async (_url, init) => {
  216. captured.push(init ?? {})
  217. return new Response("{}", { status: 200 })
  218. }
  219. const invalidBody = "{ not json }"
  220. await cortexFetch(upstream)("https://test", { method: "POST", body: invalidBody })
  221. expect(captured[0].body).toBe(invalidBody)
  222. })
  223. bun_it("rewrites role:'' to role:'assistant' in streaming SSE chunks", async () => {
  224. const chunk = `data: {"choices":[{"delta":{"role":"","content":"Hi"},"index":0}]}\n\n`
  225. const upstream: FetchLike = async () =>
  226. new Response(
  227. new ReadableStream({
  228. start: (ctrl) => {
  229. ctrl.enqueue(new TextEncoder().encode(chunk))
  230. ctrl.close()
  231. },
  232. }),
  233. {
  234. status: 200,
  235. headers: { "content-type": "text/event-stream" },
  236. },
  237. )
  238. const response = await cortexFetch(upstream)("https://test", {})
  239. const text = await response.text()
  240. expect(text).toContain('"role":"assistant"')
  241. expect(text).not.toContain('"role":""')
  242. })
  243. })