diff --git a/bun.lock b/bun.lock index 48243e652e4f..705181160a14 100644 --- a/bun.lock +++ b/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"], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 59be93d620f4..c0f82c1495e4 100644 --- a/packages/opencode/package.json +++ b/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", diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 1794927cce18..bd1213bb6d10 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/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: diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index c66ccffc120f..0f2923a587cf 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/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 - - 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 + 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("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("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 export interface Interface { - readonly methods: () => Effect.Effect> + readonly methods: () => Effect.Effect 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, } }), - }), + })), ), ) }) diff --git a/packages/opencode/src/server/instance/httpapi/provider.ts b/packages/opencode/src/server/instance/httpapi/provider.ts new file mode 100644 index 000000000000..23e2d1ea7354 --- /dev/null +++ b/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)) diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts index 363e93a2402d..4d48a3447437 100644 --- a/packages/opencode/src/server/instance/httpapi/server.ts +++ b/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({ @@ -112,6 +114,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( @@ -120,6 +123,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 }) => @@ -131,5 +137,6 @@ export namespace ExperimentalHttpApiServer { Layer.provideMerge(NodeHttpServer.layerTest), Layer.provideMerge(Question.defaultLayer), Layer.provideMerge(Permission.defaultLayer), + Layer.provideMerge(ProviderAuth.defaultLayer), ) } diff --git a/packages/opencode/src/server/instance/provider.ts b/packages/opencode/src/server/instance/provider.ts index 8018dfbea4c1..f66b0daaaf45 100644 --- a/packages/opencode/src/server/instance/provider.ts +++ b/packages/opencode/src/server/instance/provider.ts @@ -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()), }, }, }, diff --git a/packages/server/package.json b/packages/server/package.json deleted file mode 100644 index 9b8b31299d6d..000000000000 --- a/packages/server/package.json +++ /dev/null @@ -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:" - } -} diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts deleted file mode 100644 index 375e3584b408..000000000000 --- a/packages/server/src/api/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { makeQuestionHandler } from "./question.js" -export type { QuestionOps } from "./question.js" diff --git a/packages/server/src/api/question.ts b/packages/server/src/api/question.ts deleted file mode 100644 index f72c37aa19ea..000000000000 --- a/packages/server/src/api/question.ts +++ /dev/null @@ -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 { - readonly list: () => Effect.Effect, never, R> - readonly reply: (input: { - requestID: string - answers: Schema.Schema.Type["answers"] - }) => Effect.Effect -} - -export const makeQuestionHandler = (ops: QuestionOps) => - 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 - }) { - yield* ops.reply({ - requestID: ctx.params.requestID, - answers: ctx.payload.answers, - }) - return true - }) - - return handlers.handle("list", list).handle("reply", reply) - }), - ) diff --git a/packages/server/src/definition/api.ts b/packages/server/src/definition/api.ts deleted file mode 100644 index e2f70196da94..000000000000 --- a/packages/server/src/definition/api.ts +++ /dev/null @@ -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.", - }), - ) diff --git a/packages/server/src/definition/index.ts b/packages/server/src/definition/index.ts deleted file mode 100644 index e9a52dc93019..000000000000 --- a/packages/server/src/definition/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { api } from "./api.js" -export { questionApi, QuestionReply, QuestionRequest } from "./question.js" diff --git a/packages/server/src/definition/question.ts b/packages/server/src/definition/question.ts deleted file mode 100644 index 0d161e013d6e..000000000000 --- a/packages/server/src/definition/question.ts +++ /dev/null @@ -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")({ - 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")({ - ...base, - custom: Schema.optional(Schema.Boolean).annotate({ - description: "Allow typing a custom answer (default: true)", - }), -}) {} - -export class QuestionTool extends Schema.Class("QuestionTool")({ - messageID: MessageID, - callID: Schema.String, -}) {} - -export class QuestionRequest extends Schema.Class("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")({ - 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.", - }), - ), -) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts deleted file mode 100644 index 67b82a0be56c..000000000000 --- a/packages/server/src/index.ts +++ /dev/null @@ -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" diff --git a/packages/server/src/openapi.ts b/packages/server/src/openapi.ts deleted file mode 100644 index dda870d2b6b6..000000000000 --- a/packages/server/src/openapi.ts +++ /dev/null @@ -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) diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts deleted file mode 100644 index 9e89fe74c224..000000000000 --- a/packages/server/src/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { HttpApi, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" - -export type ServerApi = HttpApi.HttpApi - -export type OpenApiSpec = OpenApi.OpenAPISpec diff --git a/packages/server/sst-env.d.ts b/packages/server/sst-env.d.ts deleted file mode 100644 index 64441936d7a0..000000000000 --- a/packages/server/sst-env.d.ts +++ /dev/null @@ -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 */ - -/// - -import "sst" -export {} \ No newline at end of file diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json deleted file mode 100644 index eac2af3845f2..000000000000 --- a/packages/server/tsconfig.json +++ /dev/null @@ -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"] -}