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
21 changes: 21 additions & 0 deletions .github/workflows/release-fork.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,27 @@ jobs:
# macOS has no other job in the repo, so it's safe to save here.
save-cache: ${{ matrix.name == 'macos' }}

# Embed a models.dev catalog snapshot into the binary so air-gapped
# users can start opencode without reaching https://models.dev at
# runtime (see packages/opencode/script/generate.ts fallback order).
- name: Fetch models.dev Snapshot
if: inputs.platforms == '' || contains(inputs.platforms, matrix.name)
run: |
snapshot_path="$RUNNER_TEMP/models-dev-api.json"
if command -v cygpath &>/dev/null; then
snapshot_path="$(cygpath -m "$snapshot_path")"
fi
for attempt in 1 2 3; do
if curl -fsSL --max-time 30 https://models.dev/api.json -o "$snapshot_path"; then
echo "models.dev snapshot downloaded (attempt $attempt)"
echo "MODELS_DEV_API_JSON=$snapshot_path" >> "$GITHUB_ENV"
exit 0
fi
echo "models.dev download failed (attempt $attempt), retrying..."
sleep 5
done
echo "::warning::Failed to download models.dev api.json; build will fall back to @opencode-ai/models snapshot or an empty catalog"

- name: Build CLI
if: inputs.platforms == '' || contains(inputs.platforms, matrix.name)
run: ./packages/opencode/script/build.ts --single --skip-install
Expand Down
41 changes: 36 additions & 5 deletions packages/core/src/models-dev.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Global } from "./global"
Expand Down Expand Up @@ -142,6 +142,14 @@ export const layer = Layer.effect(
)
const ttl = Duration.minutes(5)
const lockKey = `models-dev:${filepath}`
let fetchFailed = false
let emptyFallback = false

const publishFetchFailed = Effect.fn("ModelsDev.publishFetchFailed")(function* () {
if (fetchFailed) return
fetchFailed = true
yield* events.publish(Event.FetchFailed, { source })
})

const fresh = Effect.fnUntraced(function* () {
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
Expand Down Expand Up @@ -173,9 +181,11 @@ export const layer = Layer.effect(
Effect.map((v) => v as Record<string, Provider> | undefined),
)

const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
)
const loadSnapshot = Effect.sync(() => {
if (typeof OPENCODE_MODELS_DEV === "undefined") return undefined
if (Object.keys(OPENCODE_MODELS_DEV).length === 0) return undefined
return OPENCODE_MODELS_DEV
})

const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
Expand Down Expand Up @@ -204,7 +214,20 @@ export const layer = Layer.effect(
yield* Flock.effect(lockKey)
return yield* fetchAndWrite()
}),
).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) =>
Effect.logError("Failed to fetch models.dev", { cause: cause }).pipe(
Effect.andThen(publishFetchFailed()),
Effect.as(undefined),
),
),
)
if (!text) {
emptyFallback = true
return {}
}
return JSON.parse(text) as Record<string, Provider>
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)

Expand All @@ -221,11 +244,19 @@ export const layer = Layer.effect(
// our outer check and lock acquisition.
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
fetchFailed = false
emptyFallback = false
yield* invalidate
yield* events.publish(Event.Refreshed, {})
}),
).pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) =>
Effect.logError("Failed to fetch models.dev", { cause: cause }).pipe(
Effect.andThen(emptyFallback ? publishFetchFailed() : Effect.void),
),
),
Effect.ignore,
)
})
Expand Down
133 changes: 133 additions & 0 deletions packages/core/test/models-dev.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import path from "path"
import { afterEach, describe, expect, test } from "bun:test"
import { Effect, Fiber, Layer, Stream } from "effect"
import type { Scope } from "effect/Scope"
import { EventV2 } from "@opencode-ai/core/event"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ModelsDev } from "@opencode-ai/core/models-dev"

const eventLayer = EventV2.defaultLayer
const layer = ModelsDev.defaultLayer.pipe(Layer.provideMerge(eventLayer))
const original = {
path: Flag.OPENCODE_MODELS_PATH,
url: Flag.OPENCODE_MODELS_URL,
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
}

const fixture = {
acme: {
api: "https://acme.test",
name: "Acme",
env: ["ACME_API_KEY"],
id: "acme",
models: {
"acme-large": {
id: "acme-large",
name: "Acme Large",
release_date: "2026-01-01",
attachment: false,
reasoning: false,
temperature: true,
tool_call: true,
limit: { context: 128_000, output: 4096 },
},
},
},
} satisfies Record<string, ModelsDev.Provider>

afterEach(() => {
Flag.OPENCODE_MODELS_PATH = original.path
Flag.OPENCODE_MODELS_URL = original.url
Flag.OPENCODE_DISABLE_MODELS_FETCH = original.disabled
})

describe("ModelsDev", () => {
test("returns empty catalog and publishes fetch_failed when offline without cache or snapshot", () =>
runWithFlags(
{
path: path.join(import.meta.dir, "fixtures", "missing-models-dev.json"),
url: "http://127.0.0.1:9",
disabled: false,
},
Effect.gen(function* () {
const events = yield* EventV2.Service
const failed = yield* events.subscribe(ModelsDev.Event.FetchFailed).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
)
const models = yield* ModelsDev.Service.use((service) => service.get())

expect(models).toEqual({})
expect((yield* Fiber.join(failed)).map((event) => event.data)).toEqual([{ source: "http://127.0.0.1:9" }])
}),
),
)

test("loads disk cache before network", () =>
runWithFlags(
{
path: path.join(import.meta.dir, "plugin", "fixtures", "models-dev.json"),
url: "http://127.0.0.1:9",
disabled: false,
},
Effect.gen(function* () {
const models = yield* ModelsDev.Service.use((service) => service.get())

expect(Object.keys(models)).toEqual(["acme", "local"])
expect(models.acme.name).toEqual("Acme")
}),
),
)

test("successful refresh after fallback repopulates the cached catalog", async () => {
let online = false
const server = Bun.serve({
port: 0,
fetch() {
if (!online) return new Response("offline", { status: 503 })
return Response.json(fixture)
},
})
try {
await runWithFlags(
{
path: path.join(import.meta.dir, "fixtures", "missing-models-dev-refresh.json"),
url: server.url.origin,
disabled: false,
},
Effect.gen(function* () {
const events = yield* EventV2.Service
const refreshed = yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
)
const service = yield* ModelsDev.Service

expect(yield* service.get()).toEqual({})
online = true
yield* service.refresh(true)
expect(yield* Fiber.join(refreshed)).toHaveLength(1)
expect(yield* service.get()).toEqual(fixture)
}),
)
} finally {
server.stop(true)
}
})
})

function run<A, E>(effect: Effect.Effect<A, E, EventV2.Service | ModelsDev.Service | Scope>) {
return Effect.runPromise(effect.pipe(Effect.scoped, Effect.provide(layer)))
}

function runWithFlags<A, E, R>(
next: { path: string | undefined; url: string | undefined; disabled: boolean },
effect: Effect.Effect<A, E, EventV2.Service | ModelsDev.Service | Scope>,
) {
Flag.OPENCODE_MODELS_PATH = next.path
Flag.OPENCODE_MODELS_URL = next.url
Flag.OPENCODE_DISABLE_MODELS_FETCH = next.disabled
return run(effect)
}
29 changes: 25 additions & 4 deletions packages/opencode/script/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,28 @@ const dir = path.resolve(__dirname, "..")
process.chdir(dir)

const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
export const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
console.log("Loaded models.dev snapshot")

async function loadModelsData() {
if (process.env.MODELS_DEV_API_JSON) {
console.log("Loaded models.dev snapshot from MODELS_DEV_API_JSON")
return Bun.file(process.env.MODELS_DEV_API_JSON).text()
}

const response = await fetch(`${modelsUrl}/api.json`).catch(() => undefined)
if (response?.ok) {
console.log("Loaded models.dev snapshot from api.json")
return response.text()
}

const snapshotSpecifier = "@opencode-ai/models/snapshot"
const snapshot = await import(snapshotSpecifier).catch(() => undefined)
if (snapshot) {
console.log("Loaded models.dev snapshot from @opencode-ai/models")
return JSON.stringify(snapshot.providers)
}

console.log("Loaded no models.dev snapshot")
return "undefined"
}

export const modelsData = await loadModelsData()
4 changes: 2 additions & 2 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,7 +1103,7 @@ export class InitError extends Schema.TaggedErrorClass<InitError>()("ProviderIni

export class NoProvidersError extends Schema.TaggedErrorClass<NoProvidersError>()("ProviderNoProvidersError", {}) {
override get message() {
return "No providers are available"
return "No providers are available. Configure a provider or restore network access to load the model catalog."
}

static isInstance(input: unknown): input is NoProvidersError {
Expand All @@ -1115,7 +1115,7 @@ export class NoModelsError extends Schema.TaggedErrorClass<NoModelsError>()("Pro
providerID: ProviderV2.ID,
}) {
override get message() {
return `No models are available for provider: ${this.providerID}`
return `No models are available for provider: ${this.providerID}. Configure a model for this provider or restore network access to load the model catalog.`
}

static isInstance(input: unknown): input is NoModelsError {
Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,7 +786,16 @@ export const layer = Layer.effect(
.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model)
.pipe(Effect.orDie)
if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model
return yield* provider.defaultModel().pipe(Effect.orDie)
const exit = yield* provider.defaultModel().pipe(Effect.exit)
if (Exit.isSuccess(exit)) return exit.value
const err = Cause.squash(exit.cause)
if (Provider.NoProvidersError.isInstance(err) || Provider.NoModelsError.isInstance(err)) {
yield* events.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({ message: err.message }).toObject(),
})
}
return yield* Effect.die(err)
})

const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
Expand Down
11 changes: 10 additions & 1 deletion packages/schema/src/models-dev.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
export * as ModelsDev from "./models-dev"

import { Schema } from "effect"
import { define, inventory } from "./event"

const Refreshed = define({
type: "models-dev.refreshed",
schema: {},
})
export const Event = { Refreshed, Definitions: inventory(Refreshed) }

const FetchFailed = define({
type: "models-dev.fetch_failed",
schema: {
source: Schema.String,
},
})

export const Event = { Refreshed, FetchFailed, Definitions: inventory(Refreshed, FetchFailed) }
34 changes: 34 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type ClientOptions = {

export type Event =
| EventModelsDevRefreshed
| EventModelsDevFetchFailed
| EventIntegrationUpdated
| EventIntegrationConnectionUpdated
| EventCatalogUpdated
Expand Down Expand Up @@ -756,6 +757,13 @@ export type GlobalEvent = {
[key: string]: unknown
}
}
| {
id: string
type: "models-dev.fetch_failed"
properties: {
source: string
}
}
| {
id: string
type: "integration.updated"
Expand Down Expand Up @@ -2829,6 +2837,7 @@ export type ProviderNotFoundError = {

export type V2Event =
| V2EventModelsDevRefreshed
| V2EventModelsDevFetchFailed
| V2EventIntegrationUpdated
| V2EventIntegrationConnectionUpdated
| V2EventCatalogUpdated
Expand Down Expand Up @@ -4395,6 +4404,23 @@ export type V2EventModelsDevRefreshed = {
}
}

export type V2EventModelsDevFetchFailed = {
id: string
metadata?: {
[key: string]: unknown
}
durable?: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
type: "models-dev.fetch_failed"
data: {
source: string
}
}

export type V2EventIntegrationUpdated = {
id: string
metadata?: {
Expand Down Expand Up @@ -6260,6 +6286,14 @@ export type EventModelsDevRefreshed = {
}
}

export type EventModelsDevFetchFailed = {
id: string
type: "models-dev.fetch_failed"
properties: {
source: string
}
}

export type EventIntegrationUpdated = {
id: string
type: "integration.updated"
Expand Down
Loading
Loading