add experimental config providers HttpApi slice

Add a parallel experimental config providers HttpApi endpoint, keep the schema local to the slice for now, and normalize the provider payload through JSON serialization so the draft route returns a stable JSON shape.
This commit is contained in:
Kit Langton
2026-04-13 23:43:39 -04:00
parent 87b2a9d749
commit 370690c1f8
3 changed files with 202 additions and 1 deletions

View File

@@ -0,0 +1,168 @@
import { AppLayer } from "@/effect/app-runtime"
import { memoMap } from "@/effect/run-service"
import { Provider } from "@/provider/provider"
import { lazy } from "@/util/lazy"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import type { Handler } from "hono"
import { mapValues } from "remeda"
const ApiInfo = Schema.Struct({
id: Schema.String,
url: Schema.String,
npm: Schema.String,
}).annotate({ identifier: "ConfigProvidersModelApi" })
const Mode = Schema.Struct({
text: Schema.Boolean,
audio: Schema.Boolean,
image: Schema.Boolean,
video: Schema.Boolean,
pdf: Schema.Boolean,
}).annotate({ identifier: "ConfigProvidersModelMode" })
const Interleaved = Schema.Union([
Schema.Boolean,
Schema.Struct({
field: Schema.Union([Schema.Literal("reasoning_content"), Schema.Literal("reasoning_details")]),
}),
]).annotate({ identifier: "ConfigProvidersModelInterleaved" })
const Capabilities = Schema.Struct({
temperature: Schema.Boolean,
reasoning: Schema.Boolean,
attachment: Schema.Boolean,
toolcall: Schema.Boolean,
input: Mode,
output: Mode,
interleaved: Interleaved,
}).annotate({ identifier: "ConfigProvidersModelCapabilities" })
const Cache = Schema.Struct({
read: Schema.Number,
write: Schema.Number,
}).annotate({ identifier: "ConfigProvidersModelCache" })
const ExperimentalOver200K = Schema.Struct({
input: Schema.Number,
output: Schema.Number,
cache: Cache,
})
.pipe(Schema.optional)
.annotate({ identifier: "ConfigProvidersModelExperimentalOver200K" })
const Cost = Schema.Struct({
input: Schema.Number,
output: Schema.Number,
cache: Cache,
experimentalOver200K: ExperimentalOver200K,
}).annotate({ identifier: "ConfigProvidersModelCost" })
const Limit = Schema.Struct({
context: Schema.Number,
input: Schema.optional(Schema.Number),
output: Schema.Number,
}).annotate({ identifier: "ConfigProvidersModelLimit" })
const Model = Schema.Struct({
id: Schema.String,
providerID: Schema.String,
api: ApiInfo,
name: Schema.String,
family: Schema.optional(Schema.String),
capabilities: Capabilities,
cost: Cost,
limit: Limit,
status: Schema.Union([
Schema.Literal("alpha"),
Schema.Literal("beta"),
Schema.Literal("deprecated"),
Schema.Literal("active"),
]),
options: Schema.Record(Schema.String, Schema.Unknown),
headers: Schema.Record(Schema.String, Schema.String),
release_date: Schema.String,
variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))),
}).annotate({ identifier: "ConfigProvidersModel" })
const ProviderInfo = Schema.Struct({
id: Schema.String,
name: Schema.String,
source: Schema.Union([
Schema.Literal("env"),
Schema.Literal("config"),
Schema.Literal("custom"),
Schema.Literal("api"),
]),
env: Schema.Array(Schema.String),
key: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Unknown),
models: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "ConfigProvidersProvider" })
const Providers = Schema.Unknown
const root = "/experimental/httpapi/config"
const Api = HttpApi.make("config")
.add(
HttpApiGroup.make("config")
.add(
HttpApiEndpoint.get("providers", `${root}/providers`, {
success: Providers,
}).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: "Experimental HttpApi config routes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
const svc = yield* Provider.Service
const all = mapValues(yield* svc.list(), (item) => item)
return Schema.decodeUnknownSync(Providers)(
JSON.parse(
JSON.stringify({
providers: Object.values(all),
default: mapValues(all, (item) => Provider.sort(Object.values(item.models))[0].id),
}),
),
)
})
const ConfigLive = HttpApiBuilder.group(Api, "config", (handlers) => handlers.handle("providers", providers))
const web = lazy(() =>
HttpRouter.toWebHandler(
Layer.mergeAll(
AppLayer,
HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
Layer.provide(ConfigLive),
Layer.provide(HttpServer.layerServices),
),
),
{
disableLogger: true,
memoMap,
},
),
)
export const ConfigHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw)

View File

@@ -1,7 +1,12 @@
import { lazy } from "@/util/lazy"
import { Hono } from "hono"
import { ConfigHttpApiHandler } from "./config"
import { QuestionHttpApiHandler } from "./question"
export const HttpApiRoutes = lazy(() =>
new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler),
new Hono()
.all("/question", QuestionHttpApiHandler)
.all("/question/*", QuestionHttpApiHandler)
.all("/config", ConfigHttpApiHandler)
.all("/config/*", ConfigHttpApiHandler),
)

View File

@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { Server } from "../../src/server/server"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("experimental config providers httpapi", () => {
test("lists config providers and serves docs", async () => {
await using tmp = await tmpdir({ git: true })
const app = Server.Default().app
const headers = {
"content-type": "application/json",
"x-opencode-directory": tmp.path,
}
const res = await app.request("/experimental/httpapi/config/providers", { headers })
expect(res.status).toBe(200)
const body = await res.json()
expect(Array.isArray(body.providers)).toBe(true)
expect(typeof body.default).toBe("object")
const doc = await app.request("/experimental/httpapi/config/doc", { headers })
expect(doc.status).toBe(200)
const spec = await doc.json()
expect(spec.paths["/experimental/httpapi/config/providers"]?.get?.operationId).toBe("config.providers")
})
})