provider-opencode.test.ts 9.3 KB

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