Files
opencode/packages/core/test/models.test.ts

264 lines
8.4 KiB
TypeScript

import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "@opencode-ai/core/models"
import { it } from "./lib/effect"
import { rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
// resolve providers without network. These tests need to drive the on-disk
// cache themselves and silence the eager refresh fork. Save/restore around
// the suite — never leak the mutation to subsequent test files in the same
// bun process.
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 is required: ModelsDev.layer 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(ModelsDev.layer).pipe(
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
Layer.provide(AppFileSystem.defaultLayer),
)
const writeCache = (data: object, mtimeMs?: number) =>
Effect.promise(async () => {
await mkdir(Global.Path.cache, { recursive: true })
await writeFile(cacheFile, JSON.stringify(data))
if (mtimeMs !== undefined) {
const t = mtimeMs / 1000
await utimes(cacheFile, t, t)
}
})
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 bundled snapshot when disk empty and fetch disabled", () =>
Effect.gen(function* () {
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
ModelsDev.Service.use((s) => s.get()),
)
expect(Object.keys(result).length).toBeGreaterThan(0)
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
)
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()
// mutate disk between calls — cache should mask the change
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* () {
// Fresh: mtime within the 5-minute TTL.
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* () {
// Stale: mtime 10 minutes ago, beyond the 5-minute TTL.
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)
// retryTransient retries 5xx, so calls may be > 1.
const final = yield* Ref.get(state)
expect(final.calls.length).toBeGreaterThanOrEqual(1)
}),
)
})