From df7e2fe63ae7c62cb76bd0f768c727b5609173b5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:42:41 -0400 Subject: [PATCH] feat: migrate Provider.Model and Provider.Info to Effect Schema, add config providers HttpApi slice - migrate Provider.Model from Zod to Schema.Class with Schema.mutableKey on fields that the provider service mutates (id, name, cost, options, headers, variants, api.id) - migrate Provider.Info from Zod to Schema.Class with Schema.mutableKey on mutated fields (name, env, options, models) - inner types (Mode, Interleaved, Capabilities, Cache, Cost, Limit, Api) stay as Schema.Struct to avoid adding named refs to the SDK - derive .zod compat surfaces for existing Hono routes - add config httpapi route with GET /config/providers using typed ConfigProvidersResponse schema - wire into httpapi/server.ts and bridge /config/providers behind flag - zero SDK diff confirmed --- packages/opencode/src/provider/provider.ts | 176 +++++++++--------- .../opencode/src/server/instance/config.ts | 2 +- .../src/server/instance/httpapi/config.ts | 56 ++++++ .../src/server/instance/httpapi/server.ts | 3 + .../opencode/src/server/instance/index.ts | 1 + .../opencode/src/server/instance/provider.ts | 2 +- 6 files changed, 153 insertions(+), 87 deletions(-) create mode 100644 packages/opencode/src/server/instance/httpapi/config.ts diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 432dbab34a..ea0f0bd424 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,4 +1,5 @@ import z from "zod" +import { zod } from "@/util/effect-zod" import os from "os" import fuzzysort from "fuzzysort" import { Config } from "../config" @@ -18,7 +19,7 @@ import { Flag } from "../flag/flag" import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Schema } from "effect" import { EffectBridge } from "@/effect" import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -815,91 +816,96 @@ function custom(dep: CustomDep): Record { } } -export const Model = z - .object({ - id: ModelID.zod, - providerID: ProviderID.zod, - api: z.object({ - id: z.string(), - url: z.string(), - npm: z.string(), - }), - name: z.string(), - family: z.string().optional(), - capabilities: z.object({ - temperature: z.boolean(), - reasoning: z.boolean(), - attachment: z.boolean(), - toolcall: z.boolean(), - input: z.object({ - text: z.boolean(), - audio: z.boolean(), - image: z.boolean(), - video: z.boolean(), - pdf: z.boolean(), - }), - output: z.object({ - text: z.boolean(), - audio: z.boolean(), - image: z.boolean(), - video: z.boolean(), - pdf: z.boolean(), - }), - interleaved: z.union([ - z.boolean(), - z.object({ - field: z.enum(["reasoning_content", "reasoning_details"]), - }), - ]), - }), - cost: z.object({ - input: z.number(), - output: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), - }), - experimentalOver200K: z - .object({ - input: z.number(), - output: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), - }), - }) - .optional(), - }), - limit: z.object({ - context: z.number(), - input: z.number().optional(), - output: z.number(), - }), - status: z.enum(["alpha", "beta", "deprecated", "active"]), - options: z.record(z.string(), z.any()), - headers: z.record(z.string(), z.string()), - release_date: z.string(), - variants: z.record(z.string(), z.record(z.string(), z.any())).optional(), - }) - .meta({ - ref: "Model", - }) -export type Model = z.infer +const ModeSchema = Schema.Struct({ + text: Schema.Boolean, + audio: Schema.Boolean, + image: Schema.Boolean, + video: Schema.Boolean, + pdf: Schema.Boolean, +}) -export const Info = z - .object({ - id: ProviderID.zod, - name: z.string(), - source: z.enum(["env", "config", "custom", "api"]), - env: z.string().array(), - key: z.string().optional(), - options: z.record(z.string(), z.any()), - models: z.record(z.string(), Model), - }) - .meta({ - ref: "Provider", - }) -export type Info = z.infer +const InterleavedSchema = Schema.Union([ + Schema.Boolean, + Schema.Struct({ + field: Schema.Literals(["reasoning_content", "reasoning_details"]), + }), +]) + +const CapabilitiesSchema = Schema.Struct({ + temperature: Schema.Boolean, + reasoning: Schema.Boolean, + attachment: Schema.Boolean, + toolcall: Schema.Boolean, + input: ModeSchema, + output: ModeSchema, + interleaved: InterleavedSchema, +}) + +const CacheSchema = Schema.Struct({ + read: Schema.Number, + write: Schema.Number, +}) + +const CostSchema = Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + cache: CacheSchema, + experimentalOver200K: Schema.mutableKey( + Schema.optional( + Schema.Struct({ + input: Schema.Number, + output: Schema.Number, + cache: CacheSchema, + }), + ), + ), +}) + +const LimitSchema = Schema.Struct({ + context: Schema.Number, + input: Schema.optional(Schema.Number), + output: Schema.Number, +}) + +const ApiSchema = Schema.Struct({ + id: Schema.mutableKey(Schema.String), + url: Schema.String, + npm: Schema.String, +}) + +export class Model extends Schema.Class("Model")({ + id: Schema.mutableKey(ModelID), + providerID: ProviderID, + api: ApiSchema, + name: Schema.mutableKey(Schema.String), + family: Schema.optional(Schema.String), + capabilities: CapabilitiesSchema, + cost: Schema.mutableKey(CostSchema), + limit: LimitSchema, + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + options: Schema.mutableKey(Schema.Record(Schema.String, Schema.mutableKey(Schema.Any))), + headers: Schema.mutableKey(Schema.Record(Schema.String, Schema.mutableKey(Schema.String))), + release_date: Schema.String, + variants: Schema.mutableKey( + Schema.optional( + Schema.Record(Schema.String, Schema.mutableKey(Schema.Record(Schema.String, Schema.mutableKey(Schema.Any)))), + ), + ), +}) { + static readonly zod = zod(this) +} + +export class Info extends Schema.Class("Provider")({ + id: ProviderID, + name: Schema.mutableKey(Schema.String), + source: Schema.Literals(["env", "config", "custom", "api"]), + env: Schema.mutableKey(Schema.mutable(Schema.Array(Schema.String))), + key: Schema.optional(Schema.String), + options: Schema.mutableKey(Schema.Record(Schema.String, Schema.mutableKey(Schema.Any))), + models: Schema.mutableKey(Schema.Record(Schema.String, Schema.mutableKey(Model))), +}) { + static readonly zod = zod(this) +} export interface Interface { readonly list: () => Effect.Effect> diff --git a/packages/opencode/src/server/instance/config.ts b/packages/opencode/src/server/instance/config.ts index e3291a8c36..eb101e3fa5 100644 --- a/packages/opencode/src/server/instance/config.ts +++ b/packages/opencode/src/server/instance/config.ts @@ -72,7 +72,7 @@ export const ConfigRoutes = lazy(() => "application/json": { schema: resolver( z.object({ - providers: Provider.Info.array(), + providers: z.array(Provider.Info.zod), default: z.record(z.string(), z.string()), }), ), diff --git a/packages/opencode/src/server/instance/httpapi/config.ts b/packages/opencode/src/server/instance/httpapi/config.ts new file mode 100644 index 0000000000..80129ce0a3 --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/config.ts @@ -0,0 +1,56 @@ +import { Provider } from "@/provider" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { mapValues } from "remeda" + +const ConfigProvidersResponse = Schema.Struct({ + providers: Schema.Array(Provider.Info), + default: Schema.Record(Schema.String, Schema.String), +}) + +const root = "/config" + +export const ConfigApi = HttpApi.make("config") + .add( + HttpApiGroup.make("config") + .add( + HttpApiEndpoint.get("providers", `${root}/providers`, { + success: ConfigProvidersResponse, + }).annotateMerge( + OpenApi.annotations({ + identifier: "config.providers", + summary: "List config providers", + description: "Get a list of all configured AI providers and their default models.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "config", + description: "Config routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode HttpApi", + version: "0.0.1", + description: "Effect HttpApi surface for instance routes.", + }), + ) + +export const configHandlers = Layer.unwrap( + Effect.gen(function* () { + const svc = yield* Provider.Service + + const providers = Effect.fn("ConfigHttpApi.providers")(function* () { + const all = mapValues(yield* svc.list().pipe(Effect.orDie), (item) => item) + return { + providers: Object.values(all), + default: mapValues(all, (item) => Provider.sort(Object.values(item.models))[0].id), + } + }) + + return HttpApiBuilder.group(ConfigApi, "config", (handlers) => handlers.handle("providers", providers)) + }), +).pipe(Layer.provide(Provider.defaultLayer)) diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts index 299a177f50..09ea0dc917 100644 --- a/packages/opencode/src/server/instance/httpapi/server.ts +++ b/packages/opencode/src/server/instance/httpapi/server.ts @@ -10,6 +10,7 @@ import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" import { lazy } from "@/util/lazy" import { Filesystem } from "@/util" +import { ConfigApi, configHandlers } from "./config" import { PermissionApi, permissionHandlers } from "./permission" import { ProviderApi, providerHandlers } from "./provider" import { QuestionApi, questionHandlers } from "./question" @@ -109,11 +110,13 @@ export namespace ExperimentalHttpApiServer { const QuestionSecured = QuestionApi.middleware(Authorization) const PermissionSecured = PermissionApi.middleware(Authorization) const ProviderSecured = ProviderApi.middleware(Authorization) + const ConfigSecured = ConfigApi.middleware(Authorization) export const routes = Layer.mergeAll( HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)), HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)), HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)), + HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)), ).pipe( Layer.provide(auth), Layer.provide(normalize), diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 9ef6da63ac..7aa8e94a14 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -47,6 +47,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { .all("/permission", (c) => handler(c.req.raw)) .all("/permission/*", (c) => handler(c.req.raw)) .all("/provider/auth", (c) => handler(c.req.raw)) + .all("/config/providers", (c) => handler(c.req.raw)) } return app diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts index 0057218f3b..58e2445188 100644 --- a/packages/opencode/src/server/instance/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -27,7 +27,7 @@ export const ProviderRoutes = lazy(() => "application/json": { schema: resolver( z.object({ - all: Provider.Info.array(), + all: z.array(Provider.Info.zod), default: z.record(z.string(), z.string()), connected: z.array(z.string()), }),