Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton
5435a699b8 refactor(config): use Schema.Literals 2026-04-14 21:07:50 -04:00
Kit Langton
2a118b5214 refactor(config): use effectful HttpApi group builder
Build the config providers HttpApi handler with an effectful group callback so the provider service is resolved once at layer construction time and the endpoint handler closes over the resulting service access.
2026-04-14 20:43:39 -04:00
Kit Langton
0c430ada0b test(config): dispose instances after httpapi test
Align the config providers HttpApi server test with the shared instance cleanup pattern used by the other experimental slices.
2026-04-14 20:43:38 -04:00
Kit Langton
370690c1f8 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.
2026-04-14 20:43:38 -04:00
3 changed files with 214 additions and 1 deletions

View File

@@ -0,0 +1,175 @@
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.Literals(["reasoning_content", "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 ConfigLive = HttpApiBuilder.group(
Api,
"config",
Effect.fn("ConfigHttpApi.handlers")(function* (handlers) {
const svc = yield* Provider.Service
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
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),
}),
),
)
})
return handlers.handle("providers", providers)
}),
).pipe(Layer.provide(Provider.defaultLayer))
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,33 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
afterEach(async () => {
await Instance.disposeAll()
})
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")
})
})