models.test.ts 12 KB

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