provider-opencode.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import { describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import { Catalog } from "@opencode-ai/core/catalog"
  4. import { Integration } from "@opencode-ai/core/integration"
  5. import { ModelV2 } from "@opencode-ai/core/model"
  6. import { PluginV2 } from "@opencode-ai/core/plugin"
  7. import { PluginHost } from "@opencode-ai/core/plugin/host"
  8. import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
  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 host = yield* PluginHost.make(plugin)
  16. const integration = yield* Integration.Service
  17. yield* OpencodePlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
  18. })
  19. function required<T>(value: T | undefined): T {
  20. if (value === undefined) throw new Error("Expected value")
  21. return value
  22. }
  23. function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
  24. return Effect.acquireUseRelease(
  25. Effect.sync(() => {
  26. const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
  27. Object.entries(vars).forEach(([key, value]) => {
  28. if (value === undefined) delete process.env[key]
  29. else process.env[key] = value
  30. })
  31. return previous
  32. }),
  33. effect,
  34. (previous) =>
  35. Effect.sync(() =>
  36. Object.entries(previous).forEach(([key, value]) => {
  37. if (value === undefined) delete process.env[key]
  38. else process.env[key] = value
  39. }),
  40. ),
  41. )
  42. }
  43. const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
  44. describe("OpencodePlugin", () => {
  45. it.effect("uses a public key and disables paid models without credentials", () =>
  46. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  47. Effect.gen(function* () {
  48. const catalog = yield* Catalog.Service
  49. yield* catalog.transform((catalog) => {
  50. const provider = new ProviderV2.Info({
  51. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  52. api: { type: "aisdk", package: "test-provider" },
  53. })
  54. const model = new ModelV2.Info({
  55. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  56. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  57. cost: cost(1),
  58. })
  59. catalog.provider.update(provider.id, () => {})
  60. catalog.model.update(provider.id, model.id, (draft) => {
  61. draft.cost = [...model.cost]
  62. })
  63. })
  64. yield* addPlugin()
  65. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  66. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
  67. }),
  68. ),
  69. )
  70. it.effect("keeps free models without credentials", () =>
  71. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  72. Effect.gen(function* () {
  73. const catalog = yield* Catalog.Service
  74. yield* catalog.transform((catalog) => {
  75. const provider = new ProviderV2.Info({
  76. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  77. api: { type: "aisdk", package: "test-provider" },
  78. })
  79. const model = new ModelV2.Info({
  80. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")),
  81. api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" },
  82. cost: cost(0),
  83. })
  84. catalog.provider.update(provider.id, () => {})
  85. catalog.model.update(provider.id, model.id, (draft) => {
  86. draft.cost = [...model.cost]
  87. })
  88. })
  89. yield* addPlugin()
  90. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  91. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
  92. }),
  93. ),
  94. )
  95. it.effect("treats output-only cost as free without credentials", () =>
  96. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  97. Effect.gen(function* () {
  98. const catalog = yield* Catalog.Service
  99. yield* catalog.transform((catalog) => {
  100. const provider = new ProviderV2.Info({
  101. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  102. api: { type: "aisdk", package: "test-provider" },
  103. })
  104. const model = new ModelV2.Info({
  105. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")),
  106. api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" },
  107. cost: cost(0, 1),
  108. })
  109. catalog.provider.update(provider.id, () => {})
  110. catalog.model.update(provider.id, model.id, (draft) => {
  111. draft.cost = [...model.cost]
  112. })
  113. })
  114. yield* addPlugin()
  115. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  116. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
  117. true,
  118. )
  119. }),
  120. ),
  121. )
  122. it.effect("uses OPENCODE_API_KEY as credentials", () =>
  123. withEnv({ OPENCODE_API_KEY: "secret" }, () =>
  124. Effect.gen(function* () {
  125. const catalog = yield* Catalog.Service
  126. yield* catalog.transform((catalog) => {
  127. const provider = new ProviderV2.Info({
  128. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  129. api: { type: "aisdk", package: "test-provider" },
  130. })
  131. const model = new ModelV2.Info({
  132. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  133. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  134. cost: cost(1),
  135. })
  136. catalog.provider.update(provider.id, () => {})
  137. catalog.model.update(provider.id, model.id, (draft) => {
  138. draft.cost = [...model.cost]
  139. })
  140. })
  141. yield* addPlugin()
  142. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
  143. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  144. }),
  145. ),
  146. )
  147. it.effect("uses configured provider env vars as credentials", () =>
  148. withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
  149. Effect.gen(function* () {
  150. const catalog = yield* Catalog.Service
  151. const integrations = yield* Integration.Service
  152. yield* integrations.transform((editor) => {
  153. editor.method.update({
  154. integrationID: Integration.ID.make("opencode"),
  155. method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
  156. })
  157. })
  158. yield* catalog.transform((catalog) => {
  159. const provider = new ProviderV2.Info({
  160. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  161. api: { type: "aisdk", package: "test-provider" },
  162. })
  163. const model = new ModelV2.Info({
  164. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  165. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  166. cost: cost(1),
  167. })
  168. catalog.provider.update(provider.id, () => {})
  169. catalog.model.update(provider.id, model.id, (draft) => {
  170. draft.cost = [...model.cost]
  171. })
  172. })
  173. yield* addPlugin()
  174. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
  175. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  176. }),
  177. ),
  178. )
  179. it.effect("uses configured apiKey as credentials", () =>
  180. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  181. Effect.gen(function* () {
  182. const catalog = yield* Catalog.Service
  183. yield* catalog.transform((catalog) => {
  184. const provider = new ProviderV2.Info({
  185. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  186. api: { type: "aisdk", package: "test-provider" },
  187. request: {
  188. headers: {},
  189. body: { apiKey: "configured" },
  190. },
  191. })
  192. const model = new ModelV2.Info({
  193. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  194. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  195. cost: cost(1),
  196. })
  197. catalog.provider.update(provider.id, (draft) => {
  198. draft.request = provider.request
  199. })
  200. catalog.model.update(provider.id, model.id, (draft) => {
  201. draft.cost = [...model.cost]
  202. })
  203. })
  204. yield* addPlugin()
  205. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured")
  206. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  207. }),
  208. ),
  209. )
  210. it.effect("ignores non-opencode providers and models", () =>
  211. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  212. Effect.gen(function* () {
  213. const catalog = yield* Catalog.Service
  214. yield* catalog.transform((catalog) => {
  215. const provider = new ProviderV2.Info({
  216. ...ProviderV2.Info.empty(ProviderV2.ID.openai),
  217. api: { type: "aisdk", package: "test-provider" },
  218. })
  219. const model = new ModelV2.Info({
  220. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  221. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  222. cost: cost(1),
  223. })
  224. catalog.provider.update(provider.id, () => {})
  225. catalog.model.update(provider.id, model.id, (draft) => {
  226. draft.cost = [...model.cost]
  227. })
  228. })
  229. yield* addPlugin()
  230. expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined()
  231. expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
  232. }),
  233. ),
  234. )
  235. it.effect("prefers gpt-5-nano as the opencode small model", () =>
  236. Effect.gen(function* () {
  237. const catalog = yield* Catalog.Service
  238. const providerID = ProviderV2.ID.opencode
  239. yield* catalog.transform((catalog) => {
  240. catalog.provider.update(providerID, () => {})
  241. catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
  242. model.capabilities.input = ["text"]
  243. model.capabilities.output = ["text"]
  244. model.cost = [...cost(1, 1)]
  245. model.time.released = Date.now()
  246. })
  247. catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
  248. model.capabilities.input = ["text"]
  249. model.capabilities.output = ["text"]
  250. model.cost = [...cost(10, 10)]
  251. model.time.released = Date.now()
  252. })
  253. })
  254. const selected = yield* catalog.model.small(providerID)
  255. expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
  256. }),
  257. )
  258. })