1
0

models.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
  2. import { Money } from "@opencode-ai/schema/money"
  3. import { Effect, Layer, Ref } from "effect"
  4. import { HttpClient, HttpClientResponse } from "effect/unstable/http"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
  7. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  8. import { Flag } from "@opencode-ai/core/flag/flag"
  9. import { Global } from "@opencode-ai/core/global"
  10. import { ModelV2 } from "@opencode-ai/core/model"
  11. import { ModelsDev } from "@opencode-ai/core/models-dev"
  12. import { ProviderV2 } from "@opencode-ai/core/provider"
  13. import { it } from "./lib/effect"
  14. import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
  15. import path from "path"
  16. // test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
  17. // resolve providers without network. These tests need to drive the on-disk
  18. // cache themselves and silence the eager refresh fork. Save/restore around
  19. // the suite — never leak the mutation to subsequent test files in the same
  20. // bun process.
  21. const ORIGINAL_MODELS_PATH = Flag.OPENCODE_MODELS_PATH
  22. const ORIGINAL_DISABLE_FETCH = Flag.OPENCODE_DISABLE_MODELS_FETCH
  23. beforeAll(() => {
  24. Flag.OPENCODE_MODELS_PATH = undefined
  25. Flag.OPENCODE_DISABLE_MODELS_FETCH = true
  26. })
  27. afterAll(() => {
  28. Flag.OPENCODE_MODELS_PATH = ORIGINAL_MODELS_PATH
  29. Flag.OPENCODE_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH
  30. })
  31. const cacheFile = path.join(Global.Path.cache, "models.json")
  32. const fixture = {
  33. acme: {
  34. id: "acme",
  35. name: "Acme",
  36. env: ["ACME_API_KEY"],
  37. models: {
  38. "acme-1": {
  39. id: "acme-1",
  40. name: "Acme One",
  41. release_date: "2026-01-01",
  42. attachment: false,
  43. reasoning: false,
  44. temperature: true,
  45. tool_call: true,
  46. limit: { context: 128000, output: 8192 },
  47. },
  48. },
  49. },
  50. }
  51. const fixtureSnapshot = [
  52. {
  53. info: {
  54. id: ProviderV2.ID.make("acme"),
  55. name: "Acme",
  56. package: "",
  57. },
  58. models: [
  59. {
  60. id: ModelV2.ID.make("acme-1"),
  61. modelID: ModelV2.ID.make("acme-1"),
  62. providerID: ProviderV2.ID.make("acme"),
  63. name: "Acme One",
  64. family: undefined,
  65. package: undefined,
  66. settings: undefined,
  67. capabilities: { tools: true, input: [], output: [] },
  68. variants: [],
  69. time: { released: Date.parse("2026-01-01") },
  70. cost: [
  71. {
  72. input: Money.USDPerMillionTokens.zero,
  73. output: Money.USDPerMillionTokens.zero,
  74. cache: {
  75. read: Money.USDPerMillionTokens.zero,
  76. write: Money.USDPerMillionTokens.zero,
  77. },
  78. },
  79. ],
  80. status: "active",
  81. enabled: true,
  82. limit: { context: 128000, input: undefined, output: 8192 },
  83. headers: undefined,
  84. body: undefined,
  85. },
  86. ],
  87. environment: ["ACME_API_KEY"],
  88. },
  89. ] satisfies readonly ModelsDev.Snapshot[]
  90. const fixture2 = {
  91. beta: {
  92. id: "beta",
  93. name: "Beta",
  94. env: ["BETA_API_KEY"],
  95. models: {
  96. "beta-1": {
  97. id: "beta-1",
  98. name: "Beta One",
  99. release_date: "2026-02-01",
  100. attachment: false,
  101. reasoning: true,
  102. temperature: false,
  103. tool_call: false,
  104. limit: { context: 64000, output: 4096 },
  105. },
  106. },
  107. },
  108. }
  109. const fixture2Snapshot = [
  110. {
  111. info: {
  112. id: ProviderV2.ID.make("beta"),
  113. name: "Beta",
  114. package: "",
  115. },
  116. models: [
  117. {
  118. id: ModelV2.ID.make("beta-1"),
  119. modelID: ModelV2.ID.make("beta-1"),
  120. providerID: ProviderV2.ID.make("beta"),
  121. name: "Beta One",
  122. family: undefined,
  123. package: undefined,
  124. settings: undefined,
  125. capabilities: { tools: false, input: [], output: [] },
  126. variants: [],
  127. time: { released: Date.parse("2026-02-01") },
  128. cost: [
  129. {
  130. input: Money.USDPerMillionTokens.zero,
  131. output: Money.USDPerMillionTokens.zero,
  132. cache: {
  133. read: Money.USDPerMillionTokens.zero,
  134. write: Money.USDPerMillionTokens.zero,
  135. },
  136. },
  137. ],
  138. status: "active",
  139. enabled: true,
  140. limit: { context: 64000, input: undefined, output: 4096 },
  141. headers: undefined,
  142. body: undefined,
  143. },
  144. ],
  145. environment: ["BETA_API_KEY"],
  146. },
  147. ] satisfies readonly ModelsDev.Snapshot[]
  148. interface MockState {
  149. body: string
  150. status: number
  151. calls: Array<{ url: string; userAgent: string | null }>
  152. }
  153. const makeMockClient = (state: Ref.Ref<MockState>) =>
  154. HttpClient.make((request) =>
  155. Effect.gen(function* () {
  156. yield* Ref.update(state, (s) => ({
  157. ...s,
  158. calls: [...s.calls, { url: request.url, userAgent: request.headers["user-agent"] ?? null }],
  159. }))
  160. const s = yield* Ref.get(state)
  161. return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status }))
  162. }),
  163. )
  164. const buildLayer = (state: Ref.Ref<MockState>) =>
  165. // Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
  166. // and Effect.provide uses a process-global MemoMap by default — without fresh,
  167. // every test would reuse the cachedInvalidateWithTTL state from the first run.
  168. Layer.fresh(
  169. AppNodeBuilder.build(ModelsDev.node, [
  170. [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
  171. ]),
  172. )
  173. const writeCacheText = (text: string, mtimeMs?: number) =>
  174. Effect.promise(async () => {
  175. await mkdir(Global.Path.cache, { recursive: true })
  176. await writeFile(cacheFile, text)
  177. if (mtimeMs !== undefined) {
  178. const t = mtimeMs / 1000
  179. await utimes(cacheFile, t, t)
  180. }
  181. })
  182. const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.stringify(data), mtimeMs)
  183. const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
  184. eff.pipe(Effect.provide(buildLayer(state)))
  185. beforeEach(async () => {
  186. await rm(cacheFile, { force: true })
  187. })
  188. afterAll(async () => {
  189. await rm(cacheFile, { force: true })
  190. })
  191. const initialState: MockState = {
  192. body: JSON.stringify(fixture),
  193. status: 200,
  194. calls: [],
  195. }
  196. describe("ModelsDev Service", () => {
  197. it.live("get() returns normalized snapshots from disk when cache file exists", () =>
  198. Effect.gen(function* () {
  199. yield* writeCache(fixture)
  200. const state = yield* Ref.make(initialState)
  201. const result = yield* provided(
  202. state,
  203. ModelsDev.Service.use((s) => s.get()),
  204. )
  205. expect(result).toEqual(fixtureSnapshot)
  206. const final = yield* Ref.get(state)
  207. expect(final.calls).toEqual([])
  208. }),
  209. )
  210. it.live("get() returns empty catalog when disk empty, fetch disabled, and no bundled snapshot is injected", () =>
  211. Effect.gen(function* () {
  212. const state = yield* Ref.make(initialState)
  213. const result = yield* provided(
  214. state,
  215. ModelsDev.Service.use((s) => s.get()),
  216. )
  217. expect(result).toEqual([])
  218. const final = yield* Ref.get(state)
  219. expect(final.calls).toEqual([])
  220. }),
  221. )
  222. it.live("get() recovers from a corrupted cache file by fetching a fresh catalog", () =>
  223. Effect.gen(function* () {
  224. yield* writeCacheText("{")
  225. const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
  226. const context = yield* Layer.build(buildLayer(state))
  227. const result = yield* Effect.acquireUseRelease(
  228. Effect.sync(() => {
  229. Flag.OPENCODE_DISABLE_MODELS_FETCH = false
  230. }),
  231. () => ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)),
  232. () =>
  233. Effect.sync(() => {
  234. Flag.OPENCODE_DISABLE_MODELS_FETCH = true
  235. }),
  236. )
  237. expect(result).toEqual(fixture2Snapshot)
  238. expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
  239. const final = yield* Ref.get(state)
  240. expect(final.calls.length).toBe(1)
  241. }),
  242. )
  243. it.live("get() is single-flight under concurrent calls", () =>
  244. Effect.gen(function* () {
  245. yield* writeCache(fixture)
  246. const state = yield* Ref.make(initialState)
  247. const results = yield* provided(
  248. state,
  249. Effect.gen(function* () {
  250. const svc = yield* ModelsDev.Service
  251. return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
  252. concurrency: "unbounded",
  253. })
  254. }),
  255. )
  256. for (const result of results) expect(result).toEqual(fixtureSnapshot)
  257. }),
  258. )
  259. it.live("get() caches across calls (later disk writes are ignored until invalidate)", () =>
  260. Effect.gen(function* () {
  261. yield* writeCache(fixture)
  262. const state = yield* Ref.make(initialState)
  263. const first = yield* provided(
  264. state,
  265. Effect.gen(function* () {
  266. const svc = yield* ModelsDev.Service
  267. const a = yield* svc.get()
  268. // mutate disk between calls — cache should mask the change
  269. yield* writeCache(fixture2)
  270. const b = yield* svc.get()
  271. return { a, b }
  272. }),
  273. )
  274. expect(first.a).toEqual(fixtureSnapshot)
  275. expect(first.b).toEqual(fixtureSnapshot)
  276. }),
  277. )
  278. it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
  279. Effect.gen(function* () {
  280. yield* writeCache(fixture)
  281. const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
  282. const result = yield* provided(
  283. state,
  284. Effect.gen(function* () {
  285. const svc = yield* ModelsDev.Service
  286. const before = yield* svc.get()
  287. yield* svc.refresh(true)
  288. const after = yield* svc.get()
  289. return { before, after }
  290. }),
  291. )
  292. expect(result.before).toEqual(fixtureSnapshot)
  293. expect(result.after).toEqual(fixture2Snapshot)
  294. const final = yield* Ref.get(state)
  295. expect(final.calls.length).toBe(1)
  296. expect(final.calls[0].url).toContain("/api.json")
  297. expect(final.calls[0].userAgent).toContain("/cli")
  298. }),
  299. )
  300. it.live("refresh(false) skips fetch when on-disk file is fresh", () =>
  301. Effect.gen(function* () {
  302. // Fresh: mtime within the 5-minute TTL.
  303. yield* writeCache(fixture, Date.now() - 1000)
  304. const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
  305. yield* provided(
  306. state,
  307. ModelsDev.Service.use((s) => s.refresh(false)),
  308. )
  309. const final = yield* Ref.get(state)
  310. expect(final.calls).toEqual([])
  311. }),
  312. )
  313. it.live("refresh(false) fetches when on-disk file is stale", () =>
  314. Effect.gen(function* () {
  315. // Stale: mtime 10 minutes ago, beyond the 5-minute TTL.
  316. yield* writeCache(fixture, Date.now() - 10 * 60 * 1000)
  317. const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
  318. const after = yield* provided(
  319. state,
  320. Effect.gen(function* () {
  321. const svc = yield* ModelsDev.Service
  322. yield* svc.refresh(false)
  323. return yield* svc.get()
  324. }),
  325. )
  326. const final = yield* Ref.get(state)
  327. expect(final.calls.length).toBe(1)
  328. expect(after).toEqual(fixture2Snapshot)
  329. }),
  330. )
  331. it.live("refresh swallows HTTP errors and leaves cache intact", () =>
  332. Effect.gen(function* () {
  333. yield* writeCache(fixture)
  334. const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
  335. const result = yield* provided(
  336. state,
  337. Effect.gen(function* () {
  338. const svc = yield* ModelsDev.Service
  339. yield* svc.refresh(true)
  340. return yield* svc.get()
  341. }),
  342. )
  343. expect(result).toEqual(fixtureSnapshot)
  344. // retryTransient retries 5xx, so calls may be > 1.
  345. const final = yield* Ref.get(state)
  346. expect(final.calls.length).toBeGreaterThanOrEqual(1)
  347. }),
  348. )
  349. })