models.test.ts 11 KB

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