models.test.ts 12 KB

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