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
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
188 changes: 110 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,119 @@ 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 at build time; absent in dev — `tryPromise` covers both.
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)))

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 {}
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
const text = yield* Effect.scoped(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
return yield* fetchAndWrite()
}),
)
return JSON.parse(text) as Record<string, Provider>
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)

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* Effect.scoped(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
// Re-check under the lock: another process may have refreshed between
// our outer check and lock acquisition.
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
yield* invalidate
}),
).pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })),
Effect.ignore,
)
})

if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
// Schedule.spaced runs the effect once, then waits between completions.
yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("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
Loading