account.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import path from "path"
  2. import { describe, expect } from "bun:test"
  3. import { produce } from "immer"
  4. import { Effect, Fiber, Layer, Option, Stream } from "effect"
  5. import { Auth } from "@opencode-ai/core/auth"
  6. import { Catalog } from "@opencode-ai/core/catalog"
  7. import { EventV2 } from "@opencode-ai/core/event"
  8. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  9. import { Global } from "@opencode-ai/core/global"
  10. import { PluginV2 } from "@opencode-ai/core/plugin"
  11. import { AccountPlugin } from "@opencode-ai/core/plugin/account"
  12. import { ModelV2 } from "@opencode-ai/core/model"
  13. import { ProviderV2 } from "@opencode-ai/core/provider"
  14. import { tmpdir } from "./fixture/tmpdir"
  15. import { testEffect } from "./lib/effect"
  16. const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))
  17. function context(
  18. records: { provider: ProviderV2.Info; models: Map<ModelV2.ID, ModelV2.Info> }[],
  19. updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>,
  20. ): Catalog.Editor {
  21. return {
  22. provider: {
  23. list: () => records,
  24. get: (providerID) => records.find((item) => item.provider.id === providerID),
  25. update: (providerID, fn) => {
  26. const record = records.find((item) => item.provider.id === providerID)
  27. const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn)
  28. if (record) record.provider = provider
  29. else records.push({ provider, models: new Map<ModelV2.ID, ModelV2.Info>() })
  30. updates.push({
  31. id: providerID,
  32. enabled: provider.enabled,
  33. apiKey:
  34. typeof provider.options.aisdk.provider.apiKey === "string"
  35. ? provider.options.aisdk.provider.apiKey
  36. : undefined,
  37. })
  38. },
  39. remove: (providerID) => {
  40. const index = records.findIndex((item) => item.provider.id === providerID)
  41. if (index !== -1) records.splice(index, 1)
  42. },
  43. },
  44. model: {
  45. get: () => undefined,
  46. update: () => {},
  47. remove: () => {},
  48. default: {
  49. get: () => undefined,
  50. set: () => {},
  51. },
  52. },
  53. }
  54. }
  55. function testLayer(dir: string) {
  56. return Auth.layer.pipe(
  57. Layer.provide(AppFileSystem.defaultLayer),
  58. Layer.provideMerge(EventV2.defaultLayer),
  59. Layer.provide(
  60. Global.layerWith({
  61. data: dir,
  62. cache: path.join(dir, "cache"),
  63. config: path.join(dir, "config"),
  64. state: path.join(dir, "state"),
  65. tmp: path.join(dir, "tmp"),
  66. bin: path.join(dir, "bin"),
  67. log: path.join(dir, "log"),
  68. repos: path.join(dir, "repos"),
  69. }),
  70. ),
  71. )
  72. }
  73. describe("Auth", () => {
  74. it.live("emits account lifecycle events", () =>
  75. Effect.acquireRelease(
  76. Effect.promise(() => tmpdir()),
  77. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  78. ).pipe(
  79. Effect.flatMap((tmp) =>
  80. Effect.gen(function* () {
  81. const accounts = yield* Auth.Service
  82. const eventSvc = yield* EventV2.Service
  83. const addedFiber = yield* eventSvc
  84. .subscribe(Auth.Event.Added)
  85. .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
  86. const switchedFiber = yield* eventSvc
  87. .subscribe(Auth.Event.Switched)
  88. .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
  89. const removedFiber = yield* eventSvc
  90. .subscribe(Auth.Event.Removed)
  91. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  92. yield* Effect.yieldNow
  93. const first = yield* accounts.create({
  94. serviceID: Auth.ServiceID.make("provider"),
  95. credential: new Auth.ApiKeyCredential({ type: "api", key: "raw-key" }),
  96. })
  97. expect(first).toBeDefined()
  98. if (!first) return
  99. expect(first.description).toBe("default")
  100. expect(first.credential.type).toBe("api")
  101. if (first.credential.type === "api") expect(first.credential.key).toBe("raw-key")
  102. yield* accounts.update(first.id, { description: "keep" })
  103. const updated = yield* accounts.get(first.id)
  104. expect(updated?.description).toBe("keep")
  105. expect(updated?.credential.type).toBe("api")
  106. if (updated?.credential.type === "api") expect(updated.credential.key).toBe("raw-key")
  107. const second = yield* accounts.create({
  108. serviceID: Auth.ServiceID.make("provider"),
  109. credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }),
  110. })
  111. expect(second).toBeDefined()
  112. if (!second) return
  113. yield* accounts.remove(second.id)
  114. const added = Array.from(yield* Fiber.join(addedFiber))
  115. const switched = Array.from(yield* Fiber.join(switchedFiber))
  116. const removed = Array.from(yield* Fiber.join(removedFiber))
  117. expect(added.map((event) => event.data.account.id)).toEqual([first.id, second.id])
  118. expect(switched.map((event) => event.data)).toEqual([
  119. { serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id },
  120. { serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id },
  121. { serviceID: Auth.ServiceID.make("provider"), from: second.id, to: first.id },
  122. ])
  123. expect(removed[0]?.data.account.id).toBe(second.id)
  124. }).pipe(Effect.provide(testLayer(tmp.path))),
  125. ),
  126. ),
  127. )
  128. it.live("always switches to newly created accounts", () =>
  129. Effect.acquireRelease(
  130. Effect.promise(() => tmpdir()),
  131. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  132. ).pipe(
  133. Effect.flatMap((tmp) =>
  134. Effect.gen(function* () {
  135. const accounts = yield* Auth.Service
  136. const eventSvc = yield* EventV2.Service
  137. const switchedFiber = yield* eventSvc
  138. .subscribe(Auth.Event.Switched)
  139. .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
  140. yield* Effect.yieldNow
  141. const first = yield* accounts.create({
  142. serviceID: Auth.ServiceID.make("provider"),
  143. credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }),
  144. })
  145. const second = yield* accounts.create({
  146. serviceID: Auth.ServiceID.make("provider"),
  147. credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }),
  148. })
  149. const third = yield* accounts.create({
  150. serviceID: Auth.ServiceID.make("provider"),
  151. credential: new Auth.ApiKeyCredential({ type: "api", key: "third-key" }),
  152. })
  153. expect(first).toBeDefined()
  154. expect(second).toBeDefined()
  155. expect(third).toBeDefined()
  156. if (!first || !second || !third) return
  157. expect((yield* accounts.active(Auth.ServiceID.make("provider")))?.id).toBe(third.id)
  158. expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([
  159. { serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id },
  160. { serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id },
  161. { serviceID: Auth.ServiceID.make("provider"), from: second.id, to: third.id },
  162. ])
  163. }).pipe(Effect.provide(testLayer(tmp.path))),
  164. ),
  165. ),
  166. )
  167. it.live("account plugin refreshes providers on account lifecycle events", () =>
  168. Effect.acquireRelease(
  169. Effect.promise(() => tmpdir()),
  170. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  171. ).pipe(
  172. Effect.flatMap((tmp) =>
  173. Effect.gen(function* () {
  174. const accounts = yield* Auth.Service
  175. const plugin = yield* PluginV2.Service
  176. const records = [
  177. {
  178. provider: ProviderV2.Info.empty(ProviderV2.ID.make("provider")),
  179. models: new Map<ModelV2.ID, ModelV2.Info>(),
  180. },
  181. ]
  182. const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = []
  183. const catalog = Catalog.Service.of({
  184. transform: () => Effect.die("unexpected catalog.transform"),
  185. provider: {
  186. get: () => Effect.die("unexpected provider.get"),
  187. all: () => Effect.succeed([]),
  188. available: () => Effect.succeed([]),
  189. },
  190. model: {
  191. get: () => Effect.die("unexpected model.get"),
  192. all: () => Effect.succeed([]),
  193. available: () => Effect.succeed([]),
  194. default: () => Effect.succeed(Option.none<ModelV2.Info>()),
  195. small: () => Effect.succeed(Option.none<ModelV2.Info>()),
  196. },
  197. })
  198. const eventSvc = yield* EventV2.Service
  199. yield* plugin.add({
  200. ...AccountPlugin,
  201. effect: AccountPlugin.effect.pipe(
  202. Effect.provideService(Auth.Service, accounts),
  203. Effect.provideService(Catalog.Service, catalog),
  204. Effect.provideService(EventV2.Service, eventSvc),
  205. Effect.provideService(PluginV2.Service, plugin),
  206. ),
  207. })
  208. yield* Effect.yieldNow
  209. const first = yield* accounts.create({
  210. serviceID: Auth.ServiceID.make("provider"),
  211. credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }),
  212. })
  213. expect(first).toBeDefined()
  214. if (!first) return
  215. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  216. expect(updates).toEqual([
  217. {
  218. id: ProviderV2.ID.make("provider"),
  219. enabled: { via: "account", service: Auth.ServiceID.make("provider") },
  220. apiKey: "first-key",
  221. },
  222. ])
  223. updates.length = 0
  224. const second = yield* accounts.create({
  225. serviceID: Auth.ServiceID.make("provider"),
  226. credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }),
  227. })
  228. expect(second).toBeDefined()
  229. if (!second) return
  230. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  231. expect(updates).toEqual([
  232. {
  233. id: ProviderV2.ID.make("provider"),
  234. enabled: { via: "account", service: Auth.ServiceID.make("provider") },
  235. apiKey: "second-key",
  236. },
  237. ])
  238. updates.length = 0
  239. yield* accounts.activate(first.id)
  240. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  241. expect(updates).toEqual([
  242. {
  243. id: ProviderV2.ID.make("provider"),
  244. enabled: { via: "account", service: Auth.ServiceID.make("provider") },
  245. apiKey: "first-key",
  246. },
  247. ])
  248. updates.length = 0
  249. yield* accounts.remove(first.id)
  250. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  251. expect(updates).toEqual([
  252. {
  253. id: ProviderV2.ID.make("provider"),
  254. enabled: { via: "account", service: Auth.ServiceID.make("provider") },
  255. apiKey: "second-key",
  256. },
  257. ])
  258. updates.length = 0
  259. yield* accounts.remove(second.id)
  260. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  261. expect(updates).toEqual([])
  262. }).pipe(Effect.provide(testLayer(tmp.path))),
  263. ),
  264. ),
  265. )
  266. })