import path from "path" import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { Global } from "./global" import { Flag } from "./flag/flag" import { Flock } from "./util/flock" import { Hash } from "./util/hash" import { AppFileSystem } from "./filesystem" import { InstallationChannel, InstallationVersion } from "./installation/version" export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export type CatalogModelStatus = typeof CatalogModelStatus.Type const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` const CostTier = Schema.Struct({ input: Schema.Finite, output: Schema.Finite, cache_read: Schema.optional(Schema.Finite), cache_write: Schema.optional(Schema.Finite), tier: Schema.Struct({ type: Schema.Literal("context"), size: Schema.Finite, }), }) const Cost = Schema.Struct({ input: Schema.Finite, output: Schema.Finite, cache_read: Schema.optional(Schema.Finite), cache_write: Schema.optional(Schema.Finite), tiers: Schema.optional(Schema.Array(CostTier)), context_over_200k: Schema.optional( Schema.Struct({ input: Schema.Finite, output: Schema.Finite, cache_read: Schema.optional(Schema.Finite), cache_write: Schema.optional(Schema.Finite), }), ), }) export const Model = Schema.Struct({ id: Schema.String, name: Schema.String, family: Schema.optional(Schema.String), release_date: Schema.String, attachment: Schema.Boolean, reasoning: Schema.Boolean, temperature: Schema.Boolean, tool_call: Schema.Boolean, interleaved: Schema.optional( Schema.Union([ Schema.Literal(true), Schema.Struct({ field: Schema.Literals(["reasoning_content", "reasoning_details"]), }), ]), ), cost: Schema.optional(Cost), limit: Schema.Struct({ context: Schema.Finite, input: Schema.optional(Schema.Finite), output: Schema.Finite, }), modalities: Schema.optional( Schema.Struct({ input: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])), output: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])), }), ), experimental: Schema.optional( Schema.Struct({ modes: Schema.optional( Schema.Record( Schema.String, Schema.Struct({ cost: Schema.optional(Cost), provider: Schema.optional( Schema.Struct({ body: Schema.optional(Schema.Record(Schema.String, Schema.MutableJson)), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), }), ), }), ), ), }), ), status: Schema.optional(CatalogModelStatus), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), }) export type Model = Schema.Schema.Type export const Provider = Schema.Struct({ api: Schema.optional(Schema.String), name: Schema.String, env: Schema.Array(Schema.String), id: Schema.String, npm: Schema.optional(Schema.String), models: Schema.Record(Schema.String, Model), }) export type Provider = Schema.Schema.Type declare const OPENCODE_MODELS_DEV: Record | undefined export interface Interface { readonly get: () => Effect.Effect> readonly refresh: (force?: boolean) => Effect.Effect } export class Service extends Context.Service()("@opencode/ModelsDev") {} export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service const http = HttpClient.filterStatusOk( (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ retryOn: "errors-and-responses", times: 2, schedule: Schedule.exponential(200).pipe(Schedule.jittered), }), ), ) 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}` 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) }) const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () { return yield* HttpClientRequest.get(`${source}/api.json`).pipe( HttpClientRequest.setHeader("User-Agent", USER_AGENT), http.execute, Effect.flatMap((res) => res.text), Effect.timeout("10 seconds"), ) }) const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe( Effect.catch(() => Effect.succeed(undefined)), Effect.map((v) => v as Record | undefined), ) const loadSnapshot = Effect.sync(() => typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV, ) const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { const text = yield* fetchApi() yield* fs.writeWithDirs(filepath, text) return text }) 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 }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) const get = (): Effect.Effect> => 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").pipe(Effect.annotateLogs("cause", 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 = layer.pipe( Layer.provide(FetchHttpClient.layer), Layer.provide(AppFileSystem.defaultLayer), ) export * as ModelsDev from "./models-dev"