provider-dynamic.test.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import { Npm } from "@opencode-ai/core/npm"
  2. import { describe, expect } from "bun:test"
  3. import { Cause, Effect, Layer } from "effect"
  4. import fs from "fs/promises"
  5. import os from "os"
  6. import path from "path"
  7. import { fileURLToPath } from "url"
  8. import { AISDK } from "@opencode-ai/core/aisdk"
  9. import { ModelV2 } from "@opencode-ai/core/model"
  10. import { PluginV2 } from "@opencode-ai/core/plugin"
  11. import { PluginHost } from "@opencode-ai/core/plugin/host"
  12. import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
  13. import { ProviderV2 } from "@opencode-ai/core/provider"
  14. import { testEffect } from "../lib/effect"
  15. import { PluginTestLayer } from "./fixture"
  16. const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
  17. const fixtureProviderPath = fileURLToPath(fixtureProvider)
  18. const it = testEffect(PluginTestLayer)
  19. const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginTestLayer)))
  20. function npmEntrypoint(entrypoint?: string) {
  21. return Npm.Service.of({
  22. add: () => Effect.succeed({ directory: "", entrypoint }),
  23. install: () => Effect.void,
  24. which: () => Effect.succeed(undefined),
  25. })
  26. }
  27. const addPlugin = Effect.fn(function* (npm?: Npm.Interface) {
  28. const plugin = yield* PluginV2.Service
  29. const host = yield* PluginHost.make()
  30. yield* plugin.add({
  31. id: DynamicProviderPlugin.id,
  32. effect: DynamicProviderPlugin.effect(npm ? { ...host, npm } : host),
  33. })
  34. })
  35. function tempEntrypoint(source: string) {
  36. return Effect.acquireRelease(
  37. Effect.promise(async () => {
  38. const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-provider-dynamic-"))
  39. const entrypoint = path.join(directory, "provider.mjs")
  40. await Bun.write(entrypoint, source)
  41. return { directory, entrypoint }
  42. }),
  43. (tmp) => Effect.promise(() => fs.rm(tmp.directory, { recursive: true, force: true })),
  44. )
  45. }
  46. describe("DynamicProviderPlugin", () => {
  47. it.effect("creates an SDK from a provider factory export", () =>
  48. Effect.gen(function* () {
  49. const plugin = yield* PluginV2.Service
  50. yield* addPlugin()
  51. const result = yield* plugin.trigger(
  52. "aisdk.sdk",
  53. {
  54. model: new ModelV2.Info({
  55. ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
  56. api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
  57. }),
  58. package: fixtureProvider,
  59. options: { name: "custom", marker: "dynamic" },
  60. },
  61. {},
  62. )
  63. expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom" })
  64. expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "dynamic", name: "custom" } })
  65. }),
  66. )
  67. it.effect("does not override an SDK already supplied by an earlier plugin", () =>
  68. Effect.gen(function* () {
  69. const plugin = yield* PluginV2.Service
  70. const sdk = { marker: "existing" }
  71. yield* addPlugin()
  72. const result = yield* plugin.trigger(
  73. "aisdk.sdk",
  74. {
  75. model: new ModelV2.Info({
  76. ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
  77. api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
  78. }),
  79. package: fixtureProvider,
  80. options: { name: "custom", marker: "dynamic" },
  81. },
  82. { sdk },
  83. )
  84. expect(result.sdk).toBe(sdk)
  85. }),
  86. )
  87. it.effect("injects the provider ID as the SDK factory name", () =>
  88. Effect.gen(function* () {
  89. const plugin = yield* PluginV2.Service
  90. yield* addPlugin()
  91. const result = yield* plugin.trigger(
  92. "aisdk.sdk",
  93. {
  94. model: new ModelV2.Info({
  95. ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")),
  96. api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
  97. }),
  98. package: fixtureProvider,
  99. options: { name: "custom-provider", marker: "dynamic" },
  100. },
  101. {},
  102. )
  103. expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" })
  104. }),
  105. )
  106. it.effect("loads npm packages through their resolved import entrypoint", () =>
  107. Effect.gen(function* () {
  108. const plugin = yield* PluginV2.Service
  109. yield* addPlugin(npmEntrypoint(fixtureProviderPath))
  110. const result = yield* plugin.trigger(
  111. "aisdk.sdk",
  112. {
  113. model: new ModelV2.Info({
  114. ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")),
  115. api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" },
  116. }),
  117. package: "fixture-provider",
  118. options: { name: "npm-provider", marker: "npm" },
  119. },
  120. {},
  121. )
  122. expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "npm", name: "npm-provider" } })
  123. }),
  124. )
  125. itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () =>
  126. Effect.gen(function* () {
  127. const plugin = yield* PluginV2.Service
  128. const aisdk = yield* AISDK.Service
  129. yield* addPlugin(npmEntrypoint())
  130. const exit = yield* aisdk
  131. .language(
  132. new ModelV2.Info({
  133. ...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")),
  134. api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
  135. }),
  136. )
  137. .pipe(Effect.exit)
  138. expect(exit._tag).toBe("Failure")
  139. if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
  140. }),
  141. )
  142. itWithAISDK.effect("wraps dynamic import failures as AISDK init errors", () =>
  143. Effect.gen(function* () {
  144. const plugin = yield* PluginV2.Service
  145. const aisdk = yield* AISDK.Service
  146. yield* addPlugin()
  147. const exit = yield* aisdk
  148. .language(
  149. new ModelV2.Info({
  150. ...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")),
  151. api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "file:///missing/provider-factory.js" },
  152. }),
  153. )
  154. .pipe(Effect.exit)
  155. expect(exit._tag).toBe("Failure")
  156. if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
  157. }),
  158. )
  159. itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
  160. Effect.gen(function* () {
  161. const plugin = yield* PluginV2.Service
  162. const aisdk = yield* AISDK.Service
  163. const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
  164. yield* addPlugin(npmEntrypoint(tmp.entrypoint))
  165. const exit = yield* aisdk
  166. .language(
  167. new ModelV2.Info({
  168. ...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")),
  169. api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
  170. }),
  171. )
  172. .pipe(Effect.exit)
  173. expect(exit._tag).toBe("Failure")
  174. if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
  175. }),
  176. )
  177. itWithAISDK.effect("uses the model api.id for the default language model", () =>
  178. Effect.gen(function* () {
  179. const plugin = yield* PluginV2.Service
  180. const aisdk = yield* AISDK.Service
  181. yield* addPlugin()
  182. const language = yield* aisdk.language(
  183. new ModelV2.Info({
  184. ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")),
  185. api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider },
  186. }),
  187. )
  188. expect(language).toMatchObject({ modelID: "test-model-api", options: { name: "custom" } })
  189. }),
  190. )
  191. })