models.test.ts 10 KB

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