refactor: move provider and config provider routes onto HttpApi (#23004)

This commit is contained in:
Kit Langton
2026-04-16 23:10:45 -04:00
committed by GitHub
parent c51f3e35ca
commit ee7339f2c6
8 changed files with 323 additions and 158 deletions

View File

@@ -3,7 +3,6 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Config } from "../../config"
import { Provider } from "../../provider"
import { mapValues } from "remeda"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { AppRuntime } from "../../effect/app-runtime"
@@ -70,12 +69,7 @@ export const ConfigRoutes = lazy(() =>
description: "List of providers",
content: {
"application/json": {
schema: resolver(
z.object({
providers: Provider.Info.array(),
default: z.record(z.string(), z.string()),
}),
),
schema: resolver(Provider.ConfigProvidersResult.zod),
},
},
},
@@ -84,10 +78,10 @@ export const ConfigRoutes = lazy(() =>
async (c) =>
jsonRequest("ConfigRoutes.providers", c, function* () {
const svc = yield* Provider.Service
const providers = mapValues(yield* svc.list(), (item) => item)
const providers = yield* svc.list()
return {
providers: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
default: Provider.defaultModelIDs(providers),
}
}),
),

View File

@@ -0,0 +1,51 @@
import { Config } from "@/config"
import { Provider } from "@/provider"
import { Effect, Layer } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const root = "/config"
export const ConfigApi = HttpApi.make("config")
.add(
HttpApiGroup.make("config")
.add(
HttpApiEndpoint.get("providers", `${root}/providers`, {
success: Provider.ConfigProvidersResult,
}).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.",
}),
)
export const configHandlers = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* Provider.Service
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
const providers = yield* svc.list()
return {
providers: Object.values(providers),
default: Provider.defaultModelIDs(providers),
}
})
return HttpApiBuilder.group(ConfigApi, "config", (handlers) => handlers.handle("providers", providers))
}),
).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer))

View File

@@ -1,6 +1,11 @@
import { ProviderAuth } from "@/provider"
import { Effect, Layer } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Config } from "@/config"
import { ModelsDev } from "@/provider"
import { Provider } from "@/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const root = "/provider"
@@ -8,6 +13,15 @@ export const ProviderApi = HttpApi.make("provider")
.add(
HttpApiGroup.make("provider")
.add(
HttpApiEndpoint.get("list", root, {
success: Provider.ListResult,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.list",
summary: "List providers",
description: "Get a list of all available AI providers, including both available and connected ones.",
}),
),
HttpApiEndpoint.get("auth", `${root}/auth`, {
success: ProviderAuth.Methods,
}).annotateMerge(
@@ -17,6 +31,28 @@ export const ProviderApi = HttpApi.make("provider")
description: "Retrieve available authentication methods for all AI providers.",
}),
),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID },
payload: ProviderAuth.AuthorizeInput,
success: ProviderAuth.Authorization,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.authorize",
summary: "Start OAuth authorization",
description: "Start the OAuth authorization flow for a provider.",
}),
),
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
params: { providerID: ProviderID },
payload: ProviderAuth.CallbackInput,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.callback",
summary: "Handle OAuth callback",
description: "Handle the OAuth callback from a provider after user authorization.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
@@ -35,12 +71,72 @@ export const ProviderApi = HttpApi.make("provider")
export const providerHandlers = Layer.unwrap(
Effect.gen(function* () {
const cfg = yield* Config.Service
const provider = yield* Provider.Service
const svc = yield* ProviderAuth.Service
const list = Effect.fn("ProviderHttpApi.list")(function* () {
const config = yield* cfg.get()
const all = yield* Effect.promise(() => ModelsDev.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
for (const [key, value] of Object.entries(all)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
const connected = yield* provider.list()
const providers = Object.assign(
mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)),
connected,
)
return {
all: Object.values(providers),
default: Provider.defaultModelIDs(providers),
connected: Object.keys(connected),
}
})
const auth = Effect.fn("ProviderHttpApi.auth")(function* () {
return yield* svc.methods()
})
return HttpApiBuilder.group(ProviderApi, "provider", (handlers) => handlers.handle("auth", auth))
const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.AuthorizeInput
}) {
const result = yield* svc
.authorize({
providerID: ctx.params.providerID,
method: ctx.payload.method,
inputs: ctx.payload.inputs,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
if (!result) return yield* new HttpApiError.BadRequest({})
return result
})
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.CallbackInput
}) {
yield* svc
.callback({
providerID: ctx.params.providerID,
method: ctx.payload.method,
code: ctx.payload.code,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return true
})
return HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
handlers.handle("list", list).handle("auth", auth).handle("authorize", authorize).handle("callback", callback),
)
}),
).pipe(Layer.provide(ProviderAuth.defaultLayer))
).pipe(
Layer.provide(ProviderAuth.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Config.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"
@@ -108,8 +109,10 @@ const instance = HttpRouter.middleware()(
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(ConfigSecured).pipe(Layer.provide(configHandlers)),
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)),

View File

@@ -1,7 +1,7 @@
import { describeRoute, resolver, validator } from "hono-openapi"
import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect } from "effect"
import { Context, Effect } from "effect"
import z from "zod"
import { Format } from "../../format"
import { TuiRoutes } from "./tui"
@@ -41,12 +41,17 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
app
.all("/question", (c) => handler(c.req.raw))
.all("/question/*", (c) => handler(c.req.raw))
.all("/permission", (c) => handler(c.req.raw))
.all("/permission/*", (c) => handler(c.req.raw))
.all("/provider/auth", (c) => handler(c.req.raw))
const context = Context.empty() as Context.Context<unknown>
app.get("/question", (c) => handler(c.req.raw, context))
app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context))
app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
app.get("/permission", (c) => handler(c.req.raw, context))
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
app.get("/config/providers", (c) => handler(c.req.raw, context))
app.get("/provider", (c) => handler(c.req.raw, context))
app.get("/provider/auth", (c) => handler(c.req.raw, context))
app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context))
app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
}
return app

View File

@@ -25,13 +25,7 @@ export const ProviderRoutes = lazy(() =>
description: "List of providers",
content: {
"application/json": {
schema: resolver(
z.object({
all: Provider.Info.array(),
default: z.record(z.string(), z.string()),
connected: z.array(z.string()),
}),
),
schema: resolver(Provider.ListResult.zod),
},
},
},
@@ -59,7 +53,7 @@ export const ProviderRoutes = lazy(() =>
)
return {
all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
default: Provider.defaultModelIDs(providers),
connected: Object.keys(connected),
}
}),
@@ -116,13 +110,7 @@ export const ProviderRoutes = lazy(() =>
providerID: ProviderID.zod.meta({ description: "Provider ID" }),
}),
),
validator(
"json",
z.object({
method: z.number().meta({ description: "Auth method index" }),
inputs: z.record(z.string(), z.string()).optional().meta({ description: "Prompt inputs" }),
}),
),
validator("json", ProviderAuth.AuthorizeInput.zod),
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, inputs } = c.req.valid("json")
@@ -162,13 +150,7 @@ export const ProviderRoutes = lazy(() =>
providerID: ProviderID.zod.meta({ description: "Provider ID" }),
}),
),
validator(
"json",
z.object({
method: z.number().meta({ description: "Auth method index" }),
code: z.string().optional().meta({ description: "OAuth authorization code" }),
}),
),
validator("json", ProviderAuth.CallbackInput.zod),
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, code } = c.req.valid("json")