mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-08 09:37:47 +03:00
1119 lines
41 KiB
TypeScript
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)
|
|
}),
|
|
)
|
|
})
|