| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- import { describe, expect, test } from "bun:test"
- import { Money } from "@opencode-ai/schema/money"
- import { Effect, Layer, Ref } from "effect"
- import { HttpClient, HttpClientResponse } from "effect/unstable/http"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
- import { LayerNode } from "@opencode-ai/util/effect/layer-node"
- import { KV } from "@opencode-ai/core/kv"
- import { Model } from "@opencode-ai/core/model"
- import { ModelsDev } from "@opencode-ai/core/models-dev"
- import { Provider } from "@opencode-ai/core/provider"
- import { it } from "./lib/effect"
- const cacheKey = "models-dev:catalog"
- test("normalizes permissive interleaved values to compatibility", () => {
- expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
- expect(Model.compatibility({ field: "vendor_reasoning" })).toEqual({ reasoningField: "vendor_reasoning" })
- expect(Model.compatibility(true)).toBeUndefined()
- expect(Model.compatibility(false)).toBeUndefined()
- })
- const fixture = {
- acme: {
- id: "acme",
- name: "Acme",
- env: ["ACME_API_KEY"],
- npm: "@ai-sdk/openai-compatible",
- models: {
- "acme-1": {
- id: "acme-1",
- name: "Acme One",
- release_date: "2026-01-01",
- attachment: false,
- reasoning: false,
- temperature: true,
- tool_call: true,
- interleaved: { field: "vendor_reasoning" },
- limit: { context: 128000, output: 8192 },
- },
- },
- },
- }
- const fixtureSnapshot = [
- {
- info: {
- id: Provider.ID.make("acme"),
- name: "Acme",
- package: Provider.aisdk("@ai-sdk/openai-compatible"),
- },
- models: [
- {
- id: Model.ID.make("acme-1"),
- modelID: Model.ID.make("acme-1"),
- providerID: Provider.ID.make("acme"),
- name: "Acme One",
- compatibility: { reasoningField: "vendor_reasoning" },
- family: undefined,
- package: undefined,
- settings: undefined,
- capabilities: { tools: true, input: [], output: [] },
- variants: [],
- time: { released: Date.parse("2026-01-01") },
- cost: [
- {
- input: Money.USDPerMillionTokens.zero,
- output: Money.USDPerMillionTokens.zero,
- cache: {
- read: Money.USDPerMillionTokens.zero,
- write: Money.USDPerMillionTokens.zero,
- },
- },
- ],
- status: "active",
- enabled: true,
- limit: { context: 128000, input: undefined, output: 8192 },
- headers: undefined,
- body: undefined,
- },
- ],
- environment: ["ACME_API_KEY"],
- },
- ] satisfies readonly ModelsDev.Snapshot[]
- const fixture2 = {
- beta: {
- id: "beta",
- name: "Beta",
- env: ["BETA_API_KEY"],
- npm: "@ai-sdk/openai-compatible",
- models: {
- "beta-1": {
- id: "beta-1",
- name: "Beta One",
- release_date: "2026-02-01",
- attachment: false,
- reasoning: true,
- temperature: false,
- tool_call: false,
- limit: { context: 64000, output: 4096 },
- },
- },
- },
- }
- const fixture2Snapshot = [
- {
- info: {
- id: Provider.ID.make("beta"),
- name: "Beta",
- package: Provider.aisdk("@ai-sdk/openai-compatible"),
- },
- models: [
- {
- id: Model.ID.make("beta-1"),
- modelID: Model.ID.make("beta-1"),
- providerID: Provider.ID.make("beta"),
- name: "Beta One",
- family: undefined,
- package: undefined,
- settings: undefined,
- capabilities: { tools: false, input: [], output: [] },
- variants: [],
- time: { released: Date.parse("2026-02-01") },
- cost: [
- {
- input: Money.USDPerMillionTokens.zero,
- output: Money.USDPerMillionTokens.zero,
- cache: {
- read: Money.USDPerMillionTokens.zero,
- write: Money.USDPerMillionTokens.zero,
- },
- },
- ],
- status: "active",
- enabled: true,
- limit: { context: 64000, input: undefined, output: 4096 },
- headers: undefined,
- body: undefined,
- },
- ],
- environment: ["BETA_API_KEY"],
- },
- ] satisfies readonly ModelsDev.Snapshot[]
- interface MockState {
- body: string
- status: number
- calls: Array<{ url: string; userAgent: string | null }>
- }
- const makeMockClient = (state: Ref.Ref<MockState>) =>
- HttpClient.make((request) =>
- Effect.gen(function* () {
- yield* Ref.update(state, (s) => ({
- ...s,
- calls: [...s.calls, { url: request.url, userAgent: request.headers["user-agent"] ?? null }],
- }))
- const s = yield* Ref.get(state)
- return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status }))
- }),
- )
- interface MockCache {
- readonly values: Map<string, KV.Value>
- }
- const makeMockKV = (cache: MockCache) =>
- Layer.mock(KV.Service, {
- get: (key) => Effect.sync(() => cache.values.get(key)),
- set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
- remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
- })
- const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
- // Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
- // and Effect.provide uses a process-global MemoMap by default — without fresh,
- // every test would reuse the cachedInvalidateWithTTL state from the first run.
- Layer.fresh(
- AppNodeBuilder.build(ModelsDev.node, [
- [ModelsDev.node, ModelsDev.configured(options)],
- [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
- [KV.node, makeMockKV(cache)],
- ]),
- )
- const makeCache = (): MockCache => ({ values: new Map() })
- const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
- cache.values.set(cacheKey, { updatedAt, body: text })
- const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
- writeCacheText(cache, JSON.stringify(data), updatedAt)
- const provided = <A, E>(state: Ref.Ref<MockState>, cache: MockCache, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
- eff.pipe(Effect.provide(buildLayer(state, cache)))
- const initialState: MockState = {
- body: JSON.stringify(fixture),
- status: 200,
- calls: [],
- }
- describe("ModelsDev Service", () => {
- it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
- Effect.gen(function* () {
- const cache = makeCache()
- writeCache(cache, fixture)
- const state = yield* Ref.make(initialState)
- const result = yield* provided(
- state,
- cache,
- ModelsDev.Service.use((s) => s.get()),
- )
- expect(result).toEqual(fixtureSnapshot)
- const final = yield* Ref.get(state)
- expect(final.calls).toEqual([])
- }),
- )
- it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () =>
- Effect.gen(function* () {
- const cache = makeCache()
- const state = yield* Ref.make(initialState)
- const result = yield* provided(
- state,
- cache,
- ModelsDev.Service.use((s) => s.get()),
- )
- expect(result).toEqual([])
- const final = yield* Ref.get(state)
- expect(final.calls).toEqual([])
- }),
- )
- it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
- Effect.gen(function* () {
- const cache = makeCache()
- writeCacheText(cache, "{")
- const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
- const context = yield* Layer.build(buildLayer(state, cache, { fetch: true }))
- const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
- expect(result).toEqual(fixture2Snapshot)
- expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
- const final = yield* Ref.get(state)
- expect(final.calls.length).toBe(1)
- }),
- )
- it.live("uses the default models URL when the configured URL is empty", () =>
- Effect.gen(function* () {
- const cache = makeCache()
- const state = yield* Ref.make(initialState)
- yield* ModelsDev.Service.use((service) => service.get()).pipe(
- Effect.provide(buildLayer(state, cache, { url: "", fetch: true })),
- )
- expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
- }),
- )
- it.live("get() is single-flight under concurrent calls", () =>
- Effect.gen(function* () {
- const cache = makeCache()
- const state = yield* Ref.make(initialState)
- const results = yield* Effect.gen(function* () {
- const svc = yield* ModelsDev.Service
- return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
- concurrency: "unbounded",
- })
- }).pipe(Effect.provide(buildLayer(state, cache, { fetch: true })))
- for (const result of results) expect(result).toEqual(fixtureSnapshot)
- expect((yield* Ref.get(state)).calls.length).toBe(1)
- }),
- )
- it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
- Effect.gen(function* () {
- const cache = makeCache()
- writeCache(cache, fixture)
- const state = yield* Ref.make(initialState)
- const first = yield* provided(
- state,
- cache,
- Effect.gen(function* () {
- const svc = yield* ModelsDev.Service
- const a = yield* svc.get()
- writeCache(cache, fixture2)
- const b = yield* svc.get()
- return { a, b }
- }),
- )
- expect(first.a).toEqual(fixtureSnapshot)
- expect(first.b).toEqual(fixtureSnapshot)
- }),
- )
- it.live("refresh(true) fetches via HttpClient and updates the cache", () =>
- Effect.gen(function* () {
- const cache = makeCache()
- writeCache(cache, fixture)
- const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
- const result = yield* provided(
- state,
- cache,
- Effect.gen(function* () {
- const svc = yield* ModelsDev.Service
- const before = yield* svc.get()
- yield* svc.refresh(true)
- const after = yield* svc.get()
- return { before, after }
- }),
- )
- expect(result.before).toEqual(fixtureSnapshot)
- expect(result.after).toEqual(fixture2Snapshot)
- expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
- const final = yield* Ref.get(state)
- expect(final.calls.length).toBe(1)
- expect(final.calls[0].url).toContain("/api.json")
- expect(final.calls[0].userAgent).toContain("/opencode")
- }),
- )
- it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
- Effect.gen(function* () {
- const cache = makeCache()
- writeCache(cache, fixture, Date.now() - 1000)
- const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
- yield* provided(
- state,
- cache,
- ModelsDev.Service.use((s) => s.refresh(false)),
- )
- const final = yield* Ref.get(state)
- expect(final.calls).toEqual([])
- }),
- )
- it.live("refresh(false) fetches when the KV entry is stale", () =>
- Effect.gen(function* () {
- const cache = makeCache()
- writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
- const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
- const after = yield* provided(
- state,
- cache,
- Effect.gen(function* () {
- const svc = yield* ModelsDev.Service
- yield* svc.refresh(false)
- return yield* svc.get()
- }),
- )
- const final = yield* Ref.get(state)
- expect(final.calls.length).toBe(1)
- expect(after).toEqual(fixture2Snapshot)
- }),
- )
- it.live("refresh swallows HTTP errors and leaves cache intact", () =>
- Effect.gen(function* () {
- const cache = makeCache()
- writeCache(cache, fixture)
- const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" })
- const result = yield* provided(
- state,
- cache,
- Effect.gen(function* () {
- const svc = yield* ModelsDev.Service
- yield* svc.refresh(true)
- return yield* svc.get()
- }),
- )
- expect(result).toEqual(fixtureSnapshot)
- // retryTransient retries 5xx, so calls may be > 1.
- const final = yield* Ref.get(state)
- expect(final.calls.length).toBeGreaterThanOrEqual(1)
- }),
- )
- })
|