models.test.ts 9.4 KB

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