provider-opencode.test.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import { describe, expect } from "bun:test"
  2. import { DateTime, Effect, Layer, Option } from "effect"
  3. import { Catalog } from "@opencode-ai/core/catalog"
  4. import { Credential } from "@opencode-ai/core/credential"
  5. import { EventV2 } from "@opencode-ai/core/event"
  6. import { Location } from "@opencode-ai/core/location"
  7. import { ModelV2 } from "@opencode-ai/core/model"
  8. import { PluginV2 } from "@opencode-ai/core/plugin"
  9. import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
  10. import { ProviderV2 } from "@opencode-ai/core/provider"
  11. import { AbsolutePath } from "@opencode-ai/core/schema"
  12. import { location } from "../fixture/location"
  13. import { it, model, provider, withEnv } from "./provider-helper"
  14. const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
  15. const locationLayer = Layer.succeed(
  16. Location.Service,
  17. Location.Service.of(location({ directory: AbsolutePath.make("test") })),
  18. )
  19. describe("OpencodePlugin", () => {
  20. it.effect("uses a public key and disables paid models without credentials", () =>
  21. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  22. Effect.gen(function* () {
  23. const plugin = yield* PluginV2.Service
  24. const catalog = yield* Catalog.Service
  25. yield* plugin.add(OpencodePlugin)
  26. const transform = yield* catalog.transform()
  27. yield* transform((catalog) => {
  28. const item = provider("opencode")
  29. catalog.provider.update(item.id, () => {})
  30. const paid = model("opencode", "paid", { cost: cost(1) })
  31. catalog.model.update(item.id, paid.id, (draft) => {
  32. draft.cost = [...paid.cost]
  33. })
  34. })
  35. expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  36. expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
  37. }),
  38. ),
  39. )
  40. it.effect("keeps free models without credentials", () =>
  41. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  42. Effect.gen(function* () {
  43. const plugin = yield* PluginV2.Service
  44. const catalog = yield* Catalog.Service
  45. yield* plugin.add(OpencodePlugin)
  46. const transform = yield* catalog.transform()
  47. yield* transform((catalog) => {
  48. const item = provider("opencode")
  49. catalog.provider.update(item.id, () => {})
  50. const free = model("opencode", "free", { cost: cost(0) })
  51. catalog.model.update(item.id, free.id, (draft) => {
  52. draft.cost = [...free.cost]
  53. })
  54. })
  55. expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  56. expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
  57. }),
  58. ),
  59. )
  60. it.effect("treats output-only cost as free without credentials", () =>
  61. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  62. Effect.gen(function* () {
  63. const plugin = yield* PluginV2.Service
  64. const catalog = yield* Catalog.Service
  65. yield* plugin.add(OpencodePlugin)
  66. const transform = yield* catalog.transform()
  67. yield* transform((catalog) => {
  68. const item = provider("opencode")
  69. catalog.provider.update(item.id, () => {})
  70. const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) })
  71. catalog.model.update(item.id, outputOnly.id, (draft) => {
  72. draft.cost = [...outputOnly.cost]
  73. })
  74. })
  75. expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  76. expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(true)
  77. }),
  78. ),
  79. )
  80. it.effect("uses OPENCODE_API_KEY as credentials", () =>
  81. withEnv({ OPENCODE_API_KEY: "secret" }, () =>
  82. Effect.gen(function* () {
  83. const plugin = yield* PluginV2.Service
  84. const catalog = yield* Catalog.Service
  85. yield* plugin.add(OpencodePlugin)
  86. const transform = yield* catalog.transform()
  87. yield* transform((catalog) => {
  88. const item = provider("opencode")
  89. catalog.provider.update(item.id, () => {})
  90. const paid = model("opencode", "paid", { cost: cost(1) })
  91. catalog.model.update(item.id, paid.id, (draft) => {
  92. draft.cost = [...paid.cost]
  93. })
  94. })
  95. expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
  96. expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  97. }),
  98. ),
  99. )
  100. it.effect("uses configured provider env vars as credentials", () =>
  101. withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
  102. Effect.gen(function* () {
  103. const plugin = yield* PluginV2.Service
  104. const catalog = yield* Catalog.Service
  105. yield* plugin.add(OpencodePlugin)
  106. const transform = yield* catalog.transform()
  107. yield* transform((catalog) => {
  108. const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
  109. catalog.provider.update(item.id, (draft) => {
  110. draft.env = [...item.env]
  111. })
  112. const paid = model("opencode", "paid", { cost: cost(1) })
  113. catalog.model.update(item.id, paid.id, (draft) => {
  114. draft.cost = [...paid.cost]
  115. })
  116. })
  117. expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
  118. expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  119. }),
  120. ),
  121. )
  122. it.effect("uses configured apiKey as credentials", () =>
  123. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  124. Effect.gen(function* () {
  125. const plugin = yield* PluginV2.Service
  126. const catalog = yield* Catalog.Service
  127. yield* plugin.add(OpencodePlugin)
  128. const transform = yield* catalog.transform()
  129. yield* transform((catalog) => {
  130. const item = provider("opencode", {
  131. request: {
  132. headers: {},
  133. body: { apiKey: "configured" },
  134. },
  135. })
  136. catalog.provider.update(item.id, (draft) => {
  137. draft.request = item.request
  138. })
  139. const paid = model("opencode", "paid", { cost: cost(1) })
  140. catalog.model.update(item.id, paid.id, (draft) => {
  141. draft.cost = [...paid.cost]
  142. })
  143. })
  144. expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured")
  145. expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  146. }),
  147. ),
  148. )
  149. it.effect("uses auth-enabled providers as credentials", () =>
  150. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  151. Effect.gen(function* () {
  152. const plugin = yield* PluginV2.Service
  153. const catalog = yield* Catalog.Service
  154. yield* plugin.add(OpencodePlugin)
  155. const transform = yield* catalog.transform()
  156. yield* transform((catalog) => {
  157. const item = provider("opencode", {
  158. enabled: { via: "credential", credentialID: Credential.ID.make("credential") },
  159. })
  160. catalog.provider.update(item.id, (draft) => {
  161. draft.enabled = item.enabled
  162. })
  163. const paid = model("opencode", "paid", { cost: cost(1) })
  164. catalog.model.update(item.id, paid.id, (draft) => {
  165. draft.cost = [...paid.cost]
  166. })
  167. })
  168. expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
  169. expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  170. }),
  171. ),
  172. )
  173. it.effect("ignores non-opencode providers and models", () =>
  174. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  175. Effect.gen(function* () {
  176. const plugin = yield* PluginV2.Service
  177. const catalog = yield* Catalog.Service
  178. yield* plugin.add(OpencodePlugin)
  179. const transform = yield* catalog.transform()
  180. yield* transform((catalog) => {
  181. const item = provider("openai")
  182. catalog.provider.update(item.id, () => {})
  183. const paid = model("openai", "paid", { cost: cost(1) })
  184. catalog.model.update(item.id, paid.id, (draft) => {
  185. draft.cost = [...paid.cost]
  186. })
  187. })
  188. expect((yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined()
  189. expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
  190. }),
  191. ),
  192. )
  193. it.effect("prefers gpt-5-nano as the opencode small model", () =>
  194. Effect.gen(function* () {
  195. const catalog = yield* Catalog.Service
  196. const providerID = ProviderV2.ID.opencode
  197. const transform = yield* catalog.transform()
  198. yield* transform((catalog) => {
  199. catalog.provider.update(providerID, () => {})
  200. catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
  201. model.capabilities.input = ["text"]
  202. model.capabilities.output = ["text"]
  203. model.cost = [...cost(1, 1)]
  204. model.time.released = DateTime.makeUnsafe(Date.now())
  205. })
  206. catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
  207. model.capabilities.input = ["text"]
  208. model.capabilities.output = ["text"]
  209. model.cost = [...cost(10, 10)]
  210. model.time.released = DateTime.makeUnsafe(Date.now())
  211. })
  212. })
  213. const selected = yield* catalog.model.small(providerID)
  214. expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
  215. }).pipe(
  216. Effect.provide(Catalog.locationLayer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(locationLayer))),
  217. ),
  218. )
  219. })