public.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. import { OpenApi } from "effect/unstable/httpapi"
  2. import { KirinCodeHttpApi } from "./api"
  3. import { QueryBooleanOpenApi } from "./groups/query"
  4. type OpenApiParameter = {
  5. name: string
  6. in: string
  7. required?: boolean
  8. schema?: OpenApiSchema
  9. }
  10. type OpenApiOperation = {
  11. parameters?: OpenApiParameter[]
  12. responses?: Record<string, OpenApiResponse>
  13. requestBody?: {
  14. required?: boolean
  15. content?: Record<string, { schema?: OpenApiSchema }>
  16. }
  17. security?: unknown
  18. }
  19. type OpenApiPathItem = Partial<Record<"get" | "post" | "put" | "delete" | "patch", OpenApiOperation>>
  20. type OpenApiSpec = {
  21. components?: {
  22. schemas?: Record<string, OpenApiSchema>
  23. securitySchemes?: Record<string, unknown>
  24. }
  25. paths?: Record<string, OpenApiPathItem>
  26. }
  27. type OpenApiSchema = {
  28. $ref?: string
  29. additionalProperties?: OpenApiSchema | boolean
  30. allOf?: OpenApiSchema[]
  31. anyOf?: OpenApiSchema[]
  32. description?: string
  33. enum?: Array<string | boolean>
  34. items?: OpenApiSchema
  35. maximum?: number
  36. minimum?: number
  37. oneOf?: OpenApiSchema[]
  38. pattern?: string
  39. prefixItems?: OpenApiSchema[]
  40. properties?: Record<string, OpenApiSchema>
  41. required?: string[]
  42. type?: string
  43. }
  44. type OpenApiResponse = {
  45. description?: string
  46. content?: Record<string, { schema?: OpenApiSchema }>
  47. }
  48. // Query schemas describe decoded Effect values, but the generated SDK needs the
  49. // public call shape. These keep SDK callers passing numbers/booleans while the
  50. // server still decodes string query params at runtime.
  51. const QueryParameterSchemas: Record<string, OpenApiSchema> = {
  52. "GET /experimental/session start": { type: "number" },
  53. "GET /experimental/session roots": QueryBooleanOpenApi,
  54. "GET /experimental/session archived": QueryBooleanOpenApi,
  55. "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
  56. "GET /experimental/session cursor": { type: "number" },
  57. "GET /experimental/session limit": { type: "number" },
  58. "GET /session start": { type: "number" },
  59. "GET /session roots": QueryBooleanOpenApi,
  60. "GET /session limit": { type: "number" },
  61. "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
  62. "GET /vcs/diff context": { type: "integer", minimum: 0 },
  63. "GET /api/session limit": { type: "number" },
  64. "GET /api/session start": { type: "number" },
  65. "GET /api/session roots": QueryBooleanOpenApi,
  66. "GET /api/session/{sessionID}/message limit": { type: "number" },
  67. }
  68. const LegacyComponentDescriptions: Record<string, string> = {
  69. LogLevel: "Log level",
  70. ServerConfig: "Server configuration for kirincode serve and web commands",
  71. LayoutConfig: "@deprecated Always uses stretch layout.",
  72. }
  73. function matchLegacyOpenApi(input: Record<string, unknown>) {
  74. const spec = input as OpenApiSpec
  75. // Effect's multi-document JSON Schema deduplicator can produce self-referencing
  76. // component schemas (e.g. `{"$ref":"#/components/schemas/X"}` as the definition
  77. // of X itself) when the same AST node appears both as a standalone endpoint
  78. // payload and inside an annotated union arm. Resolve these by inlining the
  79. // actual schema from any parent union that references them.
  80. fixSelfReferencingComponents(spec)
  81. // Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI,
  82. // but the legacy SDK expected plain `T` for optional fields. Strip null
  83. // from all component schemas so both request and response types match.
  84. for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
  85. spec.components!.schemas![name] = stripOptionalNull(structuredClone(schema))
  86. }
  87. normalizeComponentNames(spec)
  88. collapseDuplicateComponents(spec)
  89. applyLegacySchemaOverrides(spec)
  90. normalizeComponentDescriptions(spec)
  91. addLegacyErrorSchemas(spec)
  92. delete spec.components?.securitySchemes
  93. for (const [path, item] of Object.entries(spec.paths ?? {})) {
  94. for (const method of ["get", "post", "put", "delete", "patch"] as const) {
  95. const operation = item[method]
  96. if (!operation) continue
  97. const isV2Api = isV2ApiPath(path)
  98. if (operation.requestBody) {
  99. // The legacy OpenAPI surface never marked request bodies as required.
  100. // Keep that SDK surface stable while the HttpApi spec is tightened.
  101. if (!isV2Api) delete operation.requestBody.required
  102. const body = operation.requestBody.content?.["application/json"]
  103. if (body?.schema) body.schema = stripOptionalNull(structuredClone(body.schema))
  104. if (path === "/experimental/workspace" && method === "post") {
  105. // Workspace creation fields `branch` and `extra` are Schema.NullOr —
  106. // genuinely nullable, not just optional. Re-add the null that the
  107. // component-level strip above removed.
  108. const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace(
  109. "#/components/schemas/",
  110. "",
  111. )
  112. const properties = ref
  113. ? spec.components?.schemas?.[ref]?.properties
  114. : operation.requestBody.content?.["application/json"]?.schema?.properties
  115. if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] }
  116. if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] }
  117. }
  118. if (path === "/experimental/workspace/warp" && method === "post") {
  119. const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace(
  120. "#/components/schemas/",
  121. "",
  122. )
  123. const properties = ref
  124. ? spec.components?.schemas?.[ref]?.properties
  125. : operation.requestBody.content?.["application/json"]?.schema?.properties
  126. if (properties?.id) properties.id = { anyOf: [properties.id, { type: "null" }] }
  127. }
  128. }
  129. for (const response of Object.values(operation.responses ?? {})) {
  130. for (const content of Object.values(response.content ?? {})) {
  131. if (content.schema) content.schema = stripOptionalNull(structuredClone(content.schema))
  132. }
  133. }
  134. if (!isV2Api) {
  135. // Auth is still runtime middleware outside the legacy public OpenAPI
  136. // metadata, so the legacy SDK should not expose auth schemes or
  137. // generated 401 error unions.
  138. delete operation.security
  139. delete operation.responses?.["401"]
  140. normalizeLegacyErrorResponses(operation)
  141. }
  142. normalizeLegacyOperation(operation, path, method)
  143. if ((path === "/event" || path === "/global/event" || path === "/api/event") && method === "get") {
  144. // HttpApi has no first-class SSE response schema, and these handlers are
  145. // raw/streaming routes. Document the actual wire protocol explicitly.
  146. operation.responses!["200"] = {
  147. description: "Event stream",
  148. content: {
  149. "text/event-stream": {
  150. schema:
  151. path === "/event"
  152. ? { $ref: "#/components/schemas/Event" }
  153. : path === "/global/event"
  154. ? { $ref: "#/components/schemas/GlobalEvent" }
  155. : { $ref: "#/components/schemas/V2Event" },
  156. },
  157. },
  158. }
  159. }
  160. const route = `${method.toUpperCase()} ${path}`
  161. for (const param of operation.parameters ?? []) normalizeParameter(param, route)
  162. }
  163. }
  164. deleteUnusedLegacyErrorComponents(spec)
  165. return input
  166. }
  167. function isV2ApiPath(path: string) {
  168. return path === "/api" || path.startsWith("/api/")
  169. }
  170. function addLegacyErrorSchemas(spec: OpenApiSpec) {
  171. if (!spec.components?.schemas) return
  172. spec.components.schemas.BadRequestError = {
  173. type: "object",
  174. required: ["name", "data"],
  175. properties: {
  176. name: { type: "string", enum: ["BadRequest"] },
  177. data: {
  178. type: "object",
  179. required: ["message"],
  180. properties: {
  181. message: { type: "string" },
  182. kind: {
  183. type: "string",
  184. enum: ["Params", "Headers", "Query", "Body", "Payload"],
  185. },
  186. },
  187. },
  188. },
  189. }
  190. spec.components.schemas.NotFoundError = {
  191. type: "object",
  192. required: ["name", "data"],
  193. properties: {
  194. name: { type: "string", enum: ["NotFoundError"] },
  195. data: {
  196. type: "object",
  197. required: ["message"],
  198. properties: {
  199. message: { type: "string" },
  200. },
  201. },
  202. },
  203. }
  204. }
  205. function collapseDuplicateComponents(spec: OpenApiSpec) {
  206. const schemas = spec.components?.schemas
  207. if (!schemas) return
  208. for (const name of Object.keys(schemas)) {
  209. const base = name.replace(/\d+$/, "")
  210. if (base === name || !schemas[base]) continue
  211. if (stableSchema(schemas[name], schemas) !== stableSchema(schemas[base], schemas)) continue
  212. rewriteRefs(spec, name, base)
  213. delete schemas[name]
  214. }
  215. }
  216. function normalizeComponentNames(spec: OpenApiSpec) {
  217. const schemas = spec.components?.schemas
  218. if (!schemas) return
  219. for (const name of Object.keys(schemas)) {
  220. const next = componentTypeName(name)
  221. if (next === name) continue
  222. if (schemas[next]) {
  223. if (stableSchema(schemas[name], schemas) === stableSchema(schemas[next], schemas)) {
  224. rewriteRefs(spec, name, next)
  225. delete schemas[name]
  226. }
  227. continue
  228. }
  229. schemas[next] = schemas[name]
  230. rewriteRefs(spec, name, next)
  231. delete schemas[name]
  232. }
  233. }
  234. function componentTypeName(name: string) {
  235. if (!name.includes(".")) return name
  236. return name
  237. .split(".")
  238. .filter((part) => !/^\d+$/.test(part))
  239. .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
  240. .join("")
  241. }
  242. function applyLegacySchemaOverrides(spec: OpenApiSpec) {
  243. const schemas = spec.components?.schemas
  244. if (!schemas) return
  245. if (schemas.AgentConfig) schemas.AgentConfig.additionalProperties = {}
  246. if (schemas.Command?.properties?.template) schemas.Command.properties.template = { type: "string" }
  247. if (schemas.Workspace?.properties) {
  248. schemas.Workspace.properties.branch = nullable(schemas.Workspace.properties.branch)
  249. schemas.Workspace.properties.directory = nullable(schemas.Workspace.properties.directory)
  250. schemas.Workspace.properties.extra = nullable(schemas.Workspace.properties.extra)
  251. }
  252. if (schemas.GlobalSession?.properties?.project)
  253. schemas.GlobalSession.properties.project = nullable(schemas.GlobalSession.properties.project)
  254. const providerOptions = schemas.ProviderConfig?.properties?.options
  255. if (providerOptions) providerOptions.additionalProperties = {}
  256. const model = schemas.ProviderConfig?.properties?.models?.additionalProperties
  257. const variants = typeof model === "object" ? model.properties?.variants?.additionalProperties : undefined
  258. if (variants && typeof variants === "object") variants.additionalProperties = {}
  259. const syncInfo = schemas.SyncEventSessionUpdated?.properties?.data?.properties?.info
  260. if (syncInfo?.properties) makePropertiesNullable(syncInfo.properties)
  261. }
  262. function normalizeComponentDescriptions(spec: OpenApiSpec) {
  263. for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
  264. const description = LegacyComponentDescriptions[name]
  265. if (description) {
  266. schema.description = description
  267. continue
  268. }
  269. delete schema.description
  270. }
  271. }
  272. function makePropertiesNullable(properties: Record<string, OpenApiSchema>) {
  273. for (const [key, value] of Object.entries(properties)) {
  274. if (key === "share" && value.properties?.url) {
  275. value.properties.url = nullable(value.properties.url)
  276. continue
  277. }
  278. if (key === "time" && value.properties) {
  279. makePropertiesNullable(value.properties)
  280. continue
  281. }
  282. properties[key] = nullable(value)
  283. }
  284. }
  285. function nullable(schema: OpenApiSchema): OpenApiSchema {
  286. if (flattenOptions(schema.anyOf ?? schema.oneOf)?.some((item) => item.type === "null")) return schema
  287. return { anyOf: [schema, { type: "null" }] }
  288. }
  289. function stableSchema(input: unknown, schemas: Record<string, OpenApiSchema>): string {
  290. return JSON.stringify(canonicalizeSchema(input, schemas))
  291. }
  292. function canonicalizeSchema(input: unknown, schemas: Record<string, OpenApiSchema>): unknown {
  293. if (Array.isArray(input)) return input.map((item) => canonicalizeSchema(item, schemas))
  294. if (!input || typeof input !== "object") return input
  295. const schema = input as OpenApiSchema
  296. if (schema.$ref) return { $ref: canonicalRef(schema.$ref, schemas) }
  297. return Object.fromEntries(
  298. Object.entries(input)
  299. .filter(([key]) => key !== "description")
  300. .sort(([a], [b]) => a.localeCompare(b))
  301. .map(([key, value]) => [key, canonicalizeSchema(value, schemas)]),
  302. )
  303. }
  304. function canonicalRef(ref: string, schemas: Record<string, OpenApiSchema>) {
  305. const name = ref.replace("#/components/schemas/", "")
  306. const base = name.replace(/\d+$/, "")
  307. if (base !== name && schemas[base]) return `#/components/schemas/${base}`
  308. return ref
  309. }
  310. function rewriteRefs(input: unknown, from: string, to: string): void {
  311. if (Array.isArray(input)) {
  312. for (const item of input) rewriteRefs(item, from, to)
  313. return
  314. }
  315. if (!input || typeof input !== "object") return
  316. const schema = input as OpenApiSchema
  317. if (schema.$ref === `#/components/schemas/${from}`) schema.$ref = `#/components/schemas/${to}`
  318. for (const value of Object.values(input)) rewriteRefs(value, from, to)
  319. }
  320. function normalizeLegacyErrorResponses(operation: OpenApiOperation) {
  321. if (operation.responses?.["400"] && isLegacyBadRequestResponse(operation.responses["400"])) {
  322. operation.responses["400"] = legacyErrorResponse("Bad request", "BadRequestError")
  323. }
  324. if (operation.responses?.["404"] && isBuiltInErrorResponse(operation.responses["404"], "NotFound")) {
  325. operation.responses["404"] = legacyErrorResponse("Not found", "NotFoundError")
  326. }
  327. }
  328. function deleteUnusedLegacyErrorComponents(spec: OpenApiSpec) {
  329. for (const name of [
  330. "Unauthorized",
  331. "EffectHttpApiErrorBadRequest",
  332. "EffectHttpApiErrorNotFound",
  333. "effect_HttpApiError_BadRequest",
  334. "effect_HttpApiError_NotFound",
  335. ]) {
  336. if (referencesComponent(spec.paths, name)) continue
  337. delete spec.components?.schemas?.[name]
  338. }
  339. }
  340. function referencesComponent(input: unknown, name: string): boolean {
  341. if (Array.isArray(input)) return input.some((item) => referencesComponent(item, name))
  342. if (!input || typeof input !== "object") return false
  343. if ((input as OpenApiSchema).$ref === `#/components/schemas/${name}`) return true
  344. return Object.values(input).some((value) => referencesComponent(value, name))
  345. }
  346. function normalizeLegacyOperation(operation: OpenApiOperation, path: string, method: string) {
  347. if (path === "/experimental/console/switch" && method === "post") delete operation.responses?.["400"]
  348. if ((path !== "/session/{sessionID}/message" && path !== "/session/{sessionID}/command") || method !== "post") return
  349. const response = operation.responses?.["200"]?.content?.["application/json"]
  350. if (!response) return
  351. response.schema = {
  352. type: "object",
  353. required: ["info", "parts"],
  354. properties: {
  355. info: { $ref: "#/components/schemas/AssistantMessage" },
  356. parts: {
  357. type: "array",
  358. items: { $ref: "#/components/schemas/Part" },
  359. },
  360. },
  361. }
  362. }
  363. function isRefResponse(response: OpenApiResponse, name: string) {
  364. return response.content?.["application/json"]?.schema?.$ref === `#/components/schemas/${name}`
  365. }
  366. function isBuiltInErrorResponse(response: OpenApiResponse, name: "BadRequest" | "NotFound") {
  367. return response.description === name || isRefResponse(response, `EffectHttpApiError${name}`)
  368. }
  369. function isLegacyBadRequestResponse(response: OpenApiResponse) {
  370. return isBuiltInErrorResponse(response, "BadRequest") || isRefResponse(response, "InvalidRequestError")
  371. }
  372. function legacyErrorResponse(description: string, name: "BadRequestError" | "NotFoundError"): OpenApiResponse {
  373. return {
  374. description,
  375. content: {
  376. "application/json": {
  377. schema: { $ref: `#/components/schemas/${name}` },
  378. },
  379. },
  380. }
  381. }
  382. /**
  383. * Fix component schemas that are self-referencing `$ref`s — an Effect OpenAPI
  384. * generation bug where annotated union arms that share AST nodes with other
  385. * endpoints produce `{"$ref":"#/components/schemas/X"}` as the definition of X.
  386. *
  387. * Resolves by finding the actual schema from a parent union's `anyOf`/`oneOf`
  388. * that references the broken component, then inlining that schema.
  389. */
  390. function fixSelfReferencingComponents(spec: OpenApiSpec) {
  391. const schemas = spec.components?.schemas
  392. if (!schemas) return
  393. const selfRefs = new Set<string>()
  394. for (const [name, schema] of Object.entries(schemas)) {
  395. if (schema.$ref === `#/components/schemas/${name}`) selfRefs.add(name)
  396. }
  397. if (selfRefs.size === 0) return
  398. // Find a parent union component whose anyOf/oneOf contains a $ref to the
  399. // broken component — that parent was generated correctly and holds the inline
  400. // schema we need.
  401. for (const [, schema] of Object.entries(schemas)) {
  402. for (const member of schema.anyOf ?? schema.oneOf ?? []) {
  403. const ref = member.$ref?.replace("#/components/schemas/", "")
  404. if (!ref || !selfRefs.has(ref)) continue
  405. // This member's $ref points to a self-referencing component. The member
  406. // itself is just {$ref:...}, so the actual schema must be resolved from
  407. // the union. Since the union component was generated before the
  408. // deduplicator broke things, the inline version lives elsewhere. Generate
  409. // a fresh spec without the transform to get the correct schema.
  410. // Simpler approach: look through all paths for an endpoint that uses this
  411. // schema as a payload (it would have been expanded by the ref-expansion
  412. // logic above if we ran after that, but we run before). Instead, just
  413. // delete the broken component — if it's referenced via $ref elsewhere,
  414. // the ref expansion in the request body loop will inline it anyway.
  415. }
  416. }
  417. // Simplest fix: generate the raw spec (without transform) to get correct schemas
  418. const raw: OpenApiSpec = OpenApi.fromApi(KirinCodeHttpApi)
  419. const rawSchemas = raw.components?.schemas
  420. if (!rawSchemas) return
  421. for (const name of selfRefs) {
  422. if (rawSchemas[name]) schemas[name] = rawSchemas[name]
  423. }
  424. }
  425. /** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */
  426. function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema {
  427. if (schema.allOf?.length === 1) {
  428. const [constraint] = schema.allOf
  429. delete schema.allOf
  430. return stripOptionalNull({ ...schema, ...constraint })
  431. }
  432. if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} }
  433. const options = flattenOptions(schema.anyOf ?? schema.oneOf)
  434. if (options) {
  435. const withoutNull = options.filter((item) => item.type !== "null")
  436. if (withoutNull.length === 1) return stripOptionalNull(withoutNull[0])
  437. if (schema.anyOf) schema.anyOf = withoutNull.map(stripOptionalNull)
  438. if (schema.oneOf) schema.oneOf = withoutNull.map(stripOptionalNull)
  439. }
  440. if (schema.allOf) {
  441. const allOf = schema.allOf.map(stripOptionalNull)
  442. if (schema.type) {
  443. delete schema.allOf
  444. for (const item of allOf) Object.assign(schema, item)
  445. } else {
  446. schema.allOf = allOf
  447. }
  448. }
  449. if (schema.prefixItems && schema.items) delete schema.prefixItems
  450. if (schema.items) schema.items = stripOptionalNull(schema.items)
  451. if (schema.properties) {
  452. for (const [key, value] of Object.entries(schema.properties)) {
  453. schema.properties[key] = stripOptionalNull(value)
  454. }
  455. }
  456. if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
  457. schema.additionalProperties = stripOptionalNull(schema.additionalProperties)
  458. }
  459. return schema
  460. }
  461. function isEmptyObjectUnion(schema: OpenApiSchema) {
  462. const options = schema.anyOf ?? schema.oneOf
  463. return options?.length === 2 && options.some(isBareObjectSchema) && options.some(isBareArraySchema)
  464. }
  465. function isBareObjectSchema(schema: OpenApiSchema) {
  466. return schema.type === "object" && !schema.properties && !schema.additionalProperties
  467. }
  468. function isBareArraySchema(schema: OpenApiSchema) {
  469. return schema.type === "array" && !schema.items && !schema.prefixItems
  470. }
  471. function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | undefined {
  472. return options?.flatMap((item) => flattenOptions(item.anyOf ?? item.oneOf) ?? [item])
  473. }
  474. function normalizeParameter(param: OpenApiParameter, route: string) {
  475. if (!param.schema || typeof param.schema !== "object") return
  476. if (param.in === "path") {
  477. param.schema = stripOptionalNull(param.schema)
  478. return
  479. }
  480. if (param.in === "query") {
  481. const override = QueryParameterSchemas[`${route} ${param.name}`]
  482. if (override) {
  483. param.schema = override
  484. return
  485. }
  486. }
  487. param.schema = stripOptionalNull(param.schema)
  488. }
  489. export const PublicApi = KirinCodeHttpApi.annotateMerge(
  490. OpenApi.annotations({
  491. title: "kirincode",
  492. version: "1.0.0",
  493. description: "kirincode api",
  494. transform: matchLegacyOpenApi,
  495. }),
  496. )