models.test.ts 12 KB

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