Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton
df7e2fe63a 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
2026-04-15 23:50:09 -04:00
6 changed files with 153 additions and 87 deletions

View File

@@ -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<string, CustomLoader> {
}
}
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<typeof Model>
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<typeof Info>
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>("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<Info>("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<Record<ProviderID, Info>>

View File

@@ -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()),
}),
),

View File

@@ -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))

View File

@@ -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),

View File

@@ -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

View File

@@ -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()),
}),