provider-dynamic.test.ts 7.4 KB

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