provider-opencode.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. import { describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import { Catalog } from "@opencode-ai/core/catalog"
  4. import { Credential } from "@opencode-ai/core/credential"
  5. import { Integration } from "@opencode-ai/core/integration"
  6. import { ModelV2 } from "@opencode-ai/core/model"
  7. import { PluginV2 } from "@opencode-ai/core/plugin"
  8. import { PluginHost } from "@opencode-ai/core/plugin/host"
  9. import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
  10. import { ProviderV2 } from "@opencode-ai/core/provider"
  11. import { testEffect } from "../lib/effect"
  12. import { PluginTestLayer } from "./fixture"
  13. const it = testEffect(PluginTestLayer)
  14. const addPlugin = Effect.fn(function* () {
  15. const plugin = yield* PluginV2.Service
  16. const host = yield* PluginHost.make(plugin)
  17. const integration = yield* Integration.Service
  18. yield* OpencodePlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
  19. })
  20. function required<T>(value: T | undefined): T {
  21. if (value === undefined) throw new Error("Expected value")
  22. return value
  23. }
  24. function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
  25. return Effect.acquireUseRelease(
  26. Effect.sync(() => {
  27. const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
  28. Object.entries(vars).forEach(([key, value]) => {
  29. if (value === undefined) delete process.env[key]
  30. else process.env[key] = value
  31. })
  32. return previous
  33. }),
  34. effect,
  35. (previous) =>
  36. Effect.sync(() =>
  37. Object.entries(previous).forEach(([key, value]) => {
  38. if (value === undefined) delete process.env[key]
  39. else process.env[key] = value
  40. }),
  41. ),
  42. )
  43. }
  44. const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
  45. describe("OpencodePlugin", () => {
  46. it.effect("registers account and service account methods", () =>
  47. Effect.gen(function* () {
  48. yield* addPlugin()
  49. expect((yield* (yield* Integration.Service).get(Integration.ID.make("opencode")))?.methods).toEqual([
  50. {
  51. id: Integration.MethodID.make("device"),
  52. type: "oauth",
  53. label: "OpenCode Console account",
  54. },
  55. { type: "key", label: "API key (service account)" },
  56. ])
  57. }),
  58. )
  59. it.live("loads providers and models from the connected OpenCode server", () =>
  60. Effect.acquireUseRelease(
  61. Effect.sync(() => {
  62. const authorization: Array<string | null> = []
  63. return {
  64. authorization,
  65. server: Bun.serve({
  66. port: 0,
  67. fetch: (request) => {
  68. authorization.push(request.headers.get("authorization"))
  69. return Response.json({
  70. config: {
  71. providers: {
  72. remote: {
  73. name: "Remote",
  74. models: {
  75. model: { name: "Remote Model" },
  76. },
  77. },
  78. },
  79. },
  80. })
  81. },
  82. }),
  83. }
  84. }),
  85. ({ authorization, server }) =>
  86. Effect.gen(function* () {
  87. const credentials = yield* Credential.Service
  88. yield* credentials.create({
  89. integrationID: Integration.ID.make("opencode"),
  90. value: new Credential.Key({
  91. type: "key",
  92. key: "secret",
  93. metadata: { server: server.url.origin },
  94. }),
  95. })
  96. yield* addPlugin()
  97. const catalog = yield* Catalog.Service
  98. expect((yield* catalog.provider.get(ProviderV2.ID.make("remote")))?.name).toBe("Remote")
  99. expect((yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))?.name).toBe(
  100. "Remote Model",
  101. )
  102. expect(authorization).toContain("Bearer secret")
  103. }),
  104. ({ server }) => Effect.promise(() => server.stop(true)),
  105. ),
  106. )
  107. it.effect("uses a public key and disables paid models without credentials", () =>
  108. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  109. Effect.gen(function* () {
  110. const catalog = yield* Catalog.Service
  111. yield* catalog.transform((catalog) => {
  112. const provider = new ProviderV2.Info({
  113. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  114. api: { type: "aisdk", package: "test-provider" },
  115. })
  116. const model = new ModelV2.Info({
  117. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  118. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  119. cost: cost(1),
  120. })
  121. catalog.provider.update(provider.id, () => {})
  122. catalog.model.update(provider.id, model.id, (draft) => {
  123. draft.cost = [...model.cost]
  124. })
  125. })
  126. yield* addPlugin()
  127. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  128. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
  129. }),
  130. ),
  131. )
  132. it.effect("keeps free models without credentials", () =>
  133. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  134. Effect.gen(function* () {
  135. const catalog = yield* Catalog.Service
  136. yield* catalog.transform((catalog) => {
  137. const provider = new ProviderV2.Info({
  138. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  139. api: { type: "aisdk", package: "test-provider" },
  140. })
  141. const model = new ModelV2.Info({
  142. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")),
  143. api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" },
  144. cost: cost(0),
  145. })
  146. catalog.provider.update(provider.id, () => {})
  147. catalog.model.update(provider.id, model.id, (draft) => {
  148. draft.cost = [...model.cost]
  149. })
  150. })
  151. yield* addPlugin()
  152. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  153. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
  154. }),
  155. ),
  156. )
  157. it.effect("treats output-only cost as free without credentials", () =>
  158. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  159. Effect.gen(function* () {
  160. const catalog = yield* Catalog.Service
  161. yield* catalog.transform((catalog) => {
  162. const provider = new ProviderV2.Info({
  163. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  164. api: { type: "aisdk", package: "test-provider" },
  165. })
  166. const model = new ModelV2.Info({
  167. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")),
  168. api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" },
  169. cost: cost(0, 1),
  170. })
  171. catalog.provider.update(provider.id, () => {})
  172. catalog.model.update(provider.id, model.id, (draft) => {
  173. draft.cost = [...model.cost]
  174. })
  175. })
  176. yield* addPlugin()
  177. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  178. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
  179. true,
  180. )
  181. }),
  182. ),
  183. )
  184. it.effect("uses OPENCODE_API_KEY as credentials", () =>
  185. withEnv({ OPENCODE_API_KEY: "secret" }, () =>
  186. Effect.gen(function* () {
  187. const catalog = yield* Catalog.Service
  188. yield* catalog.transform((catalog) => {
  189. const provider = new ProviderV2.Info({
  190. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  191. api: { type: "aisdk", package: "test-provider" },
  192. })
  193. const model = new ModelV2.Info({
  194. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  195. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  196. cost: cost(1),
  197. })
  198. catalog.provider.update(provider.id, () => {})
  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).toBeUndefined()
  205. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  206. }),
  207. ),
  208. )
  209. it.effect("uses configured provider env vars as credentials", () =>
  210. withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
  211. Effect.gen(function* () {
  212. const catalog = yield* Catalog.Service
  213. const integrations = yield* Integration.Service
  214. yield* integrations.transform((editor) => {
  215. editor.method.update({
  216. integrationID: Integration.ID.make("opencode"),
  217. method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
  218. })
  219. })
  220. yield* catalog.transform((catalog) => {
  221. const provider = new ProviderV2.Info({
  222. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  223. api: { type: "aisdk", package: "test-provider" },
  224. })
  225. const model = new ModelV2.Info({
  226. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  227. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  228. cost: cost(1),
  229. })
  230. catalog.provider.update(provider.id, () => {})
  231. catalog.model.update(provider.id, model.id, (draft) => {
  232. draft.cost = [...model.cost]
  233. })
  234. })
  235. yield* addPlugin()
  236. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
  237. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  238. }),
  239. ),
  240. )
  241. it.effect("uses configured apiKey as credentials", () =>
  242. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  243. Effect.gen(function* () {
  244. const catalog = yield* Catalog.Service
  245. yield* catalog.transform((catalog) => {
  246. const provider = new ProviderV2.Info({
  247. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  248. api: { type: "aisdk", package: "test-provider" },
  249. request: {
  250. headers: {},
  251. body: { apiKey: "configured" },
  252. },
  253. })
  254. const model = new ModelV2.Info({
  255. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  256. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  257. cost: cost(1),
  258. })
  259. catalog.provider.update(provider.id, (draft) => {
  260. draft.request = provider.request
  261. })
  262. catalog.model.update(provider.id, model.id, (draft) => {
  263. draft.cost = [...model.cost]
  264. })
  265. })
  266. yield* addPlugin()
  267. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured")
  268. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  269. }),
  270. ),
  271. )
  272. it.effect("ignores non-opencode providers and models", () =>
  273. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  274. Effect.gen(function* () {
  275. const catalog = yield* Catalog.Service
  276. yield* catalog.transform((catalog) => {
  277. const provider = new ProviderV2.Info({
  278. ...ProviderV2.Info.empty(ProviderV2.ID.openai),
  279. api: { type: "aisdk", package: "test-provider" },
  280. })
  281. const model = new ModelV2.Info({
  282. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  283. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  284. cost: cost(1),
  285. })
  286. catalog.provider.update(provider.id, () => {})
  287. catalog.model.update(provider.id, model.id, (draft) => {
  288. draft.cost = [...model.cost]
  289. })
  290. })
  291. yield* addPlugin()
  292. expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined()
  293. expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
  294. }),
  295. ),
  296. )
  297. it.effect("prefers gpt-5-nano as the opencode small model", () =>
  298. Effect.gen(function* () {
  299. const catalog = yield* Catalog.Service
  300. const providerID = ProviderV2.ID.opencode
  301. yield* catalog.transform((catalog) => {
  302. catalog.provider.update(providerID, () => {})
  303. catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
  304. model.capabilities.input = ["text"]
  305. model.capabilities.output = ["text"]
  306. model.cost = [...cost(1, 1)]
  307. model.time.released = Date.now()
  308. })
  309. catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
  310. model.capabilities.input = ["text"]
  311. model.capabilities.output = ["text"]
  312. model.cost = [...cost(10, 10)]
  313. model.time.released = Date.now()
  314. })
  315. })
  316. const selected = yield* catalog.model.small(providerID)
  317. expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
  318. }),
  319. )
  320. })