provider-snowflake-cortex.test.ts 10 KB

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