From 370690c1f8b2eab7358dd35ccaf31e7265015d6a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 23:43:39 -0400 Subject: [PATCH 1/4] 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. --- .../src/server/instance/httpapi/config.ts | 168 ++++++++++++++++++ .../src/server/instance/httpapi/index.ts | 7 +- .../server/config-providers-httpapi.test.ts | 28 +++ 3 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/server/instance/httpapi/config.ts create mode 100644 packages/opencode/test/server/config-providers-httpapi.test.ts 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..6e3fb04018 --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/config.ts @@ -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) diff --git a/packages/opencode/src/server/instance/httpapi/index.ts b/packages/opencode/src/server/instance/httpapi/index.ts index 523041de84..d6d81c8a47 100644 --- a/packages/opencode/src/server/instance/httpapi/index.ts +++ b/packages/opencode/src/server/instance/httpapi/index.ts @@ -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), ) diff --git a/packages/opencode/test/server/config-providers-httpapi.test.ts b/packages/opencode/test/server/config-providers-httpapi.test.ts new file mode 100644 index 0000000000..1678ce06ed --- /dev/null +++ b/packages/opencode/test/server/config-providers-httpapi.test.ts @@ -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") + }) +}) From 0c430ada0b0beee0616002209f1fb87ffe386063 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 14 Apr 2026 09:23:16 -0400 Subject: [PATCH 2/4] 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. --- .../opencode/test/server/config-providers-httpapi.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/server/config-providers-httpapi.test.ts b/packages/opencode/test/server/config-providers-httpapi.test.ts index 1678ce06ed..467498d37b 100644 --- a/packages/opencode/test/server/config-providers-httpapi.test.ts +++ b/packages/opencode/test/server/config-providers-httpapi.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, test } from "bun:test" +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 }) From 2a118b52147675f4d71dd684b672ab1a7788560b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 14 Apr 2026 19:40:05 -0400 Subject: [PATCH 3/4] 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. --- .../src/server/instance/httpapi/config.ts | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/server/instance/httpapi/config.ts b/packages/opencode/src/server/instance/httpapi/config.ts index 6e3fb04018..65251e82da 100644 --- a/packages/opencode/src/server/instance/httpapi/config.ts +++ b/packages/opencode/src/server/instance/httpapi/config.ts @@ -134,20 +134,27 @@ const Api = HttpApi.make("config") }), ) -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", + Effect.fn("ConfigHttpApi.handlers")(function* (handlers) { + const svc = yield* Provider.Service -const ConfigLive = HttpApiBuilder.group(Api, "config", (handlers) => handlers.handle("providers", providers)) + 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( From 5435a699b879b55ffaf9da78ac2dd0c6ff484a42 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 14 Apr 2026 21:07:50 -0400 Subject: [PATCH 4/4] refactor(config): use Schema.Literals --- packages/opencode/src/server/instance/httpapi/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/server/instance/httpapi/config.ts b/packages/opencode/src/server/instance/httpapi/config.ts index 65251e82da..97a2bbf2ae 100644 --- a/packages/opencode/src/server/instance/httpapi/config.ts +++ b/packages/opencode/src/server/instance/httpapi/config.ts @@ -25,7 +25,7 @@ const Mode = Schema.Struct({ const Interleaved = Schema.Union([ Schema.Boolean, Schema.Struct({ - field: Schema.Union([Schema.Literal("reasoning_content"), Schema.Literal("reasoning_details")]), + field: Schema.Literals(["reasoning_content", "reasoning_details"]), }), ]).annotate({ identifier: "ConfigProvidersModelInterleaved" })