provider-dynamic.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  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.locationLayer.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(plugin)
  30. yield* DynamicProviderPlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm ?? (yield* Npm.Service)))
  31. })
  32. function tempEntrypoint(source: string) {
  33. return Effect.acquireRelease(
  34. Effect.promise(async () => {
  35. const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-provider-dynamic-"))
  36. const entrypoint = path.join(directory, "provider.mjs")
  37. await Bun.write(entrypoint, source)
  38. return { directory, entrypoint }
  39. }),
  40. (tmp) => Effect.promise(() => fs.rm(tmp.directory, { recursive: true, force: true })),
  41. )
  42. }
  43. describe("DynamicProviderPlugin", () => {
  44. it.effect("creates an SDK from a provider factory export", () =>
  45. Effect.gen(function* () {
  46. const aisdk = yield* AISDK.Service
  47. yield* addPlugin()
  48. const result = yield* aisdk.runSDK({
  49. model: ModelV2.Info.make({
  50. ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
  51. api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
  52. }),
  53. package: fixtureProvider,
  54. options: { name: "custom", marker: "dynamic" },
  55. })
  56. expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom" })
  57. expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "dynamic", name: "custom" } })
  58. }),
  59. )
  60. it.effect("does not override an SDK already supplied by an earlier plugin", () =>
  61. Effect.gen(function* () {
  62. const aisdk = yield* AISDK.Service
  63. const sdk = { marker: "existing" }
  64. yield* addPlugin()
  65. const result = yield* aisdk.runSDK({
  66. model: ModelV2.Info.make({
  67. ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
  68. api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
  69. }),
  70. package: fixtureProvider,
  71. options: { name: "custom", marker: "dynamic" },
  72. sdk,
  73. })
  74. expect(result.sdk).toBe(sdk)
  75. }),
  76. )
  77. it.effect("injects the provider ID as the SDK factory name", () =>
  78. Effect.gen(function* () {
  79. const aisdk = yield* AISDK.Service
  80. yield* addPlugin()
  81. const result = yield* aisdk.runSDK({
  82. model: ModelV2.Info.make({
  83. ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")),
  84. api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider },
  85. }),
  86. package: fixtureProvider,
  87. options: { name: "custom-provider", marker: "dynamic" },
  88. })
  89. expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" })
  90. }),
  91. )
  92. it.effect("loads npm packages through their resolved import entrypoint", () =>
  93. Effect.gen(function* () {
  94. const aisdk = yield* AISDK.Service
  95. yield* addPlugin(npmEntrypoint(fixtureProviderPath))
  96. const result = yield* aisdk.runSDK({
  97. model: ModelV2.Info.make({
  98. ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")),
  99. api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" },
  100. }),
  101. package: "fixture-provider",
  102. options: { name: "npm-provider", marker: "npm" },
  103. })
  104. expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "npm", name: "npm-provider" } })
  105. }),
  106. )
  107. itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () =>
  108. Effect.gen(function* () {
  109. const aisdk = yield* AISDK.Service
  110. yield* addPlugin(npmEntrypoint())
  111. const exit = yield* aisdk
  112. .language(
  113. ModelV2.Info.make({
  114. ...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")),
  115. api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
  116. }),
  117. )
  118. .pipe(Effect.exit)
  119. expect(exit._tag).toBe("Failure")
  120. if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
  121. }),
  122. )
  123. itWithAISDK.effect("wraps dynamic import failures as AISDK init errors", () =>
  124. Effect.gen(function* () {
  125. const aisdk = yield* AISDK.Service
  126. yield* addPlugin()
  127. const exit = yield* aisdk
  128. .language(
  129. ModelV2.Info.make({
  130. ...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")),
  131. api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "file:///missing/provider-factory.js" },
  132. }),
  133. )
  134. .pipe(Effect.exit)
  135. expect(exit._tag).toBe("Failure")
  136. if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
  137. }),
  138. )
  139. itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
  140. Effect.gen(function* () {
  141. const plugin = yield* PluginV2.Service
  142. const aisdk = yield* AISDK.Service
  143. const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
  144. yield* addPlugin(npmEntrypoint(tmp.entrypoint))
  145. const exit = yield* aisdk
  146. .language(
  147. ModelV2.Info.make({
  148. ...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")),
  149. api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "fixture-provider" },
  150. }),
  151. )
  152. .pipe(Effect.exit)
  153. expect(exit._tag).toBe("Failure")
  154. if (exit._tag === "Failure") expect(Cause.prettyErrors(exit.cause).join("\n")).toContain("AISDK.InitError")
  155. }),
  156. )
  157. itWithAISDK.effect("uses the model api.id for the default language model", () =>
  158. Effect.gen(function* () {
  159. const plugin = yield* PluginV2.Service
  160. const aisdk = yield* AISDK.Service
  161. yield* addPlugin()
  162. const language = yield* aisdk.language(
  163. ModelV2.Info.make({
  164. ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")),
  165. api: { id: ModelV2.ID.make("test-model-api"), type: "aisdk", package: fixtureProvider },
  166. }),
  167. )
  168. expect(language).toMatchObject({ modelID: "test-model-api", options: { name: "custom" } })
  169. }),
  170. )
  171. })