refactor(core): move models.dev into core (#27347)

This commit is contained in:
Dax 2026-05-13 20:58:24 -04:00 committed by GitHub
parent 9818c9e8d0
commit 16c457e712
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 345 additions and 153 deletions

View file

@ -5,6 +5,7 @@ import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { Instance } from "./instance"
type ProviderRecord = {
provider: ProviderV2.Info
@ -56,6 +57,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
yield* Instance.Service
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
const plugin = yield* PluginV2.Service

View file

@ -0,0 +1,12 @@
import { Layer, LayerMap } from "effect"
import { Instance } from "./instance"
import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
export class InstanceServiceMap extends LayerMap.Service<InstanceServiceMap>()("@opencode/example/InstanceServiceMap", {
lookup: (ref: Instance.Ref) => {
const instance = Layer.succeed(Instance.Service, Instance.Service.of(ref))
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(instance))
},
idleTimeToLive: "5 minutes",
}) {}

View file

@ -0,0 +1,10 @@
import { Context } from "effect"
export * as Instance from "./instance"
export type Ref = {
readonly directory: string
readonly workspaceID?: string
}
export class Service extends Context.Service<Service, Ref>()("@opencode/Instance") {}

View file

@ -0,0 +1,2 @@
// Auto-generated by build.ts - do not edit
export declare const snapshot: Record<string, unknown>

File diff suppressed because one or more lines are too long

View file

@ -1,15 +1,17 @@
import { Global } from "@opencode-ai/core/global"
import path from "path"
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 { Flock } from "@opencode-ai/core/util/flock"
import { Hash } from "@opencode-ai/core/util/hash"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { CatalogModelStatus } from "./model-status"
import { RuntimeFlags } from "@/effect/runtime-flags"
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,
@ -110,14 +112,21 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
type Requirements = AppFileSystem.Service | HttpClient.HttpClient | RuntimeFlags.Service
type Requirements = AppFileSystem.Service | HttpClient.HttpClient
export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const flags = yield* RuntimeFlags.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(
@ -136,7 +145,7 @@ export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
HttpClientRequest.setHeader("User-Agent", Installation.userAgent(flags.client)),
HttpClientRequest.setHeader("User-Agent", USER_AGENT),
http.execute,
Effect.flatMap((res) => res.text),
Effect.timeout("10 seconds"),
@ -212,7 +221,6 @@ export const layer: Layer.Layer<Service, never, Requirements> = Layer.effect(
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
export * as ModelsDev from "./models"

View file

@ -0,0 +1,66 @@
export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer } from "effect"
import { AuthV2 } from "../auth"
import { Catalog } from "../catalog"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { AuthPlugin } from "./auth"
import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
type Plugin = {
id: PluginV2.ID
effect: Effect.Effect<PluginV2.HookFunctions | void, never, Catalog.Service | AuthV2.Service | Npm.Service>
}
export interface Interface {
readonly wait: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {}
export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Service | AuthV2.Service | Npm.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
const npm = yield* Npm.Service
const done = yield* Deferred.make<void>()
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
yield* plugin.add({
id: input.id,
effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(AuthV2.Service, auth),
Effect.provideService(Npm.Service, npm),
),
})
})
const boot = Effect.gen(function* () {
yield* add(EnvPlugin)
yield* add(AuthPlugin)
for (const item of ProviderPlugins) {
yield* add(item)
}
yield* add(ModelsDevPlugin)
}).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe(Effect.exit, Effect.flatMap((exit) => Deferred.done(done, exit)), Effect.forkScoped)
return Service.of({
wait: () => Deferred.await(done),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Catalog.defaultLayer),
Layer.provide(PluginV2.defaultLayer),
Layer.provide(Layer.orDie(AuthV2.defaultLayer)),
Layer.provide(Npm.defaultLayer),
)

View file

@ -1,9 +1,9 @@
import { DateTime, Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelsDev } from "@/provider/models"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Catalog } from "../catalog"
import { ModelV2 } from "../model"
import { ModelsDev } from "../models"
import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
function released(date: string) {
const time = Date.parse(date)

View file

@ -1,12 +1,14 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { testEffect } from "./lib/effect"
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
const it = testEffect(Catalog.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer), Layer.provide(instanceLayer)))
describe("CatalogV2", () => {
it.effect("normalizes provider baseURL into endpoint url", () =>

View file

@ -4,11 +4,10 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "../../src/provider/models"
import { it } from "../lib/effect"
import { ModelsDev } from "@opencode-ai/core/models"
import { it } from "./lib/effect"
import { rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
import { RuntimeFlags } from "@/effect/runtime-flags"
// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
// resolve providers without network. These tests need to drive the on-disk
@ -93,7 +92,6 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
Layer.fresh(ModelsDev.layer).pipe(
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(RuntimeFlags.layer({ client: "test-client" })),
)
const writeCache = (data: object, mtimeMs?: number) =>
@ -138,14 +136,14 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns {} when disk empty and fetch disabled", () =>
it.live("get() returns bundled snapshot when disk empty and fetch disabled", () =>
Effect.gen(function* () {
const state = yield* Ref.make(initialState)
const result = yield* provided(
state,
ModelsDev.Service.use((s) => s.get()),
)
expect(result).toEqual({})
expect(Object.keys(result).length).toBeGreaterThan(0)
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
@ -207,7 +205,7 @@ describe("ModelsDev Service", () => {
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
expect(final.calls[0].userAgent).toContain("/test-client")
expect(final.calls[0].userAgent).toContain("/cli")
}),
)
@ -257,7 +255,7 @@ describe("ModelsDev Service", () => {
}),
)
expect(result).toEqual(fixture)
// withTransientReadRetry retries 5xx, so calls may be > 1.
// retryTransient retries 5xx, so calls may be > 1.
const final = yield* Ref.get(state)
expect(final.calls.length).toBeGreaterThanOrEqual(1)
}),

View file

@ -4,7 +4,7 @@ import { AuthV2 } from "@opencode-ai/core/auth"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))

View file

@ -5,7 +5,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))

View file

@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"
import { AISDK } from "@opencode-ai/core/aisdk"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { it, model } from "./provider-helper"
const itAISDK = testEffect(Layer.provideMerge(AISDK.layer, PluginV2.defaultLayer))

View file

@ -9,7 +9,7 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { DynamicProviderPlugin } from "@opencode-ai/core/plugin/provider/dynamic"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { fixtureProvider, it, model, npmLayer } from "./provider-helper"
const fixtureProviderPath = fileURLToPath(fixtureProvider)

View file

@ -3,7 +3,7 @@ import { Effect } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { fakeSelectorSdk, it, model } from "../v2/plugin/provider-helper"
import { fakeSelectorSdk, it, model } from "./provider-helper"
describe("GithubCopilotPlugin", () => {
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>

View file

@ -4,7 +4,7 @@ import { AuthV2 } from "@opencode-ai/core/auth"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { it, model, npmLayer, provider, withEnv } from "./provider-helper"
const gitlabSDKOptions: Record<string, unknown>[] = []

View file

@ -4,7 +4,7 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GooglePlugin } from "@opencode-ai/core/plugin/provider/google"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { it, model } from "./provider-helper"
const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))

View file

@ -6,7 +6,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
import { it, model } from "./provider-helper"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
const aisdkIt = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginV2.defaultLayer)))

View file

@ -5,7 +5,7 @@ import { Effect, Layer, Option } from "effect"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Option } from "effect"
import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
@ -8,6 +9,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider, withEnv } from "./provider-helper"
const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }]
const instanceLayer = Layer.succeed(Instance.Service, Instance.Service.of({ directory: "test" }))
describe("OpencodePlugin", () => {
it.effect("uses a public key and cancels paid models without credentials", () =>
@ -190,6 +192,6 @@ describe("OpencodePlugin", () => {
const selected = yield* catalog.model.small(providerID)
expect(Option.getOrUndefined(selected)?.id).toBe(ModelV2.ID.make("gpt-5-nano"))
}).pipe(Effect.provide(Catalog.defaultLayer)),
}).pipe(Effect.provide(Catalog.defaultLayer.pipe(Layer.provide(instanceLayer)))),
)
})

View file

@ -4,7 +4,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { XAIPlugin } from "@opencode-ai/core/plugin/provider/xai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../../lib/effect"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk } from "./provider-helper"
const it = testEffect(PluginV2.defaultLayer)

View file

@ -13,11 +13,11 @@ 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())
await Bun.write(
path.join(dir, "src/provider/models-snapshot.js"),
path.join(dir, "../core/src/models-snapshot.js"),
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
)
await Bun.write(
path.join(dir, "src/provider/models-snapshot.d.ts"),
path.join(dir, "../core/src/models-snapshot.d.ts"),
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
)
console.log("Generated models-snapshot.js")

View file

@ -1,22 +1,23 @@
import { EOL } from "os"
import { Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { InstanceServiceMap } from "@opencode-ai/core/instance-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { effectCmd } from "../../effect-cmd"
import { PluginBoot } from "@/v2/plugin-boot"
const layer = Catalog.defaultLayer.pipe(Layer.provide(PluginBoot.defaultLayer))
const Runtime = Layer.mergeAll(InstanceServiceMap.layer)
export const V2Command = effectCmd({
command: "v2",
describe: "debug v2 catalog and built-in plugins",
instance: false,
handler: Effect.fn("Cli.debug.v2")(function* () {
const result = yield* Effect.gen(function* () {
handler: Effect.fn("Cli.debug.v2")(
function* () {
yield* PluginBoot.Service.use((service) => service.wait())
const catalog = yield* Catalog.Service
const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id))
const all = (yield* catalog.provider.all()).sort((a, b) => a.id.localeCompare(b.id))
return {
const result = {
providers,
default: catalog.model
.default()
@ -33,8 +34,13 @@ export const V2Command = effectCmd({
),
),
}
}).pipe(Effect.provide(layer), Effect.orDie)
process.stdout.write(JSON.stringify(result, null, 2) + EOL)
}),
process.stdout.write(JSON.stringify(result, null, 2) + EOL)
},
Effect.provide(
InstanceServiceMap.get({
directory: process.cwd(),
}),
),
Effect.provide(Runtime),
),
})

View file

@ -19,7 +19,7 @@ import type {
import { UI } from "../ui"
import { cmd } from "./cmd"
import { effectCmd } from "../effect-cmd"
import { ModelsDev } from "@/provider/models"
import { ModelsDev } from "@opencode-ai/core/models"
import { InstanceRef } from "@/effect/instance-ref"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"

View file

@ -2,7 +2,7 @@ import { EOL } from "os"
import { Effect } from "effect"
import { Provider } from "@/provider/provider"
import { ProviderID } from "../../provider/schema"
import { ModelsDev } from "@/provider/models"
import { ModelsDev } from "@opencode-ai/core/models"
import { effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"

View file

@ -3,7 +3,7 @@ import { cmd } from "./cmd"
import { CliError, effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import * as Prompt from "../effect/prompt"
import { ModelsDev } from "@/provider/models"
import { ModelsDev } from "@opencode-ai/core/models"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"

View file

@ -14,7 +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 { ModelsDev } from "@opencode-ai/core/models"
import { Provider } from "@/provider/provider"
import { ProviderAuth } from "@/provider/auth"
import { Agent } from "@/agent/agent"

View file

@ -1,7 +1,6 @@
import { Schema } from "effect"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
export { CatalogModelStatus } from "@opencode-ai/core/models"
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
export type ModelStatus = typeof ModelStatus.Type

View file

@ -8,7 +8,7 @@ import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin"
import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "./models"
import * as ModelsDev from "@opencode-ai/core/models"
import { Auth } from "../auth"
import { Env } from "../env"
import { InstallationVersion } from "@opencode-ai/core/installation/version"

View file

@ -2,7 +2,7 @@ import type { ModelMessage, ToolResultPart } from "ai"
import { mergeDeep, unique } from "remeda"
import type { JSONSchema7 } from "@ai-sdk/provider"
import type * as Provider from "./provider"
import type * as ModelsDev from "./models"
import type * as ModelsDev from "@opencode-ai/core/models"
import { iife } from "@/util/iife"
import { Flag } from "@opencode-ai/core/flag/flag"

View file

@ -0,0 +1,59 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { Instance } from "@opencode-ai/core/instance"
import { InstanceServiceMap } from "@opencode-ai/core/instance-layer"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
export const InstanceQuery = Schema.Struct({
instance: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
}),
),
}).annotate({ identifier: "V2InstanceQuery" })
export const instanceQueryOpenApi = OpenApi.annotations({
transform: (operation) => {
const parameters = operation.parameters
if (!Array.isArray(parameters)) return operation
return {
...operation,
parameters: parameters.map((parameter) =>
parameter?.name === "instance" && parameter?.in === "query"
? { ...parameter, style: "deepObject", explode: true }
: parameter,
),
}
},
})
export class V2InstanceMiddleware extends HttpApiMiddleware.Service<
V2InstanceMiddleware,
{
provides: Catalog.Service | PluginBoot.Service
}
>()("@opencode/ExperimentalHttpApiV2Instance") {}
function ref(request: HttpServerRequest.HttpServerRequest): Instance.Ref {
const query = new URL(request.url, "http://localhost").searchParams
return {
directory: query.get("instance[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
workspaceID: query.get("instance[workspace]") || request.headers["x-opencode-workspace"],
}
}
export const layer = Layer.effect(
V2InstanceMiddleware,
Effect.gen(function* () {
const instances = yield* InstanceServiceMap
return V2InstanceMiddleware.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* effect.pipe(Effect.provide(instances.get(ref(request))))
}),
)
}),
).pipe(Layer.provide(InstanceServiceMap.layer))

View file

@ -2,12 +2,14 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../../middleware/authorization"
import { InstanceQuery, instanceQueryOpenApi, V2InstanceMiddleware } from "./instance"
export const ModelGroup = HttpApiGroup.make("v2.model")
.add(
HttpApiEndpoint.get("models", "/api/model", {
query: InstanceQuery,
success: Schema.Array(ModelV2.Info),
}).annotateMerge(
}).annotateMerge(instanceQueryOpenApi).annotateMerge(
OpenApi.annotations({
identifier: "v2.model.list",
summary: "List v2 models",
@ -21,4 +23,5 @@ export const ModelGroup = HttpApiGroup.make("v2.model")
description: "Experimental v2 model routes.",
}),
)
.middleware(V2InstanceMiddleware)
.middleware(Authorization)

View file

@ -3,12 +3,14 @@ import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ApiNotFoundError } from "../../errors"
import { Authorization } from "../../middleware/authorization"
import { InstanceQuery, instanceQueryOpenApi, V2InstanceMiddleware } from "./instance"
export const ProviderGroup = HttpApiGroup.make("v2.provider")
.add(
HttpApiEndpoint.get("providers", "/api/provider", {
query: InstanceQuery,
success: Schema.Array(ProviderV2.Info),
}).annotateMerge(
}).annotateMerge(instanceQueryOpenApi).annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.list",
summary: "List v2 providers",
@ -19,9 +21,10 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
.add(
HttpApiEndpoint.get("provider", "/api/provider/:providerID", {
params: { providerID: ProviderV2.ID },
query: InstanceQuery,
success: ProviderV2.Info,
error: ApiNotFoundError,
}).annotateMerge(
}).annotateMerge(instanceQueryOpenApi).annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.get",
summary: "Get v2 provider",
@ -35,4 +38,5 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
description: "Experimental v2 provider routes.",
}),
)
.middleware(V2InstanceMiddleware)
.middleware(Authorization)

View file

@ -1,6 +1,6 @@
import { ProviderAuth } from "@/provider/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@/provider/models"
import { ModelsDev } from "@opencode-ai/core/models"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"

View file

@ -1,12 +1,12 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { SessionV2 } from "@/v2/session"
import { Layer } from "effect"
import { layer as v2InstanceLayer } from "../groups/v2/instance"
import { messageHandlers } from "./v2/message"
import { modelHandlers } from "./v2/model"
import { providerHandlers } from "./v2/provider"
import { sessionHandlers } from "./v2/session"
export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers, providerHandlers).pipe(
Layer.provide(Catalog.defaultLayer),
Layer.provide(v2InstanceLayer),
Layer.provide(SessionV2.defaultLayer),
)

View file

@ -1,12 +1,19 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (handlers) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
return handlers.handle("models", () => catalog.model.available())
return handlers.handle(
"models",
Effect.fn(function* () {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait()
return yield* catalog.model.available()
}),
)
}),
)

View file

@ -1,4 +1,5 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
@ -6,13 +7,22 @@ import { notFound } from "../../errors"
export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provider", (handlers) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
return handlers
.handle("providers", () => catalog.provider.available())
.handle(
"providers",
Effect.fn(function* () {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait()
return yield* catalog.provider.available()
}),
)
.handle(
"provider",
Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait()
return yield* catalog.provider
.get(ctx.params.providerID)
.pipe(Effect.catchTag("CatalogV2.ProviderNotFound", () => Effect.fail(notFound("Provider not found"))))

View file

@ -29,7 +29,7 @@ import { InstanceLayer } from "@/project/instance-layer"
import { Plugin } from "@/plugin"
import { Project } from "@/project/project"
import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@/provider/models"
import { ModelsDev } from "@opencode-ai/core/models"
import { Provider } from "@/provider/provider"
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"

View file

@ -1,50 +0,0 @@
export * as PluginBoot from "./plugin-boot"
import { Npm } from "@opencode-ai/core/npm"
import { Effect, Layer } from "effect"
import { AuthV2 } from "@opencode-ai/core/auth"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { EnvPlugin } from "@opencode-ai/core/plugin/env"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ModelsDevPlugin } from "./plugin/models-dev"
type Plugin = {
id: PluginV2.ID
effect: Effect.Effect<PluginV2.HookFunctions | void, never, Catalog.Service | AuthV2.Service | Npm.Service>
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
const npm = yield* Npm.Service
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
yield* plugin.add({
id: input.id,
effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(AuthV2.Service, auth),
Effect.provideService(Npm.Service, npm),
),
})
})
yield* add(EnvPlugin)
yield* add(AuthPlugin)
for (const item of ProviderPlugins) {
yield* add(item)
}
yield* add(ModelsDevPlugin)
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Catalog.defaultLayer),
Layer.provide(PluginV2.defaultLayer),
Layer.provide(Layer.orDie(AuthV2.defaultLayer)),
Layer.provide(Npm.defaultLayer),
)

View file

@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ConfigProvider } from "@/config/provider"
import { CatalogModelStatus, ModelStatus } from "@/provider/model-status"
import { ModelsDev } from "@/provider/models"
import { ModelsDev } from "@opencode-ai/core/models"
import { Provider } from "@/provider/provider"
describe("provider model status schemas", () => {

View file

@ -7,7 +7,7 @@ import { Global } from "@opencode-ai/core/global"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Plugin } from "../../src/plugin/index"
import { ModelsDev } from "@/provider/models"
import { ModelsDev } from "@opencode-ai/core/models"
import { Provider } from "@/provider/provider"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "@/util/filesystem"

View file

@ -9,7 +9,7 @@ import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import { ModelsDev } from "@/provider/models"
import { ModelsDev } from "@opencode-ai/core/models"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "@/util/filesystem"
import { tmpdir } from "../fixture/fixture"

View file

@ -4385,10 +4385,20 @@ export class Model extends HeyApiClient {
*
* Retrieve available v2 models ordered by release date.
*/
public list<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
public list<ThrowOnError extends boolean = false>(
parameters?: {
instance?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "instance" }] }])
return (options?.client ?? this.client).get<V2ModelListResponses, unknown, ThrowOnError>({
url: "/api/model",
...options,
...params,
})
}
}
@ -4399,10 +4409,20 @@ export class Provider2 extends HeyApiClient {
*
* Retrieve active v2 AI providers so clients can show provider availability and configuration.
*/
public list<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
public list<ThrowOnError extends boolean = false>(
parameters?: {
instance?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "instance" }] }])
return (options?.client ?? this.client).get<V2ProviderListResponses, unknown, ThrowOnError>({
url: "/api/provider",
...options,
...params,
})
}
@ -4414,10 +4434,24 @@ export class Provider2 extends HeyApiClient {
public get<ThrowOnError extends boolean = false>(
parameters: {
providerID: string
instance?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }])
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "providerID" },
{ in: "query", key: "instance" },
],
},
],
)
return (options?.client ?? this.client).get<V2ProviderGetResponses, V2ProviderGetErrors, ThrowOnError>({
url: "/api/provider/{providerID}",
...options,

View file

@ -15,8 +15,6 @@ export type Event =
| EventPermissionReplied
| EventSessionDiff
| EventSessionError
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
@ -42,6 +40,8 @@ export type Event =
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
@ -799,8 +799,6 @@ export type GlobalEvent = {
| EventPermissionReplied
| EventSessionDiff
| EventSessionError
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
@ -826,6 +824,8 @@ export type GlobalEvent = {
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventInstallationUpdated
| EventInstallationUpdateAvailable
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
@ -2496,22 +2496,6 @@ export type EventSessionError = {
}
}
export type EventInstallationUpdated = {
id: string
type: "installation.updated"
properties: {
version: string
}
}
export type EventInstallationUpdateAvailable = {
id: string
type: "installation.update-available"
properties: {
version: string
}
}
export type EventQuestionAsked = {
id: string
type: "question.asked"
@ -2681,6 +2665,22 @@ export type EventPtyDeleted = {
}
}
export type EventInstallationUpdated = {
id: string
type: "installation.updated"
properties: {
version: string
}
}
export type EventInstallationUpdateAvailable = {
id: string
type: "installation.update-available"
properties: {
version: string
}
}
export type EventMessageUpdated = {
id: string
type: "message.updated"
@ -6673,7 +6673,12 @@ export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2Sess
export type V2ModelListData = {
body?: never
path?: never
query?: never
query?: {
instance?: {
directory?: string
workspace?: string
}
}
url: "/api/model"
}
@ -6689,7 +6694,12 @@ export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponse
export type V2ProviderListData = {
body?: never
path?: never
query?: never
query?: {
instance?: {
directory?: string
workspace?: string
}
}
url: "/api/provider"
}
@ -6707,7 +6717,12 @@ export type V2ProviderGetData = {
path: {
providerID: string
}
query?: never
query?: {
instance?: {
directory?: string
workspace?: string
}
}
url: "/api/provider/{providerID}"
}