feat(server-v2): expose /openapi.json via @fastify/swagger

- register @fastify/swagger before routes and serve GET /openapi.json
- add v2-specific openapi transform for multipart upload, binary downloads, and the fs-action/question oneOf dispatchers
- project the session-action dispatcher into archive only (v2 registers a subset of v1 routes)
- reuse protocol wire schemas, no inline re-declaration
This commit is contained in:
haozhe.yang 2026-06-30 17:19:11 +08:00
parent 299e6d6bd8
commit ce0e4f2589
5 changed files with 514 additions and 1 deletions

View file

@ -21,6 +21,7 @@
"@moonshot-ai/agent-core-v2": "workspace:^",
"@moonshot-ai/protocol": "workspace:^",
"@fastify/multipart": "^10.0.0",
"@fastify/swagger": "^9.7.0",
"fastify": "^5.1.0",
"node-pty": "^1.1.0",
"pino": "^9.5.0",

View file

@ -0,0 +1,341 @@
/**
* `openapi` transforms post-process the `@fastify/swagger` document so the
* routes that swagger cannot describe from static schemas (multipart upload,
* binary download, and the `{tail}` dispatchers) are represented accurately.
*
* Tailored to the route subset `server-v2` actually registers. Notably, the
* session-action dispatcher only serves `::archive` in v2, so this module
* projects `/sessions/{tail}` into a single `/sessions/{session_id}:archive`
* operation unlike v1, which clones it into fork / compact / undo. Wire
* schemas are re-used from `@moonshot-ai/protocol`; none are re-declared here.
*/
import {
archiveSessionResponseSchema,
fsDiffRequestSchema,
fsDiffResponseSchema,
fsGitStatusRequestSchema,
fsGitStatusResponseSchema,
fsGrepRequestSchema,
fsGrepResponseSchema,
fsListManyRequestSchema,
fsListManyResponseSchema,
fsListRequestSchema,
fsListResponseSchema,
fsMkdirRequestSchema,
fsMkdirResponseSchema,
fsOpenInRequestSchema,
fsOpenInResponseSchema,
fsOpenRequestSchema,
fsOpenResponseSchema,
fsReadRequestSchema,
fsReadResponseSchema,
fsRevealRequestSchema,
fsRevealResponseSchema,
fsSearchRequestSchema,
fsSearchResponseSchema,
fsStatManyRequestSchema,
fsStatManyResponseSchema,
fsStatRequestSchema,
fsStatResponseSchema,
questionDismissResultSchema,
questionResolveRequestSchema,
questionResolveResultSchema,
} from '@moonshot-ai/protocol';
import { z } from 'zod';
import {
openApiDocumentEnvelopeJsonSchema,
openApiDocumentJsonSchema,
} from '../middleware/schema';
const binarySchema = {
type: 'string',
format: 'binary',
} as const;
const fileUploadMultipartSchema = {
type: 'object',
properties: {
file: binarySchema,
name: { type: 'string' },
expires_in_sec: { type: 'number', minimum: 0 },
},
required: ['file'],
} as const;
const errorEnvelopeSchema = openApiDocumentEnvelopeJsonSchema(z.null());
const fsActionRequestSchema = {
oneOf: [
openApiDocumentJsonSchema(fsListRequestSchema),
openApiDocumentJsonSchema(fsReadRequestSchema),
openApiDocumentJsonSchema(fsListManyRequestSchema),
openApiDocumentJsonSchema(fsStatRequestSchema),
openApiDocumentJsonSchema(fsStatManyRequestSchema),
openApiDocumentJsonSchema(fsMkdirRequestSchema),
openApiDocumentJsonSchema(fsSearchRequestSchema),
openApiDocumentJsonSchema(fsGrepRequestSchema),
openApiDocumentJsonSchema(fsGitStatusRequestSchema),
openApiDocumentJsonSchema(fsDiffRequestSchema),
openApiDocumentJsonSchema(fsOpenRequestSchema),
openApiDocumentJsonSchema(fsOpenInRequestSchema),
openApiDocumentJsonSchema(fsRevealRequestSchema),
],
} as const;
const fsActionResponseSchema = {
oneOf: [
openApiDocumentEnvelopeJsonSchema(fsListResponseSchema),
openApiDocumentEnvelopeJsonSchema(fsReadResponseSchema),
openApiDocumentEnvelopeJsonSchema(fsListManyResponseSchema),
openApiDocumentEnvelopeJsonSchema(fsStatResponseSchema),
openApiDocumentEnvelopeJsonSchema(fsStatManyResponseSchema),
openApiDocumentEnvelopeJsonSchema(fsMkdirResponseSchema),
openApiDocumentEnvelopeJsonSchema(fsSearchResponseSchema),
openApiDocumentEnvelopeJsonSchema(fsGrepResponseSchema),
openApiDocumentEnvelopeJsonSchema(fsGitStatusResponseSchema),
openApiDocumentEnvelopeJsonSchema(fsDiffResponseSchema),
openApiDocumentEnvelopeJsonSchema(fsOpenResponseSchema),
openApiDocumentEnvelopeJsonSchema(fsOpenInResponseSchema),
openApiDocumentEnvelopeJsonSchema(fsRevealResponseSchema),
],
} as const;
const questionResponseSchema = {
oneOf: [
openApiDocumentEnvelopeJsonSchema(questionResolveResultSchema),
openApiDocumentEnvelopeJsonSchema(questionDismissResultSchema),
],
} as const;
export function transformOpenApiDocument(
document: Record<string, unknown>,
): Record<string, unknown> {
const paths = asRecord(document['paths']);
if (paths === undefined) return document;
patchFileUpload(paths);
patchFileDownload(paths);
patchSessionAction(paths);
patchFsAction(paths);
patchFsDownload(paths);
patchQuestionResolveOrDismiss(paths);
return document;
}
function patchFileUpload(paths: Record<string, unknown>): void {
const operation = getOperation(paths, '/api/v1/files', 'post');
if (operation === undefined) return;
operation['requestBody'] = {
required: true,
content: {
'multipart/form-data': {
schema: fileUploadMultipartSchema,
},
},
};
}
function patchFileDownload(paths: Record<string, unknown>): void {
const operation = getOperation(paths, '/api/v1/files/{file_id}', 'get');
if (operation === undefined) return;
setResponse(operation, '200', {
description: 'Binary file download',
headers: {
'content-disposition': headerString(),
'content-length': headerInteger(),
etag: headerString(),
},
content: {
'application/octet-stream': {
schema: binarySchema,
},
},
});
setResponse(operation, '404', {
description: 'File not found',
content: jsonContent(errorEnvelopeSchema),
});
}
function patchSessionAction(paths: Record<string, unknown>): void {
const internalPath = '/api/v1/sessions/{tail}';
const pathItem = asRecord(paths[internalPath]);
const operation = asRecord(pathItem?.['post']);
if (pathItem === undefined || operation === undefined) return;
const cloned = cloneRecord(pathItem);
replacePathParamName(cloned, 'tail', 'session_id');
const clonedOperation = asRecord(cloned['post']);
if (clonedOperation !== undefined) {
clonedOperation['operationId'] = 'runSessionArchiveAction';
setResponse(clonedOperation, '200', {
description: 'Session archive response',
content: jsonContent(openApiDocumentEnvelopeJsonSchema(archiveSessionResponseSchema)),
});
}
paths['/api/v1/sessions/{session_id}:archive'] = cloned;
delete paths[internalPath];
}
function patchFsAction(paths: Record<string, unknown>): void {
const operation = getOperation(paths, '/api/v1/sessions/{session_id}/{tail}', 'post');
if (operation === undefined) return;
operation['description'] = appendDescription(
operation['description'],
'The request and response schemas depend on the `fs:<action>` path tail and are represented as OpenAPI `oneOf` unions.',
);
operation['requestBody'] = {
required: true,
content: jsonContent(fsActionRequestSchema),
};
setResponse(operation, '200', {
description: 'Filesystem action response',
content: jsonContent(fsActionResponseSchema),
});
}
function patchFsDownload(paths: Record<string, unknown>): void {
const operation = getOperation(paths, '/api/v1/sessions/{session_id}/fs/{*}', 'get');
if (operation === undefined) return;
setResponse(operation, '200', {
description: 'Binary workspace file download',
headers: {
'content-disposition': headerString(),
'content-length': headerInteger(),
etag: headerString(),
'last-modified': headerString(),
},
content: {
'application/octet-stream': {
schema: binarySchema,
},
},
});
setResponse(operation, '206', {
description: 'Partial binary workspace file download',
headers: {
'content-disposition': headerString(),
'content-length': headerInteger(),
'content-range': headerString(),
etag: headerString(),
'last-modified': headerString(),
},
content: {
'application/octet-stream': {
schema: binarySchema,
},
},
});
setResponse(operation, '304', {
description: 'Not modified',
headers: {
etag: headerString(),
},
});
}
function patchQuestionResolveOrDismiss(paths: Record<string, unknown>): void {
const operation = getOperation(paths, '/api/v1/sessions/{session_id}/questions/{tail}', 'post');
if (operation === undefined) return;
operation['description'] = appendDescription(
operation['description'],
'Resolve uses the question response body; `:dismiss` sends an empty body.',
);
operation['requestBody'] = {
required: false,
content: jsonContent(openApiDocumentJsonSchema(questionResolveRequestSchema)),
};
setResponse(operation, '200', {
description: 'Question resolved or dismissed',
content: jsonContent(questionResponseSchema),
});
}
function getOperation(
paths: Record<string, unknown>,
path: string,
method: string,
): Record<string, unknown> | undefined {
const pathItem = asRecord(paths[path]);
if (pathItem === undefined) return undefined;
return asRecord(pathItem[method]);
}
function setResponse(
operation: Record<string, unknown>,
statusCode: string,
response: Record<string, unknown>,
): void {
const responses = asRecord(operation['responses']) ?? {};
responses[statusCode] = response;
operation['responses'] = responses;
}
function jsonContent(schema: Record<string, unknown>): Record<string, unknown> {
return {
'application/json': {
schema,
},
};
}
function headerString(): Record<string, unknown> {
return {
schema: {
type: 'string',
},
};
}
function headerInteger(): Record<string, unknown> {
return {
schema: {
type: 'integer',
},
};
}
function appendDescription(existing: unknown, extra: string): string {
if (typeof existing !== 'string' || existing.length === 0) return extra;
return `${existing} ${extra}`;
}
function replacePathParamName(
container: Record<string, unknown>,
from: string,
to: string,
): void {
const params = container['parameters'];
if (Array.isArray(params)) {
for (const param of params) {
const record = asRecord(param);
if (record?.['in'] === 'path' && record['name'] === from) {
record['name'] = to;
}
}
}
for (const method of ['get', 'post', 'put', 'patch', 'delete']) {
const operation = asRecord(container[method]);
if (operation !== undefined) {
replacePathParamName(operation, from, to);
}
}
}
function cloneRecord(value: Record<string, unknown>): Record<string, unknown> {
return structuredClone(value);
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== 'object' || value === null) return undefined;
return value as Record<string, unknown>;
}

View file

@ -19,6 +19,7 @@ import {
import Fastify, { type FastifyInstance } from 'fastify';
import { installErrorHandler } from './error-handler';
import { transformOpenApiDocument } from './openapi/transforms';
import { resolveRequestId } from './request-id';
import { registerApiV1Routes } from './routes/registerApiV1Routes';
// Registers the real `node-pty` `ITerminalBackend`, overriding the
@ -92,8 +93,51 @@ export async function startServer(opts: ServerStartOptions = {}): Promise<Runnin
core.dispose();
};
const serverVersion = getServerVersion();
async function registerOpenApi(): Promise<void> {
const { default: swagger } = await import('@fastify/swagger');
await app.register(swagger, {
openapi: {
info: {
title: 'Kimi Code Server API',
description:
'REST API for the Kimi Code local server. All JSON responses are wrapped in a uniform envelope `{ code, msg, data, request_id }`.',
version: serverVersion,
},
tags: [
{ name: 'meta', description: 'Server metadata' },
{ name: 'auth', description: 'Auth readiness & login state' },
{ name: 'models', description: 'Configured model aliases' },
{ name: 'providers', description: 'Configured providers' },
{ name: 'sessions', description: 'Session lifecycle' },
{ name: 'workspaces', description: 'Workspace registry + folder picker' },
{ name: 'messages', description: 'Message history' },
{ name: 'prompts', description: 'Prompt submission & abort' },
{ name: 'approvals', description: 'Approval resolution' },
{ name: 'questions', description: 'Question resolution & dismiss' },
{ name: 'tools', description: 'Tool & MCP server management' },
{ name: 'tasks', description: 'Background tasks' },
{ name: 'terminals', description: 'PTY terminal sessions' },
{ name: 'fs', description: 'Filesystem operations' },
{ name: 'files', description: 'File upload & download' },
],
},
transformObject: (documentObject) => {
if (!('openapiObject' in documentObject)) {
return documentObject.swaggerObject;
}
return transformOpenApiDocument(documentObject.openapiObject as Record<string, unknown>);
},
});
}
// `@fastify/swagger` collects route schemas via an `onRoute` hook, so it must
// be registered before any routes it should document.
await registerOpenApi();
await registerApiV1Routes(app, core, {
serverVersion: getServerVersion(),
serverVersion,
debugEndpoints: opts.debugEndpoints,
onShutdown: () => {
void close();
@ -103,6 +147,11 @@ export async function startServer(opts: ServerStartOptions = {}): Promise<Runnin
registerRpcRoutes(app, core, { token: opts.rpcToken });
registerWs(app, core, { token: opts.rpcToken });
app.get('/openapi.json', async (_req, reply) => {
const openApiDocument = (app as unknown as { swagger(): unknown }).swagger();
return reply.type('application/json').send(openApiDocument);
});
const host = opts.host ?? DEFAULT_HOST;
const port = opts.port ?? DEFAULT_PORT;
await app.listen({ host, port });

View file

@ -0,0 +1,119 @@
/**
* OpenAPI smoke test for server-v2.
*
* Boots the server, fetches `/openapi.json`, and asserts that `@fastify/swagger`
* is wired and that the v2-specific post-processing transforms ran (as opposed
* to a verbatim copy of v1's transforms, which would fabricate endpoints v2
* does not register).
*/
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { type RunningServer, startServer } from '../src/start';
describe('server-v2 OpenAPI', () => {
let server: RunningServer | undefined;
let home: string | undefined;
afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true });
home = undefined;
}
});
async function fetchOpenApi(): Promise<Record<string, unknown>> {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-openapi-'));
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
});
const res = await fetch(`http://127.0.0.1:${server.port}/openapi.json`);
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json');
return (await res.json()) as Record<string, unknown>;
}
it('returns a valid OpenAPI 3 document', async () => {
const doc = await fetchOpenApi();
expect(doc['openapi']).toMatch(/^3\.\d+\.\d+$/);
const info = asRecord(doc['info']);
expect(info['title']).toBe('Kimi Code Server API');
expect(typeof info['version']).toBe('string');
});
it('covers the core /api/v1 routes v2 registers', async () => {
const doc = await fetchOpenApi();
const paths = asRecord(doc['paths']);
expect(paths['/api/v1/healthz']).toBeDefined();
expect(paths['/api/v1/meta']).toBeDefined();
expect(paths['/api/v1/sessions']).toBeDefined();
expect(paths['/api/v1/files']).toBeDefined();
expect(paths['/api/v1/sessions/{session_id}/fs/{*}']).toBeDefined();
});
it('projects the session-action dispatcher into archive only', async () => {
const doc = await fetchOpenApi();
const paths = asRecord(doc['paths']);
// v2 only registers ::archive — the generic `{tail}` path must be gone and
// the v1-only actions must not be fabricated.
expect(paths['/api/v1/sessions/{tail}']).toBeUndefined();
expect(paths['/api/v1/sessions/{session_id}:archive']).toBeDefined();
expect(paths['/api/v1/sessions/{session_id}:fork']).toBeUndefined();
expect(paths['/api/v1/sessions/{session_id}:undo']).toBeUndefined();
const archiveOp = operation(doc, '/api/v1/sessions/{session_id}:archive', 'post');
expect(archiveOp['operationId']).toBe('runSessionArchiveAction');
const params = archiveOp['parameters'] as Array<Record<string, unknown>>;
expect(params.some((p) => p['in'] === 'path' && p['name'] === 'session_id')).toBe(true);
expect(params.some((p) => p['name'] === 'tail')).toBe(false);
});
it('describes the file upload as multipart/form-data', async () => {
const doc = await fetchOpenApi();
const uploadOp = operation(doc, '/api/v1/files', 'post');
const requestBody = asRecord(uploadOp['requestBody']);
const content = asRecord(requestBody['content']);
expect(content['multipart/form-data']).toBeDefined();
});
it('represents the fs-action dispatcher as a oneOf union', async () => {
const doc = await fetchOpenApi();
const fsActionOp = operation(doc, '/api/v1/sessions/{session_id}/{tail}', 'post');
const requestBody = asRecord(fsActionOp['requestBody']);
const content = asRecord(requestBody['content']);
const json = asRecord(content['application/json']);
const schema = asRecord(json['schema']);
expect(Array.isArray(schema['oneOf'])).toBe(true);
});
});
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
throw new Error('expected object');
}
return value as Record<string, unknown>;
}
function operation(
doc: Record<string, unknown>,
path: string,
method: string,
): Record<string, unknown> {
const paths = asRecord(doc['paths']);
const pathItem = asRecord(paths[path]);
return asRecord(pathItem[method]);
}

3
pnpm-lock.yaml generated
View file

@ -675,6 +675,9 @@ importers:
'@fastify/multipart':
specifier: ^10.0.0
version: 10.0.0
'@fastify/swagger':
specifier: ^9.7.0
version: 9.7.0
'@moonshot-ai/agent-core-v2':
specifier: workspace:^
version: link:../agent-core-v2