Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 1 addition & 2 deletions packages/opencode/src/cli/cmd/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ export const ModelsCommand = effectCmd({
}),
handler: Effect.fn("Cli.models")(function* (args) {
if (args.refresh) {
// followup: lift ModelsDev into an Effect Service so this drops the Effect.promise wrap.
yield* Effect.promise(() => ModelsDev.refresh(true))
yield* ModelsDev.Service.use((s) => s.refresh(true))
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
}

Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/effect/app-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { ProviderAuth } from "@/provider/auth"
import { Agent } from "@/agent/agent"
Expand Down Expand Up @@ -66,6 +67,7 @@ export const AppLayer = Layer.mergeAll(
Storage.defaultLayer,
Snapshot.defaultLayer,
Plugin.defaultLayer,
ModelsDev.defaultLayer,
Provider.defaultLayer,
ProviderAuth.defaultLayer,
Agent.defaultLayer,
Expand Down
191 changes: 113 additions & 78 deletions packages/opencode/src/provider/models.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,14 @@
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import path from "path"
import { Schema } from "effect"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Installation } from "../installation"
import { Flag } from "@opencode-ai/core/flag/flag"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@opencode-ai/core/util/flock"
import { Hash } from "@opencode-ai/core/util/hash"

// Try to import bundled snapshot (generated at build time)
// Falls back to undefined in dev mode when snapshot doesn't exist
/* @ts-ignore */

const log = Log.create({ service: "models.dev" })
const source = url()
const filepath = path.join(
Global.Path.cache,
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const ttl = 5 * 60 * 1000
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { makeRuntime } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client"

const Cost = Schema.Struct({
input: Schema.Finite,
Expand Down Expand Up @@ -101,76 +90,122 @@ export const Provider = Schema.Struct({

export type Provider = Schema.Schema.Type<typeof Provider>

function url() {
return Flag.OPENCODE_MODELS_URL || "https://models.dev"
export interface Interface {
readonly get: () => Effect.Effect<Record<string, Provider>>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this a thunk?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ADAM! Great question. Effect.fn returns a function always, and we're using that for free log spans and some stack tracing biz. Yeah, a bit diff from ZIO patterns.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effects are still lazy and it doesn't have to be a thunk to work, but Effect.fn is actually pretty cool.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aight, ty ty

readonly refresh: (force?: boolean) => Effect.Effect<void>
}

function fresh() {
return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}

function skip(force: boolean) {
return !force && fresh()
}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))

const fetchApi = async () => {
const result = await fetch(`${url()}/api.json`, {
headers: { "User-Agent": Installation.USER_AGENT },
signal: AbortSignal.timeout(10000),
})
return { ok: result.ok, text: await result.text() }
}
const source = Flag.OPENCODE_MODELS_URL || "https://models.dev"
const filepath = path.join(
Global.Path.cache,
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const ttl = Duration.minutes(5)
const lockKey = `models-dev:${filepath}`

export const Data = lazy(async () => {
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
if (result) return result
// @ts-ignore
const snapshot = await import("./models-snapshot.js")
.then((m) => m.snapshot as Record<string, unknown>)
.catch(() => undefined)
if (snapshot) return snapshot
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
return Flock.withLock(`models-dev:${filepath}`, async () => {
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
if (result) return result
const result2 = await fetchApi()
if (result2.ok) {
await Filesystem.write(filepath, result2.text).catch((e) => {
log.error("Failed to write models cache", { error: e })
})
}
return JSON.parse(result2.text)
})
})
const fresh = Effect.fnUntraced(function* () {
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!stat) return false
const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
return Date.now() - mtime < Duration.toMillis(ttl)
})

export async function get() {
const result = await Data()
return result as Record<string, Provider>
}
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
HttpClientRequest.setHeader("User-Agent", Installation.USER_AGENT),
http.execute,
Effect.flatMap((res) => res.text),
Effect.timeout("10 seconds"),
)
})

export async function refresh(force = false) {
if (skip(force)) return Data.reset()
await Flock.withLock(`models-dev:${filepath}`, async () => {
if (skip(force)) return Data.reset()
const result = await fetchApi()
if (!result.ok) return
await Filesystem.write(filepath, result.text)
Data.reset()
}).catch((e) => {
log.error("Failed to fetch models.dev", {
error: e,
const loadFromDisk = fs
.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath)
.pipe(
Effect.catch(() => Effect.succeed(undefined)),
Effect.map((v) => v as Record<string, Provider> | undefined),
)

// Bundled snapshot fallback — present in built releases, missing in dev.
const loadSnapshot = Effect.tryPromise({
// @ts-ignore — generated at build time, may not exist in dev
try: () => import("./models-snapshot.js").then((m) => m.snapshot as Record<string, Provider> | undefined),
catch: () => undefined,
}).pipe(Effect.catch(() => Effect.succeed(undefined)))

// Cross-process file lock — Flock is the right primitive (in-process
// semaphores can't coordinate concurrent opencode CLIs writing the same cache).
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
yield* fs.writeWithDirs(filepath, text)
return text
})
})
}

if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
void refresh()
setInterval(
async () => {
await refresh()
},
60 * 1000 * 60,
).unref()
}
const populate = Effect.gen(function* () {
const fromDisk = yield* loadFromDisk
if (fromDisk) return fromDisk
const snapshot = yield* loadSnapshot
if (snapshot) return snapshot
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
return yield* Effect.scoped(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
const text = yield* fetchAndWrite()
return JSON.parse(text) as Record<string, Provider>
}),
)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)

// Single-flight cache: concurrent first-call dedupes via the cached effect.
// refresh() invalidates so the next get() re-populates from disk.
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)

const get = (): Effect.Effect<Record<string, Provider>> => cachedGet

const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
if (!force && (yield* fresh())) return yield* invalidate
yield* Effect.scoped(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
}),
).pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })),
Effect.ignore,
)
yield* invalidate
})

if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
// Eager initial refresh + 1 hour cadence (matches pre-Service behavior).
yield* Effect.forkScoped(
refresh().pipe(Effect.andThen(refresh().pipe(Effect.repeat(Schedule.fixed("60 minutes")))), Effect.ignore),
)
}

return Service.of({ get, refresh })
}),
)

export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
)

// Promise-style compat for callers in Promise-context (Hono routes, legacy CLI handlers).
// makeRuntime uses the shared memoMap so this runtime's Service instance is the same one
// AppRuntime sees — Effect callers and Promise callers operate on the same cache.
const runtime = makeRuntime(Service, defaultLayer)
export const get = () => runtime.runPromise((s) => s.get())
export const refresh = (force = false) => runtime.runPromise((s) => s.refresh(force))

export * as ModelsDev from "./models"
6 changes: 4 additions & 2 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1074,7 +1074,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
const layer: Layer.Layer<
Service,
never,
Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service
Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service | ModelsDev.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
Expand All @@ -1083,13 +1083,14 @@ const layer: Layer.Layer<
const auth = yield* Auth.Service
const env = yield* Env.Service
const plugin = yield* Plugin.Service
const modelsDevSvc = yield* ModelsDev.Service

const state = yield* InstanceState.make<State>(() =>
Effect.gen(function* () {
using _ = log.time("state")
const bridge = yield* EffectBridge.make()
const cfg = yield* config.get()
const modelsDev = yield* Effect.promise(() => ModelsDev.get())
const modelsDev = yield* modelsDevSvc.get()
const database = mapValues(modelsDev, fromModelsDevProvider)

const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
Expand Down Expand Up @@ -1722,6 +1723,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Config.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(ModelsDev.defaultLayer),
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"

const list = Effect.fn("ProviderHttpApi.list")(function* () {
const config = yield* cfg.get()
const all = yield* Effect.promise(() => ModelsDev.get())
const all = yield* ModelsDev.Service.use((s) => s.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { InstanceStore } from "@/project/instance-store"
import { Plugin } from "@/plugin"
import { Project } from "@/project/project"
import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { Pty } from "@/pty"
import { Question } from "@/question"
Expand Down Expand Up @@ -155,6 +156,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
InstanceBootstrap.defaultLayer,
InstanceStore.defaultLayer,
MCP.defaultLayer,
ModelsDev.defaultLayer,
Permission.defaultLayer,
Plugin.defaultLayer,
Project.defaultLayer,
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/server/routes/instance/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const ProviderRoutes = lazy(() =>
const svc = yield* Provider.Service
const cfg = yield* Config.Service
const config = yield* cfg.get()
const all = yield* Effect.promise(() => ModelsDev.get())
const all = yield* ModelsDev.Service.use((s) => s.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
Expand Down
Loading