provider-opencode.test.ts 20 KB

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