Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 47 additions & 18 deletions packages/core/src/models-dev.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest } from "effect/http"
import { HttpClient, HttpClientError, HttpClientRequest } from "effect/http"
import { ModelsDev } from "@opencode/schema/models-dev"
import { Money } from "@opencode/schema/money"
import { App } from "./app.js"
Expand Down Expand Up @@ -297,6 +297,11 @@ const Cache = Schema.Struct({
digest: Schema.optional(Schema.String),
body: CatalogJson,
})
const CacheMeta = Schema.Struct({
updatedAt: Schema.Number,
digest: Schema.optional(Schema.String),
etag: Schema.optional(Schema.String),
})
const defaultSource = "https://models.opencode.ai"

// Bundled snapshot of https://models.opencode.ai/api.json, committed at
Expand Down Expand Up @@ -348,6 +353,7 @@ export const layer = (options?: Options) =>
const fetch = options?.fetch ?? true
const userAgent = App.useragent(app)
const key = cacheKey(source)
const metaKey = `${key}:meta`
const ttl = Duration.minutes(5)
const lock = Semaphore.makeUnsafe(1)

Expand All @@ -360,14 +366,17 @@ export const layer = (options?: Options) =>
updatedAt: cached.value.updatedAt,
digest: cached.value.digest,
}
if (value !== undefined) yield* kv.remove(key)
if (value === undefined) return
yield* kv.remove(key)
yield* kv.remove(metaKey)
})

const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* (etag?: string) {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
HttpClientRequest.setHeader("User-Agent", userAgent),
HttpClientRequest.setHeaders(etag ? { "If-None-Match": etag } : {}),
http.execute,
Effect.flatMap((res) => res.text),
Effect.flatMap((res) => Effect.map(res.text, (text) => ({ text, etag: res.headers.etag }))),
Effect.timeout("10 seconds"),
)
})
Expand All @@ -383,12 +392,21 @@ export const layer = (options?: Options) =>
// periodic fetch below still refreshes on top.
const loadSnapshot = options?.snapshot === false ? Effect.undefined : bundledSnapshot

const writeMeta = (updatedAt: number, digest: string | undefined, etag: string | undefined) =>
kv.set(metaKey, { updatedAt, ...(digest ? { digest } : {}), ...(etag ? { etag } : {}) })

// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
// passed it in Aug 2026); a boot without a cache hit just refetches.
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string, digest = bodyDigest(text)) {
yield* kv.set(key, { updatedAt: Date.now(), digest, body: text }).pipe(
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (
text: string,
etag: string | undefined,
digest = bodyDigest(text),
) {
const updatedAt = Date.now()
yield* kv.set(key, { updatedAt, digest, body: text }).pipe(
Effect.andThen(writeMeta(updatedAt, digest, etag)),
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
Expand All @@ -397,9 +415,9 @@ export const layer = (options?: Options) =>
})

const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = yield* decodeCatalog(text)
yield* writeCache(text)
const response = yield* fetchApi()
const catalog = yield* decodeCatalog(response.text)
yield* writeCache(response.text, response.etag)
return catalog
})

Expand Down Expand Up @@ -429,16 +447,27 @@ export const layer = (options?: Options) =>
yield* lock
.withPermit(
Effect.gen(function* () {
const stored = yield* loadFromCache()
const stored = Option.getOrUndefined(
Schema.decodeUnknownOption(CacheMeta)((yield* kv.get(metaKey)) ?? (yield* kv.get(key))),
)
if (!force && stored && Date.now() - stored.updatedAt < Duration.toMillis(ttl)) return
const text = yield* fetchApi()
const digest = bodyDigest(text)
// models.dev rarely changes between polls; skip the cache write,
// invalidation, and Refreshed event for a byte-identical body so
// downstream provider/model update listeners stay quiet.
if (!force && stored?.digest === digest) return
yield* decodeCatalog(text)
yield* writeCache(text, digest)
const response = yield* fetchApi(force ? undefined : stored?.etag).pipe(
Effect.catchIf(
(error) => HttpClientError.isHttpClientError(error) && error.response?.status === 304,
() => Effect.undefined,
),
)
const digest = response && bodyDigest(response.text)
// models.dev rarely changes between polls; skip invalidation and
// the Refreshed event for a byte-identical body so downstream
// provider/model update listeners stay quiet.
if (!force && stored && (!response || stored.digest === digest)) {
yield* writeMeta(Date.now(), stored.digest, response?.etag ?? stored.etag)
return
}
if (!response) return
yield* decodeCatalog(response.text)
yield* writeCache(response.text, response.etag, digest)
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {}, { global: true })
}),
Expand Down
69 changes: 63 additions & 6 deletions packages/core/test/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { location } from "./fixture/location"
import { it } from "./lib/effect"

const cacheKey = "models-dev:catalog"
const metaKey = `${cacheKey}:meta`

test("normalizes permissive interleaved values to compatibility", () => {
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
Expand Down Expand Up @@ -155,18 +156,32 @@ const fixture2Snapshot = [
interface MockState {
body: string
status: number
calls: Array<{ url: string; userAgent: string | null }>
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 }],
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.body, { status: s.status }))
return HttpClientResponse.fromWeb(
request,
new Response(s.status === 304 ? null : s.body, {
status: s.status,
headers: s.etag ? { etag: s.etag } : {},
}),
)
}),
)

Expand Down Expand Up @@ -567,9 +582,9 @@ describe("ModelsDev Service", () => {
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const seeded = structuredClone(cache.values.get(cacheKey))
const body = cache.values.get(cacheKey)
// The server serves a byte-identical body, so the refresh still hits
// the network but must not rewrite the cache or publish Refreshed.
// the network but must only record the check time, not publish Refreshed.
const state = yield* Ref.make(initialState)
yield* provided(
state,
Expand All @@ -594,7 +609,49 @@ describe("ModelsDev Service", () => {
)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(cache.values.get(cacheKey)).toEqual(seeded)
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 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 }))
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
}),
)
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)
}),
)

Expand Down
Loading