| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537 |
- import { OpenApi } from "effect/unstable/httpapi"
- import { KirinCodeHttpApi } from "./api"
- import { QueryBooleanOpenApi } from "./groups/query"
- type OpenApiParameter = {
- name: string
- in: string
- required?: boolean
- schema?: OpenApiSchema
- }
- type OpenApiOperation = {
- parameters?: OpenApiParameter[]
- responses?: Record<string, OpenApiResponse>
- requestBody?: {
- required?: boolean
- content?: Record<string, { schema?: OpenApiSchema }>
- }
- security?: unknown
- }
- type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
- type OpenApiSpec = {
- components?: {
- schemas?: Record<string, OpenApiSchema>
- securitySchemes?: Record<string, unknown>
- }
- paths?: Record<string, OpenApiPathItem>
- }
- type OpenApiSchema = {
- $ref?: string
- additionalProperties?: OpenApiSchema | boolean
- allOf?: OpenApiSchema[]
- anyOf?: OpenApiSchema[]
- description?: string
- enum?: Array<string | boolean>
- items?: OpenApiSchema
- maximum?: number
- minimum?: number
- oneOf?: OpenApiSchema[]
- pattern?: string
- prefixItems?: OpenApiSchema[]
- properties?: Record<string, OpenApiSchema>
- required?: string[]
- type?: string
- }
- type OpenApiResponse = {
- description?: string
- content?: Record<string, { schema?: OpenApiSchema }>
- }
- // 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<string, OpenApiSchema> = {
- "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<string, string> = {
- LogLevel: "Log level",
- ServerConfig: "Server configuration for kirincode serve and web commands",
- LayoutConfig: "@deprecated Always uses stretch layout.",
- }
- function matchLegacyOpenApi(input: Record<string, unknown>) {
- 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<string, OpenApiSchema>) {
- 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, OpenApiSchema>): string {
- return JSON.stringify(canonicalizeSchema(input, schemas))
- }
- function canonicalizeSchema(input: unknown, schemas: Record<string, OpenApiSchema>): 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<string, OpenApiSchema>) {
- 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<string>()
- 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(KirinCodeHttpApi)
- 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 = KirinCodeHttpApi.annotateMerge(
- OpenApi.annotations({
- title: "kirincode",
- version: "1.0.0",
- description: "kirincode api",
- transform: matchLegacyOpenApi,
- }),
- )
|