Просмотр исходного кода

add experimental provider auth HttpApi slice (#22389)

Kit Langton 4 месяцев назад
Родитель
Сommit
5eae926846

+ 0 - 14
bun.lock

@@ -358,7 +358,6 @@
         "@opencode-ai/plugin": "workspace:*",
         "@opencode-ai/script": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
-        "@opencode-ai/server": "workspace:*",
         "@openrouter/ai-sdk-provider": "2.5.1",
         "@opentelemetry/api": "1.9.0",
         "@opentelemetry/context-async-hooks": "2.6.1",
@@ -506,17 +505,6 @@
         "typescript": "catalog:",
       },
     },
-    "packages/server": {
-      "name": "@opencode-ai/server",
-      "version": "1.4.6",
-      "dependencies": {
-        "effect": "catalog:",
-      },
-      "devDependencies": {
-        "@typescript/native-preview": "catalog:",
-        "typescript": "catalog:",
-      },
-    },
     "packages/shared": {
       "name": "@opencode-ai/shared",
       "version": "1.4.6",
@@ -1568,8 +1556,6 @@
 
     "@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/slack": ["@opencode-ai/slack@workspace:packages/slack"],

+ 0 - 1
packages/opencode/package.json

@@ -115,7 +115,6 @@
     "@opencode-ai/plugin": "workspace:*",
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
-    "@opencode-ai/server": "workspace:*",
     "@openrouter/ai-sdk-provider": "2.5.1",
     "@opentelemetry/api": "1.9.0",
     "@opentelemetry/context-async-hooks": "2.6.1",

+ 11 - 2
packages/opencode/specs/effect/http-api.md

@@ -156,6 +156,14 @@ Ordering for a route-group migration:
 3. move tagged route-facing errors to `Schema.TaggedErrorClass` where needed
 4. switch existing Zod boundary validators to derived `.zod`
 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:
 
@@ -195,8 +203,9 @@ Use the same sequence for each route group.
 4. Define the `HttpApi` contract separately from the handlers.
 5. Implement handlers by yielding the existing service from context.
 6. Mount the new surface in parallel under an experimental prefix.
-7. Add one end-to-end test and one OpenAPI-focused test.
-8. Compare ergonomics before migrating the next endpoint.
+7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
+8. Add one end-to-end test and one OpenAPI-focused test.
+9. Compare ergonomics before migrating the next endpoint.
 
 Rule of thumb:
 

+ 57 - 64
packages/opencode/src/provider/auth.ts

@@ -2,70 +2,62 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
 import { NamedError } from "@opencode-ai/shared/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>
+  const When = Schema.Struct({
+    key: Schema.String,
+    op: Schema.Literals(["eq", "neq"]),
+    value: Schema.String,
+  })
+
+  const TextPrompt = Schema.Struct({
+    type: Schema.Literal("text"),
+    key: Schema.String,
+    message: Schema.String,
+    placeholder: Schema.optional(Schema.String),
+    when: Schema.optional(When),
+  })
+
+  const SelectOption = Schema.Struct({
+    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 }))
 
@@ -94,7 +86,7 @@ export namespace ProviderAuth {
   type Hook = NonNullable<Hooks["auth"]>
 
   export interface Interface {
-    readonly methods: () => Effect.Effect<Record<ProviderID, Method[]>>
+    readonly methods: () => Effect.Effect<Methods>
     readonly authorize: (input: {
       providerID: ProviderID
       method: number
@@ -131,11 +123,12 @@ export namespace ProviderAuth {
         }),
       )
 
+      const decode = Schema.decodeUnknownSync(Methods)
       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 decode(
+          Record.map(hooks, (item) =>
+            item.methods.map((method) => ({
               type: method.type,
               label: method.label,
               prompts: method.prompts?.map((prompt) => {
@@ -156,7 +149,7 @@ export namespace ProviderAuth {
                   when: prompt.when,
                 }
               }),
-            }),
+            })),
           ),
         )
       })

+ 46 - 0
packages/opencode/src/server/instance/httpapi/provider.ts

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

+ 7 - 0
packages/opencode/src/server/instance/httpapi/server.ts

@@ -10,8 +10,10 @@ import { InstanceBootstrap } from "@/project/bootstrap"
 import { Instance } from "@/project/instance"
 import { Filesystem } from "@/util/filesystem"
 import { Permission } from "@/permission"
+import { ProviderAuth } from "@/provider/auth"
 import { Question } from "@/question"
 import { PermissionApi, PermissionLive } from "./permission"
+import { ProviderApi, ProviderLive } from "./provider"
 import { QuestionApi, QuestionLive } from "./question"
 
 const Query = Schema.Struct({
@@ -108,6 +110,7 @@ export namespace ExperimentalHttpApiServer {
 
   const QuestionSecured = QuestionApi.middleware(Authorization)
   const PermissionSecured = PermissionApi.middleware(Authorization)
+  const ProviderSecured = ProviderApi.middleware(Authorization)
 
   export const routes = Layer.mergeAll(
     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(
       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))
 
   export const layer = (opts: { hostname: string; port: number }) =>
@@ -127,5 +133,6 @@ export namespace ExperimentalHttpApiServer {
     Layer.provideMerge(NodeHttpServer.layerTest),
     Layer.provideMerge(Question.defaultLayer),
     Layer.provideMerge(Permission.defaultLayer),
+    Layer.provideMerge(ProviderAuth.defaultLayer),
   )
 }

+ 2 - 2
packages/opencode/src/server/instance/provider.ts

@@ -82,7 +82,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),
               },
             },
           },
@@ -103,7 +103,7 @@ export const ProviderRoutes = lazy(() =>
             description: "Authorization URL and method",
             content: {
               "application/json": {
-                schema: resolver(ProviderAuth.Authorization.optional()),
+                schema: resolver(ProviderAuth.Authorization.zod.optional()),
               },
             },
           },

+ 0 - 30
packages/server/package.json

@@ -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:"
-  }
-}

+ 0 - 2
packages/server/src/api/index.ts

@@ -1,2 +0,0 @@
-export { makeQuestionHandler } from "./question.js"
-export type { QuestionOps } from "./question.js"

+ 0 - 37
packages/server/src/api/question.ts

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

+ 0 - 12
packages/server/src/definition/api.ts

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

+ 0 - 2
packages/server/src/definition/index.ts

@@ -1,2 +0,0 @@
-export { api } from "./api.js"
-export { questionApi, QuestionReply, QuestionRequest } from "./question.js"

+ 0 - 94
packages/server/src/definition/question.ts

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

+ 0 - 6
packages/server/src/index.ts

@@ -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"

+ 0 - 5
packages/server/src/openapi.ts

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

+ 0 - 5
packages/server/src/types.ts

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

+ 0 - 10
packages/server/sst-env.d.ts

@@ -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 {}

+ 0 - 15
packages/server/tsconfig.json

@@ -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"]
-}