| import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" |
| 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/core/effect/app-node-platform" |
| import { LayerNode } from "@opencode-ai/core/effect/layer-node" |
| import { Flag } from "@opencode-ai/core/flag/flag" |
| import { Global } from "@opencode-ai/core/global" |
| import { ModelsDev } from "@opencode-ai/core/models-dev" |
| import { it } from "./lib/effect" |
| import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises" |
| import path from "path" |
|
|
| |
| |
| |
| |
| |
| const ORIGINAL_MODELS_PATH = Flag.OPENCODE_MODELS_PATH |
| const ORIGINAL_DISABLE_FETCH = Flag.OPENCODE_DISABLE_MODELS_FETCH |
| beforeAll(() => { |
| Flag.OPENCODE_MODELS_PATH = undefined |
| Flag.OPENCODE_DISABLE_MODELS_FETCH = true |
| }) |
| afterAll(() => { |
| Flag.OPENCODE_MODELS_PATH = ORIGINAL_MODELS_PATH |
| Flag.OPENCODE_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH |
| }) |
|
|
| const cacheFile = path.join(Global.Path.cache, "models.json") |
|
|
| const fixture: Record<string, ModelsDev.Provider> = { |
| acme: { |
| id: "acme", |
| name: "Acme", |
| env: ["ACME_API_KEY"], |
| models: { |
| "acme-1": { |
| id: "acme-1", |
| name: "Acme One", |
| release_date: "2026-01-01", |
| attachment: false, |
| reasoning: false, |
| temperature: true, |
| tool_call: true, |
| limit: { context: 128000, output: 8192 }, |
| }, |
| }, |
| }, |
| } |
|
|
| const fixture2: Record<string, ModelsDev.Provider> = { |
| beta: { |
| id: "beta", |
| name: "Beta", |
| env: ["BETA_API_KEY"], |
| 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 }, |
| }, |
| }, |
| }, |
| } |
|
|
| 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 })) |
| }), |
| ) |
|
|
| const buildLayer = (state: Ref.Ref<MockState>) => |
| |
| |
| |
| Layer.fresh( |
| AppNodeBuilder.build(ModelsDev.node, [ |
| [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))], |
| ]), |
| ) |
|
|
| const writeCacheText = (text: string, mtimeMs?: number) => |
| Effect.promise(async () => { |
| await mkdir(Global.Path.cache, { recursive: true }) |
| await writeFile(cacheFile, text) |
| if (mtimeMs !== undefined) { |
| const t = mtimeMs / 1000 |
| await utimes(cacheFile, t, t) |
| } |
| }) |
|
|
| const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.stringify(data), mtimeMs) |
|
|
| const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) => |
| eff.pipe(Effect.provide(buildLayer(state))) |
|
|
| beforeEach(async () => { |
| await rm(cacheFile, { force: true }) |
| }) |
|
|
| afterAll(async () => { |
| await rm(cacheFile, { force: true }) |
| }) |
|
|
| const initialState: MockState = { |
| body: JSON.stringify(fixture), |
| status: 200, |
| calls: [], |
| } |
|
|
| describe("ModelsDev Service", () => { |
| it.live("get() returns providers from disk when cache file exists", () => |
| Effect.gen(function* () { |
| yield* writeCache(fixture) |
| const state = yield* Ref.make(initialState) |
| const result = yield* provided( |
| state, |
| ModelsDev.Service.use((s) => s.get()), |
| ) |
| expect(result).toEqual(fixture) |
| const final = yield* Ref.get(state) |
| expect(final.calls).toEqual([]) |
| }), |
| ) |
|
|
| it.live("get() returns empty catalog when disk empty, fetch disabled, and no bundled snapshot is injected", () => |
| Effect.gen(function* () { |
| const state = yield* Ref.make(initialState) |
| const result = yield* provided( |
| state, |
| 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 cache file by fetching a fresh catalog", () => |
| Effect.gen(function* () { |
| yield* writeCacheText("{") |
| const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) |
| const context = yield* Layer.build(buildLayer(state)) |
| const result = yield* Effect.acquireUseRelease( |
| Effect.sync(() => { |
| Flag.OPENCODE_DISABLE_MODELS_FETCH = false |
| }), |
| () => ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)), |
| () => |
| Effect.sync(() => { |
| Flag.OPENCODE_DISABLE_MODELS_FETCH = true |
| }), |
| ) |
| expect(result).toEqual(fixture2) |
| expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2)) |
| const final = yield* Ref.get(state) |
| expect(final.calls.length).toBe(1) |
| }), |
| ) |
|
|
| it.live("get() is single-flight under concurrent calls", () => |
| Effect.gen(function* () { |
| yield* writeCache(fixture) |
| const state = yield* Ref.make(initialState) |
| const results = yield* provided( |
| state, |
| Effect.gen(function* () { |
| const svc = yield* ModelsDev.Service |
| return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], { |
| concurrency: "unbounded", |
| }) |
| }), |
| ) |
| for (const result of results) expect(result).toEqual(fixture) |
| }), |
| ) |
|
|
| it.live("get() caches across calls (later disk writes are ignored until invalidate)", () => |
| Effect.gen(function* () { |
| yield* writeCache(fixture) |
| const state = yield* Ref.make(initialState) |
| const first = yield* provided( |
| state, |
| Effect.gen(function* () { |
| const svc = yield* ModelsDev.Service |
| const a = yield* svc.get() |
| |
| yield* writeCache(fixture2) |
| const b = yield* svc.get() |
| return { a, b } |
| }), |
| ) |
| expect(first.a).toEqual(fixture) |
| expect(first.b).toEqual(fixture) |
| }), |
| ) |
|
|
| it.live("refresh(true) fetches via HttpClient and updates the cache", () => |
| Effect.gen(function* () { |
| yield* writeCache(fixture) |
| const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) |
| const result = yield* provided( |
| state, |
| 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(fixture) |
| expect(result.after).toEqual(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("/cli") |
| }), |
| ) |
|
|
| it.live("refresh(false) skips fetch when on-disk file is fresh", () => |
| Effect.gen(function* () { |
| |
| yield* writeCache(fixture, Date.now() - 1000) |
| const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) |
| yield* provided( |
| state, |
| ModelsDev.Service.use((s) => s.refresh(false)), |
| ) |
| const final = yield* Ref.get(state) |
| expect(final.calls).toEqual([]) |
| }), |
| ) |
|
|
| it.live("refresh(false) fetches when on-disk file is stale", () => |
| Effect.gen(function* () { |
| |
| yield* writeCache(fixture, Date.now() - 10 * 60 * 1000) |
| const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) |
| const after = yield* provided( |
| state, |
| 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(fixture2) |
| }), |
| ) |
|
|
| it.live("refresh swallows HTTP errors and leaves cache intact", () => |
| Effect.gen(function* () { |
| yield* writeCache(fixture) |
| const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" }) |
| const result = yield* provided( |
| state, |
| Effect.gen(function* () { |
| const svc = yield* ModelsDev.Service |
| yield* svc.refresh(true) |
| return yield* svc.get() |
| }), |
| ) |
| expect(result).toEqual(fixture) |
| |
| const final = yield* Ref.get(state) |
| expect(final.calls.length).toBeGreaterThanOrEqual(1) |
| }), |
| ) |
| }) |
|
|