feat(server-v2): add minimal server wired to agent-core-v2 DI

Add `packages/server-v2`, a minimal Fastify server bootstrapped through the
`agent-core-v2` DI engine (`bootstrap()` -> Core `Scope`), speaking the same
`/api/v1` interface as the v1 server. Route handlers resolve Core services
via `core.accessor.get(IXxx)`.

- Serve healthz, meta, auth, oauth (login poll/start/cancel/logout) and
  shutdown with the standard `{code, msg, data, request_id}` envelope.
- Re-point root `pnpm dev:server` to server-v2 and add a `src/main.ts`
  foreground entry.
- Add `build/hash-imports-loader.mjs` so tsx resolves `agent-core-v2`'s `#/`
  array-fallback subpath imports.
- `agent-core-v2` Phase 0 fixes so it compiles and boots: add the `event`
  domain (`IEventService`), fix `gateway` `event.subscribe` -> `event.on`, fix
  the `wireRecord` fallback `BlobStoreService` construction, export the
  `provider` barrel, and fix a `tooldedup` test import casing.
This commit is contained in:
haozhe.yang 2026-06-28 17:26:39 +08:00
parent ca5aea91b6
commit 4e7209394c
35 changed files with 1748 additions and 7 deletions

View file

@ -0,0 +1,79 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
/**
* Node ESM `resolve` hook: correctly resolve `#/` subpath imports against the
* importing package's own `package.json` `imports` field including array
* fallbacks such as `"#/*": ["./src/*.ts", "./src/<name>/index.ts"]`.
*
* `tsx` (its resolver) only honors the first array element and therefore breaks
* on directory-style `#/` imports (for example `#/_base/errors`
* `_base/errors/index.ts`). This loader short-circuits `#/` resolution before
* tsx sees it, mirroring the Vite `hashImportsPlugin` used by the v2 tests.
*/
const pkgCache = new Map();
function findPackageJson(fromFileUrl) {
let dir = dirname(fileURLToPath(fromFileUrl));
for (;;) {
const candidate = join(dir, 'package.json');
if (existsSync(candidate)) return candidate;
const parent = dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
}
function readPackage(pkgPath) {
let pkg = pkgCache.get(pkgPath);
if (pkg === undefined) {
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
pkgCache.set(pkgPath, pkg);
}
return pkg;
}
function resolveTarget(pkgDir, target, rest) {
const resolved = rest === undefined ? target : target.replace('*', rest);
const full = join(pkgDir, resolved);
return existsSync(full) ? pathToFileURL(full).href : undefined;
}
function resolveHashImport(specifier, parentURL) {
if (parentURL === undefined) return undefined;
const pkgPath = findPackageJson(parentURL);
if (pkgPath === undefined) return undefined;
const imports = readPackage(pkgPath).imports;
if (imports === undefined) return undefined;
const pkgDir = dirname(pkgPath);
for (const [key, raw] of Object.entries(imports)) {
if (!key.startsWith('#')) continue;
const targets = Array.isArray(raw) ? raw : [raw];
if (key.endsWith('*')) {
const prefix = key.slice(0, -1);
if (!specifier.startsWith(prefix)) continue;
const rest = specifier.slice(prefix.length);
for (const target of targets) {
const url = resolveTarget(pkgDir, target, rest);
if (url !== undefined) return url;
}
} else if (specifier === key) {
for (const target of targets) {
const url = resolveTarget(pkgDir, target, undefined);
if (url !== undefined) return url;
}
}
}
return undefined;
}
export async function resolve(specifier, context, nextResolve) {
if (specifier.startsWith('#/')) {
const url = resolveHashImport(specifier, context.parentURL);
if (url !== undefined) return { url, shortCircuit: true };
}
return nextResolve(specifier, context);
}

View file

@ -0,0 +1,8 @@
import { register } from 'node:module';
/**
* Registers the `#/` subpath-import resolver. Pass to Node via `--import`
* (alongside tsx) so source-executed v2 code resolves directory-style `#/`
* imports (array fallback) that tsx's resolver mishandles.
*/
register('./hash-imports-loader.mjs', import.meta.url);

View file

@ -66,6 +66,7 @@
./packages/agent-core
./packages/agent-core-v2
./packages/server
./packages/server-v2
./packages/server-e2e
./packages/kaos
./packages/kimi-migration-legacy
@ -89,6 +90,7 @@
"@moonshot-ai/agent-core"
"@moonshot-ai/agent-core-v2"
"@moonshot-ai/server"
"@moonshot-ai/server-v2"
"@moonshot-ai/server-e2e"
"@moonshot-ai/kaos"
"@moonshot-ai/kosong"

View file

@ -10,7 +10,7 @@
"build:packages": "pnpm -r --filter './packages/*' run build",
"dev:cli": "pnpm -C apps/kimi-code run dev",
"dev:web": "pnpm -C apps/kimi-web run dev",
"dev:server": "pnpm -C apps/kimi-code run dev:server",
"dev:server": "pnpm -C packages/server-v2 run dev",
"build:plugin-marketplace": "pnpm -C apps/kimi-code run build:plugin-marketplace",
"vis": "pnpm -C apps/vis run dev",
"dev:docs": "pnpm -C docs install --ignore-workspace && pnpm -C docs run dev",

View file

@ -0,0 +1,23 @@
/**
* `event` domain (L0) process-wide pub/sub event bus contract.
*
* Defines `IEventService`, a minimal type-tagged event bus used by business
* domains to broadcast facts (for example session lifecycle changes) to an
* unknown set of consumers. Bound at Core scope; a single global instance.
*/
import { createDecorator, type IDisposable, type ServiceIdentifier } from '#/_base/di';
export interface DomainEvent {
readonly type: string;
readonly payload: unknown;
}
export interface IEventService {
readonly _serviceBrand: undefined;
publish(event: DomainEvent): void;
subscribe(handler: (event: DomainEvent) => void): IDisposable;
}
export const IEventService: ServiceIdentifier<IEventService> =
createDecorator<IEventService>('eventService');

View file

@ -0,0 +1,35 @@
/**
* `event` domain (L0) `IEventService` implementation.
*
* Delivers published events to subscribers through the `_base/event` `Emitter`
* primitive. Bound at Core scope.
*/
import { Disposable, type IDisposable } from '#/_base/di';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { Emitter } from '#/_base/event';
import { type DomainEvent, IEventService } from './event';
export class EventService extends Disposable implements IEventService {
declare readonly _serviceBrand: undefined;
private readonly emitter = this._register(new Emitter<DomainEvent>());
publish(event: DomainEvent): void {
this.emitter.fire(event);
}
subscribe(handler: (event: DomainEvent) => void): IDisposable {
return this.emitter.event(handler);
}
}
registerScopedService(
LifecycleScope.Core,
IEventService,
EventService,
InstantiationType.Delayed,
'event',
);

View file

@ -0,0 +1,8 @@
/**
* `event` domain barrel re-exports the event bus contract (`event`) and its
* scoped service (`eventService`). Importing this barrel registers the
* `IEventService` binding into the scope registry.
*/
export * from './event';
export * from './eventService';

View file

@ -96,8 +96,10 @@ export class WSBroadcastService extends Disposable implements IWSBroadcastServic
constructor(@IEventSink event: IEventSink) {
super();
event.subscribe(() => {
});
this._register(
event.on(() => {
}),
);
}
}

View file

@ -15,6 +15,7 @@ export * from './kosong/index';
export * from './sessionIndex/index';
export * from './sessionMetaStore/index';
export * from './config/index';
export * from './provider/index';
import './skill/index';
export * from './permission/index';

View file

@ -14,6 +14,7 @@ import {
IBlobStoreService,
type BlobStoreServiceOptions,
} from '#/blobStore';
import { IHostFileSystem } from '#/hostFs';
import { IAppendLogStore } from '#/storage';
import { BlobStoreService } from '../blobStore/blobStoreService';
import { OrderedHookSlot } from '../hooks';
@ -62,6 +63,7 @@ export class WireRecordService extends Disposable implements IWireRecord {
private readonly options: WireRecordServiceOptions = {},
@IBlobStoreService injectedBlobStore?: IBlobStoreService,
@IAppendLogStore private readonly log?: IAppendLogStore,
@IHostFileSystem private readonly hostFs?: IHostFileSystem,
) {
super();
this.blobStore = this.resolveBlobStore(options, injectedBlobStore);
@ -328,12 +330,12 @@ export class WireRecordService extends Disposable implements IWireRecord {
injectedBlobStore: IBlobStoreService | undefined,
): IBlobStoreService | undefined {
if (options.blobStore !== undefined) return options.blobStore;
if (options.homedir === undefined) return injectedBlobStore;
if (options.homedir === undefined || this.hostFs === undefined) return injectedBlobStore;
const blobOptions: BlobStoreServiceOptions = {
blobsDir: join(options.homedir, 'blobs'),
};
return new BlobStoreService(blobOptions);
return new BlobStoreService(blobOptions, this.hostFs);
}
}

View file

@ -4,8 +4,8 @@ import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { ITelemetryService, type TelemetryProperties } from '#/telemetry/telemetry';
import { IToolDedupe, type ToolDedupResult } from '#/toolDedup/toolDedupe';
import { ToolDedupeService, __testing } from '#/toolDedup/toolDedupeService';
import { IToolDedupe, type ToolDedupResult } from '#/tooldedup/toolDedupe';
import { ToolDedupeService, __testing } from '#/tooldedup/toolDedupeService';
import { ITurnService } from '#/turn';
import { stubTurnWithHooks } from '../turn/stubs';

View file

@ -0,0 +1,32 @@
{
"name": "@moonshot-ai/server-v2",
"version": "0.0.0",
"private": true,
"description": "Kimi Code server backed by the DI × Scope agent engine (agent-core-v2)",
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "tsdown",
"dev": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs --import ../../build/register-hash-imports-loader.mjs ./src/main.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
"@moonshot-ai/agent-core-v2": "workspace:^",
"@moonshot-ai/protocol": "workspace:^",
"fastify": "^5.1.0",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"ulid": "^3.0.1",
"zod": "catalog:"
},
"devDependencies": {
"tsx": "^4.21.0"
}
}

9
packages/server-v2/src/env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
// Raw-string imports for prompt sources. `agent-core-v2` loads several prompt
// templates via `*.md?raw`; this declaration lets server-v2's typecheck process
// those transitive imports. Vite/Vitest handles `?raw` natively; tsdown uses the
// shared `raw-text-plugin` for the same import shape.
declare module '*?raw' {
const content: string;
export default content;
}

View file

@ -0,0 +1,12 @@
/**
* Re-export the envelope helpers from `@moonshot-ai/protocol`.
*
* The wire-shape source of truth lives in `@moonshot-ai/protocol`. Re-exporting
* the protocol helpers preserves field order and JSON output for server
* responses.
*
* Keep this file as a re-export shim (not a direct re-export from the package
* barrel) so downstream `from './envelope'` imports inside the server stay
* stable and don't all need to be touched.
*/
export { okEnvelope, errEnvelope, type Envelope } from '@moonshot-ai/protocol';

View file

@ -0,0 +1,51 @@
/**
* Fastify error hook for unhandled server exceptions.
*
* Wraps unhandled exceptions in the Feishu-style envelope:
* - HTTP status ALWAYS 200 (business outcome lives in `code`);
* - `code: 50001` (`internal.error`) for unknown exceptions;
* - `request_id` echoes the inbound request id (set by Fastify's
* `genReqId` via `resolveRequestId`);
* - `data: null`.
*
* Validation failures are handled by route-level middleware as 40001
* `validation.failed`; this handler remains the catch-all unknown-exception path.
*
* The handler logs `err` + the resolved `request_id` so operators can
* correlate log lines with the envelope returned to the client. This is the
* single place a stack trace ever crosses our process boundary into a log
* we never bleed it into the JSON response.
*/
import { errEnvelope, ErrorCode } from '@moonshot-ai/protocol';
import type { FastifyError } from 'fastify';
/**
* Loose Fastify-instance shape so this helper accepts both the default
* `FastifyInstance` and the server's pino-typed variant
* (`FastifyInstance<…, ServerLogger>`). The type checker chokes on the
* concrete generic mismatch otherwise.
*/
interface ErrorHandlerHost {
setErrorHandler(
handler: (
err: FastifyError,
req: { id: string; log: { error: (obj: object | string, msg?: string) => void } },
reply: { status(code: number): { send(payload: unknown): void } },
) => void,
): unknown;
}
export function installErrorHandler(app: ErrorHandlerHost): void {
app.setErrorHandler((err, req, reply) => {
const requestId = req.id;
req.log.error({ err, request_id: requestId }, 'unhandled error');
reply.status(200).send(
errEnvelope(
ErrorCode.INTERNAL_ERROR,
err.message !== undefined && err.message !== '' ? err.message : 'internal error',
requestId,
),
);
});
}

View file

@ -0,0 +1,15 @@
/**
* `@moonshot-ai/server-v2` public surface the Kimi Code server backed by the
* DI × Scope agent engine (`@moonshot-ai/agent-core-v2`).
*/
export { startServer } from './start';
export type { ServerStartOptions, RunningServer } from './start';
export { okEnvelope, errEnvelope } from './envelope';
export type { Envelope } from './envelope';
export { createServerLogger } from './services/pinoLoggerService';
export type {
CreateLoggerOptions,
ServerLogger,
ServerLogLevel,
} from './services/pinoLoggerService';

View file

@ -0,0 +1,94 @@
/**
* CLI entry for `pnpm dev:server` boots server-v2 (the `@moonshot-ai/agent-core-v2`
* DI engine) in the foreground and blocks until SIGINT/SIGTERM.
*
* Flags: `--port <n>` (default 58627), `--host <h>` (default 127.0.0.1),
* `--log-level <level>` (default info).
*/
import type { ServerLogLevel } from './services/pinoLoggerService';
import { type RunningServer, startServer } from './start';
interface CliOptions {
readonly host?: string;
readonly port?: number;
readonly logLevel?: ServerLogLevel;
}
const LOG_LEVELS: readonly ServerLogLevel[] = [
'fatal',
'error',
'warn',
'info',
'debug',
'trace',
'silent',
];
function parseArgs(argv: readonly string[]): CliOptions {
let host: string | undefined;
let port: number | undefined;
let logLevel: ServerLogLevel | undefined;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = argv[i + 1];
if ((arg === '--host' || arg === '-h') && next !== undefined) {
host = next;
i++;
} else if ((arg === '--port' || arg === '-p') && next !== undefined) {
const n = Number(next);
if (Number.isInteger(n) && n >= 0 && n <= 65535) port = n;
i++;
} else if (
arg === '--log-level' &&
next !== undefined &&
(LOG_LEVELS as readonly string[]).includes(next)
) {
logLevel = next as ServerLogLevel;
i++;
}
}
return { host, port, logLevel };
}
async function main(): Promise<never> {
const opts = parseArgs(process.argv.slice(2));
const server: RunningServer = await startServer({
host: opts.host,
port: opts.port,
logLevel: opts.logLevel ?? 'info',
});
const origin = `http://${server.host}:${server.port}`;
process.stdout.write(`Kimi server (v2) ready at ${origin}\n`);
let stopping = false;
const shutdown = async (): Promise<void> => {
if (stopping) return;
stopping = true;
try {
await server.close();
} catch {
// best-effort
}
process.exit(0);
};
process.once('SIGINT', () => {
void shutdown();
});
process.once('SIGTERM', () => {
void shutdown();
});
return new Promise<never>(() => {
// Keeps the event loop alive; the process ends via shutdown()/process.exit.
});
}
main().catch((error: unknown) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});

View file

@ -0,0 +1,314 @@
/**
* `defineRoute` single-source-of-truth route declaration helper.
*
* One object declares **both** the runtime Zod validators (preHandler) and the
* Swagger/OpenAPI response schema. The 200-response is automatically expanded
* into a `oneOf` union that covers the success envelope (code: 0) and every
* declared error envelope (code: 4xxxx / 5xxxx) with its precise `details`
* shape.
*
* Path params use OpenAPI `{param}` syntax; the helper converts them to
* Fastify `:param` syntax internally.
*
* Example:
* ```ts
* const route = defineRoute({
* method: 'POST',
* path: '/sessions/{session_id}/prompts',
* body: promptSubmissionSchema,
* params: sessionIdParamSchema,
* success: { data: promptSubmitResultSchema },
* errors: {
* 40001: { detailsSchema: z.array(z.object({ path: z.string(), message: z.string() })) },
* 40110: {},
* 40111: { detailsSchema: z.object({ provider_id: z.string() }) },
* 40113: { detailsSchema: z.object({ model_id: z.string() }).partial() },
* 40401: {},
* // Errors that carry non-null data (e.g. idempotent conflicts):
* // 40903: { dataSchema: z.object({ aborted: z.literal(false) }) },
* },
* description: 'Submit a prompt to a session',
* tags: ['prompts'],
* }, async (req, reply) => {
* // req.body → PromptSubmission (inferred from promptSubmissionSchema)
* // req.params → { session_id: string } (inferred from sessionIdParamSchema)
* });
*
* app.post(route.path, route.options, route.handler);
* ```
*/
import { z } from 'zod';
import { jsonSchema, openApiDocumentJsonSchema } from './schema';
import { validateBody, validateParams, validateQuery } from './validate';
// ---------------------------------------------------------------------------
// Path conversion
// ---------------------------------------------------------------------------
/** Convert OpenAPI `{param}` segments to Fastify `:param` segments. */
function toFastifyPath(openApiPath: string): string {
return openApiPath.replace(/\{([^}]+)\}/g, ':$1');
}
// ---------------------------------------------------------------------------
// Schema builders
// ---------------------------------------------------------------------------
/** Build a Zod schema for an error envelope with a specific code. */
function buildErrorEnvelopeSchema(
code: number,
dataSchema: z.ZodTypeAny = z.null(),
detailsSchema?: z.ZodTypeAny,
): z.ZodTypeAny {
const base = z.object({
code: z.literal(code),
msg: z.string(),
data: dataSchema,
request_id: z.string(),
});
if (detailsSchema) {
return base.extend({
details: detailsSchema.nullable().optional(),
});
}
return base.extend({
details: z.unknown().optional(),
});
}
/** Build a Zod schema for the success envelope (code: 0). */
function buildSuccessEnvelopeSchema(successDataSchema: z.ZodTypeAny): z.ZodTypeAny {
return z.object({
code: z.literal(0),
msg: z.string(),
data: successDataSchema,
request_id: z.string(),
details: z.unknown().optional(),
});
}
/**
* Build the unified 200-response schema.
*
* When error variants are present: returns a `oneOf` array where each variant
* is an OpenAPI 3 schema object (success first, then errors in ascending code
* order).
*
* When no error variants are present: returns the success envelope schema
* directly for simpler Swagger output and backward compatibility with tests
* that expect a plain schema object.
*/
function buildUnifiedResponseSchema(
successDataSchema: z.ZodTypeAny,
errors: Record<number, { dataSchema?: z.ZodTypeAny; detailsSchema?: z.ZodTypeAny }>,
): Record<string, unknown> {
// Error variants — sorted by code for deterministic output
const errorEntries = Object.entries(errors)
.map(([code, cfg]) => [Number(code), cfg] as const)
.sort((a, b) => a[0] - b[0]);
// No errors → return the plain envelope schema (not wrapped in oneOf)
if (errorEntries.length === 0) {
return openApiDocumentJsonSchema(
buildSuccessEnvelopeSchema(successDataSchema),
'output',
);
}
const variants: Record<string, unknown>[] = [];
// Success variant
variants.push(
openApiDocumentJsonSchema(
buildSuccessEnvelopeSchema(successDataSchema),
'output',
),
);
for (const [code, cfg] of errorEntries) {
variants.push(
openApiDocumentJsonSchema(
buildErrorEnvelopeSchema(code, cfg.dataSchema, cfg.detailsSchema),
'output',
),
);
}
return { oneOf: variants };
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type InferZod<T extends z.ZodTypeAny | undefined> = T extends z.ZodTypeAny
? z.infer<T>
: unknown;
export interface DefineRouteOptions<
TBody extends z.ZodTypeAny | undefined,
TParams extends z.ZodTypeAny | undefined,
TQuery extends z.ZodTypeAny | undefined,
TSuccessData extends z.ZodTypeAny | undefined,
> {
/** HTTP method (used by the consumer for registration bookkeeping). */
method: string;
/** Route path with OpenAPI `{param}` syntax. */
path: string;
/** Request-body Zod schema. */
body?: TBody;
/** Route-params Zod schema. */
params?: TParams;
/** Query-string Zod schema. */
querystring?: TQuery;
/** Success payload schema (wrapped in envelope code:0 automatically). */
success?: { data: TSuccessData };
/**
* Error variants for the 200-response oneOf.
* Key = business error code.
* `dataSchema` defaults to `z.null()`; override for idempotent-conflict
* shapes such as `{code:40903, data:{aborted:false}}`.
* `detailsSchema` describes the structured error context when present.
*/
errors?: Record<number, { dataSchema?: z.ZodTypeAny; detailsSchema?: z.ZodTypeAny }>;
/**
* Raw response schemas that are NOT envelope-wrapped.
* Useful for binary-stream endpoints (e.g. file download).
*/
rawResponse?: Record<number, Record<string, unknown>>;
/** Swagger description. */
description?: string;
/** Swagger summary. */
summary?: string;
/** Swagger tags. */
tags?: string[];
/** Swagger operationId. */
operationId?: string;
/** Swagger consumes. */
consumes?: string[];
}
export interface RouteDefinition<
TBody extends z.ZodTypeAny | undefined,
TParams extends z.ZodTypeAny | undefined,
TQuery extends z.ZodTypeAny | undefined,
> {
method: string;
/** Fastify-style path (`:param`). */
path: string;
options: {
preHandler: unknown[];
schema: Record<string, unknown>;
};
handler: (
req: {
id: string;
body: InferZod<TBody>;
params: InferZod<TParams>;
headers: Record<string, unknown>;
} & (TQuery extends z.ZodTypeAny ? { query: InferZod<TQuery> } : {}),
reply: { send(payload: unknown): unknown },
) => Promise<void> | void;
}
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
/**
* Declare a route from a single Zod-based definition.
*
* Returns a `RouteDefinition` carrying:
* - `path` converted to Fastify `:param` syntax
* - `options` `preHandler` (runtime validation) + `schema` (Swagger)
* - `handler` typed request / reply callback
*
* The caller is responsible for registering the definition on the correct
* app verb, e.g. `app.post(route.path, route.options, route.handler)`.
*/
export function defineRoute<
TBody extends z.ZodTypeAny | undefined,
TParams extends z.ZodTypeAny | undefined,
TQuery extends z.ZodTypeAny | undefined,
TSuccessData extends z.ZodTypeAny | undefined,
>(
options: DefineRouteOptions<TBody, TParams, TQuery, TSuccessData>,
handler: RouteDefinition<TBody, TParams, TQuery>['handler'],
): RouteDefinition<TBody, TParams, TQuery> {
// -- runtime validators ----------------------------------------------------
const preHandler: unknown[] = [];
if (options.params) {
preHandler.push(validateParams(options.params));
}
if (options.body) {
preHandler.push(validateBody(options.body));
}
if (options.querystring) {
preHandler.push(validateQuery(options.querystring));
}
// -- swagger schema --------------------------------------------------------
const schema: Record<string, unknown> = {};
if (options.body) {
schema['body'] = jsonSchema(options.body);
}
if (options.params) {
schema['params'] = jsonSchema(options.params);
}
if (options.querystring) {
schema['querystring'] = jsonSchema(options.querystring);
}
const hasResponse =
options.success !== undefined ||
(options.errors !== undefined && Object.keys(options.errors).length > 0) ||
options.rawResponse !== undefined;
if (hasResponse) {
const responses: Record<string, unknown> = {};
if (options.success || options.errors) {
responses['200'] = buildUnifiedResponseSchema(
options.success?.data ?? z.null(),
options.errors ?? {},
);
}
if (options.rawResponse) {
for (const [code, rawSchema] of Object.entries(options.rawResponse)) {
responses[String(code)] = rawSchema;
}
}
schema['response'] = responses;
}
if (options.description) {
schema['description'] = options.description;
}
if (options.summary) {
schema['summary'] = options.summary;
}
if (options.tags) {
schema['tags'] = options.tags;
}
if (options.operationId) {
schema['operationId'] = options.operationId;
}
if (options.consumes) {
schema['consumes'] = options.consumes;
}
return {
method: options.method,
path: toFastifyPath(options.path),
options: { preHandler, schema },
handler,
};
}

View file

@ -0,0 +1,158 @@
/**
* Zod JSON Schema conversion helpers for Fastify `@fastify/swagger`.
*
* All server REST responses are wrapped in a uniform envelope
* `{ code, msg, data, request_id }`. The helpers here let route modules
* declare their OpenAPI schema by re-using the same Zod schemas that
* already drive runtime validation no second source of truth.
*/
import { envelopeSchema } from '@moonshot-ai/protocol';
import { z } from 'zod';
/**
* Convert a Zod schema to a plain JSON Schema object suitable for
* Fastify's `schema` option.
*
* We drop the top-level `$schema` key because Fastify/OpenAPI inline
* schemas don't need it.
*/
export function jsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
return jsonSchemaForTarget(schema, 'input', 'draft-7');
}
/**
* Convert a Zod schema to a response-side Fastify JSON Schema object.
*/
export function outputJsonSchema(schema: z.ZodTypeAny): Record<string, unknown> {
return jsonSchemaForTarget(schema, 'output', 'draft-7');
}
/**
* Convert a Zod schema directly to an OpenAPI 3 schema object.
*
* Fastify route schemas use draft-7 because Fastify validates/serializes with
* AJV; `@fastify/swagger` converts those schemas to OpenAPI. Post-processing
* hooks run after that conversion, so schemas inserted there must already use
* OpenAPI 3 semantics.
*/
export function openApiDocumentJsonSchema(
schema: z.ZodTypeAny,
io: 'input' | 'output' = 'input',
): Record<string, unknown> {
return jsonSchemaForTarget(schema, io, 'openapi-3.0');
}
function jsonSchemaForTarget(
schema: z.ZodTypeAny,
io: 'input' | 'output',
target: 'draft-7' | 'openapi-3.0',
): Record<string, unknown> {
const converted = z.toJSONSchema(schema, {
target,
io,
unrepresentable: 'any',
}) as Record<string, unknown>;
if (converted['$schema'] !== undefined) {
delete converted['$schema'];
}
return converted;
}
/**
* Wrap a data Zod schema in the server's envelope shape and return its
* JSON Schema representation.
*/
export function envelopeJsonSchema(
dataSchema: z.ZodTypeAny,
): Record<string, unknown> {
return outputJsonSchema(envelopeSchema(dataSchema));
}
export function openApiDocumentEnvelopeJsonSchema(
dataSchema: z.ZodTypeAny,
): Record<string, unknown> {
return openApiDocumentJsonSchema(envelopeSchema(dataSchema), 'output');
}
/**
* Build a Fastify route-schema bag from Zod schemas + metadata.
*
* All Zod fields are automatically converted via `jsonSchema()`.
* The `response` map values are also wrapped in envelopes unless you
* pass an explicit `rawResponse` option.
*/
export interface RouteSchemaOptions {
/** Request body Zod schema. */
body?: z.ZodTypeAny;
/** Query-string Zod schema. */
querystring?: z.ZodTypeAny;
/** Route params Zod schema. */
params?: z.ZodTypeAny;
/**
* Response schema map: status code Zod schema.
* Each schema is automatically wrapped in the envelope.
* Use `rawResponse` if you need an unwrapped schema (e.g. binary
* download success path).
*/
response?: Record<number, z.ZodTypeAny>;
/**
* Response schema map that is NOT envelope-wrapped.
* Useful for the `200` on binary-stream endpoints.
*/
rawResponse?: Record<number, Record<string, unknown>>;
description?: string;
summary?: string;
tags?: string[];
operationId?: string;
consumes?: string[];
produces?: string[];
}
export function buildRouteSchema(options: RouteSchemaOptions): Record<string, unknown> {
const schema: Record<string, unknown> = {};
if (options.body) {
schema['body'] = jsonSchema(options.body);
}
if (options.querystring) {
schema['querystring'] = jsonSchema(options.querystring);
}
if (options.params) {
schema['params'] = jsonSchema(options.params);
}
if (options.response || options.rawResponse) {
const responses: Record<string, unknown> = {};
if (options.response) {
for (const [code, zodSchema] of Object.entries(options.response)) {
responses[String(code)] = envelopeJsonSchema(zodSchema);
}
}
if (options.rawResponse) {
for (const [code, rawSchema] of Object.entries(options.rawResponse)) {
responses[String(code)] = rawSchema;
}
}
schema['response'] = responses;
}
if (options.description) {
schema['description'] = options.description;
}
if (options.summary) {
schema['summary'] = options.summary;
}
if (options.tags) {
schema['tags'] = options.tags;
}
if (options.operationId) {
schema['operationId'] = options.operationId;
}
if (options.consumes) {
schema['consumes'] = options.consumes;
}
if (options.produces) {
schema['produces'] = options.produces;
}
return schema;
}

View file

@ -0,0 +1,132 @@
/**
* Generic Zod body / query / params validators for Fastify routes.
*
* On validation success: the parsed value replaces the raw input on the
* request object so handlers operate on a `T`-typed payload (no need to
* re-parse). On failure: we send a `40001 validation.failed` envelope with a
* `details` array of `{path, message}` issues (REST.md §1.4) and return early
* the handler does not run.
*
* The envelope shape matches `errEnvelope(40001, ...)` with the extra
* `details` field at top level. REST.md §1.4 specifies `details` as a free
* field per code; for `40001` clients expect `Array<{path, message}>` so they
* can highlight the offending field. We extend `errEnvelope`'s output rather
* than reshape it this is the single error code that carries structured
* details so a one-off shape is acceptable.
*
* Note: route-level Zod failures land here at the preHandler stage and never
* reach the generic error hook, which only emits 50001 for unknown exceptions.
*/
import { ErrorCode } from '@moonshot-ai/protocol';
import type { z } from 'zod';
/**
* Minimal Fastify request/reply shapes keep our hook independent of the
* concrete generic parameters so it works against the server's pino-typed
* variant without TS friction.
*/
interface ValidationRequest {
id: string;
body?: unknown;
query?: unknown;
params?: unknown;
}
interface ValidationReply {
send(payload: unknown): unknown;
}
type PreHandlerHook = (
req: ValidationRequest,
reply: ValidationReply,
done: (err?: Error) => void,
) => void;
interface ValidationDetailItem {
path: string;
message: string;
}
function zodIssuesToDetails(error: z.ZodError): ValidationDetailItem[] {
return error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
}));
}
function buildValidationEnvelope(
details: ValidationDetailItem[],
requestId: string,
): {
code: number;
msg: string;
data: null;
request_id: string;
details: ValidationDetailItem[];
} {
const first = details[0];
const msg = first === undefined
? 'validation failed'
: first.path === ''
? first.message
: `${first.path}: ${first.message}`;
return {
code: ErrorCode.VALIDATION_FAILED,
msg,
data: null,
request_id: requestId,
details,
};
}
/**
* Build a Fastify `preHandler` that parses `req.body` against `schema`.
* On success, replaces `req.body` with the parsed value.
*/
export function validateBody<T>(schema: z.ZodType<T>): PreHandlerHook {
return (req, reply, done) => {
const result = schema.safeParse(req.body);
if (!result.success) {
reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id));
return;
}
req.body = result.data;
done();
};
}
/**
* Build a Fastify `preHandler` that parses `req.query` against `schema`.
* On success, replaces `req.query` with the parsed value.
*
* Fastify deserializes query strings as `Record<string, string>` so numeric
* fields arrive as strings. The schema is responsible for coercing
* (`z.coerce.number()` etc.) when needed; we don't pre-coerce here.
*/
export function validateQuery<T>(schema: z.ZodType<T>): PreHandlerHook {
return (req, reply, done) => {
const result = schema.safeParse(req.query);
if (!result.success) {
reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id));
return;
}
req.query = result.data;
done();
};
}
/**
* Build a Fastify `preHandler` that parses `req.params` against `schema`.
*/
export function validateParams<T>(schema: z.ZodType<T>): PreHandlerHook {
return (req, reply, done) => {
const result = schema.safeParse(req.params);
if (!result.success) {
reply.send(buildValidationEnvelope(zodIssuesToDetails(result.error), req.id));
return;
}
req.params = result.data;
done();
};
}

View file

@ -0,0 +1,21 @@
/**
* `request_id` resolution at the server's REST boundary.
*
* Delegates to `parseOrGenerateRequestId` from `@moonshot-ai/protocol`, which:
* - returns a bare 26-char ULID (no `req_` prefix);
* - validates client-supplied `X-Request-Id` is a real ULID and regenerates
* a fresh one on malformed input so operator logs do not carry
* attacker-controlled strings verbatim.
*/
import { parseOrGenerateRequestId } from '@moonshot-ai/protocol';
const REQUEST_ID_HEADER = 'x-request-id';
export function resolveRequestId(
headers: Record<string, string | string[] | undefined>,
): string {
const raw = headers[REQUEST_ID_HEADER];
const supplied = Array.isArray(raw) ? raw[0] : raw;
return parseOrGenerateRequestId(supplied);
}

View file

@ -0,0 +1,62 @@
/**
* `GET /auth` readiness probe.
*
* Single readiness signal that web/IDE clients hit on first paint to decide
* between onboarding vs. chat UI. Returns 200 + envelope regardless of provider
* state.
*
* v2's `IAuthSummaryService.summarize()` returns a per-provider `AuthStatus[]`
* (`{ loggedIn, provider? }`), which is a simpler model than the v1
* `AuthSummary` wire shape (`{ ready, providers_count, default_model,
* managed_provider }`). This handler projects the v2 snapshot onto the v1 wire
* shape: `ready` reflects any authenticated provider, `providers_count` counts
* the snapshot entries, `default_model` is `null` (v2 has no model catalog
* yet), and `managed_provider` surfaces the authenticated provider when present.
*/
import { IAuthSummaryService, type Scope } from '@moonshot-ai/agent-core-v2';
import { authSummarySchema } from '@moonshot-ai/protocol';
import type { AuthSummary } from '@moonshot-ai/protocol';
import { okEnvelope } from '../envelope';
import { defineRoute } from '../middleware/defineRoute';
interface RouteHost {
get(
path: string,
options: { schema?: Record<string, unknown> },
handler: (
req: { id: string },
reply: { send(payload: unknown): void },
) => Promise<void> | void,
): unknown;
}
export function registerAuthRoute(app: RouteHost, core: Scope): void {
const route = defineRoute(
{
method: 'GET',
path: '/auth',
success: { data: authSummarySchema },
description: 'Get server auth readiness snapshot',
tags: ['auth'],
},
async (req, reply) => {
const statuses = await core.accessor.get(IAuthSummaryService).summarize();
const authenticated = statuses.find((s) => s.loggedIn);
const firstNamed = statuses.find((s) => s.provider !== undefined);
const summary: AuthSummary = {
ready: authenticated !== undefined,
providers_count: statuses.length,
default_model: null,
managed_provider: authenticated?.provider !== undefined
? { name: authenticated.provider, status: 'authenticated' }
: firstNamed?.provider !== undefined
? { name: firstNamed.provider, status: 'unauthenticated' }
: null,
};
reply.send(okEnvelope(summary, req.id));
},
);
app.get(route.path, route.options, route.handler as Parameters<RouteHost['get']>[2]);
}

View file

@ -0,0 +1,69 @@
/**
* `GET /meta` route handler.
*
* Returns `server_version`, the declared `capabilities` map, a per-process
* `server_id` (ULID minted at boot), and `started_at`.
*
* **Capabilities**: the wire schema (`metaCapabilitiesSchema`) only permits the
* literal `true` for each capability, so this mirrors the v1 response exactly to
* keep the interface unchanged. server-v2 v0.1 does not yet back every
* capability (no WebSocket / file upload / fs query / mcp / terminal); clients
* must treat unbacked capabilities as not-yet-available until the corresponding
* routes are wired.
*
* **No DI**: pure server-self info; the payload is frozen at registration time.
*/
import { metaResponseSchema } from '@moonshot-ai/protocol';
import type { MetaResponse } from '@moonshot-ai/protocol';
import { okEnvelope } from '../envelope';
import { defineRoute } from '../middleware/defineRoute';
interface RouteHost {
get(
path: string,
options: { schema?: Record<string, unknown> },
handler: (
req: { id: string },
reply: { send(payload: unknown): void },
) => Promise<void> | void,
): unknown;
}
export interface MetaRouteOptions {
readonly serverVersion: string;
readonly serverId: string;
readonly startedAt: string;
}
export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void {
const data: MetaResponse = Object.freeze({
server_version: opts.serverVersion,
capabilities: Object.freeze({
websocket: true as const,
file_upload: true as const,
fs_query: true as const,
mcp: true as const,
background_tasks: true as const,
terminal: true as const,
}),
server_id: opts.serverId,
started_at: opts.startedAt,
open_in_apps: [],
});
const route = defineRoute(
{
method: 'GET',
path: '/meta',
success: { data: metaResponseSchema },
description: 'Get server metadata',
tags: ['meta'],
},
async (req, reply) => {
reply.send(okEnvelope(data, req.id));
},
);
app.get(route.path, route.options, route.handler as Parameters<RouteHost['get']>[2]);
}

View file

@ -0,0 +1,145 @@
/**
* `/oauth/*` REST routes.
*
* POST /oauth/login start a device-code flow OAuthFlowStart
* GET /oauth/login poll current flow state OAuthFlowSnapshot | null
* DELETE /oauth/login cancel pending flow { cancelled, status }
* POST /oauth/logout logout { logged_out, provider }
*
* Backed by the v2 `IOAuthService` (Core scope), which already returns the
* protocol wire types, so the handlers only swap the v1 accessor
* (`ix.invokeFunction`) for the v2 one (`core.accessor.get`).
*/
import { IOAuthService, type Scope } from '@moonshot-ai/agent-core-v2';
import {
oauthFlowSnapshotSchema,
oauthFlowStartSchema,
oauthLoginCancelResponseSchema,
oauthLoginQuerySchema,
oauthLoginStartRequestSchema,
oauthLogoutRequestSchema,
oauthLogoutResponseSchema,
} from '@moonshot-ai/protocol';
import { z } from 'zod';
import { okEnvelope } from '../envelope';
import { defineRoute } from '../middleware/defineRoute';
interface RouteHost {
get(
path: string,
options: { preHandler?: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; query: unknown },
reply: { send(payload: unknown): void },
) => Promise<void> | void,
): unknown;
post(
path: string,
options: { preHandler?: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; body: unknown },
reply: { send(payload: unknown): void },
) => Promise<void> | void,
): unknown;
delete(
path: string,
options: { preHandler?: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; query: unknown },
reply: { send(payload: unknown): void },
) => Promise<void> | void,
): unknown;
}
const oauthFlowSnapshotOrNullSchema = z.union([
oauthFlowSnapshotSchema,
z.null(),
]);
export function registerOAuthRoutes(app: RouteHost, core: Scope): void {
// POST /oauth/login — start device flow ----------------------------------
const loginStartRoute = defineRoute(
{
method: 'POST',
path: '/oauth/login',
body: oauthLoginStartRequestSchema,
success: { data: oauthFlowStartSchema },
description: 'Start an OAuth device-code flow',
tags: ['auth'],
},
async (req, reply) => {
const result = await core.accessor.get(IOAuthService).startLogin(req.body.provider);
reply.send(okEnvelope(result, req.id));
},
);
app.post(
loginStartRoute.path,
loginStartRoute.options,
loginStartRoute.handler as Parameters<RouteHost['post']>[2],
);
// GET /oauth/login — poll current flow state -----------------------------
const loginPollRoute = defineRoute(
{
method: 'GET',
path: '/oauth/login',
querystring: oauthLoginQuerySchema,
success: { data: oauthFlowSnapshotOrNullSchema },
description: 'Poll the current OAuth device-code flow',
tags: ['auth'],
},
async (req, reply) => {
const snapshot = core.accessor.get(IOAuthService).getFlow(req.query.provider);
reply.send(okEnvelope(snapshot ?? null, req.id));
},
);
app.get(
loginPollRoute.path,
loginPollRoute.options,
loginPollRoute.handler as Parameters<RouteHost['get']>[2],
);
// DELETE /oauth/login — cancel pending flow ------------------------------
const loginCancelRoute = defineRoute(
{
method: 'DELETE',
path: '/oauth/login',
querystring: oauthLoginQuerySchema,
success: { data: oauthLoginCancelResponseSchema },
description: 'Cancel the current OAuth device-code flow',
tags: ['auth'],
},
async (req, reply) => {
const result = await core.accessor.get(IOAuthService).cancelLogin(req.query.provider);
reply.send(okEnvelope(result, req.id));
},
);
app.delete(
loginCancelRoute.path,
loginCancelRoute.options,
loginCancelRoute.handler as Parameters<RouteHost['delete']>[2],
);
// POST /oauth/logout -----------------------------------------------------
const logoutRoute = defineRoute(
{
method: 'POST',
path: '/oauth/logout',
body: oauthLogoutRequestSchema,
success: { data: oauthLogoutResponseSchema },
description: 'Logout the managed OAuth provider',
tags: ['auth'],
},
async (req, reply) => {
const result = await core.accessor.get(IOAuthService).logout(req.body.provider);
reply.send(okEnvelope(result, req.id));
},
);
app.post(
logoutRoute.path,
logoutRoute.options,
logoutRoute.handler as Parameters<RouteHost['post']>[2],
);
}

View file

@ -0,0 +1,87 @@
/**
* `/api/v1` route registration.
*
* Mirrors the v1 server's prefixing and per-module delegation, but resolves
* services from the `agent-core-v2` Core `Scope` instead of the v1 flat
* `IInstantiationService`. v0.1 mounts the subset of routes that v2 can serve
* end-to-end today (health, meta, auth readiness, OAuth device flow, shutdown).
*/
import type { Scope } from '@moonshot-ai/agent-core-v2';
import { ulid } from 'ulid';
import { okEnvelope } from '../envelope';
import { registerAuthRoute } from './auth';
import { registerMetaRoute } from './meta';
import { registerOAuthRoutes } from './oauth';
import { registerShutdownRoutes } from './shutdown';
interface ApiV1AppHost {
register(
plugin: (apiV1: ApiV1RouteHost) => Promise<void> | void,
opts: { prefix: string },
): unknown;
}
interface ApiV1RouteHost {
get(
path: string,
options: { schema?: Record<string, unknown> },
handler: (
req: { id: string },
reply: { send(payload: unknown): unknown },
) => unknown,
): unknown;
}
export interface RegisterApiV1RoutesOptions {
readonly serverVersion: string;
readonly debugEndpoints?: boolean;
readonly onShutdown: () => void;
}
export async function registerApiV1Routes(
app: ApiV1AppHost,
core: Scope,
opts: RegisterApiV1RoutesOptions,
): Promise<void> {
await app.register(async (apiV1) => {
registerHealthRoute(apiV1);
registerMetaRoute(apiV1, {
serverVersion: opts.serverVersion,
serverId: ulid(),
startedAt: new Date().toISOString(),
});
registerAuthRoute(apiV1 as unknown as Parameters<typeof registerAuthRoute>[0], core);
registerOAuthRoutes(apiV1 as unknown as Parameters<typeof registerOAuthRoutes>[0], core);
registerShutdownRoutes(apiV1 as unknown as Parameters<typeof registerShutdownRoutes>[0], {
onShutdown: opts.onShutdown,
});
}, { prefix: '/api/v1' });
}
function registerHealthRoute(apiV1: ApiV1RouteHost): void {
apiV1.get('/healthz', {
schema: {
description: 'Health check',
response: {
200: {
type: 'object',
properties: {
code: { type: 'number' },
msg: { type: 'string' },
data: {
type: 'object',
properties: { ok: { type: 'boolean' } },
},
request_id: { type: 'string' },
},
},
},
},
}, async (req, reply) => {
return reply.send(okEnvelope({ ok: true }, req.id));
});
}

View file

@ -0,0 +1,53 @@
/**
* `POST /shutdown` route handler.
*
* Gracefully terminates the server. The actual `close()` + scope disposal is
* supplied by `start.ts` via `onShutdown` so it stays next to the bootstrap
* (and remains overridable in tests). The handler replies before triggering
* shutdown so callers receive a clean 200 instead of a dropped connection.
*/
import { z } from 'zod';
import { okEnvelope } from '../envelope';
import { defineRoute } from '../middleware/defineRoute';
interface ShutdownRouteHost {
post(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
}
export interface ShutdownRouteOptions {
readonly onShutdown: () => void;
}
export function registerShutdownRoutes(
app: ShutdownRouteHost,
opts: ShutdownRouteOptions,
): void {
const route = defineRoute(
{
method: 'POST',
path: '/shutdown',
success: { data: z.object({ ok: z.literal(true) }) },
description: 'Gracefully shut down the server',
tags: ['meta'],
},
(req, reply) => {
reply.send(okEnvelope({ ok: true }, req.id));
// Let the response flush before tearing the server down.
setImmediate(() => opts.onShutdown());
},
);
app.post(
route.path,
route.options,
route.handler as Parameters<ShutdownRouteHost['post']>[2],
);
}

View file

@ -0,0 +1,42 @@
/**
* Pino logger factory for the server process.
*
* Produces the `ServerLogger` handed to Fastify via `loggerInstance`. Unlike the
* v1 server, server-v2 does not adapt this logger into the engine's
* `ILogService` `agent-core-v2` registers its own `ILogService` at Core scope,
* so the HTTP-layer logger stays a plain pino instance.
*/
import { pino, type Logger, type LoggerOptions } from 'pino';
import prettyStream from 'pino-pretty';
export type ServerLogger = Logger;
export type ServerLogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent';
export interface CreateLoggerOptions {
level: ServerLogLevel;
pretty?: boolean;
}
export function createServerLogger(opts: CreateLoggerOptions): ServerLogger {
const pretty = opts.pretty ?? process.stdout.isTTY === true;
const base: LoggerOptions = {
level: opts.level,
base: { name: 'kimi-server-v2' },
timestamp: pino.stdTimeFunctions.isoTime,
};
if (pretty) {
return pino(
base,
prettyStream({
colorize: true,
translateTime: 'SYS:HH:MM:ss.l o',
ignore: 'pid,hostname',
singleLine: false,
destination: process.stdout,
}),
);
}
return pino(base);
}

View file

@ -0,0 +1,83 @@
/**
* Server bootstrap wires `@moonshot-ai/agent-core-v2` (DI × Scope engine) into
* a Fastify HTTP server that speaks the same `/api/v1` interface as the v1
* server.
*
* Composition root: `bootstrap()` builds the Core `Scope`; route handlers resolve
* Core-scoped services through `core.accessor.get(IXxx)`.
*/
import { bootstrap, resolveConfigPath, resolveKimiHome, type Scope } from '@moonshot-ai/agent-core-v2';
import Fastify, { type FastifyInstance } from 'fastify';
import { installErrorHandler } from './error-handler';
import { registerApiV1Routes } from './routes/registerApiV1Routes';
import { resolveRequestId } from './request-id';
import {
createServerLogger,
type ServerLogger,
type ServerLogLevel,
} from './services/pinoLoggerService';
import { getServerVersion } from './version';
export interface ServerStartOptions {
readonly host?: string;
readonly port?: number;
readonly homeDir?: string;
readonly configPath?: string;
readonly logLevel?: ServerLogLevel;
readonly logger?: ServerLogger;
readonly debugEndpoints?: boolean;
}
export interface RunningServer {
readonly app: FastifyInstance;
readonly core: Scope;
readonly host: string;
readonly port: number;
close(): Promise<void>;
}
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_PORT = 58627;
export async function startServer(opts: ServerStartOptions = {}): Promise<RunningServer> {
const homeDir = resolveKimiHome(opts.homeDir);
const configPath = resolveConfigPath({ homeDir, configPath: opts.configPath });
const { core } = bootstrap({ homeDir, configPath });
const logger = opts.logger ?? createServerLogger({ level: opts.logLevel ?? 'info' });
const app = Fastify({
loggerInstance: logger,
disableRequestLogging: false,
genReqId: (req) => resolveRequestId(req.headers),
}) as unknown as FastifyInstance;
// Validation is performed by the route-level Zod preHandlers (defineRoute),
// not by Fastify's AJV layer — keep both compilers as pass-throughs.
app.setValidatorCompiler(() => () => true);
app.setSerializerCompiler(() => (data) => JSON.stringify(data));
installErrorHandler(app);
const close = async (): Promise<void> => {
await app.close();
core.dispose();
};
await registerApiV1Routes(app, core, {
serverVersion: getServerVersion(),
debugEndpoints: opts.debugEndpoints,
onShutdown: () => {
void close();
},
});
const host = opts.host ?? DEFAULT_HOST;
const port = opts.port ?? DEFAULT_PORT;
await app.listen({ host, port });
const address = app.server.address();
const boundPort = typeof address === 'object' && address !== null ? address.port : port;
return { app, core, host, port: boundPort, close };
}

View file

@ -0,0 +1,17 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
let cached: string | undefined;
export function getServerVersion(): string {
if (cached !== undefined) return cached;
try {
const pkgUrl = new URL('../package.json', import.meta.url);
const raw = readFileSync(fileURLToPath(pkgUrl), 'utf-8');
const pkg = JSON.parse(raw) as { version?: unknown };
cached = typeof pkg.version === 'string' ? pkg.version : '0.0.0';
} catch {
cached = '0.0.0';
}
return cached;
}

View file

@ -0,0 +1,75 @@
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 boot', () => {
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;
}
});
it('boots agent-core-v2 and serves the basic /api/v1 routes', async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-'));
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
});
const base = `http://127.0.0.1:${server.port}`;
const healthz = await fetch(`${base}/api/v1/healthz`);
expect(healthz.status).toBe(200);
const healthBody = await healthz.json() as {
code: number;
data: { ok: boolean };
request_id: string;
};
expect(healthBody.code).toBe(0);
expect(healthBody.data.ok).toBe(true);
expect(typeof healthBody.request_id).toBe('string');
const meta = await fetch(`${base}/api/v1/meta`);
expect(meta.status).toBe(200);
const metaBody = await meta.json() as {
code: number;
data: { server_id: string; server_version: string; capabilities: Record<string, boolean> };
};
expect(metaBody.code).toBe(0);
expect(typeof metaBody.data.server_id).toBe('string');
expect(typeof metaBody.data.server_version).toBe('string');
expect(metaBody.data.capabilities).toBeDefined();
const auth = await fetch(`${base}/api/v1/auth`);
expect(auth.status).toBe(200);
const authBody = await auth.json() as {
code: number;
data: { ready: boolean; providers_count: number; default_model: string | null };
};
expect(authBody.code).toBe(0);
expect(typeof authBody.data.ready).toBe('boolean');
expect(authBody.data.providers_count).toBeGreaterThanOrEqual(0);
// Poll with no flow in flight → null payload; exercises the v2 IOAuthService
// wiring without starting a real (networked) device-code flow.
const oauthPoll = await fetch(`${base}/api/v1/oauth/login`);
expect(oauthPoll.status).toBe(200);
const oauthBody = await oauthPoll.json() as { code: number; data: null };
expect(oauthBody.code).toBe(0);
expect(oauthBody.data).toBeNull();
});
});

View file

@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"experimentalDecorators": true
},
"include": [
"src",
"test",
"../../packages/*/src/**/*.ts",
"../../packages/*/src/**/*.tsx",
"../../packages/*/test/**/*.ts"
]
}

View file

@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"experimentalDecorators": true
},
"include": ["src", "test"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
entry: ['./src/index.ts'],
format: ['esm'],
dts: true,
outDir: 'dist',
clean: true,
});

View file

@ -0,0 +1,53 @@
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { defineConfig, type Plugin } from 'vitest/config';
import { rawTextPlugin } from '../../build/raw-text-plugin.mjs';
function findPackageRoot(importer: string | undefined): string | undefined {
if (!importer) return undefined;
let dir = dirname(importer.split('?')[0] ?? importer);
for (;;) {
if (existsSync(join(dir, 'package.json'))) return dir;
const parent = dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
}
/**
* Resolve `#/` subpath imports the way Node's package.json `imports` field does,
* scoped to the importer's owning package. server-v2 inlines
* `@moonshot-ai/agent-core-v2` source (and transitively kosong/kaos), whose
* internal `#/foo` imports must resolve against each package's own `src/`.
*
* Mirrors `packages/agent-core-v2/vitest.config.ts`.
*/
function hashImportsPlugin(): Plugin {
return {
name: 'resolve-hash-imports',
enforce: 'pre',
resolveId(id, importer) {
if (!id.startsWith('#/')) return null;
const pkgRoot = findPackageRoot(importer);
if (!pkgRoot) return null;
const sub = id.slice(2);
for (const candidate of [`src/${sub}.ts`, `src/${sub}/index.ts`]) {
const full = join(pkgRoot, candidate);
if (existsSync(full)) return full;
}
return null;
},
};
}
export default defineConfig({
// `rawTextPlugin` is required because server-v2 pulls in agent-core-v2's full
// barrel, which imports `*.md?raw` prompt templates.
plugins: [rawTextPlugin(), hashImportsPlugin()],
test: {
name: 'server-v2',
include: ['test/**/*.{test,e2e}.ts'],
},
});

28
pnpm-lock.yaml generated
View file

@ -658,6 +658,34 @@ importers:
specifier: ^8.18.0
version: 8.18.1
packages/server-v2:
dependencies:
'@moonshot-ai/agent-core-v2':
specifier: workspace:^
version: link:../agent-core-v2
'@moonshot-ai/protocol':
specifier: workspace:^
version: link:../protocol
fastify:
specifier: ^5.1.0
version: 5.8.5
pino:
specifier: ^9.5.0
version: 9.14.0
pino-pretty:
specifier: ^13.0.0
version: 13.1.3
ulid:
specifier: ^3.0.1
version: 3.0.2
zod:
specifier: 'catalog:'
version: 4.3.6
devDependencies:
tsx:
specifier: ^4.21.0
version: 4.21.0
packages/telemetry: {}
packages: