1
0

provider-opencode.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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 OAuth and API key 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: "Sign in with OpenCode",
  54. prompts: [
  55. {
  56. type: "text",
  57. key: "server",
  58. message: "OpenCode server",
  59. placeholder: "https://console.opencode.ai",
  60. },
  61. ],
  62. },
  63. { type: "key", label: "API key" },
  64. ])
  65. }),
  66. )
  67. it.live("loads providers and models from the connected OpenCode server", () =>
  68. Effect.acquireUseRelease(
  69. Effect.sync(() => {
  70. const authorization: Array<string | null> = []
  71. return {
  72. authorization,
  73. server: Bun.serve({
  74. port: 0,
  75. fetch: (request) => {
  76. authorization.push(request.headers.get("authorization"))
  77. return Response.json({
  78. config: {
  79. providers: {
  80. remote: {
  81. name: "Remote",
  82. models: {
  83. model: { name: "Remote Model" },
  84. },
  85. },
  86. },
  87. },
  88. })
  89. },
  90. }),
  91. }
  92. }),
  93. ({ authorization, server }) =>
  94. Effect.gen(function* () {
  95. const credentials = yield* Credential.Service
  96. yield* credentials.create({
  97. integrationID: Integration.ID.make("opencode"),
  98. value: new Credential.Key({
  99. type: "key",
  100. key: "secret",
  101. metadata: { server: server.url.origin },
  102. }),
  103. })
  104. yield* addPlugin()
  105. const catalog = yield* Catalog.Service
  106. expect((yield* catalog.provider.get(ProviderV2.ID.make("remote")))?.name).toBe("Remote")
  107. expect((yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))?.name).toBe(
  108. "Remote Model",
  109. )
  110. expect(authorization).toContain("Bearer secret")
  111. }),
  112. ({ server }) => Effect.promise(() => server.stop(true)),
  113. ),
  114. )
  115. it.effect("uses a public key and disables paid models without credentials", () =>
  116. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  117. Effect.gen(function* () {
  118. const catalog = yield* Catalog.Service
  119. yield* catalog.transform((catalog) => {
  120. const provider = new ProviderV2.Info({
  121. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  122. api: { type: "aisdk", package: "test-provider" },
  123. })
  124. const model = new ModelV2.Info({
  125. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  126. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  127. cost: cost(1),
  128. })
  129. catalog.provider.update(provider.id, () => {})
  130. catalog.model.update(provider.id, model.id, (draft) => {
  131. draft.cost = [...model.cost]
  132. })
  133. })
  134. yield* addPlugin()
  135. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  136. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
  137. }),
  138. ),
  139. )
  140. it.effect("keeps free models without credentials", () =>
  141. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  142. Effect.gen(function* () {
  143. const catalog = yield* Catalog.Service
  144. yield* catalog.transform((catalog) => {
  145. const provider = new ProviderV2.Info({
  146. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  147. api: { type: "aisdk", package: "test-provider" },
  148. })
  149. const model = new ModelV2.Info({
  150. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")),
  151. api: { id: ModelV2.ID.make("free"), type: "aisdk", package: "test-provider" },
  152. cost: cost(0),
  153. })
  154. catalog.provider.update(provider.id, () => {})
  155. catalog.model.update(provider.id, model.id, (draft) => {
  156. draft.cost = [...model.cost]
  157. })
  158. })
  159. yield* addPlugin()
  160. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  161. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
  162. }),
  163. ),
  164. )
  165. it.effect("treats output-only cost as free without credentials", () =>
  166. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  167. Effect.gen(function* () {
  168. const catalog = yield* Catalog.Service
  169. yield* catalog.transform((catalog) => {
  170. const provider = new ProviderV2.Info({
  171. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  172. api: { type: "aisdk", package: "test-provider" },
  173. })
  174. const model = new ModelV2.Info({
  175. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")),
  176. api: { id: ModelV2.ID.make("output-only"), type: "aisdk", package: "test-provider" },
  177. cost: cost(0, 1),
  178. })
  179. catalog.provider.update(provider.id, () => {})
  180. catalog.model.update(provider.id, model.id, (draft) => {
  181. draft.cost = [...model.cost]
  182. })
  183. })
  184. yield* addPlugin()
  185. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("public")
  186. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
  187. true,
  188. )
  189. }),
  190. ),
  191. )
  192. it.effect("uses OPENCODE_API_KEY as credentials", () =>
  193. withEnv({ OPENCODE_API_KEY: "secret" }, () =>
  194. Effect.gen(function* () {
  195. const catalog = yield* Catalog.Service
  196. yield* catalog.transform((catalog) => {
  197. const provider = new ProviderV2.Info({
  198. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  199. api: { type: "aisdk", package: "test-provider" },
  200. })
  201. const model = new ModelV2.Info({
  202. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  203. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  204. cost: cost(1),
  205. })
  206. catalog.provider.update(provider.id, () => {})
  207. catalog.model.update(provider.id, model.id, (draft) => {
  208. draft.cost = [...model.cost]
  209. })
  210. })
  211. yield* addPlugin()
  212. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
  213. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  214. }),
  215. ),
  216. )
  217. it.effect("uses configured provider env vars as credentials", () =>
  218. withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
  219. Effect.gen(function* () {
  220. const catalog = yield* Catalog.Service
  221. const integrations = yield* Integration.Service
  222. yield* integrations.transform((editor) => {
  223. editor.method.update({
  224. integrationID: Integration.ID.make("opencode"),
  225. method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
  226. })
  227. })
  228. yield* catalog.transform((catalog) => {
  229. const provider = new ProviderV2.Info({
  230. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  231. api: { type: "aisdk", package: "test-provider" },
  232. })
  233. const model = new ModelV2.Info({
  234. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  235. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  236. cost: cost(1),
  237. })
  238. catalog.provider.update(provider.id, () => {})
  239. catalog.model.update(provider.id, model.id, (draft) => {
  240. draft.cost = [...model.cost]
  241. })
  242. })
  243. yield* addPlugin()
  244. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
  245. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  246. }),
  247. ),
  248. )
  249. it.effect("uses configured apiKey as credentials", () =>
  250. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  251. Effect.gen(function* () {
  252. const catalog = yield* Catalog.Service
  253. yield* catalog.transform((catalog) => {
  254. const provider = new ProviderV2.Info({
  255. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  256. api: { type: "aisdk", package: "test-provider" },
  257. request: {
  258. headers: {},
  259. body: { apiKey: "configured" },
  260. },
  261. })
  262. const model = new ModelV2.Info({
  263. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  264. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  265. cost: cost(1),
  266. })
  267. catalog.provider.update(provider.id, (draft) => {
  268. draft.request = provider.request
  269. })
  270. catalog.model.update(provider.id, model.id, (draft) => {
  271. draft.cost = [...model.cost]
  272. })
  273. })
  274. yield* addPlugin()
  275. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBe("configured")
  276. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  277. }),
  278. ),
  279. )
  280. it.effect("ignores non-opencode providers and models", () =>
  281. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  282. Effect.gen(function* () {
  283. const catalog = yield* Catalog.Service
  284. yield* catalog.transform((catalog) => {
  285. const provider = new ProviderV2.Info({
  286. ...ProviderV2.Info.empty(ProviderV2.ID.openai),
  287. api: { type: "aisdk", package: "test-provider" },
  288. })
  289. const model = new ModelV2.Info({
  290. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  291. api: { id: ModelV2.ID.make("paid"), type: "aisdk", package: "test-provider" },
  292. cost: cost(1),
  293. })
  294. catalog.provider.update(provider.id, () => {})
  295. catalog.model.update(provider.id, model.id, (draft) => {
  296. draft.cost = [...model.cost]
  297. })
  298. })
  299. yield* addPlugin()
  300. expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).request.body.apiKey).toBeUndefined()
  301. expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
  302. }),
  303. ),
  304. )
  305. it.effect("prefers gpt-5-nano as the opencode small model", () =>
  306. Effect.gen(function* () {
  307. const catalog = yield* Catalog.Service
  308. const providerID = ProviderV2.ID.opencode
  309. yield* catalog.transform((catalog) => {
  310. catalog.provider.update(providerID, () => {})
  311. catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
  312. model.capabilities.input = ["text"]
  313. model.capabilities.output = ["text"]
  314. model.cost = [...cost(1, 1)]
  315. model.time.released = Date.now()
  316. })
  317. catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
  318. model.capabilities.input = ["text"]
  319. model.capabilities.output = ["text"]
  320. model.cost = [...cost(10, 10)]
  321. model.time.released = Date.now()
  322. })
  323. })
  324. const selected = yield* catalog.model.small(providerID)
  325. expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
  326. }),
  327. )
  328. })