Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions packages/opencode/specs/effect/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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`
Expand All @@ -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

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

Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
Expand Down
27 changes: 15 additions & 12 deletions packages/opencode/src/config/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.",
}),
Expand All @@ -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({
Expand All @@ -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" })
Expand Down
9 changes: 5 additions & 4 deletions packages/opencode/src/config/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))),
Expand Down Expand Up @@ -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"
10 changes: 6 additions & 4 deletions packages/opencode/src/config/server.ts
Original file line number Diff line number Diff line change
@@ -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",
}),
Expand All @@ -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"
22 changes: 19 additions & 3 deletions packages/opencode/src/server/routes/instance/httpapi/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 change: 1 addition & 0 deletions packages/opencode/src/server/routes/instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading