add experimental provider auth HttpApi slice (#22389)

This commit is contained in:
Kit Langton
2026-04-16 02:07:42 +00:00
committed by GitHub
parent cce05c1665
commit 5eae926846
18 changed files with 122 additions and 300 deletions
-14
View File
@@ -358,7 +358,6 @@
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1", "@openrouter/ai-sdk-provider": "2.5.1",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/context-async-hooks": "2.6.1",
@@ -506,17 +505,6 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.4.6",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/shared": { "packages/shared": {
"name": "@opencode-ai/shared", "name": "@opencode-ai/shared",
"version": "1.4.6", "version": "1.4.6",
@@ -1568,8 +1556,6 @@
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"], "@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/shared": ["@opencode-ai/shared@workspace:packages/shared"], "@opencode-ai/shared": ["@opencode-ai/shared@workspace:packages/shared"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"], "@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
-1
View File
@@ -115,7 +115,6 @@
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1", "@openrouter/ai-sdk-provider": "2.5.1",
"@opentelemetry/api": "1.9.0", "@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/context-async-hooks": "2.6.1",
+11 -2
View File
@@ -156,6 +156,14 @@ Ordering for a route-group migration:
3. move tagged route-facing errors to `Schema.TaggedErrorClass` where needed 3. move tagged route-facing errors to `Schema.TaggedErrorClass` where needed
4. switch existing Zod boundary validators to derived `.zod` 4. switch existing Zod boundary validators to derived `.zod`
5. define the `HttpApi` contract from the canonical Effect schemas 5. define the `HttpApi` contract from the canonical Effect schemas
6. regenerate the SDK (`./packages/sdk/js/script/build.ts`) and verify zero diff against `dev`
SDK shape rule:
- every schema migration must preserve the generated SDK output byte-for-byte
- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema
- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
Temporary exception: Temporary exception:
@@ -195,8 +203,9 @@ Use the same sequence for each route group.
4. Define the `HttpApi` contract separately from the handlers. 4. Define the `HttpApi` contract separately from the handlers.
5. Implement handlers by yielding the existing service from context. 5. Implement handlers by yielding the existing service from context.
6. Mount the new surface in parallel under an experimental prefix. 6. Mount the new surface in parallel under an experimental prefix.
7. Add one end-to-end test and one OpenAPI-focused test. 7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
8. Compare ergonomics before migrating the next endpoint. 8. Add one end-to-end test and one OpenAPI-focused test.
9. Compare ergonomics before migrating the next endpoint.
Rule of thumb: Rule of thumb:
+56 -63
View File
@@ -2,70 +2,62 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/shared/util/error" import { NamedError } from "@opencode-ai/shared/util/error"
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { ProviderID } from "./schema" 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" import z from "zod"
export namespace ProviderAuth { export namespace ProviderAuth {
export const Method = z const When = Schema.Struct({
.object({ key: Schema.String,
type: z.union([z.literal("oauth"), z.literal("api")]), op: Schema.Literals(["eq", "neq"]),
label: z.string(), value: Schema.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 const TextPrompt = Schema.Struct({
.object({ type: Schema.Literal("text"),
url: z.string(), key: Schema.String,
method: z.union([z.literal("auto"), z.literal("code")]), message: Schema.String,
instructions: z.string(), placeholder: Schema.optional(Schema.String),
}) when: Schema.optional(When),
.meta({ })
ref: "ProviderAuthAuthorization",
}) const SelectOption = Schema.Struct({
export type Authorization = z.infer<typeof Authorization> label: Schema.String,
value: Schema.String,
hint: Schema.optional(Schema.String),
})
const SelectPrompt = Schema.Struct({
type: Schema.Literal("select"),
key: Schema.String,
message: Schema.String,
options: Schema.Array(SelectOption),
when: Schema.optional(When),
})
const Prompt = Schema.Union([TextPrompt, SelectPrompt])
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 const Methods = Schema.Record(Schema.String, Schema.Array(Method)).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Methods = typeof Methods.Type
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 OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod })) export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod }))
@@ -94,7 +86,7 @@ export namespace ProviderAuth {
type Hook = NonNullable<Hooks["auth"]> type Hook = NonNullable<Hooks["auth"]>
export interface Interface { export interface Interface {
readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>> readonly methods: () => Effect.Effect<Methods>
readonly authorize: (input: { readonly authorize: (input: {
providerID: ProviderID providerID: ProviderID
method: number method: number
@@ -131,11 +123,12 @@ export namespace ProviderAuth {
}), }),
) )
const decode = Schema.decodeUnknownSync(Methods)
const methods = Effect.fn("ProviderAuth.methods")(function* () { const methods = Effect.fn("ProviderAuth.methods")(function* () {
const hooks = (yield* InstanceState.get(state)).hooks const hooks = (yield* InstanceState.get(state)).hooks
return Record.map(hooks, (item) => return decode(
item.methods.map( Record.map(hooks, (item) =>
(method): Method => ({ item.methods.map((method) => ({
type: method.type, type: method.type,
label: method.label, label: method.label,
prompts: method.prompts?.map((prompt) => { prompts: method.prompts?.map((prompt) => {
@@ -156,7 +149,7 @@ export namespace ProviderAuth {
when: prompt.when, when: prompt.when,
} }
}), }),
}), })),
), ),
) )
}) })
@@ -0,0 +1,46 @@
import { ProviderAuth } from "@/provider/auth"
import { Effect, Layer } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const root = "/experimental/httpapi/provider"
export const ProviderApi = 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.",
}),
)
export const ProviderLive = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* ProviderAuth.Service
const auth = Effect.fn("ProviderHttpApi.auth")(function* () {
return yield* svc.methods()
})
return HttpApiBuilder.group(ProviderApi, "provider", (handlers) => handlers.handle("auth", auth))
}),
).pipe(Layer.provide(ProviderAuth.defaultLayer))
@@ -10,8 +10,10 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { ProviderAuth } from "@/provider/auth"
import { Question } from "@/question" import { Question } from "@/question"
import { PermissionApi, PermissionLive } from "./permission" import { PermissionApi, PermissionLive } from "./permission"
import { ProviderApi, ProviderLive } from "./provider"
import { QuestionApi, QuestionLive } from "./question" import { QuestionApi, QuestionLive } from "./question"
const Query = Schema.Struct({ const Query = Schema.Struct({
@@ -108,6 +110,7 @@ export namespace ExperimentalHttpApiServer {
const QuestionSecured = QuestionApi.middleware(Authorization) const QuestionSecured = QuestionApi.middleware(Authorization)
const PermissionSecured = PermissionApi.middleware(Authorization) const PermissionSecured = PermissionApi.middleware(Authorization)
const ProviderSecured = ProviderApi.middleware(Authorization)
export const routes = Layer.mergeAll( export const routes = Layer.mergeAll(
HttpApiBuilder.layer(QuestionSecured, { openapiPath: "/experimental/httpapi/question/doc" }).pipe( HttpApiBuilder.layer(QuestionSecured, { openapiPath: "/experimental/httpapi/question/doc" }).pipe(
@@ -116,6 +119,9 @@ export namespace ExperimentalHttpApiServer {
HttpApiBuilder.layer(PermissionSecured, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe( HttpApiBuilder.layer(PermissionSecured, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe(
Layer.provide(PermissionLive), Layer.provide(PermissionLive),
), ),
HttpApiBuilder.layer(ProviderSecured, { openapiPath: "/experimental/httpapi/provider/doc" }).pipe(
Layer.provide(ProviderLive),
),
).pipe(Layer.provide(auth), Layer.provide(normalize), Layer.provide(instance)) ).pipe(Layer.provide(auth), Layer.provide(normalize), Layer.provide(instance))
export const layer = (opts: { hostname: string; port: number }) => export const layer = (opts: { hostname: string; port: number }) =>
@@ -127,5 +133,6 @@ export namespace ExperimentalHttpApiServer {
Layer.provideMerge(NodeHttpServer.layerTest), Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(Question.defaultLayer), Layer.provideMerge(Question.defaultLayer),
Layer.provideMerge(Permission.defaultLayer), Layer.provideMerge(Permission.defaultLayer),
Layer.provideMerge(ProviderAuth.defaultLayer),
) )
} }
@@ -82,7 +82,7 @@ export const ProviderRoutes = lazy(() =>
description: "Provider auth methods", description: "Provider auth methods",
content: { content: {
"application/json": { "application/json": {
schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))), schema: resolver(ProviderAuth.Methods.zod),
}, },
}, },
}, },
@@ -103,7 +103,7 @@ export const ProviderRoutes = lazy(() =>
description: "Authorization URL and method", description: "Authorization URL and method",
content: { content: {
"application/json": { "application/json": {
schema: resolver(ProviderAuth.Authorization.optional()), schema: resolver(ProviderAuth.Authorization.zod.optional()),
}, },
}, },
}, },
-30
View File
@@ -1,30 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.4.6",
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts",
"./openapi": "./src/openapi.ts",
"./definition": "./src/definition/index.ts",
"./definition/api": "./src/definition/api.ts",
"./definition/question": "./src/definition/question.ts",
"./api": "./src/api/index.ts",
"./api/question": "./src/api/question.ts"
},
"files": [
"dist"
],
"scripts": {
"typecheck": "tsgo --noEmit",
"build": "tsc"
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
},
"dependencies": {
"effect": "catalog:"
}
}
-2
View File
@@ -1,2 +0,0 @@
export { makeQuestionHandler } from "./question.js"
export type { QuestionOps } from "./question.js"
-37
View File
@@ -1,37 +0,0 @@
import { Effect, Schema } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { QuestionReply, QuestionRequest, questionApi } from "../definition/question.js"
export interface QuestionOps<R = never> {
readonly list: () => Effect.Effect<ReadonlyArray<unknown>, never, R>
readonly reply: (input: {
requestID: string
answers: Schema.Schema.Type<typeof QuestionReply>["answers"]
}) => Effect.Effect<void, never, R>
}
export const makeQuestionHandler = <R>(ops: QuestionOps<R>) =>
HttpApiBuilder.group(
questionApi,
"question",
Effect.fn("QuestionHttpApi.handlers")(function* (handlers) {
const decode = Schema.decodeUnknownSync(Schema.Array(QuestionRequest))
const list = Effect.fn("QuestionHttpApi.list")(function* () {
return decode(yield* ops.list())
})
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
params: { requestID: string }
payload: Schema.Schema.Type<typeof QuestionReply>
}) {
yield* ops.reply({
requestID: ctx.params.requestID,
answers: ctx.payload.answers,
})
return true
})
return handlers.handle("list", list).handle("reply", reply)
}),
)
-12
View File
@@ -1,12 +0,0 @@
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { questionApi } from "./question.js"
export const api = HttpApi.make("opencode")
.addHttpApi(questionApi)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
-2
View File
@@ -1,2 +0,0 @@
export { api } from "./api.js"
export { questionApi, QuestionReply, QuestionRequest } from "./question.js"
@@ -1,94 +0,0 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const root = "/experimental/httpapi/question"
// Temporary transport-local schemas until canonical question schemas move into packages/core.
export const QuestionID = Schema.String.annotate({ identifier: "QuestionID" })
export const SessionID = Schema.String.annotate({ identifier: "SessionID" })
export const MessageID = Schema.String.annotate({ identifier: "MessageID" })
export class QuestionOption extends Schema.Class<QuestionOption>("QuestionOption")({
label: Schema.String.annotate({
description: "Display text (1-5 words, concise)",
}),
description: Schema.String.annotate({
description: "Explanation of choice",
}),
}) {}
const base = {
question: Schema.String.annotate({
description: "Complete question",
}),
header: Schema.String.annotate({
description: "Very short label (max 30 chars)",
}),
options: Schema.Array(QuestionOption).annotate({
description: "Available choices",
}),
multiple: Schema.optional(Schema.Boolean).annotate({
description: "Allow selecting multiple choices",
}),
}
export class QuestionInfo extends Schema.Class<QuestionInfo>("QuestionInfo")({
...base,
custom: Schema.optional(Schema.Boolean).annotate({
description: "Allow typing a custom answer (default: true)",
}),
}) {}
export class QuestionTool extends Schema.Class<QuestionTool>("QuestionTool")({
messageID: MessageID,
callID: Schema.String,
}) {}
export class QuestionRequest extends Schema.Class<QuestionRequest>("QuestionRequest")({
id: QuestionID,
sessionID: SessionID,
questions: Schema.Array(QuestionInfo).annotate({
description: "Questions to ask",
}),
tool: Schema.optional(QuestionTool),
}) {}
export const QuestionAnswer = Schema.Array(Schema.String).annotate({ identifier: "QuestionAnswer" })
export class QuestionReply extends Schema.Class<QuestionReply>("QuestionReply")({
answers: Schema.Array(QuestionAnswer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)",
}),
}) {}
export const questionApi = HttpApi.make("question").add(
HttpApiGroup.make("question")
.add(
HttpApiEndpoint.get("list", root, {
success: Schema.Array(QuestionRequest),
}).annotateMerge(
OpenApi.annotations({
identifier: "question.list",
summary: "List pending questions",
description: "Get all pending question requests across all sessions.",
}),
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: QuestionID },
payload: QuestionReply,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "question.reply",
summary: "Reply to question request",
description: "Provide answers to a question request from the AI assistant.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "question",
description: "Experimental HttpApi question routes.",
}),
),
)
-6
View File
@@ -1,6 +0,0 @@
export { openapi } from "./openapi.js"
export { makeQuestionHandler } from "./api/question.js"
export { api } from "./definition/api.js"
export { questionApi, QuestionReply, QuestionRequest } from "./definition/question.js"
export type { OpenApiSpec, ServerApi } from "./types.js"
export type { QuestionOps } from "./api/question.js"
-5
View File
@@ -1,5 +0,0 @@
import { OpenApi } from "effect/unstable/httpapi"
import { api } from "./definition/api.js"
import type { OpenApiSpec } from "./types.js"
export const openapi = (): OpenApiSpec => OpenApi.fromApi(api)
-5
View File
@@ -1,5 +0,0 @@
import type { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export type ServerApi = HttpApi.HttpApi<string, HttpApiGroup.Any>
export type OpenApiSpec = OpenApi.OpenAPISpec
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
-15
View File
@@ -1,15 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"rootDir": "src",
"outDir": "dist",
"module": "nodenext",
"declaration": true,
"moduleResolution": "nodenext",
"lib": ["es2022", "dom", "dom.iterable"],
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}