provider-opencode.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  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 { EventV2 } from "@opencode-ai/core/event"
  6. import { Integration } from "@opencode-ai/core/integration"
  7. import { ModelV2 } from "@opencode-ai/core/model"
  8. import { PluginV2 } from "@opencode-ai/core/plugin"
  9. import { PluginHost } from "@opencode-ai/core/plugin/host"
  10. import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
  11. import { ProviderV2 } from "@opencode-ai/core/provider"
  12. import { testEffect } from "../lib/effect"
  13. import { PluginTestLayer } from "./fixture"
  14. const it = testEffect(PluginTestLayer)
  15. const addPlugin = Effect.fn(function* () {
  16. const plugin = yield* PluginV2.Service
  17. const host = yield* PluginHost.make(plugin)
  18. const events = yield* EventV2.Service
  19. const integration = yield* Integration.Service
  20. yield* OpencodePlugin.effect(host).pipe(
  21. Effect.provideService(EventV2.Service, events),
  22. Effect.provideService(Integration.Service, integration),
  23. )
  24. })
  25. function required<T>(value: T | undefined): T {
  26. if (value === undefined) throw new Error("Expected value")
  27. return value
  28. }
  29. function eventually<A>(
  30. effect: Effect.Effect<A>,
  31. predicate: (value: A) => boolean,
  32. remaining = 1000,
  33. ): Effect.Effect<A, Error> {
  34. return Effect.gen(function* () {
  35. const value = yield* effect
  36. if (predicate(value)) return value
  37. if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
  38. yield* Effect.promise(() => Bun.sleep(1))
  39. return yield* eventually(effect, predicate, remaining - 1)
  40. })
  41. }
  42. function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
  43. return Effect.acquireUseRelease(
  44. Effect.sync(() => {
  45. const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
  46. Object.entries(vars).forEach(([key, value]) => {
  47. if (value === undefined) delete process.env[key]
  48. else process.env[key] = value
  49. })
  50. return previous
  51. }),
  52. effect,
  53. (previous) =>
  54. Effect.sync(() =>
  55. Object.entries(previous).forEach(([key, value]) => {
  56. if (value === undefined) delete process.env[key]
  57. else process.env[key] = value
  58. }),
  59. ),
  60. )
  61. }
  62. const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
  63. describe("OpencodePlugin", () => {
  64. it.effect("registers account and service account methods", () =>
  65. Effect.gen(function* () {
  66. yield* addPlugin()
  67. expect((yield* (yield* Integration.Service).get(Integration.ID.make("opencode")))?.methods).toEqual([
  68. {
  69. id: Integration.MethodID.make("device"),
  70. type: "oauth",
  71. label: "OpenCode Console account",
  72. },
  73. { type: "key", label: "API key (service account)" },
  74. ])
  75. }),
  76. )
  77. it.live("loads providers and models from the connected OpenCode server", () =>
  78. Effect.acquireUseRelease(
  79. Effect.sync(() => {
  80. const authorization: Array<string | null> = []
  81. const gate = Promise.withResolvers<void>()
  82. return {
  83. authorization,
  84. release: gate.resolve,
  85. server: Bun.serve({
  86. port: 0,
  87. fetch: async (request) => {
  88. await gate.promise
  89. authorization.push(request.headers.get("authorization"))
  90. const origin = new URL(request.url).origin
  91. return Response.json({
  92. config: {
  93. enterprise: { url: origin },
  94. provider: {
  95. remote: {
  96. name: "Remote",
  97. npm: "@ai-sdk/openai-compatible",
  98. api: `${origin}/v1`,
  99. env: ["REMOTE_API_KEY"],
  100. options: {
  101. apiKey: "{env:REMOTE_API_KEY}",
  102. headers: { "x-org-id": "org" },
  103. custom: "value",
  104. },
  105. models: {
  106. model: {
  107. name: "Remote Model",
  108. family: "remote",
  109. release_date: "2026-01-02",
  110. tool_call: true,
  111. modalities: { input: ["text", "image"], output: ["text"] },
  112. options: { apiKey: "model-secret", temperature: 0.5 },
  113. variants: { high: { apiKey: "variant-secret", temperature: 0.2 } },
  114. cost: { input: 1, output: 2, cache_read: 0.1 },
  115. limit: { context: 1000, output: 100 },
  116. },
  117. disabled: { name: "Disabled", status: "deprecated" },
  118. },
  119. },
  120. },
  121. },
  122. })
  123. },
  124. }),
  125. }
  126. }),
  127. ({ authorization, release, server }) =>
  128. Effect.gen(function* () {
  129. const credentials = yield* Credential.Service
  130. const catalog = yield* Catalog.Service
  131. yield* catalog.transform((draft) => {
  132. draft.provider.update(ProviderV2.ID.make("remote"), () => {})
  133. draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("model"), (model) => {
  134. model.variants = [
  135. {
  136. id: ModelV2.VariantID.make("custom"),
  137. settings: {},
  138. headers: { "x-custom": "true" },
  139. body: { custom: true },
  140. },
  141. ]
  142. })
  143. draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"), () => {})
  144. })
  145. yield* credentials.create({
  146. integrationID: Integration.ID.make("opencode"),
  147. value: Credential.Key.make({
  148. type: "key",
  149. key: "secret",
  150. metadata: { server: server.url.origin },
  151. }),
  152. })
  153. yield* addPlugin()
  154. expect(authorization).toEqual([])
  155. release()
  156. const provider = required(
  157. yield* eventually(
  158. catalog.provider.get(ProviderV2.ID.make("remote")),
  159. (item) => item?.integrationID === Integration.ID.make("opencode"),
  160. ),
  161. )
  162. expect(provider).toMatchObject({
  163. name: "Remote",
  164. integrationID: "opencode",
  165. package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
  166. settings: { baseURL: `${server.url.origin}/v1`, custom: "value" },
  167. headers: { "x-org-id": "org" },
  168. })
  169. expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
  170. const model = required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))
  171. expect(model).toMatchObject({
  172. name: "Remote Model",
  173. family: "remote",
  174. capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
  175. cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }],
  176. limit: { context: 1000, output: 100 },
  177. package: ProviderV2.aisdk("@ai-sdk/openai-compatible"),
  178. settings: { baseURL: `${server.url.origin}/v1`, custom: "value", temperature: 0.5 },
  179. headers: { "x-org-id": "org" },
  180. })
  181. expect(model.variants).toEqual([
  182. {
  183. id: ModelV2.VariantID.make("custom"),
  184. settings: {},
  185. headers: { "x-custom": "true" },
  186. body: { custom: true },
  187. },
  188. {
  189. id: ModelV2.VariantID.make("high"),
  190. settings: { temperature: 0.2 },
  191. headers: {},
  192. },
  193. ])
  194. expect(
  195. required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("disabled"))).enabled,
  196. ).toBe(false)
  197. expect(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"))).toBeDefined()
  198. expect(authorization).toContain("Bearer secret")
  199. }),
  200. ({ server }) => Effect.promise(() => server.stop(true)),
  201. ),
  202. )
  203. it.effect("uses a public key and disables paid models without credentials", () =>
  204. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  205. Effect.gen(function* () {
  206. const catalog = yield* Catalog.Service
  207. yield* catalog.transform((catalog) => {
  208. const provider = ProviderV2.Info.make({
  209. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  210. package: ProviderV2.aisdk("test-provider"),
  211. })
  212. const model = ModelV2.Info.make({
  213. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  214. modelID: ModelV2.ID.make("paid"),
  215. package: ProviderV2.aisdk("test-provider"),
  216. cost: cost(1),
  217. })
  218. catalog.provider.update(provider.id, () => {})
  219. catalog.model.update(provider.id, model.id, (draft) => {
  220. draft.cost = [...model.cost]
  221. })
  222. })
  223. yield* addPlugin()
  224. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("public")
  225. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
  226. }),
  227. ),
  228. )
  229. it.effect("keeps free models without credentials", () =>
  230. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  231. Effect.gen(function* () {
  232. const catalog = yield* Catalog.Service
  233. yield* catalog.transform((catalog) => {
  234. const provider = ProviderV2.Info.make({
  235. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  236. package: ProviderV2.aisdk("test-provider"),
  237. })
  238. const model = ModelV2.Info.make({
  239. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")),
  240. modelID: ModelV2.ID.make("free"),
  241. package: ProviderV2.aisdk("test-provider"),
  242. cost: cost(0),
  243. })
  244. catalog.provider.update(provider.id, () => {})
  245. catalog.model.update(provider.id, model.id, (draft) => {
  246. draft.cost = [...model.cost]
  247. })
  248. })
  249. yield* addPlugin()
  250. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("public")
  251. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
  252. }),
  253. ),
  254. )
  255. it.effect("treats output-only cost as free without credentials", () =>
  256. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  257. Effect.gen(function* () {
  258. const catalog = yield* Catalog.Service
  259. yield* catalog.transform((catalog) => {
  260. const provider = ProviderV2.Info.make({
  261. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  262. package: ProviderV2.aisdk("test-provider"),
  263. })
  264. const model = ModelV2.Info.make({
  265. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")),
  266. modelID: ModelV2.ID.make("output-only"),
  267. package: ProviderV2.aisdk("test-provider"),
  268. cost: cost(0, 1),
  269. })
  270. catalog.provider.update(provider.id, () => {})
  271. catalog.model.update(provider.id, model.id, (draft) => {
  272. draft.cost = [...model.cost]
  273. })
  274. })
  275. yield* addPlugin()
  276. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("public")
  277. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(
  278. true,
  279. )
  280. }),
  281. ),
  282. )
  283. it.effect("uses OPENCODE_API_KEY as credentials", () =>
  284. withEnv({ OPENCODE_API_KEY: "secret" }, () =>
  285. Effect.gen(function* () {
  286. const catalog = yield* Catalog.Service
  287. yield* catalog.transform((catalog) => {
  288. const provider = ProviderV2.Info.make({
  289. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  290. package: ProviderV2.aisdk("test-provider"),
  291. })
  292. const model = ModelV2.Info.make({
  293. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  294. modelID: ModelV2.ID.make("paid"),
  295. package: ProviderV2.aisdk("test-provider"),
  296. cost: cost(1),
  297. })
  298. catalog.provider.update(provider.id, () => {})
  299. catalog.model.update(provider.id, model.id, (draft) => {
  300. draft.cost = [...model.cost]
  301. })
  302. })
  303. yield* addPlugin()
  304. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBeUndefined()
  305. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  306. }),
  307. ),
  308. )
  309. it.effect("uses configured provider env vars as credentials", () =>
  310. withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
  311. Effect.gen(function* () {
  312. const catalog = yield* Catalog.Service
  313. const integrations = yield* Integration.Service
  314. yield* integrations.transform((editor) => {
  315. editor.method.update({
  316. integrationID: Integration.ID.make("opencode"),
  317. method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
  318. })
  319. })
  320. yield* catalog.transform((catalog) => {
  321. const provider = ProviderV2.Info.make({
  322. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  323. package: ProviderV2.aisdk("test-provider"),
  324. })
  325. const model = ModelV2.Info.make({
  326. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  327. modelID: ModelV2.ID.make("paid"),
  328. package: ProviderV2.aisdk("test-provider"),
  329. cost: cost(1),
  330. })
  331. catalog.provider.update(provider.id, () => {})
  332. catalog.model.update(provider.id, model.id, (draft) => {
  333. draft.cost = [...model.cost]
  334. })
  335. })
  336. yield* addPlugin()
  337. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBeUndefined()
  338. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  339. }),
  340. ),
  341. )
  342. it.effect("uses configured apiKey as credentials", () =>
  343. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  344. Effect.gen(function* () {
  345. const catalog = yield* Catalog.Service
  346. yield* catalog.transform((catalog) => {
  347. const provider = ProviderV2.Info.make({
  348. ...ProviderV2.Info.empty(ProviderV2.ID.opencode),
  349. package: ProviderV2.aisdk("test-provider"),
  350. settings: { apiKey: "configured" },
  351. })
  352. const model = ModelV2.Info.make({
  353. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  354. modelID: ModelV2.ID.make("paid"),
  355. package: ProviderV2.aisdk("test-provider"),
  356. cost: cost(1),
  357. })
  358. catalog.provider.update(provider.id, (draft) => {
  359. draft.package = provider.package
  360. draft.settings = { apiKey: "configured" }
  361. })
  362. catalog.model.update(provider.id, model.id, (draft) => {
  363. draft.cost = [...model.cost]
  364. })
  365. })
  366. yield* addPlugin()
  367. expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).settings?.apiKey).toBe("configured")
  368. expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
  369. }),
  370. ),
  371. )
  372. it.effect("ignores non-opencode providers and models", () =>
  373. withEnv({ OPENCODE_API_KEY: undefined }, () =>
  374. Effect.gen(function* () {
  375. const catalog = yield* Catalog.Service
  376. yield* catalog.transform((catalog) => {
  377. const provider = ProviderV2.Info.make({
  378. ...ProviderV2.Info.empty(ProviderV2.ID.openai),
  379. package: ProviderV2.aisdk("test-provider"),
  380. })
  381. const model = ModelV2.Info.make({
  382. ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
  383. modelID: ModelV2.ID.make("paid"),
  384. package: ProviderV2.aisdk("test-provider"),
  385. cost: cost(1),
  386. })
  387. catalog.provider.update(provider.id, () => {})
  388. catalog.model.update(provider.id, model.id, (draft) => {
  389. draft.cost = [...model.cost]
  390. })
  391. })
  392. yield* addPlugin()
  393. expect(required(yield* catalog.provider.get(ProviderV2.ID.openai)).settings?.apiKey).toBeUndefined()
  394. expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
  395. }),
  396. ),
  397. )
  398. it.effect("prefers gpt-5-nano as the opencode small model", () =>
  399. Effect.gen(function* () {
  400. const catalog = yield* Catalog.Service
  401. const providerID = ProviderV2.ID.opencode
  402. yield* catalog.transform((catalog) => {
  403. catalog.provider.update(providerID, () => {})
  404. catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
  405. model.capabilities.input = ["text"]
  406. model.capabilities.output = ["text"]
  407. model.cost = [...cost(1, 1)]
  408. model.time.released = Date.now()
  409. })
  410. catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
  411. model.capabilities.input = ["text"]
  412. model.capabilities.output = ["text"]
  413. model.cost = [...cost(10, 10)]
  414. model.time.released = Date.now()
  415. })
  416. })
  417. const selected = yield* catalog.model.small(providerID)
  418. expect(selected?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
  419. }),
  420. )
  421. })