Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton
d42b0e4826 refactor(provider): use Schema.Literals 2026-04-14 21:07:50 -04:00
Kit Langton
e373974328 refactor(provider): use effectful HttpApi group builder
Build the provider auth HttpApi handlers with an effectful group callback so the service is resolved once at layer construction time and the endpoint handler closes over the resulting service methods.
2026-04-14 20:43:39 -04:00
Kit Langton
0dcaec3b7b refactor(provider): return canonical auth methods
Make the provider auth service own the canonical Methods DTO and align the server test cleanup pattern with the other HttpApi slices.
2026-04-14 20:43:38 -04:00
Kit Langton
0bb986359b 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.
2026-04-14 20:43:38 -04:00
5 changed files with 206 additions and 66 deletions

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.Literals(["eq", "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.Literals(["oauth", "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.Literals(["auto", "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 }))
@@ -94,7 +99,7 @@ export namespace ProviderAuth {
type Hook = NonNullable<Hooks["auth"]>
export interface Interface {
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
readonly methods: () => Effect.Effect<Record<ProviderID, ReadonlyArray<Method>>>
readonly authorize: (input: {
providerID: ProviderID
method: number
@@ -133,9 +138,9 @@ export namespace ProviderAuth {
const methods = Effect.fn("ProviderAuth.methods")(function* () {
const hooks = (yield* InstanceState.get(state)).hooks
return Record.map(hooks, (item) =>
item.methods.map(
(method): Method => ({
return Schema.decodeUnknownSync(Methods)(
Record.map(hooks, (item) =>
item.methods.map((method) => ({
type: method.type,
label: method.label,
prompts: method.prompts?.map((prompt) => {
@@ -156,7 +161,7 @@ export namespace ProviderAuth {
when: prompt.when,
}
}),
}),
})),
),
)
})

View File

@@ -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),
)

View File

@@ -0,0 +1,71 @@
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 } 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 ProviderLive = HttpApiBuilder.group(
Api,
"provider",
Effect.fn("ProviderHttpApi.handlers")(function* (handlers) {
const svc = yield* ProviderAuth.Service
const auth = Effect.fn("ProviderHttpApi.auth")(function* () {
return yield* svc.methods()
})
return handlers.handle("auth", auth)
}),
).pipe(Layer.provide(ProviderAuth.defaultLayer))
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)

View File

@@ -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()),
},
},
},

View File

@@ -0,0 +1,59 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { tmpdir } from "../fixture/fixture"
import { Log } from "../../src/util/log"
Log.init({ print: false })
afterEach(async () => {
await Instance.disposeAll()
})
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)
})