From 0bb986359b2c46aeedf2f02409cb2fe4e66e4c5e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 13 Apr 2026 23:00:00 -0400 Subject: [PATCH] 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. --- packages/opencode/src/provider/auth.ts | 121 +++++++++--------- .../src/server/instance/httpapi/index.ts | 7 +- .../src/server/instance/httpapi/provider.ts | 64 +++++++++ .../opencode/src/server/instance/provider.ts | 4 +- .../test/server/provider-httpapi-auth.test.ts | 54 ++++++++ 5 files changed, 189 insertions(+), 61 deletions(-) create mode 100644 packages/opencode/src/server/instance/httpapi/provider.ts create mode 100644 packages/opencode/test/server/provider-httpapi-auth.test.ts diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index e410b86365..ddfe5a347d 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -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 + export class When extends Schema.Class("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 + export class TextPrompt extends Schema.Class("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("ProviderAuthSelectOption")({ + label: Schema.String, + value: Schema.String, + hint: Schema.optional(Schema.String), + }) { + static readonly zod = zod(this) + } + + export class SelectPrompt extends Schema.Class("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 + + export class Method extends Schema.Class("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("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 export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod })) diff --git a/packages/opencode/src/server/instance/httpapi/index.ts b/packages/opencode/src/server/instance/httpapi/index.ts index 523041de84..c3bdb8139a 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 { 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), ) diff --git a/packages/opencode/src/server/instance/httpapi/provider.ts b/packages/opencode/src/server/instance/httpapi/provider.ts new file mode 100644 index 0000000000..452557401d --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/provider.ts @@ -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) diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts index 6988d56e4e..e42fd1517e 100644 --- a/packages/opencode/src/server/instance/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -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()), }, }, }, diff --git a/packages/opencode/test/server/provider-httpapi-auth.test.ts b/packages/opencode/test/server/provider-httpapi-auth.test.ts new file mode 100644 index 0000000000..459cfc133e --- /dev/null +++ b/packages/opencode/test/server/provider-httpapi-auth.test.ts @@ -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) +})