Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 69 additions & 64 deletions packages/opencode/src/provider/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 When extends Schema.Class<When>("ProviderAuthWhen")({
key: Schema.String,
op: Schema.Literals(["eq", "neq"]),
value: Schema.String,
}) {
static readonly zod = zod(this)
}

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 }))

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -156,7 +161,7 @@ export namespace ProviderAuth {
when: prompt.when,
}
}),
}),
})),
),
)
})
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/server/instance/httpapi/index.ts
Original file line number Diff line number Diff line change
@@ -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),
)
71 changes: 71 additions & 0 deletions packages/opencode/src/server/instance/httpapi/provider.ts
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions packages/opencode/src/server/instance/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
},
},
Expand All @@ -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()),
},
},
},
Expand Down
59 changes: 59 additions & 0 deletions packages/opencode/test/server/provider-httpapi-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading