provider-opencode.test.ts 11 KB

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