mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-15 10:24:53 +00:00
Compare commits
4 Commits
beta
...
kit/provid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d42b0e4826 | ||
|
|
e373974328 | ||
|
|
0dcaec3b7b | ||
|
|
0bb986359b |
@@ -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,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
packages/opencode/src/server/instance/httpapi/provider.ts
Normal file
71
packages/opencode/src/server/instance/httpapi/provider.ts
Normal 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)
|
||||
@@ -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()),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
59
packages/opencode/test/server/provider-httpapi-auth.test.ts
Normal file
59
packages/opencode/test/server/provider-httpapi-auth.test.ts
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user