models.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
  2. import { Effect, Layer, Ref } from "effect"
  3. import { HttpClient, HttpClientResponse } from "effect/unstable/http"
  4. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  5. import { Flag } from "@opencode-ai/core/flag/flag"
  6. import { Global } from "@opencode-ai/core/global"
  7. import { ModelsDev } from "@opencode-ai/core/models"
  8. import { it } from "./lib/effect"
  9. import { rm, writeFile, utimes, mkdir } from "fs/promises"
  10. import path from "path"
  11. // test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
  12. // resolve providers without network. These tests need to drive the on-disk
  13. // cache themselves and silence the eager refresh fork. Save/restore around
  14. // the suite — never leak the mutation to subsequent test files in the same
  15. // bun process.
  16. const ORIGINAL_MODELS_PATH = Flag.OPENCODE_MODELS_PATH
  17. const ORIGINAL_DISABLE_FETCH = Flag.OPENCODE_DISABLE_MODELS_FETCH
  18. beforeAll(() => {
  19. Flag.OPENCODE_MODELS_PATH = undefined
  20. Flag.OPENCODE_DISABLE_MODELS_FETCH = true
  21. })
  22. afterAll(() => {
  23. Flag.OPENCODE_MODELS_PATH = ORIGINAL_MODELS_PATH
  24. Flag.OPENCODE_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH
  25. })
  26. const cacheFile = path.join(Global.Path.cache, "models.json")
  27. const fixture: Record<string, ModelsDev.Provider> = {
  28. acme: {
  29. id: "acme",
  30. name: "Acme",
  31. env: ["ACME_API_KEY"],
  32. models: {
  33. "acme-1": {
  34. id: "acme-1",
  35. name: "Acme One",
  36. release_date: "2026-01-01",
  37. attachment: false,
  38. reasoning: false,
  39. temperature: true,
  40. tool_call: true,
  41. limit: { context: 128000, output: 8192 },
  42. },
  43. },
  44. },
  45. }
  46. const fixture2: Record<string, ModelsDev.Provider> = {
  47. beta: {
  48. id: "beta",
  49. name: "Beta",
  50. env: ["BETA_API_KEY"],
  51. models: {
  52. "beta-1": {
  53. id: "beta-1",
  54. name: "Beta One",
  55. release_date: "2026-02-01",
  56. attachment: false,
  57. reasoning: true,
  58. temperature: false,
  59. tool_call: false,
  60. limit: { context: 64000, output: 4096 },
  61. },
  62. },
  63. },
  64. }
  65. interface MockState {
  66. body: string
  67. status: number
  68. calls: Array<{ url: string; userAgent: string | null }>
  69. }
  70. const makeMockClient = (state: Ref.Ref<MockState>) =>
  71. HttpClient.make((request) =>
  72. Effect.gen(function* () {
  73. yield* Ref.update(state, (s) => ({
  74. ...s,
  75. calls: [...s.calls, { url: request.url, userAgent: request.headers["user-agent"] ?? null }],
  76. }))
  77. const s = yield* Ref.get(state)
  78. return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status }))
  79. }),
  80. )
  81. const buildLayer = (state: Ref.Ref<MockState>) =>
  82. // Layer.fresh is required: ModelsDev.layer is a module-level Layer constant,
  83. // and Effect.provide uses a process-global MemoMap by default — without fresh,
  84. // every test would reuse the cachedInvalidateWithTTL state from the first run.
  85. Layer.fresh(ModelsDev.layer).pipe(
  86. Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
  87. Layer.provide(AppFileSystem.defaultLayer),
  88. )
  89. const writeCache = (data: object, mtimeMs?: number) =>
  90. Effect.promise(async () => {
  91. await mkdir(Global.Path.cache, { recursive: true })
  92. await writeFile(cacheFile, JSON.stringify(data))
  93. if (mtimeMs !== undefined) {
  94. const t = mtimeMs / 1000
  95. await utimes(cacheFile, t, t)
  96. }
  97. })
  98. const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
  99. eff.pipe(Effect.provide(buildLayer(state)))
  100. beforeEach(async () => {
  101. await rm(cacheFile, { force: true })
  102. })
  103. afterAll(async () => {
  104. await rm(cacheFile, { force: true })
  105. })
  106. const initialState: MockState = {
  107. body: JSON.stringify(fixture),
  108. status: 200,
  109. calls: [],
  110. }
  111. describe("ModelsDev Service", () => {
  112. it.live("get() returns providers from disk when cache file exists", () =>
  113. Effect.gen(function* () {
  114. yield* writeCache(fixture)
  115. const state = yield* Ref.make(initialState)
  116. const result = yield* provided(
  117. state,
  118. ModelsDev.Service.use((s) => s.get()),
  119. )
  120. expect(result).toEqual(fixture)
  121. const final = yield* Ref.get(state)
  122. expect(final.calls).toEqual([])
  123. }),
  124. )
  125. it.live("get() returns bundled snapshot when disk empty and fetch disabled", () =>
  126. Effect.gen(function* () {
  127. const state = yield* Ref.make(initialState)
  128. const result = yield* provided(
  129. state,
  130. ModelsDev.Service.use((s) => s.get()),
  131. )
  132. expect(Object.keys(result).length).toBeGreaterThan(0)
  133. const final = yield* Ref.get(state)
  134. expect(final.calls).toEqual([])
  135. }),
  136. )
  137. it.live("get() is single-flight under concurrent calls", () =>
  138. Effect.gen(function* () {
  139. yield* writeCache(fixture)
  140. const state = yield* Ref.make(initialState)
  141. const results = yield* provided(
  142. state,
  143. Effect.gen(function* () {
  144. const svc = yield* ModelsDev.Service
  145. return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
  146. concurrency: "unbounded",
  147. })
  148. }),
  149. )
  150. for (const result of results) expect(result).toEqual(fixture)
  151. }),
  152. )
  153. it.live("get() caches across calls (later disk writes are ignored until invalidate)", () =>
  154. Effect.gen(function* () {
  155. yield* writeCache(fixture)
  156. const state = yield* Ref.make(initialState)
  157. const first = yield* provided(
  158. state,
  159. Effect.gen(function* () {
  160. const svc = yield* ModelsDev.Service
  161. const a = yield* svc.get()
  162. // mutate disk between calls — cache should mask the change
  163. yield* writeCache(fixture2)
  164. const b = yield* svc.get()
  165. return { a, b }
  166. }),
  167. )
  168. expect(first.a).toEqual(fixture)
  169. expect(first.b).toEqual(fixture)
  170. }),
  171. )
  172. it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
  173. Effect.gen(function* () {
  174. yield* writeCache(fixture)
  175. const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
  176. const result = yield* provided(
  177. state,
  178. Effect.gen(function* () {
  179. const svc = yield* ModelsDev.Service
  180. const before = yield* svc.get()
  181. yield* svc.refresh(true)
  182. const after = yield* svc.get()
  183. return { before, after }
  184. }),
  185. )
  186. expect(result.before).toEqual(fixture)
  187. expect(result.after).toEqual(fixture2)
  188. const final = yield* Ref.get(state)
  189. expect(final.calls.length).toBe(1)
  190. expect(final.calls[0].url).toContain("/api.json")
  191. expect(final.calls[0].userAgent).toContain("/cli")
  192. }),
  193. )
  194. it.live("refresh(false) skips fetch when on-disk file is fresh", () =>
  195. Effect.gen(function* () {
  196. // Fresh: mtime within the 5-minute TTL.
  197. yield* writeCache(fixture, Date.now() - 1000)
  198. const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
  199. yield* provided(
  200. state,
  201. ModelsDev.Service.use((s) => s.refresh(false)),
  202. )
  203. const final = yield* Ref.get(state)
  204. expect(final.calls).toEqual([])
  205. }),
  206. )
  207. it.live("refresh(false) fetches when on-disk file is stale", () =>
  208. Effect.gen(function* () {
  209. // Stale: mtime 10 minutes ago, beyond the 5-minute TTL.
  210. yield* writeCache(fixture, Date.now() - 10 * 60 * 1000)
  211. const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
  212. const after = yield* provided(
  213. state,
  214. Effect.gen(function* () {
  215. const svc = yield* ModelsDev.Service
  216. yield* svc.refresh(false)
  217. return yield* svc.get()
  218. }),
  219. )
  220. const final = yield* Ref.get(state)
  221. expect(final.calls.length).toBe(1)
  222. expect(after).toEqual(fixture2)
  223. }),
  224. )
  225. it.live("refresh swallows HTTP errors and leaves cache intact", () =>
  226. Effect.gen(function* () {
  227. yield* writeCache(fixture)
  228. const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
  229. const result = yield* provided(
  230. state,
  231. Effect.gen(function* () {
  232. const svc = yield* ModelsDev.Service
  233. yield* svc.refresh(true)
  234. return yield* svc.get()
  235. }),
  236. )
  237. expect(result).toEqual(fixture)
  238. // retryTransient retries 5xx, so calls may be > 1.
  239. const final = yield* Ref.get(state)
  240. expect(final.calls.length).toBeGreaterThanOrEqual(1)
  241. }),
  242. )
  243. })