models.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  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. activation: "auto",
  47. package: Provider.aisdk("@ai-sdk/openai-compatible"),
  48. },
  49. models: [
  50. {
  51. id: Model.ID.make("acme-1"),
  52. modelID: Model.ID.make("acme-1"),
  53. providerID: Provider.ID.make("acme"),
  54. name: "Acme One",
  55. compatibility: { reasoningField: "vendor_reasoning" },
  56. family: undefined,
  57. package: undefined,
  58. settings: undefined,
  59. capabilities: { tools: true, input: [], output: [] },
  60. variants: [],
  61. time: { released: Date.parse("2026-01-01") },
  62. cost: [
  63. {
  64. input: Money.USDPerMillionTokens.zero,
  65. output: Money.USDPerMillionTokens.zero,
  66. cache: {
  67. read: Money.USDPerMillionTokens.zero,
  68. write: Money.USDPerMillionTokens.zero,
  69. },
  70. },
  71. ],
  72. status: "active",
  73. enabled: true,
  74. limit: { context: 128000, input: undefined, output: 8192 },
  75. headers: undefined,
  76. body: undefined,
  77. },
  78. ],
  79. environment: ["ACME_API_KEY"],
  80. },
  81. ] satisfies readonly ModelsDev.Snapshot[]
  82. const fixture2 = {
  83. beta: {
  84. id: "beta",
  85. name: "Beta",
  86. env: ["BETA_API_KEY"],
  87. npm: "@ai-sdk/openai-compatible",
  88. models: {
  89. "beta-1": {
  90. id: "beta-1",
  91. name: "Beta One",
  92. release_date: "2026-02-01",
  93. attachment: false,
  94. reasoning: true,
  95. temperature: false,
  96. tool_call: false,
  97. limit: { context: 64000, output: 4096 },
  98. },
  99. },
  100. },
  101. }
  102. const fixture2Snapshot = [
  103. {
  104. info: {
  105. id: Provider.ID.make("beta"),
  106. name: "Beta",
  107. activation: "auto",
  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. interface MockCache {
  159. readonly values: Map<string, KV.Value>
  160. }
  161. const makeMockKV = (cache: MockCache) =>
  162. Layer.mock(KV.Service, {
  163. get: (key) => Effect.sync(() => cache.values.get(key)),
  164. set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
  165. remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
  166. })
  167. const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
  168. // Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
  169. // and Effect.provide uses a process-global MemoMap by default — without fresh,
  170. // every test would reuse the cachedInvalidateWithTTL state from the first run.
  171. Layer.fresh(
  172. AppNodeBuilder.build(ModelsDev.node, [
  173. [ModelsDev.node, ModelsDev.configured(options)],
  174. [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
  175. [KV.node, makeMockKV(cache)],
  176. ]),
  177. )
  178. // Mirrors production KV backends whose writes die as defects (e.g. Durable
  179. // Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
  180. const makeFailingWriteKV = (cache: MockCache) =>
  181. Layer.mock(KV.Service, {
  182. get: (key) => Effect.sync(() => cache.values.get(key)),
  183. set: () => Effect.die(new Error('Failed query: insert into "kv"')),
  184. remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
  185. })
  186. const makeCache = (): MockCache => ({ values: new Map() })
  187. const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
  188. cache.values.set(cacheKey, { updatedAt, body: text })
  189. const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
  190. writeCacheText(cache, JSON.stringify(data), updatedAt)
  191. const provided = <A, E>(state: Ref.Ref<MockState>, cache: MockCache, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
  192. eff.pipe(Effect.provide(buildLayer(state, cache)))
  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 KV when a cache entry exists", () =>
  200. Effect.gen(function* () {
  201. const cache = makeCache()
  202. writeCache(cache, fixture)
  203. const state = yield* Ref.make(initialState)
  204. const result = yield* provided(
  205. state,
  206. cache,
  207. ModelsDev.Service.use((s) => s.get()),
  208. )
  209. expect(result).toEqual(fixtureSnapshot)
  210. const final = yield* Ref.get(state)
  211. expect(final.calls).toEqual([])
  212. }),
  213. )
  214. it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
  215. Effect.gen(function* () {
  216. const cache = makeCache()
  217. const state = yield* Ref.make(initialState)
  218. const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(
  219. Effect.provide(buildLayer(state, cache, { fetch: false, snapshot: false })),
  220. )
  221. expect(result).toEqual([])
  222. const final = yield* Ref.get(state)
  223. expect(final.calls).toEqual([])
  224. }),
  225. )
  226. it.live("get() falls back to the bundled snapshot when KV is empty and fetch is disabled", () =>
  227. Effect.gen(function* () {
  228. const cache = makeCache()
  229. const state = yield* Ref.make(initialState)
  230. const result = yield* provided(
  231. state,
  232. cache,
  233. ModelsDev.Service.use((s) => s.get()),
  234. )
  235. expect(result.length).toBeGreaterThan(0)
  236. const anthropic = result.find((snapshot) => snapshot.info.id === "anthropic")
  237. expect(anthropic?.environment).toContain("ANTHROPIC_API_KEY")
  238. const final = yield* Ref.get(state)
  239. expect(final.calls).toEqual([])
  240. }),
  241. )
  242. it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
  243. Effect.gen(function* () {
  244. const cache = makeCache()
  245. writeCacheText(cache, "{")
  246. const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
  247. const context = yield* Layer.build(buildLayer(state, cache, { fetch: true, snapshot: false }))
  248. const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
  249. expect(result).toEqual(fixture2Snapshot)
  250. expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
  251. const final = yield* Ref.get(state)
  252. expect(final.calls.length).toBe(1)
  253. }),
  254. )
  255. it.live("get() still populates the catalog when the KV cache write fails", () =>
  256. Effect.gen(function* () {
  257. const cache = makeCache()
  258. const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
  259. const layer = Layer.fresh(
  260. AppNodeBuilder.build(ModelsDev.node, [
  261. [ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })],
  262. [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
  263. [KV.node, makeFailingWriteKV(cache)],
  264. ]),
  265. )
  266. const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
  267. expect(result).toEqual(fixture2Snapshot)
  268. expect(cache.values.has(cacheKey)).toBe(false)
  269. const final = yield* Ref.get(state)
  270. expect(final.calls.length).toBe(1)
  271. }),
  272. )
  273. it.live("uses the default models URL when the configured URL is empty", () =>
  274. Effect.gen(function* () {
  275. const cache = makeCache()
  276. const state = yield* Ref.make(initialState)
  277. yield* ModelsDev.Service.use((service) => service.get()).pipe(
  278. Effect.provide(buildLayer(state, cache, { url: "", fetch: true, snapshot: false })),
  279. )
  280. expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
  281. }),
  282. )
  283. it.live("get() is single-flight under concurrent calls", () =>
  284. Effect.gen(function* () {
  285. const cache = makeCache()
  286. const state = yield* Ref.make(initialState)
  287. const results = yield* Effect.gen(function* () {
  288. const svc = yield* ModelsDev.Service
  289. return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
  290. concurrency: "unbounded",
  291. })
  292. }).pipe(Effect.provide(buildLayer(state, cache, { fetch: true, snapshot: false })))
  293. for (const result of results) expect(result).toEqual(fixtureSnapshot)
  294. expect((yield* Ref.get(state)).calls.length).toBe(1)
  295. }),
  296. )
  297. it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
  298. Effect.gen(function* () {
  299. const cache = makeCache()
  300. writeCache(cache, fixture)
  301. const state = yield* Ref.make(initialState)
  302. const first = yield* provided(
  303. state,
  304. cache,
  305. Effect.gen(function* () {
  306. const svc = yield* ModelsDev.Service
  307. const a = yield* svc.get()
  308. writeCache(cache, fixture2)
  309. const b = yield* svc.get()
  310. return { a, b }
  311. }),
  312. )
  313. expect(first.a).toEqual(fixtureSnapshot)
  314. expect(first.b).toEqual(fixtureSnapshot)
  315. }),
  316. )
  317. it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
  318. Effect.gen(function* () {
  319. const cache = makeCache()
  320. writeCache(cache, fixture)
  321. const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
  322. const result = yield* provided(
  323. state,
  324. cache,
  325. Effect.gen(function* () {
  326. const svc = yield* ModelsDev.Service
  327. const before = yield* svc.get()
  328. yield* svc.refresh(true)
  329. const after = yield* svc.get()
  330. return { before, after }
  331. }),
  332. )
  333. expect(result.before).toEqual(fixtureSnapshot)
  334. expect(result.after).toEqual(fixture2Snapshot)
  335. expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
  336. const final = yield* Ref.get(state)
  337. expect(final.calls.length).toBe(1)
  338. expect(final.calls[0].url).toContain("/api.json")
  339. expect(final.calls[0].userAgent).toContain("/opencode")
  340. }),
  341. )
  342. it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
  343. Effect.gen(function* () {
  344. const cache = makeCache()
  345. writeCache(cache, fixture, Date.now() - 1000)
  346. const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
  347. yield* provided(
  348. state,
  349. cache,
  350. ModelsDev.Service.use((s) => s.refresh(false)),
  351. )
  352. const final = yield* Ref.get(state)
  353. expect(final.calls).toEqual([])
  354. }),
  355. )
  356. it.live("refresh(false) fetches when the KV entry is stale", () =>
  357. Effect.gen(function* () {
  358. const cache = makeCache()
  359. writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
  360. const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
  361. const after = yield* provided(
  362. state,
  363. cache,
  364. Effect.gen(function* () {
  365. const svc = yield* ModelsDev.Service
  366. yield* svc.refresh(false)
  367. return yield* svc.get()
  368. }),
  369. )
  370. const final = yield* Ref.get(state)
  371. expect(final.calls.length).toBe(1)
  372. expect(after).toEqual(fixture2Snapshot)
  373. }),
  374. )
  375. it.live("refresh swallows HTTP errors and leaves cache intact", () =>
  376. Effect.gen(function* () {
  377. const cache = makeCache()
  378. writeCache(cache, fixture)
  379. const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
  380. const result = yield* provided(
  381. state,
  382. cache,
  383. Effect.gen(function* () {
  384. const svc = yield* ModelsDev.Service
  385. yield* svc.refresh(true)
  386. return yield* svc.get()
  387. }),
  388. )
  389. expect(result).toEqual(fixtureSnapshot)
  390. // retryTransient retries 5xx, so calls may be > 1.
  391. const final = yield* Ref.get(state)
  392. expect(final.calls.length).toBeGreaterThanOrEqual(1)
  393. }),
  394. )
  395. })