Files

1119 lines
41 KiB
TypeScript

import { describe, expect, test } from "bun:test"
import { Money } from "@opencode/schema/money"
import { Effect, Exit, Fiber, Layer, Ref, Scope, Stream, Tracer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/http"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode/util/effect/app-node-platform"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Bus } from "@opencode/core/bus"
import { KV } from "@opencode/core/kv"
import { Location } from "@opencode/core/location"
import { Model } from "@opencode/core/model"
import { bodyDigest, ModelsDev } from "@opencode/core/models-dev"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
const cacheKey = "models-dev:v2:catalog"
const metaKey = `${cacheKey}:meta`
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()
})
// A chat model in the models.dev v2 shape, served over one OpenAI-compatible API.
const sourceModel = (id: string, input: Record<string, unknown> = {}) => ({
id,
type: "chat",
name: id,
description: "A model",
release_date: "2026-01-01",
modalities: { input: ["text"], output: ["text"] },
capabilities: { tools: { supported: true }, reasoning: { supported: false }, temperature: true },
limit: { context: 128000, output: 8192 },
api: {
"chat-completions": {
base_url: "https://api.example.test/v1",
sdk: { aisdk: "@ai-sdk/openai-compatible", opencodeai: "@opencode/ai/providers/openai-compatible" },
},
},
...input,
})
const fixture = {
acme: {
id: "acme",
name: "Acme",
env: ["ACME_API_KEY"],
models: {
"acme-1": sourceModel("acme-1", {
name: "Acme One",
api: {
"chat-completions": {
base_url: "https://api.acme.test/v1",
sdk: { aisdk: "@ai-sdk/openai-compatible", opencodeai: "@opencode/ai/providers/openai-compatible" },
compatibility: { reasoning_field: "vendor_reasoning" },
},
},
}),
},
},
}
const zeroCost = [
{
input: Money.USDPerMillionTokens.zero,
output: Money.USDPerMillionTokens.zero,
cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero },
},
]
const fixtureSnapshot = [
{
info: {
id: Provider.ID.make("acme"),
name: "Acme",
activation: "auto",
package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: "https://api.acme.test/v1" },
},
models: [
{
id: Model.ID.make("acme-1"),
modelID: Model.ID.make("acme-1"),
providerID: Provider.ID.make("acme"),
type: "chat",
name: "Acme One",
compatibility: { reasoningField: "vendor_reasoning" },
family: undefined,
package: undefined,
settings: { provider: "acme" },
capabilities: { tools: true, input: ["text"], output: ["text"], temperature: true },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: zeroCost,
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"],
models: {
"beta-1": sourceModel("beta-1", {
name: "Beta One",
release_date: "2026-02-01",
capabilities: { tools: { supported: false }, reasoning: { supported: false }, temperature: false },
limit: { context: 64000, output: 4096 },
}),
},
},
}
const fixture2Snapshot = [
{
info: {
id: Provider.ID.make("beta"),
name: "Beta",
activation: "auto",
package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: "https://api.example.test/v1" },
},
models: [
{
id: Model.ID.make("beta-1"),
modelID: Model.ID.make("beta-1"),
providerID: Provider.ID.make("beta"),
type: "chat",
name: "Beta One",
compatibility: undefined,
family: undefined,
package: undefined,
settings: { provider: "beta" },
capabilities: { tools: false, input: ["text"], output: ["text"], temperature: false },
variants: [],
time: { released: Date.parse("2026-02-01") },
cost: zeroCost,
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
etag?: string
calls: Array<{ url: string; userAgent: string | null; ifNoneMatch?: string }>
}
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,
ifNoneMatch: request.headers["if-none-match"],
},
],
}))
const s = yield* Ref.get(state)
return HttpClientResponse.fromWeb(
request,
new Response(s.status === 304 ? null : s.body, {
status: s.status,
headers: s.etag ? { etag: s.etag } : {},
}),
)
}),
)
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(LayerNode.group([ModelsDev.node, Bus.node]), [
ModelsDev.node.replace(ModelsDev.configured(options)),
LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
KV.node.replace(makeMockKV(cache)),
]),
)
// Mirrors production KV backends whose writes die as defects (e.g. Durable
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
const makeFailingWriteKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
})
const makeCache = (): MockCache => ({ values: new Map() })
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
cache.values.set(cacheKey, { updatedAt, digest: bodyDigest(text), 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 | Bus.Service | Scope.Scope>,
) => 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("takes each model's package from its first API and skips URLs packages build themselves", () =>
Effect.gen(function* () {
const cache = makeCache()
const azureAPI = (protocol: string, opencodeai: string) => ({
[protocol]: {
base_url: "https://${AZURE_RESOURCE_NAME}.openai.azure.com/openai/v1",
sdk: { aisdk: "@ai-sdk/azure", opencodeai },
},
})
writeCache(cache, {
acme: {
...fixture.acme,
models: {
...fixture.acme.models,
"acme-responses": sourceModel("acme-responses", {
api: {
responses: {
base_url: "https://api.acme.test/v1",
sdk: { aisdk: "@ai-sdk/openai", opencodeai: "@opencode/ai/providers/openai/responses" },
},
"chat-completions": { base_url: "https://other.acme.test/v1", sdk: { aisdk: "@ai-sdk/openai" } },
},
}),
"acme-fallback": sourceModel("acme-fallback", {
api: { messages: { base_url: "https://claude.acme.test/v1", sdk: { aisdk: "@ai-sdk/anthropic" } } },
}),
},
},
azure: {
id: "azure",
name: "Azure",
env: ["AZURE_RESOURCE_NAME", "AZURE_API_KEY"],
models: {
"gpt-5": sourceModel("gpt-5", { api: azureAPI("responses", "@opencode/ai/providers/azure/responses") }),
"custom-chat": sourceModel("custom-chat", {
api: azureAPI("chat-completions", "@opencode/ai/providers/azure/chat"),
}),
claude: sourceModel("claude", {
api: {
messages: {
base_url: "https://${AZURE_RESOURCE_NAME}.services.ai.azure.com/anthropic/v1",
sdk: { aisdk: "@ai-sdk/anthropic", opencodeai: "@opencode/ai/providers/anthropic-compatible" },
},
},
}),
},
},
})
const state = yield* Ref.make(initialState)
const [acme, azure] = yield* provided(
state,
cache,
ModelsDev.Service.use((service) => service.get()),
)
const acmeModel = (id: string) => acme?.models.find((model) => model.id === id)
expect(acmeModel("acme-responses")?.package).toBe("@opencode/ai/providers/openai/responses")
expect(acmeModel("acme-responses")?.settings).toBeUndefined()
// Without an opencode package, the AI SDK package maps to the native one as before.
expect(acmeModel("acme-fallback")?.package).toBe("@opencode/ai/providers/anthropic")
expect(acmeModel("acme-fallback")?.settings).toEqual({ baseURL: "https://claude.acme.test/v1" })
const azureModel = (id: string) => azure?.models.find((model) => model.id === id)
expect(azure?.info.package).toBe("@opencode/ai/providers/azure/responses")
expect(azure?.info.settings).toBeUndefined()
expect(azureModel("gpt-5")?.package).toBeUndefined()
expect(azureModel("gpt-5")?.settings).toBeUndefined()
expect(azureModel("custom-chat")?.package).toBe("@opencode/ai/providers/azure/chat")
expect(azureModel("custom-chat")?.settings).toBeUndefined()
expect(azureModel("claude")?.settings).toEqual({
baseURL: "https://${AZURE_RESOURCE_NAME}.services.ai.azure.com/anthropic/v1",
provider: "azure",
})
}),
)
it.live("keeps non-chat models with their type, maps MIME modalities, and skips malformed models", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, {
acme: {
...fixture.acme,
models: {
...fixture.acme.models,
"acme-image": sourceModel("acme-image", {
type: "image",
modalities: { input: ["text", "image/png", "application/pdf"], output: ["image/png", "image/webp"] },
api: {
"chat-completions": {
base_url: "https://api.example.test/v1",
sdk: { aisdk: "@acme/ai-sdk-provider" },
},
images: {
base_url: "https://api.example.test/v1",
sdk: {
aisdk: "@acme/ai-sdk-provider",
opencodeai: "@opencode/ai/providers/openai-compatible/images",
},
},
},
experimental: { modes: { fast: {} } },
future_field: { anything: true },
}),
"acme-broken": { id: "acme-broken", type: "chat" },
"acme-sdk-image": sourceModel("acme-sdk-image", {
type: "image",
api: { images: { base_url: "https://api.example.test/v1", sdk: { aisdk: "@acme/ai-sdk-provider" } } },
}),
},
},
})
const state = yield* Ref.make(initialState)
const [acme] = yield* provided(
state,
cache,
ModelsDev.Service.use((service) => service.get()),
)
expect(acme?.models.map((model) => [model.id, model.type])).toEqual([
["acme-1", "chat"],
["acme-image", "image"],
["acme-image-fast", "image"],
])
expect(acme?.models[1]?.capabilities).toMatchObject({ input: ["text", "image", "pdf"], output: ["image"] })
expect(acme?.models[1]?.package).toBe("@opencode/ai/providers/openai-compatible/images")
// Without an opencode package a non-chat model is left out ("acme-sdk-image" above).
}),
)
it.live("maps per-token text prices, context tiers, and mode multipliers onto cost", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, {
acme: {
...fixture.acme,
models: {
"acme-1": sourceModel("acme-1", {
cost: {
text: {
input: {
price: 2,
cache: { read: 0.2, write: { "5m": 2.5, "1h": 4 } },
overrides: [
{ when: { context: { gt: 200000 } }, price: 4, cache: { read: 0.4 } },
{ when: { service_tier: { equals: "flex" } }, price: 1 },
],
},
output: { price: 10, overrides: [{ when: { context: { gt: 200000 } }, price: 15 }] },
},
image: { output: { unit: "image", price: 0.04 } },
multipliers: [{ when: { service_tier: { equals: "priority" } }, factor: { text: 2 } }],
},
experimental: { modes: { fast: { service_tier: "priority", body: { service_tier: "priority" } } } },
}),
},
},
})
const state = yield* Ref.make(initialState)
const [acme] = yield* provided(
state,
cache,
ModelsDev.Service.use((service) => service.get()),
)
const price = Money.USDPerMillionTokens.make
const tier = {
tier: { type: "context" as const, size: 200000 },
input: price(4),
output: price(15),
cache: { read: price(0.4), write: price(2.5) },
}
expect(acme?.models[0]?.cost).toEqual([
{ input: price(2), output: price(10), cache: { read: price(0.2), write: price(2.5) } },
tier,
])
expect(acme?.models[1]?.cost).toEqual([
{ input: price(4), output: price(20), cache: { read: price(0.4), write: price(5) } },
tier,
])
expect(acme?.models[1]?.body).toEqual({ service_tier: "priority" })
}),
)
it.effect("skips APIs whose opencode entrypoint this build doesn't have", () =>
Effect.gen(function* () {
const unknown = (path: string) => ({
base_url: "https://new.acme.test/v1",
sdk: { opencodeai: `@opencode/ai/providers/acme/${path}` },
})
const image = (id: string, api: Record<string, unknown>) =>
sourceModel(id, { type: "image", modalities: { input: ["text"], output: ["image/png"] }, api })
const [acme] = yield* ModelsDev.decodeSnapshot(
JSON.stringify({
acme: {
...fixture.acme,
models: {
next: sourceModel("next", {
api: { "new-api": unknown("new-api"), ...sourceModel("next").api },
}),
"sdk-fallback": sourceModel("sdk-fallback", {
api: {
"new-api": {
base_url: "https://claude.acme.test/v1",
sdk: { aisdk: "@ai-sdk/anthropic", opencodeai: "@opencode/ai/providers/acme/new-api" },
},
},
}),
"unknown-only": sourceModel("unknown-only", { api: { "new-api": unknown("new-api") } }),
"image-unknown": image("image-unknown", { images: unknown("images") }),
"image-known": image("image-known", {
images: unknown("images"),
"openai-images": {
base_url: "https://api.acme.test/v1",
sdk: { opencodeai: "@opencode/ai/providers/openai-compatible/images" },
},
}),
},
},
}),
)
const model = (id: string) => acme?.models.find((item) => item.id === id)
expect(acme?.models.map((item) => item.id)).toEqual(
["next", "sdk-fallback", "image-known"].map((id) => Model.ID.make(id)),
)
expect(acme?.info).toMatchObject({
package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: "https://api.example.test/v1" },
})
expect(model("next")).toMatchObject({ package: undefined, settings: { provider: "acme" } })
expect(model("sdk-fallback")).toMatchObject({
package: "@opencode/ai/providers/anthropic",
settings: { baseURL: "https://claude.acme.test/v1" },
})
expect(model("image-known")).toMatchObject({
package: "@opencode/ai/providers/openai-compatible/images",
settings: { baseURL: "https://api.acme.test/v1" },
})
}),
)
it.effect("skips non-chat models whose only opencode package serves another type", () =>
Effect.gen(function* () {
const api = (opencodeai: string) => ({
"chat-completions": { base_url: "https://api.acme.test/v1", sdk: { opencodeai } },
})
const chatPackage = "@opencode/ai/providers/openai-compatible"
const [acme] = yield* ModelsDev.decodeSnapshot(
JSON.stringify({
acme: {
...fixture.acme,
models: {
chat: sourceModel("chat", { api: api(chatPackage) }),
"image-chat-only": sourceModel("image-chat-only", { type: "image", api: api(chatPackage) }),
image: sourceModel("image", {
type: "image",
api: {
...api(chatPackage),
images: api("@opencode/ai/providers/openai-compatible/images")["chat-completions"],
},
}),
"decision-chat-only": sourceModel("decision-chat-only", { type: "decision", api: api(chatPackage) }),
"decision-image-only": sourceModel("decision-image-only", {
type: "decision",
api: api("@opencode/ai/providers/openrouter/images"),
}),
decision: sourceModel("decision", {
type: "decision",
api: api("@opencode/ai/providers/openrouter/decisions"),
}),
},
},
}),
)
expect(acme?.models.map((item) => [item.id, item.type, item.package ?? acme.info.package])).toEqual([
[Model.ID.make("chat"), "chat", chatPackage],
[Model.ID.make("image"), "image", "@opencode/ai/providers/openai-compatible/images"],
[Model.ID.make("decision"), "decision", "@opencode/ai/providers/openrouter/decisions"],
])
}),
)
it.live("drops model types opencode doesn't keep", () =>
Effect.gen(function* () {
const cache = makeCache()
const api = {
images: { base_url: "https://api.acme.test/v1", sdk: { opencodeai: "@opencode/ai/providers/openai/images" } },
}
const model = (id: string, type: string) => ({
id,
type,
name: id,
release_date: "2026-01-01",
modalities: { input: ["text"], output: ["image"] },
capabilities: { tools: { supported: false }, reasoning: { supported: false } },
api,
})
writeCache(cache, {
acme: {
id: "acme",
name: "Acme",
env: ["ACME_API_KEY"],
models: { "acme-image": model("acme-image", "image"), "acme-video": model("acme-video", "video") },
},
})
const state = yield* Ref.make(initialState)
const [acme] = yield* provided(
state,
cache,
ModelsDev.Service.use((service) => service.get()),
)
expect(acme?.models.map((item) => item.id)).toEqual([Model.ID.make("acme-image")])
}),
)
it.live("skips API entries and modes it can't read and keeps the model", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, {
acme: {
id: "acme",
name: "Acme",
env: ["ACME_API_KEY"],
models: {
"acme-1": {
id: "acme-1",
type: "chat",
name: "Acme One",
release_date: "2026-01-01",
modalities: { input: ["text"], output: ["text"] },
capabilities: { tools: { supported: true }, reasoning: { supported: false } },
api: {
future: { url: 42 },
"chat-completions": {
base_url: "https://api.acme.test/v1",
sdk: { opencodeai: "@opencode/ai/providers/openai-compatible" },
},
},
experimental: {
modes: { future: { cost: "free" }, fast: { body: { service_tier: "priority" } } },
},
},
},
},
})
const state = yield* Ref.make(initialState)
const [acme] = yield* provided(
state,
cache,
ModelsDev.Service.use((service) => service.get()),
)
expect(acme?.info).toMatchObject({
package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: "https://api.acme.test/v1" },
})
expect(acme?.models.map((model) => model.id)).toEqual([Model.ID.make("acme-1"), Model.ID.make("acme-1-fast")])
}),
)
it.live("reads a models.dev v1 catalog by converting it to v2", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, {
acme: {
id: "acme",
name: "Acme",
env: ["ACME_API_KEY"],
npm: "@ai-sdk/openai-compatible",
api: "https://api.acme.test/v1",
models: {
"acme-1": {
id: "acme-1",
name: "Acme One",
release_date: "2026-01-01",
attachment: false,
reasoning: true,
reasoning_options: [
{ type: "effort", values: [null, "low", "high"] },
{ type: "budget_tokens", min: 1024 },
],
temperature: true,
tool_call: true,
interleaved: { field: "vendor_reasoning" },
cost: { input: 1, output: 2, tiers: [{ tier: { type: "context", size: 200000 }, input: 3, output: 4 }] },
limit: { context: 128000, output: 8192 },
},
},
},
})
const state = yield* Ref.make(initialState)
const [acme] = yield* provided(
state,
cache,
ModelsDev.Service.use((service) => service.get()),
)
expect(acme?.info).toMatchObject({
package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: "https://api.acme.test/v1" },
})
expect(acme?.models[0]).toMatchObject({
type: "chat",
compatibility: { reasoningField: "vendor_reasoning" },
capabilities: { tools: true, input: ["text"], output: ["text"], temperature: true },
})
expect(acme?.models[0]?.variants.map((variant) => String(variant.id))).toEqual(["low", "high"])
expect(acme?.models[0]?.cost.map((item) => [item.tier?.size, item.input, item.output])).toEqual([
[undefined, 1, 2],
[200000, 3, 4],
])
}),
)
it.live("falls back to /api.json when a custom mirror has no v2 catalog", () =>
Effect.gen(function* () {
const v1Catalog = {
beta: {
id: "beta",
name: "Beta",
env: ["BETA_API_KEY"],
npm: "@ai-sdk/openai-compatible",
api: "https://api.beta.test/v1",
models: {
"beta-1": {
id: "beta-1",
name: "Beta One",
release_date: "2026-02-01",
tool_call: true,
limit: { context: 64000, output: 4096 },
},
},
},
}
const urls: string[] = []
const client = HttpClient.make((request) =>
Effect.sync(() => {
urls.push(request.url)
const v1 = request.url.endsWith("/api.json")
return HttpClientResponse.fromWeb(
request,
new Response(v1 ? JSON.stringify(v1Catalog) : null, { status: v1 ? 200 : 404 }),
)
}),
)
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
ModelsDev.node.replace(ModelsDev.configured({ url: "https://mirror.example", fetch: true, snapshot: false })),
LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, client)),
KV.node.replace(makeMockKV(makeCache())),
]),
)
const [beta] = yield* ModelsDev.Service.use((service) => service.get()).pipe(Effect.provide(layer))
expect(urls).toEqual([
"https://mirror.example/experimental/v2.0/api.json?type=chat,decision,image",
"https://mirror.example/api.json",
])
expect(beta?.models.map((model) => model.id)).toEqual([Model.ID.make("beta-1")])
}),
)
it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(
Effect.provide(buildLayer(state, cache, { fetch: false, snapshot: false })),
)
expect(result).toEqual([])
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
)
it.live("get() falls back to the bundled snapshot when KV is empty and fetch is disabled", () =>
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.length).toBeGreaterThan(0)
const anthropic = result.find((snapshot) => snapshot.info.id === "anthropic")
expect(anthropic?.environment).toContain("ANTHROPIC_API_KEY")
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, snapshot: false }))
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),
digest: bodyDigest(JSON.stringify(fixture2)),
})
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
it.live("get() still populates the catalog when the KV cache write fails", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
ModelsDev.node.replace(ModelsDev.configured({ fetch: true, snapshot: false })),
LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
KV.node.replace(makeFailingWriteKV(cache)),
]),
)
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.has(cacheKey)).toBe(false)
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, snapshot: false })),
)
expect((yield* Ref.get(state)).calls[0]?.url).toBe(
"https://models.opencode.ai/experimental/v2.0/api.json?type=chat,decision,image",
)
}),
)
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, snapshot: false })))
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),
digest: bodyDigest(JSON.stringify(fixture2)),
})
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain(ModelsDev.catalogPath)
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
const bus = yield* Bus.Service
const refreshed = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* svc.refresh(false)
return yield* Fiber.join(fiber)
}),
),
)
expect(refreshed.length).toBe(1)
return yield* svc.get()
}),
)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({
body: JSON.stringify(fixture2),
digest: bodyDigest(JSON.stringify(fixture2)),
})
}),
)
it.live("refresh publishes Refreshed globally across locations", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const bus = yield* Bus.Service
const refreshed = yield* bus
.subscribe(ModelsDev.Event.Refreshed)
.pipe(
Stream.take(1),
Stream.runCollect,
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/other-location") })),
),
Effect.forkScoped,
)
.pipe(
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* svc
.refresh(true)
.pipe(
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/initial-location") })),
),
)
return yield* Fiber.join(fiber)
}),
),
)
expect(refreshed.length).toBe(1)
expect(refreshed[0]?.location).toBeUndefined()
}),
)
}),
)
it.live("refresh(false) stays quiet when the fetched body matches the cached digest", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const body = cache.values.get(cacheKey)
// The server serves a byte-identical body, so the refresh still hits
// the network but must only record the check time, not publish Refreshed.
const state = yield* Ref.make(initialState)
yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const bus = yield* Bus.Service
const event = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* svc.refresh(false)
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
}),
),
)
expect(event._tag).toBe("None")
}),
)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(cache.values.get(cacheKey)).toBe(body)
expect(cache.values.get(metaKey)).toMatchObject({ digest: bodyDigest(JSON.stringify(fixture)) })
expect(Date.now() - (cache.values.get(metaKey) as { updatedAt: number }).updatedAt).toBeLessThan(60 * 1000)
}),
)
it.live("refresh(false) revalidates with the stored ETag and treats 304 as unchanged", () =>
Effect.gen(function* () {
const spans: Tracer.NativeSpan[] = []
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options)
spans.push(span)
return span
},
})
const cache = makeCache()
const state = yield* Ref.make<MockState>({ ...initialState, etag: 'W/"v1"' })
const body = yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
yield* svc.refresh(true)
const meta = cache.values.get(metaKey) as { updatedAt: number }
cache.values.set(metaKey, { ...meta, updatedAt: Date.now() - 10 * 60 * 1000 })
const body = cache.values.get(cacheKey)
yield* Ref.update(state, (s) => ({ ...s, status: 304 }))
spans.length = 0
const bus = yield* Bus.Service
const event = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* svc.refresh(false)
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
}),
),
)
expect(event._tag).toBe("None")
expect(yield* svc.get()).toEqual(fixtureSnapshot)
return body
}).pipe(Effect.provideService(Tracer.Tracer, tracer)),
)
const final = yield* Ref.get(state)
expect(final.calls.map((call) => call.ifNoneMatch)).toEqual([undefined, 'W/"v1"'])
expect(cache.values.get(cacheKey)).toBe(body)
expect(cache.values.get(metaKey)).toMatchObject({ etag: 'W/"v1"' })
expect(Date.now() - (cache.values.get(metaKey) as { updatedAt: number }).updatedAt).toBeLessThan(60 * 1000)
const fetchSpans = spans.filter((span) => span.name === "ModelsDev.fetchApi")
expect(fetchSpans).toHaveLength(1)
for (const span of spans.filter((span) => span.name.startsWith("ModelsDev."))) {
if (span.status._tag === "Ended") {
expect(Exit.isFailure(span.status.exit)).toBe(false)
}
}
spans.length = 0
yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
yield* svc.refresh(true)
expect(yield* svc.get()).toEqual(fixtureSnapshot)
}).pipe(Effect.provideService(Tracer.Tracer, tracer)),
)
const unrequested304 = spans.find((span) => span.name === "ModelsDev.fetchApi")
expect(unrequested304?.status._tag).toBe("Ended")
if (unrequested304?.status._tag === "Ended") {
expect(Exit.isFailure(unrequested304.status.exit)).toBe(true)
}
}),
)
it.live("refresh(false) republishes once for legacy cache entries without a digest", () =>
Effect.gen(function* () {
const cache = makeCache()
cache.values.set(cacheKey, { updatedAt: Date.now() - 10 * 60 * 1000, body: JSON.stringify(fixture) })
const state = yield* Ref.make(initialState)
yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const bus = yield* Bus.Service
const refreshed = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* svc.refresh(false)
return yield* Fiber.join(fiber)
}),
),
)
expect(refreshed.length).toBe(1)
}),
)
// The rewritten entry now carries a digest, so later identical bodies stay quiet.
expect(cache.values.get(cacheKey)).toMatchObject({ digest: bodyDigest(JSON.stringify(fixture)) })
}),
)
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)
}),
)
})