Skip to content

Commit 96a534d

Browse files
authored
feat(core): bridge GET /config through experimental HttpApi (#23712)
1 parent 9579429 commit 96a534d

8 files changed

Lines changed: 56 additions & 34 deletions

File tree

packages/opencode/specs/effect/http-api.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ When to use each:
224224
225225
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.
226226
227-
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:
227+
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:
228228
229229
```ts
230230
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
@@ -373,9 +373,9 @@ The first slice is successful if:
373373

374374
- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
375375
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
376-
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
376+
- 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`.
377377
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
378-
- `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.
378+
- `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.
379379

380380
### Integration
381381

@@ -404,8 +404,7 @@ Current instance route inventory:
404404
- `provider` - `bridged`
405405
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
406406
- `config` - `bridged` (partial)
407-
bridged endpoint: `GET /config/providers`
408-
later endpoint: `GET /config`
407+
bridged endpoints: `GET /config`, `GET /config/providers`
409408
defer `PATCH /config` for now
410409
- `project` - `bridged` (partial)
411410
bridged endpoints: `GET /project`, `GET /project/current`
@@ -431,9 +430,8 @@ Current instance route inventory:
431430
Recommended near-term sequence:
432431

433432
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
434-
2. `config` full read endpoint (`GET /config`)
435-
3. `file` JSON read endpoints
436-
4. `mcp` JSON read endpoints
433+
2. `file` JSON read endpoints
434+
3. `mcp` JSON read endpoints
437435

438436
## Checklist
439437

@@ -449,8 +447,8 @@ Recommended near-term sequence:
449447
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
450448
- [x] port `config` providers read endpoint
451449
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
450+
- [x] port `GET /config` full read endpoint
452451
- [ ] port `workspace` read endpoints
453-
- [ ] port `GET /config` full read endpoint
454452
- [ ] port `file` JSON read endpoints
455453
- [ ] decide when to remove the flag and make Effect routes the default
456454

packages/opencode/src/config/agent.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ const normalize = (agent: z.infer<typeof Info>) => {
101101
}
102102
globalThis.Object.assign(permission, agent.permission)
103103

104-
return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps }
104+
const steps = agent.steps ?? agent.maxSteps
105+
return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) }
105106
}
106107

107108
export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType<

packages/opencode/src/config/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
9191
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
9292
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
9393

94-
const InfoSchema = Schema.Struct({
94+
export const InfoSchema = Schema.Struct({
9595
$schema: Schema.optional(Schema.String).annotate({
9696
description: "JSON schema reference for configuration validation",
9797
}),

packages/opencode/src/config/mcp.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Schema } from "effect"
22
import { zod } from "@/util/effect-zod"
33
import { withStatics } from "@/util/schema"
44

5-
export class Local extends Schema.Class<Local>("McpLocalConfig")({
5+
export const Local = Schema.Struct({
66
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
77
command: Schema.mutable(Schema.Array(Schema.String)).annotate({
88
description: "Command and arguments to run the MCP server",
@@ -16,11 +16,12 @@ export class Local extends Schema.Class<Local>("McpLocalConfig")({
1616
timeout: Schema.optional(Schema.Number).annotate({
1717
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
1818
}),
19-
}) {
20-
static readonly zod = zod(this)
21-
}
19+
})
20+
.annotate({ identifier: "McpLocalConfig" })
21+
.pipe(withStatics((s) => ({ zod: zod(s) })))
22+
export type Local = Schema.Schema.Type<typeof Local>
2223

23-
export class OAuth extends Schema.Class<OAuth>("McpOAuthConfig")({
24+
export const OAuth = Schema.Struct({
2425
clientId: Schema.optional(Schema.String).annotate({
2526
description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.",
2627
}),
@@ -31,11 +32,12 @@ export class OAuth extends Schema.Class<OAuth>("McpOAuthConfig")({
3132
redirectUri: Schema.optional(Schema.String).annotate({
3233
description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
3334
}),
34-
}) {
35-
static readonly zod = zod(this)
36-
}
35+
})
36+
.annotate({ identifier: "McpOAuthConfig" })
37+
.pipe(withStatics((s) => ({ zod: zod(s) })))
38+
export type OAuth = Schema.Schema.Type<typeof OAuth>
3739

38-
export class Remote extends Schema.Class<Remote>("McpRemoteConfig")({
40+
export const Remote = Schema.Struct({
3941
type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }),
4042
url: Schema.String.annotate({ description: "URL of the remote MCP server" }),
4143
enabled: Schema.optional(Schema.Boolean).annotate({
@@ -50,9 +52,10 @@ export class Remote extends Schema.Class<Remote>("McpRemoteConfig")({
5052
timeout: Schema.optional(Schema.Number).annotate({
5153
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
5254
}),
53-
}) {
54-
static readonly zod = zod(this)
55-
}
55+
})
56+
.annotate({ identifier: "McpRemoteConfig" })
57+
.pipe(withStatics((s) => ({ zod: zod(s) })))
58+
export type Remote = Schema.Schema.Type<typeof Remote>
5659

5760
export const Info = Schema.Union([Local, Remote])
5861
.annotate({ discriminator: "type" })

packages/opencode/src/config/provider.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export const Model = Schema.Struct({
7070
),
7171
}).pipe(withStatics((s) => ({ zod: zod(s) })))
7272

73-
export class Info extends Schema.Class<Info>("ProviderConfig")({
73+
export const Info = Schema.Struct({
7474
api: Schema.optional(Schema.String),
7575
name: Schema.optional(Schema.String),
7676
env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
@@ -107,8 +107,9 @@ export class Info extends Schema.Class<Info>("ProviderConfig")({
107107
),
108108
),
109109
models: Schema.optional(Schema.Record(Schema.String, Model)),
110-
}) {
111-
static readonly zod = zod(this)
112-
}
110+
})
111+
.annotate({ identifier: "ProviderConfig" })
112+
.pipe(withStatics((s) => ({ zod: zod(s) })))
113+
export type Info = Schema.Schema.Type<typeof Info>
113114

114115
export * as ConfigProvider from "./provider"
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Schema } from "effect"
22
import { zod } from "@/util/effect-zod"
3+
import { withStatics } from "@/util/schema"
34

4-
export class Server extends Schema.Class<Server>("ServerConfig")({
5+
export const Server = Schema.Struct({
56
port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({
67
description: "Port to listen on",
78
}),
@@ -13,8 +14,9 @@ export class Server extends Schema.Class<Server>("ServerConfig")({
1314
cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
1415
description: "Additional domains to allow for CORS",
1516
}),
16-
}) {
17-
static readonly zod = zod(this)
18-
}
17+
})
18+
.annotate({ identifier: "ServerConfig" })
19+
.pipe(withStatics((s) => ({ zod: zod(s) })))
20+
export type Server = Schema.Schema.Type<typeof Server>
1921

2022
export * as ConfigServer from "./server"

packages/opencode/src/server/routes/instance/httpapi/config.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ export const ConfigApi = HttpApi.make("config")
99
.add(
1010
HttpApiGroup.make("config")
1111
.add(
12+
HttpApiEndpoint.get("get", root, {
13+
success: Config.InfoSchema,
14+
}).annotateMerge(
15+
OpenApi.annotations({
16+
identifier: "config.get",
17+
summary: "Get configuration",
18+
description: "Retrieve the current OpenCode configuration settings and preferences.",
19+
}),
20+
),
1221
HttpApiEndpoint.get("providers", `${root}/providers`, {
1322
success: Provider.ConfigProvidersResult,
1423
}).annotateMerge(
@@ -36,16 +45,23 @@ export const ConfigApi = HttpApi.make("config")
3645

3746
export const configHandlers = Layer.unwrap(
3847
Effect.gen(function* () {
39-
const svc = yield* Provider.Service
48+
const providerSvc = yield* Provider.Service
49+
const configSvc = yield* Config.Service
50+
51+
const get = Effect.fn("ConfigHttpApi.get")(function* () {
52+
return yield* configSvc.get()
53+
})
4054

4155
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
42-
const providers = yield* svc.list()
56+
const providers = yield* providerSvc.list()
4357
return {
4458
providers: Object.values(providers),
4559
default: Provider.defaultModelIDs(providers),
4660
}
4761
})
4862

49-
return HttpApiBuilder.group(ConfigApi, "config", (handlers) => handlers.handle("providers", providers))
63+
return HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
64+
handlers.handle("get", get).handle("providers", providers),
65+
)
5066
}),
5167
).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer))

packages/opencode/src/server/routes/instance/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
4040
app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
4141
app.get("/permission", (c) => handler(c.req.raw, context))
4242
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
43+
app.get("/config", (c) => handler(c.req.raw, context))
4344
app.get("/config/providers", (c) => handler(c.req.raw, context))
4445
app.get("/provider", (c) => handler(c.req.raw, context))
4546
app.get("/provider/auth", (c) => handler(c.req.raw, context))

0 commit comments

Comments
 (0)