add experimental provider auth HttpApi slice

Move the shared provider auth DTOs to Effect Schema, add a parallel experimental provider auth HttpApi endpoint, and cover the list/docs flow with a server test.
This commit is contained in:
Kit Langton
2026-04-13 23:00:00 -04:00
parent 87b2a9d749
commit 0bb986359b
5 changed files with 189 additions and 61 deletions
+63 -58
View File
@@ -2,70 +2,75 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error"
import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, Context } from "effect"
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
import z from "zod"
export namespace ProviderAuth {
export const Method = z
.object({
type: z.union([z.literal("oauth"), z.literal("api")]),
label: z.string(),
prompts: z
.array(
z.union([
z.object({
type: z.literal("text"),
key: z.string(),
message: z.string(),
placeholder: z.string().optional(),
when: z
.object({
key: z.string(),
op: z.union([z.literal("eq"), z.literal("neq")]),
value: z.string(),
})
.optional(),
}),
z.object({
type: z.literal("select"),
key: z.string(),
message: z.string(),
options: z.array(
z.object({
label: z.string(),
value: z.string(),
hint: z.string().optional(),
}),
),
when: z
.object({
key: z.string(),
op: z.union([z.literal("eq"), z.literal("neq")]),
value: z.string(),
})
.optional(),
}),
]),
)
.optional(),
})
.meta({
ref: "ProviderAuthMethod",
})
export type Method = z.infer<typeof Method>
export class When extends Schema.Class<When>("ProviderAuthWhen")({
key: Schema.String,
op: Schema.Union([Schema.Literal("eq"), Schema.Literal("neq")]),
value: Schema.String,
}) {
static readonly zod = zod(this)
}
export const Authorization = z
.object({
url: z.string(),
method: z.union([z.literal("auto"), z.literal("code")]),
instructions: z.string(),
})
.meta({
ref: "ProviderAuthAuthorization",
})
export type Authorization = z.infer<typeof Authorization>
export class TextPrompt extends Schema.Class<TextPrompt>("ProviderAuthTextPrompt")({
type: Schema.Literal("text"),
key: Schema.String,
message: Schema.String,
placeholder: Schema.optional(Schema.String),
when: Schema.optional(When),
}) {
static readonly zod = zod(this)
}
export class SelectOption extends Schema.Class<SelectOption>("ProviderAuthSelectOption")({
label: Schema.String,
value: Schema.String,
hint: Schema.optional(Schema.String),
}) {
static readonly zod = zod(this)
}
export class SelectPrompt extends Schema.Class<SelectPrompt>("ProviderAuthSelectPrompt")({
type: Schema.Literal("select"),
key: Schema.String,
message: Schema.String,
options: Schema.Array(SelectOption),
when: Schema.optional(When),
}) {
static readonly zod = zod(this)
}
export const Prompt = Schema.Union([TextPrompt, SelectPrompt])
.annotate({ discriminator: "type", identifier: "ProviderAuthPrompt" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Prompt = Schema.Schema.Type<typeof Prompt>
export class Method extends Schema.Class<Method>("ProviderAuthMethod")({
type: Schema.Union([Schema.Literal("oauth"), Schema.Literal("api")]),
label: Schema.String,
prompts: Schema.optional(Schema.Array(Prompt)),
}) {
static readonly zod = zod(this)
}
export class Authorization extends Schema.Class<Authorization>("ProviderAuthAuthorization")({
url: Schema.String,
method: Schema.Union([Schema.Literal("auto"), Schema.Literal("code")]),
instructions: Schema.String,
}) {
static readonly zod = zod(this)
}
export const Methods = Schema.Record(Schema.String, Schema.Array(Method))
.annotate({ identifier: "ProviderAuthMethods" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Methods = Schema.Schema.Type<typeof Methods>
export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
@@ -1,7 +1,12 @@
import { lazy } from "@/util/lazy"
import { Hono } from "hono"
import { ProviderHttpApiHandler } from "./provider"
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("/provider", ProviderHttpApiHandler)
.all("/provider/*", ProviderHttpApiHandler),
)
@@ -0,0 +1,64 @@
import { AppLayer } from "@/effect/app-runtime"
import { memoMap } from "@/effect/run-service"
import { ProviderAuth } from "@/provider/auth"
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"
const root = "/experimental/httpapi/provider"
const Api = HttpApi.make("provider")
.add(
HttpApiGroup.make("provider")
.add(
HttpApiEndpoint.get("auth", `${root}/auth`, {
success: ProviderAuth.Methods,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.auth",
summary: "Get provider auth methods",
description: "Retrieve available authentication methods for all AI providers.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "provider",
description: "Experimental HttpApi provider routes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
const auth = Effect.fn("ProviderHttpApi.auth")(function* () {
const svc = yield* ProviderAuth.Service
return Schema.decodeUnknownSync(ProviderAuth.Methods)(yield* svc.methods())
})
const ProviderLive = HttpApiBuilder.group(Api, "provider", (handlers) => handlers.handle("auth", auth))
const web = lazy(() =>
HttpRouter.toWebHandler(
Layer.mergeAll(
AppLayer,
HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
Layer.provide(ProviderLive),
Layer.provide(HttpServer.layerServices),
),
),
{
disableLogger: true,
memoMap,
},
),
)
export const ProviderHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw)
@@ -85,7 +85,7 @@ export const ProviderRoutes = lazy(() =>
description: "Provider auth methods",
content: {
"application/json": {
schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))),
schema: resolver(ProviderAuth.Methods.zod),
},
},
},
@@ -106,7 +106,7 @@ export const ProviderRoutes = lazy(() =>
description: "Authorization URL and method",
content: {
"application/json": {
schema: resolver(ProviderAuth.Authorization.optional()),
schema: resolver(ProviderAuth.Authorization.zod.optional()),
},
},
},
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Server } from "../../src/server/server"
import { tmpdir } from "../fixture/fixture"
import { Log } from "../../src/util/log"
Log.init({ print: false })
describe("experimental provider httpapi", () => {
test("lists provider auth methods and serves docs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginDir = path.join(dir, ".opencode", "plugin")
await fs.mkdir(pluginDir, { recursive: true })
await Bun.write(
path.join(pluginDir, "custom-copilot-auth.ts"),
[
"export default {",
' id: "demo.custom-copilot-auth",',
" server: async () => ({",
" auth: {",
' provider: "github-copilot",',
" methods: [",
' { type: "api", label: "Test Override Auth" },',
" ],",
" loader: async () => ({ access: 'test-token' }),",
" },",
" }),",
"}",
"",
].join("\n"),
)
},
})
const app = Server.Default().app
const headers = {
"content-type": "application/json",
"x-opencode-directory": tmp.path,
}
const list = await app.request("/experimental/httpapi/provider/auth", { headers })
expect(list.status).toBe(200)
const methods = await list.json()
expect(methods["github-copilot"]).toBeDefined()
expect(methods["github-copilot"][0].label).toBe("Test Override Auth")
const doc = await app.request("/experimental/httpapi/provider/doc", { headers })
expect(doc.status).toBe(200)
const spec = await doc.json()
expect(spec.paths["/experimental/httpapi/provider/auth"]?.get?.operationId).toBe("provider.auth")
}, 30000)
})