import { OpenApi } from "effect/unstable/httpapi" import { OpenCodeHttpApi } from "./api" import { QueryBooleanOpenApi } from "./groups/query" type OpenApiParameter = { name: string in: string required?: boolean schema?: OpenApiSchema } type OpenApiOperation = { parameters?: OpenApiParameter[] responses?: Record requestBody?: { required?: boolean content?: Record } security?: unknown } type OpenApiPathItem = Partial> type OpenApiSpec = { components?: { schemas?: Record securitySchemes?: Record } paths?: Record } type OpenApiSchema = { $ref?: string additionalProperties?: OpenApiSchema | boolean allOf?: OpenApiSchema[] anyOf?: OpenApiSchema[] description?: string enum?: Array items?: OpenApiSchema maximum?: number minimum?: number oneOf?: OpenApiSchema[] pattern?: string prefixItems?: OpenApiSchema[] properties?: Record required?: string[] type?: string } type OpenApiResponse = { description?: string content?: Record } // Query schemas describe decoded Effect values, but the generated SDK needs the // public call shape. These keep SDK callers passing numbers/booleans while the // server still decodes string query params at runtime. const QueryParameterSchemas: Record = { "GET /experimental/session start": { type: "number" }, "GET /experimental/session roots": QueryBooleanOpenApi, "GET /experimental/session archived": QueryBooleanOpenApi, "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, "GET /experimental/session cursor": { type: "number" }, "GET /experimental/session limit": { type: "number" }, "GET /session start": { type: "number" }, "GET /session roots": QueryBooleanOpenApi, "GET /session limit": { type: "number" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, "GET /vcs/diff context": { type: "integer", minimum: 0 }, "GET /api/session limit": { type: "number" }, "GET /api/session start": { type: "number" }, "GET /api/session roots": QueryBooleanOpenApi, "GET /api/session/{sessionID}/message limit": { type: "number" }, } const LegacyComponentDescriptions: Record = { LogLevel: "Log level", ServerConfig: "Server configuration for opencode serve and web commands", LayoutConfig: "@deprecated Always uses stretch layout.", } function matchLegacyOpenApi(input: Record) { const spec = input as OpenApiSpec // Effect's multi-document JSON Schema deduplicator can produce self-referencing // component schemas (e.g. `{"$ref":"#/components/schemas/X"}` as the definition // of X itself) when the same AST node appears both as a standalone endpoint // payload and inside an annotated union arm. Resolve these by inlining the // actual schema from any parent union that references them. fixSelfReferencingComponents(spec) // Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI, // but the legacy SDK expected plain `T` for optional fields. Strip null // from all component schemas so both request and response types match. for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { spec.components!.schemas![name] = stripOptionalNull(structuredClone(schema)) } normalizeComponentNames(spec) collapseDuplicateComponents(spec) applyLegacySchemaOverrides(spec) normalizeComponentDescriptions(spec) addLegacyErrorSchemas(spec) delete spec.components?.securitySchemes for (const [path, item] of Object.entries(spec.paths ?? {})) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] if (!operation) continue const isV2Api = isV2ApiPath(path) if (operation.requestBody) { // The legacy OpenAPI surface never marked request bodies as required. // Keep that SDK surface stable while the HttpApi spec is tightened. if (!isV2Api) delete operation.requestBody.required const body = operation.requestBody.content?.["application/json"] if (body?.schema) body.schema = stripOptionalNull(structuredClone(body.schema)) if (path === "/experimental/workspace" && method === "post") { // Workspace creation fields `branch` and `extra` are Schema.NullOr — // genuinely nullable, not just optional. Re-add the null that the // component-level strip above removed. const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace( "#/components/schemas/", "", ) const properties = ref ? spec.components?.schemas?.[ref]?.properties : operation.requestBody.content?.["application/json"]?.schema?.properties if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] } if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] } } if (path === "/experimental/workspace/warp" && method === "post") { const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace( "#/components/schemas/", "", ) const properties = ref ? spec.components?.schemas?.[ref]?.properties : operation.requestBody.content?.["application/json"]?.schema?.properties if (properties?.id) properties.id = { anyOf: [properties.id, { type: "null" }] } } } for (const response of Object.values(operation.responses ?? {})) { for (const content of Object.values(response.content ?? {})) { if (content.schema) content.schema = stripOptionalNull(structuredClone(content.schema)) } } if (!isV2Api) { // Auth is still runtime middleware outside the legacy public OpenAPI // metadata, so the legacy SDK should not expose auth schemes or // generated 401 error unions. delete operation.security delete operation.responses?.["401"] normalizeLegacyErrorResponses(operation) } normalizeLegacyOperation(operation, path, method) if ((path === "/event" || path === "/global/event" || path === "/api/event") && method === "get") { // HttpApi has no first-class SSE response schema, and these handlers are // raw/streaming routes. Document the actual wire protocol explicitly. operation.responses!["200"] = { description: "Event stream", content: { "text/event-stream": { schema: path === "/event" ? { $ref: "#/components/schemas/Event" } : path === "/global/event" ? { $ref: "#/components/schemas/GlobalEvent" } : { $ref: "#/components/schemas/V2Event" }, }, }, } } const route = `${method.toUpperCase()} ${path}` for (const param of operation.parameters ?? []) normalizeParameter(param, route) } } deleteUnusedLegacyErrorComponents(spec) return input } function isV2ApiPath(path: string) { return path === "/api" || path.startsWith("/api/") } function addLegacyErrorSchemas(spec: OpenApiSpec) { if (!spec.components?.schemas) return spec.components.schemas.BadRequestError = { type: "object", required: ["name", "data"], properties: { name: { type: "string", enum: ["BadRequest"] }, data: { type: "object", required: ["message"], properties: { message: { type: "string" }, kind: { type: "string", enum: ["Params", "Headers", "Query", "Body", "Payload"], }, }, }, }, } spec.components.schemas.NotFoundError = { type: "object", required: ["name", "data"], properties: { name: { type: "string", enum: ["NotFoundError"] }, data: { type: "object", required: ["message"], properties: { message: { type: "string" }, }, }, }, } } function collapseDuplicateComponents(spec: OpenApiSpec) { const schemas = spec.components?.schemas if (!schemas) return for (const name of Object.keys(schemas)) { const base = name.replace(/\d+$/, "") if (base === name || !schemas[base]) continue if (stableSchema(schemas[name], schemas) !== stableSchema(schemas[base], schemas)) continue rewriteRefs(spec, name, base) delete schemas[name] } } function normalizeComponentNames(spec: OpenApiSpec) { const schemas = spec.components?.schemas if (!schemas) return for (const name of Object.keys(schemas)) { const next = componentTypeName(name) if (next === name) continue if (schemas[next]) { if (stableSchema(schemas[name], schemas) === stableSchema(schemas[next], schemas)) { rewriteRefs(spec, name, next) delete schemas[name] } continue } schemas[next] = schemas[name] rewriteRefs(spec, name, next) delete schemas[name] } } function componentTypeName(name: string) { if (!name.includes(".")) return name return name .split(".") .filter((part) => !/^\d+$/.test(part)) .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) .join("") } function applyLegacySchemaOverrides(spec: OpenApiSpec) { const schemas = spec.components?.schemas if (!schemas) return if (schemas.AgentConfig) schemas.AgentConfig.additionalProperties = {} if (schemas.Command?.properties?.template) schemas.Command.properties.template = { type: "string" } if (schemas.Workspace?.properties) { schemas.Workspace.properties.branch = nullable(schemas.Workspace.properties.branch) schemas.Workspace.properties.directory = nullable(schemas.Workspace.properties.directory) schemas.Workspace.properties.extra = nullable(schemas.Workspace.properties.extra) } if (schemas.GlobalSession?.properties?.project) schemas.GlobalSession.properties.project = nullable(schemas.GlobalSession.properties.project) const providerOptions = schemas.ProviderConfig?.properties?.options if (providerOptions) providerOptions.additionalProperties = {} const model = schemas.ProviderConfig?.properties?.models?.additionalProperties const variants = typeof model === "object" ? model.properties?.variants?.additionalProperties : undefined if (variants && typeof variants === "object") variants.additionalProperties = {} const syncInfo = schemas.SyncEventSessionUpdated?.properties?.data?.properties?.info if (syncInfo?.properties) makePropertiesNullable(syncInfo.properties) } function normalizeComponentDescriptions(spec: OpenApiSpec) { for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { const description = LegacyComponentDescriptions[name] if (description) { schema.description = description continue } delete schema.description } } function makePropertiesNullable(properties: Record) { for (const [key, value] of Object.entries(properties)) { if (key === "share" && value.properties?.url) { value.properties.url = nullable(value.properties.url) continue } if (key === "time" && value.properties) { makePropertiesNullable(value.properties) continue } properties[key] = nullable(value) } } function nullable(schema: OpenApiSchema): OpenApiSchema { if (flattenOptions(schema.anyOf ?? schema.oneOf)?.some((item) => item.type === "null")) return schema return { anyOf: [schema, { type: "null" }] } } function stableSchema(input: unknown, schemas: Record): string { return JSON.stringify(canonicalizeSchema(input, schemas)) } function canonicalizeSchema(input: unknown, schemas: Record): unknown { if (Array.isArray(input)) return input.map((item) => canonicalizeSchema(item, schemas)) if (!input || typeof input !== "object") return input const schema = input as OpenApiSchema if (schema.$ref) return { $ref: canonicalRef(schema.$ref, schemas) } return Object.fromEntries( Object.entries(input) .filter(([key]) => key !== "description") .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => [key, canonicalizeSchema(value, schemas)]), ) } function canonicalRef(ref: string, schemas: Record) { const name = ref.replace("#/components/schemas/", "") const base = name.replace(/\d+$/, "") if (base !== name && schemas[base]) return `#/components/schemas/${base}` return ref } function rewriteRefs(input: unknown, from: string, to: string): void { if (Array.isArray(input)) { for (const item of input) rewriteRefs(item, from, to) return } if (!input || typeof input !== "object") return const schema = input as OpenApiSchema if (schema.$ref === `#/components/schemas/${from}`) schema.$ref = `#/components/schemas/${to}` for (const value of Object.values(input)) rewriteRefs(value, from, to) } function normalizeLegacyErrorResponses(operation: OpenApiOperation) { if (operation.responses?.["400"] && isLegacyBadRequestResponse(operation.responses["400"])) { operation.responses["400"] = legacyErrorResponse("Bad request", "BadRequestError") } if (operation.responses?.["404"] && isBuiltInErrorResponse(operation.responses["404"], "NotFound")) { operation.responses["404"] = legacyErrorResponse("Not found", "NotFoundError") } } function deleteUnusedLegacyErrorComponents(spec: OpenApiSpec) { for (const name of [ "Unauthorized", "EffectHttpApiErrorBadRequest", "EffectHttpApiErrorNotFound", "effect_HttpApiError_BadRequest", "effect_HttpApiError_NotFound", ]) { if (referencesComponent(spec.paths, name)) continue delete spec.components?.schemas?.[name] } } function referencesComponent(input: unknown, name: string): boolean { if (Array.isArray(input)) return input.some((item) => referencesComponent(item, name)) if (!input || typeof input !== "object") return false if ((input as OpenApiSchema).$ref === `#/components/schemas/${name}`) return true return Object.values(input).some((value) => referencesComponent(value, name)) } function normalizeLegacyOperation(operation: OpenApiOperation, path: string, method: string) { if (path === "/experimental/console/switch" && method === "post") delete operation.responses?.["400"] if ((path !== "/session/{sessionID}/message" && path !== "/session/{sessionID}/command") || method !== "post") return const response = operation.responses?.["200"]?.content?.["application/json"] if (!response) return response.schema = { type: "object", required: ["info", "parts"], properties: { info: { $ref: "#/components/schemas/AssistantMessage" }, parts: { type: "array", items: { $ref: "#/components/schemas/Part" }, }, }, } } function isRefResponse(response: OpenApiResponse, name: string) { return response.content?.["application/json"]?.schema?.$ref === `#/components/schemas/${name}` } function isBuiltInErrorResponse(response: OpenApiResponse, name: "BadRequest" | "NotFound") { return response.description === name || isRefResponse(response, `EffectHttpApiError${name}`) } function isLegacyBadRequestResponse(response: OpenApiResponse) { return isBuiltInErrorResponse(response, "BadRequest") || isRefResponse(response, "InvalidRequestError") } function legacyErrorResponse(description: string, name: "BadRequestError" | "NotFoundError"): OpenApiResponse { return { description, content: { "application/json": { schema: { $ref: `#/components/schemas/${name}` }, }, }, } } /** * Fix component schemas that are self-referencing `$ref`s — an Effect OpenAPI * generation bug where annotated union arms that share AST nodes with other * endpoints produce `{"$ref":"#/components/schemas/X"}` as the definition of X. * * Resolves by finding the actual schema from a parent union's `anyOf`/`oneOf` * that references the broken component, then inlining that schema. */ function fixSelfReferencingComponents(spec: OpenApiSpec) { const schemas = spec.components?.schemas if (!schemas) return const selfRefs = new Set() for (const [name, schema] of Object.entries(schemas)) { if (schema.$ref === `#/components/schemas/${name}`) selfRefs.add(name) } if (selfRefs.size === 0) return // Find a parent union component whose anyOf/oneOf contains a $ref to the // broken component — that parent was generated correctly and holds the inline // schema we need. for (const [, schema] of Object.entries(schemas)) { for (const member of schema.anyOf ?? schema.oneOf ?? []) { const ref = member.$ref?.replace("#/components/schemas/", "") if (!ref || !selfRefs.has(ref)) continue // This member's $ref points to a self-referencing component. The member // itself is just {$ref:...}, so the actual schema must be resolved from // the union. Since the union component was generated before the // deduplicator broke things, the inline version lives elsewhere. Generate // a fresh spec without the transform to get the correct schema. // Simpler approach: look through all paths for an endpoint that uses this // schema as a payload (it would have been expanded by the ref-expansion // logic above if we ran after that, but we run before). Instead, just // delete the broken component — if it's referenced via $ref elsewhere, // the ref expansion in the request body loop will inline it anyway. } } // Simplest fix: generate the raw spec (without transform) to get correct schemas const raw: OpenApiSpec = OpenApi.fromApi(OpenCodeHttpApi) const rawSchemas = raw.components?.schemas if (!rawSchemas) return for (const name of selfRefs) { if (rawSchemas[name]) schemas[name] = rawSchemas[name] } } /** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */ function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema { if (schema.allOf?.length === 1) { const [constraint] = schema.allOf delete schema.allOf return stripOptionalNull({ ...schema, ...constraint }) } if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} } const options = flattenOptions(schema.anyOf ?? schema.oneOf) if (options) { const withoutNull = options.filter((item) => item.type !== "null") if (withoutNull.length === 1) return stripOptionalNull(withoutNull[0]) if (schema.anyOf) schema.anyOf = withoutNull.map(stripOptionalNull) if (schema.oneOf) schema.oneOf = withoutNull.map(stripOptionalNull) } if (schema.allOf) { const allOf = schema.allOf.map(stripOptionalNull) if (schema.type) { delete schema.allOf for (const item of allOf) Object.assign(schema, item) } else { schema.allOf = allOf } } if (schema.prefixItems && schema.items) delete schema.prefixItems if (schema.items) schema.items = stripOptionalNull(schema.items) if (schema.properties) { for (const [key, value] of Object.entries(schema.properties)) { schema.properties[key] = stripOptionalNull(value) } } if (schema.additionalProperties && typeof schema.additionalProperties === "object") { schema.additionalProperties = stripOptionalNull(schema.additionalProperties) } return schema } function isEmptyObjectUnion(schema: OpenApiSchema) { const options = schema.anyOf ?? schema.oneOf return options?.length === 2 && options.some(isBareObjectSchema) && options.some(isBareArraySchema) } function isBareObjectSchema(schema: OpenApiSchema) { return schema.type === "object" && !schema.properties && !schema.additionalProperties } function isBareArraySchema(schema: OpenApiSchema) { return schema.type === "array" && !schema.items && !schema.prefixItems } function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined { return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item]) } function normalizeParameter(param: OpenApiParameter, route: string) { if (!param.schema || typeof param.schema !== "object") return if (param.in === "path") { param.schema = stripOptionalNull(param.schema) return } if (param.in === "query") { const override = QueryParameterSchemas[`${route} ${param.name}`] if (override) { param.schema = override return } } param.schema = stripOptionalNull(param.schema) } export const PublicApi = OpenCodeHttpApi.annotateMerge( OpenApi.annotations({ title: "opencode", version: "1.0.0", description: "opencode api", transform: matchLegacyOpenApi, }), )