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
182 changes: 172 additions & 10 deletions packages/opencode/src/provider/models.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Global } from "../global"
import { Log } from "../util"
import path from "path"
import { rename, rm } from "fs/promises"
import z from "zod"
import { Installation } from "../installation"
import { Flag } from "../flag/flag"
Expand All @@ -21,6 +22,7 @@ const filepath = path.join(
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const ttl = 5 * 60 * 1000
let catalogVersion = 0

type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[]

Expand Down Expand Up @@ -108,10 +110,86 @@ export const Provider = z.object({

export type Provider = z.infer<typeof Provider>

const PublishModel = z
.object({
id: z.string(),
name: z.string(),
family: z.string().optional(),
release_date: z.string().optional(),
attachment: z.boolean().optional(),
reasoning: z.boolean().optional(),
temperature: z.boolean().optional(),
tool_call: z.boolean().optional(),
interleaved: z
.union([
z.literal(true),
z
.object({
field: z.enum(["reasoning_content", "reasoning_details"]),
})
.strict(),
])
.optional(),
cost: Cost.optional(),
limit: z.object({
context: z.number(),
input: z.number().optional(),
output: z.number(),
}),
modalities: z
.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
experimental: z
.object({
modes: z
.record(
z.string(),
z.object({
cost: Cost.optional(),
provider: z
.object({
body: z.record(z.string(), JsonValue).optional(),
headers: z.record(z.string(), z.string()).optional(),
})
.optional(),
}),
)
.optional(),
})
.optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
})
.passthrough()

const PublishProvider = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()).optional(),
id: z.string(),
npm: z.string().optional(),
models: z.record(z.string(), PublishModel),
})
.passthrough()

const PublishCatalog = z.record(z.string(), PublishProvider)
Comment thread
Astro-Han marked this conversation as resolved.

function url() {
return Flag.OPENCODE_MODELS_URL || "https://models.dev"
}

function modelsPathOverride() {
return process.env["OPENCODE_MODELS_PATH"]
}

export function version() {
return catalogVersion
}

function fresh() {
return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl
}
Expand All @@ -128,8 +206,53 @@ const fetchApi = async () => {
return { ok: result.ok, text: await result.text() }
}

type Catalog = Record<string, Provider>

function parseCatalog(text: string): Catalog {
const parsed = JSON.parse(text)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("models.dev catalog must be an object")
}
return PublishCatalog.parse(parsed) as unknown as Catalog
}

async function validateCatalog(catalog: Catalog) {
const runtime = await import("./provider")
const withLocalProviders = withPawWorkProviders(catalog)
for (const provider of Object.values(withLocalProviders)) {
runtime.fromModelsDevProvider(provider)
}
}

async function atomicWriteFile(target: string, content: string) {
const temp = `${target}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
try {
await Filesystem.write(temp, content)
await rename(temp, target)
} catch (error) {
await rm(temp, { force: true })
throw error
}
}

async function publishCandidate(text: string) {
const catalog = parseCatalog(text)
await validateCatalog(catalog)
await atomicWriteFile(filepath, text)
catalogVersion++
Data.reset()
return withPawWorkProviders(catalog)
}

async function loadCandidate(text: string) {
const catalog = parseCatalog(text)
await validateCatalog(catalog)
return withPawWorkProviders(catalog)
}

export const Data = lazy(async () => {
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
const overridePath = modelsPathOverride()
const result = await Filesystem.readJson(overridePath ?? filepath).catch(() => {})
if (result) return result
// @ts-ignore
const snapshot = await import("./models-snapshot.js")
Expand All @@ -138,15 +261,25 @@ export const Data = lazy(async () => {
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(() => {})
const overridePath = modelsPathOverride()
const result = await Filesystem.readJson(overridePath ?? 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 })
})
try {
const catalog = await loadCandidate(result2.text)
try {
await atomicWriteFile(filepath, result2.text)
catalogVersion++
} catch (e) {
log.warn("failed to write initial models.dev catalog", { error: e })
}
return catalog
} catch (e) {
log.warn("failed to publish initial models.dev catalog", { error: e })
}
}
return JSON.parse(result2.text)
return {}
})
})

Expand All @@ -155,14 +288,39 @@ export async function get() {
return withPawWorkProviders(result as Record<string, Provider>)
}

export async function getWithVersion() {
while (true) {
const before = version()
const result = await Data()
const after = version()
if (before === after) {
return {
providers: withPawWorkProviders(result as Record<string, Provider>),
version: after,
}
}
Data.reset()
Comment thread
Astro-Han marked this conversation as resolved.
}
}
Comment thread
Astro-Han marked this conversation as resolved.

export async function refresh(force = false) {
if (skip(force)) return Data.reset()
if (modelsPathOverride()) {
catalogVersion++
Data.reset()
return
}
if (skip(force)) {
catalogVersion++
return Data.reset()
}
await Flock.withLock(`models-dev:${filepath}`, async () => {
if (skip(force)) return Data.reset()
if (skip(force)) {
catalogVersion++
return Data.reset()
}
const result = await fetchApi()
if (!result.ok) return
await Filesystem.write(filepath, result.text)
Data.reset()
await publishCandidate(result.text)
}).catch((e) => {
log.error("Failed to fetch models.dev", {
error: e,
Expand All @@ -174,7 +332,9 @@ const ModelsDevModelValue = Model
const ModelsDevProviderValue = Provider
const ModelsDevDataValue = Data
const ModelsDevGetValue = get
const ModelsDevGetWithVersionValue = getWithVersion
const ModelsDevRefreshValue = refresh
const ModelsDevVersionValue = version

export namespace ModelsDev {
export type Model = import("./models").Model
Expand All @@ -184,7 +344,9 @@ export namespace ModelsDev {
export const Provider = ModelsDevProviderValue
export const Data = ModelsDevDataValue
export const get = ModelsDevGetValue
export const getWithVersion = ModelsDevGetWithVersionValue
export const refresh = ModelsDevRefreshValue
export const version = ModelsDevVersionValue
}

if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
Expand Down
54 changes: 41 additions & 13 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,7 @@ export interface Interface {
}

interface State {
modelsVersion: number
models: Map<string, LanguageModelV3>
providers: Record<ProviderID, Info>
sdk: Map<string, BundledSDK>
Expand Down Expand Up @@ -1095,8 +1096,8 @@ const layer: Layer.Layer<
using _ = log.time("state")
const bridge = yield* EffectBridge.make()
const cfg = yield* config.get()
const modelsDev = yield* Effect.promise(() => ModelsDev.get())
const database = mapValues(modelsDev, fromModelsDevProvider)
const modelsDev = yield* Effect.promise(() => ModelsDev.getWithVersion())
const database = mapValues(modelsDev.providers, fromModelsDevProvider)

const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
const languages = new Map<string, LanguageModelV3>()
Expand Down Expand Up @@ -1173,9 +1174,14 @@ const layer: Layer.Layer<
model.provider?.npm ??
provider.npm ??
existingModel?.api.npm ??
modelsDev[providerID]?.npm ??
modelsDev.providers[providerID]?.npm ??
"@ai-sdk/openai-compatible",
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "",
url:
model.provider?.api ??
provider?.api ??
existingModel?.api.url ??
modelsDev.providers[providerID]?.api ??
"",
},
status: model.status ?? existingModel?.status ?? "active",
name,
Expand Down Expand Up @@ -1397,6 +1403,7 @@ const layer: Layer.Layer<
}

return {
modelsVersion: modelsDev.version,
models: languages,
providers,
sdk,
Expand All @@ -1406,7 +1413,17 @@ const layer: Layer.Layer<
}),
)

const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
const currentState = Effect.fn("Provider.currentState")(function* () {
const existing = yield* InstanceState.get(state)
if (existing.modelsVersion === ModelsDev.version()) return existing
yield* InstanceState.invalidate(state)
return yield* InstanceState.get(state)
})
Comment thread
Astro-Han marked this conversation as resolved.

const list = Effect.fn("Provider.list")(function* () {
const s = yield* currentState()
return s.providers
})

async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {
try {
Expand Down Expand Up @@ -1545,12 +1562,13 @@ const layer: Layer.Layer<
}
}

const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
InstanceState.use(state, (s) => s.providers[providerID]),
)
const getProvider = Effect.fn("Provider.getProvider")(function* (providerID: ProviderID) {
const s = yield* currentState()
return s.providers[providerID]
})

const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
const s = yield* InstanceState.get(state)
const s = yield* currentState()
const provider = s.providers[providerID]
if (!provider) {
const available = Object.keys(s.providers)
Expand All @@ -1568,7 +1586,7 @@ const layer: Layer.Layer<
})

const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
const s = yield* InstanceState.get(state)
const s = yield* currentState()
const envs = yield* env.all()
const key = `${model.providerID}/${model.id}`
if (s.models.has(key)) return s.models.get(key)!
Expand All @@ -1588,6 +1606,16 @@ const layer: Layer.Layer<
}

const provider = s.providers[model.providerID]
if (!provider) {
const available = Object.keys(s.providers)
const matches = fuzzysort.go(model.providerID, available, { limit: 3, threshold: -10000 })
throw new ModelNotFoundError({
providerID: model.providerID,
modelID: model.id,
suggestions: matches.map((m) => m.target),
})
}
Comment thread
Astro-Han marked this conversation as resolved.

const sdk = await resolveSDK(model, s, envs)

try {
Expand All @@ -1614,7 +1642,7 @@ const layer: Layer.Layer<
})

const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
const s = yield* InstanceState.get(state)
const s = yield* currentState()
const provider = s.providers[providerID]
if (!provider) return undefined
for (const item of query) {
Expand All @@ -1633,7 +1661,7 @@ const layer: Layer.Layer<
return yield* getModel(parsed.providerID, parsed.modelID)
}

const s = yield* InstanceState.get(state)
const s = yield* currentState()
const provider = s.providers[providerID]
if (!provider) return undefined

Expand Down Expand Up @@ -1685,7 +1713,7 @@ const layer: Layer.Layer<
const cfg = yield* config.get()
if (cfg.model) return parseModel(cfg.model)

const s = yield* InstanceState.get(state)
const s = yield* currentState()
const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => {
if (!isRecord(x) || !Array.isArray(x.recent)) return []
Expand Down
Loading
Loading