Selaa lähdekoodia

feat(core): bridge GET /config through experimental HttpApi (#23712)

Kit Langton 5 kuukautta sitten
vanhempi
commit
96a534d8c6

+ 7 - 9
packages/opencode/specs/effect/http-api.md

@@ -224,7 +224,7 @@ When to use each:
 
 Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
 
-Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
+Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those — and for pure-object schemas where handlers populate plain objects rather than class instances — add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior without the `instanceof` requirement:
 
 ```ts
 export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
@@ -373,9 +373,9 @@ The first slice is successful if:
 
 - `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
 - scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
-- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
+- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. `Schema.Class`'s Declaration AST enforces `input instanceof self || input.[ClassTypeId]` during encode (see effect-smol `Schema.ts:10479-10484`). Plain objects from zod parse fail with `Expected Foo, got {...}`. This surfaced on `GET /config` where the service returns zod-parsed plain objects and `Config.InfoSchema` referenced `ConfigProvider.Info` (class). The fix was to convert pure-object classes to `Schema.Struct(...).annotate({ identifier: "..." })` — same named SDK `$ref`, no instance requirement. Verified byte-identical `types.gen.ts` vs `dev`.
 - internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
-- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
+- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema **and** when the handler/service returns real instances. For schemas that need a named `$ref` but are populated from plain objects, use `Schema.Struct(...).annotate({ identifier: "..." })` instead. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
 
 ### Integration
 
@@ -404,8 +404,7 @@ Current instance route inventory:
 - `provider` - `bridged`
   endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
 - `config` - `bridged` (partial)
-  bridged endpoint: `GET /config/providers`
-  later endpoint: `GET /config`
+  bridged endpoints: `GET /config`, `GET /config/providers`
   defer `PATCH /config` for now
 - `project` - `bridged` (partial)
   bridged endpoints: `GET /project`, `GET /project/current`
@@ -431,9 +430,8 @@ Current instance route inventory:
 Recommended near-term sequence:
 
 1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
-2. `config` full read endpoint (`GET /config`)
-3. `file` JSON read endpoints
-4. `mcp` JSON read endpoints
+2. `file` JSON read endpoints
+3. `mcp` JSON read endpoints
 
 ## Checklist
 
@@ -449,8 +447,8 @@ Recommended near-term sequence:
 - [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
 - [x] port `config` providers read endpoint
 - [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
+- [x] port `GET /config` full read endpoint
 - [ ] port `workspace` read endpoints
-- [ ] port `GET /config` full read endpoint
 - [ ] port `file` JSON read endpoints
 - [ ] decide when to remove the flag and make Effect routes the default
 

+ 2 - 1
packages/opencode/src/config/agent.ts

@@ -101,7 +101,8 @@ const normalize = (agent: z.infer<typeof Info>) => {
   }
   globalThis.Object.assign(permission, agent.permission)
 
-  return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps }
+  const steps = agent.steps ?? agent.maxSteps
+  return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) }
 }
 
 export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType<

+ 1 - 1
packages/opencode/src/config/config.ts

@@ -91,7 +91,7 @@ const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
 const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
 const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
 
-const InfoSchema = Schema.Struct({
+export const InfoSchema = Schema.Struct({
   $schema: Schema.optional(Schema.String).annotate({
     description: "JSON schema reference for configuration validation",
   }),

+ 15 - 12
packages/opencode/src/config/mcp.ts

@@ -2,7 +2,7 @@ import { Schema } from "effect"
 import { zod } from "@/util/effect-zod"
 import { withStatics } from "@/util/schema"
 
-export class Local extends Schema.Class<Local>("McpLocalConfig")({
+export const Local = Schema.Struct({
   type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
   command: Schema.mutable(Schema.Array(Schema.String)).annotate({
     description: "Command and arguments to run the MCP server",
@@ -16,11 +16,12 @@ export class Local extends Schema.Class<Local>("McpLocalConfig")({
   timeout: Schema.optional(Schema.Number).annotate({
     description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
   }),
-}) {
-  static readonly zod = zod(this)
-}
+})
+  .annotate({ identifier: "McpLocalConfig" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Local = Schema.Schema.Type<typeof Local>
 
-export class OAuth extends Schema.Class<OAuth>("McpOAuthConfig")({
+export const OAuth = Schema.Struct({
   clientId: Schema.optional(Schema.String).annotate({
     description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.",
   }),
@@ -31,11 +32,12 @@ export class OAuth extends Schema.Class<OAuth>("McpOAuthConfig")({
   redirectUri: Schema.optional(Schema.String).annotate({
     description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
   }),
-}) {
-  static readonly zod = zod(this)
-}
+})
+  .annotate({ identifier: "McpOAuthConfig" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type OAuth = Schema.Schema.Type<typeof OAuth>
 
-export class Remote extends Schema.Class<Remote>("McpRemoteConfig")({
+export const Remote = Schema.Struct({
   type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }),
   url: Schema.String.annotate({ description: "URL of the remote MCP server" }),
   enabled: Schema.optional(Schema.Boolean).annotate({
@@ -50,9 +52,10 @@ export class Remote extends Schema.Class<Remote>("McpRemoteConfig")({
   timeout: Schema.optional(Schema.Number).annotate({
     description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
   }),
-}) {
-  static readonly zod = zod(this)
-}
+})
+  .annotate({ identifier: "McpRemoteConfig" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Remote = Schema.Schema.Type<typeof Remote>
 
 export const Info = Schema.Union([Local, Remote])
   .annotate({ discriminator: "type" })

+ 5 - 4
packages/opencode/src/config/provider.ts

@@ -70,7 +70,7 @@ export const Model = Schema.Struct({
   ),
 }).pipe(withStatics((s) => ({ zod: zod(s) })))
 
-export class Info extends Schema.Class<Info>("ProviderConfig")({
+export const Info = Schema.Struct({
   api: Schema.optional(Schema.String),
   name: Schema.optional(Schema.String),
   env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
@@ -107,8 +107,9 @@ export class Info extends Schema.Class<Info>("ProviderConfig")({
     ),
   ),
   models: Schema.optional(Schema.Record(Schema.String, Model)),
-}) {
-  static readonly zod = zod(this)
-}
+})
+  .annotate({ identifier: "ProviderConfig" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Info = Schema.Schema.Type<typeof Info>
 
 export * as ConfigProvider from "./provider"

+ 6 - 4
packages/opencode/src/config/server.ts

@@ -1,7 +1,8 @@
 import { Schema } from "effect"
 import { zod } from "@/util/effect-zod"
+import { withStatics } from "@/util/schema"
 
-export class Server extends Schema.Class<Server>("ServerConfig")({
+export const Server = Schema.Struct({
   port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({
     description: "Port to listen on",
   }),
@@ -13,8 +14,9 @@ export class Server extends Schema.Class<Server>("ServerConfig")({
   cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
     description: "Additional domains to allow for CORS",
   }),
-}) {
-  static readonly zod = zod(this)
-}
+})
+  .annotate({ identifier: "ServerConfig" })
+  .pipe(withStatics((s) => ({ zod: zod(s) })))
+export type Server = Schema.Schema.Type<typeof Server>
 
 export * as ConfigServer from "./server"

+ 19 - 3
packages/opencode/src/server/routes/instance/httpapi/config.ts

@@ -9,6 +9,15 @@ export const ConfigApi = HttpApi.make("config")
   .add(
     HttpApiGroup.make("config")
       .add(
+        HttpApiEndpoint.get("get", root, {
+          success: Config.InfoSchema,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "config.get",
+            summary: "Get configuration",
+            description: "Retrieve the current OpenCode configuration settings and preferences.",
+          }),
+        ),
         HttpApiEndpoint.get("providers", `${root}/providers`, {
           success: Provider.ConfigProvidersResult,
         }).annotateMerge(
@@ -36,16 +45,23 @@ export const ConfigApi = HttpApi.make("config")
 
 export const configHandlers = Layer.unwrap(
   Effect.gen(function* () {
-    const svc = yield* Provider.Service
+    const providerSvc = yield* Provider.Service
+    const configSvc = yield* Config.Service
+
+    const get = Effect.fn("ConfigHttpApi.get")(function* () {
+      return yield* configSvc.get()
+    })
 
     const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
-      const providers = yield* svc.list()
+      const providers = yield* providerSvc.list()
       return {
         providers: Object.values(providers),
         default: Provider.defaultModelIDs(providers),
       }
     })
 
-    return HttpApiBuilder.group(ConfigApi, "config", (handlers) => handlers.handle("providers", providers))
+    return HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
+      handlers.handle("get", get).handle("providers", providers),
+    )
   }),
 ).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer))

+ 1 - 0
packages/opencode/src/server/routes/instance/index.ts

@@ -40,6 +40,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
     app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
     app.get("/permission", (c) => handler(c.req.raw, context))
     app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
+    app.get("/config", (c) => handler(c.req.raw, context))
     app.get("/config/providers", (c) => handler(c.req.raw, context))
     app.get("/provider", (c) => handler(c.req.raw, context))
     app.get("/provider/auth", (c) => handler(c.req.raw, context))