Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/kap-server-meta-experimental-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kap-server": patch
---

Expose the effective experimental-flag map as `experimental_flags` on `GET /api/v1/meta`.
11 changes: 11 additions & 0 deletions packages/kap-server/src/protocol/rest-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ export const metaResponseSchema = z.object({
* credential. Defaults to false on hardened boots.
*/
dangerous_bypass_auth: z.boolean(),
/**
* Effective experimental-flag state (flag id → enabled), resolved by the
* FlagService from every source (master env, per-flag env, the
* `[experimental]` config section, defaults). This is the *effective* state,
* not the persisted config section: a flag enabled via
* `KIMI_CODE_EXPERIMENTAL_*` env appears here but not in `GET /config`'s
* `experimental` map. Computed per request — config-section writes flip it
* live. Clients use it to gate experimental UI; older servers omit it, so
* treat absence as "no flags enabled".
*/
experimental_flags: z.record(z.string(), z.boolean()).optional(),
/**
* Backend engine generation serving this API. `'v2'` is the DI × Scope
* engine (`@moonshot-ai/kap-server` / `agent-core-v2`); older servers omit
Expand Down
18 changes: 16 additions & 2 deletions packages/kap-server/src/routes/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
* 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.
* **No DI for the static fields**: pure server-self info; that part of the
* payload is frozen at registration time. `experimental_flags` is the
* exception — flag state flips live when the `[experimental]` config section
* changes, so it is resolved per request through the injected getter.
*/

import { okEnvelope } from '../envelope';
Expand Down Expand Up @@ -39,10 +42,17 @@ export interface MetaRouteOptions {
* the web UI can skip the token prompt and connect without a credential.
*/
readonly dangerousBypassAuth: boolean;
/**
* Resolves the effective experimental-flag map (flag id → enabled) at
* request time. Backed by `IFlagService.snapshot()` in production; tests may
* stub it. May return a promise — the handler awaits it, so flag state
* always reflects the fully loaded config (never pre-load defaults).
*/
readonly getExperimentalFlags: () => Record<string, boolean> | Promise<Record<string, boolean>>;
}

export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void {
const data: MetaResponse = Object.freeze({
const staticData = Object.freeze({
server_version: opts.serverVersion,
capabilities: Object.freeze({
websocket: true as const,
Expand All @@ -68,6 +78,10 @@ export function registerMetaRoute(app: RouteHost, opts: MetaRouteOptions): void
tags: ['meta'],
},
async (req, reply) => {
const data: MetaResponse = {
...staticData,
experimental_flags: await opts.getExperimentalFlags(),
};
reply.send(okEnvelope(data, req.id));
},
);
Expand Down
12 changes: 11 additions & 1 deletion packages/kap-server/src/routes/registerApiV1Routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
* folder picker, the session filesystem, terminals, connections, shutdown).
*/

import type { Scope } from '@moonshot-ai/agent-core-v2';
import { IConfigService, type Scope } from '@moonshot-ai/agent-core-v2';
import { IFlagService } from '@moonshot-ai/agent-core-v2/app/flag/flag';
import type { KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';
import { ulid } from 'ulid';

Expand Down Expand Up @@ -105,6 +106,15 @@ export async function registerApiV1Routes(
serverId: ulid(),
startedAt: new Date().toISOString(),
dangerousBypassAuth: opts.dangerousBypassAuth === true,
getExperimentalFlags: async () => {
// Same edge-facade contract as the config route: never project
// config-derived state before the initial load settles — an early
// /meta hit would otherwise advertise default/env-only flags and
// hide config-enabled features until the FlagService's change
// watcher catches up.
await core.accessor.get(IConfigService).ready;
return core.accessor.get(IFlagService).snapshot();
},
});

registerAuthRoute(apiV1 as unknown as Parameters<typeof registerAuthRoute>[0], core);
Expand Down
126 changes: 126 additions & 0 deletions packages/kap-server/test/meta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* `/api/v1/meta` tests — the static server-self fields are covered by
* `boot.test.ts`; these focus on `experimental_flags`, the effective
* experimental-flag map resolved per request from every flag source (env,
* master env, the `[experimental]` config section, defaults).
*/

import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { type RunningServer, startServer } from '../src/start';
import { TEST_HOST_IDENTITY } from './helpers/hostIdentity';
import { authedFetch } from './helpers/auth';

interface MetaBody {
code: number;
data: { experimental_flags?: Record<string, boolean> };
}

describe('/api/v1/meta experimental_flags', () => {
let server: RunningServer | undefined;
let home: string | undefined;

beforeEach(() => {
// Neutralize flag env vars leaking from the developer shell (e.g. a
// globally exported KIMI_CODE_EXPERIMENTAL_FLAG=1) so each test starts
// from the default-off baseline. The master env can be pinned to '0' (it
// only forces ON), but the per-flag env must be fully ABSENT — an
// explicit '0' is an env override that outranks the config section.
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0');
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', undefined);
});

afterEach(async () => {
vi.unstubAllEnvs();
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
// The query-store cache can still be flushing while we clean up; retry
// instead of flaking on ENOTEMPTY.
await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
home = undefined;
}
});

async function boot(toml?: string): Promise<string> {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-meta-'));
if (toml !== undefined) {
await writeFile(join(home, 'config.toml'), toml, 'utf-8');
}
server = await startServer({
hostIdentity: TEST_HOST_IDENTITY,
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
});
return `http://127.0.0.1:${server.port}`;
}

async function getMetaFlags(base: string): Promise<Record<string, boolean>> {
const res = await authedFetch(server as RunningServer, base, '/api/v1/meta');
expect(res.status).toBe(200);
const body = (await res.json()) as MetaBody;
expect(body.code).toBe(0);
expect(body.data.experimental_flags).toBeDefined();
return body.data.experimental_flags as Record<string, boolean>;
}

it('reports registered flags as off by default', async () => {
const base = await boot();
const flags = await getMetaFlags(base);
expect(flags['secondary-model']).toBe(false);
});

it('reports a config-enabled flag from the very first response', async () => {
// Regression for the startup race: FlagService reads the `[experimental]`
// section from a config that loads asynchronously, so the handler awaits
// IConfigService.ready before snapshotting — a persisted flag must be
// visible even to the earliest request.
const base = await boot('[experimental]\nsecondary-model = true\n');
const flags = await getMetaFlags(base);
expect(flags['secondary-model']).toBe(true);
});

it('reflects a flag enabled via its KIMI_CODE_EXPERIMENTAL_* env var', async () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', '1');
const base = await boot();
const flags = await getMetaFlags(base);
expect(flags['secondary-model']).toBe(true);
});

it('flips live when the [experimental] config section is written via POST /config', async () => {
const base = await boot();
expect((await getMetaFlags(base))['secondary-model']).toBe(false);

const res = await authedFetch(server as RunningServer, base, '/api/v1/config', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ experimental: { 'secondary-model': true } }),
});
expect(res.status).toBe(200);

expect((await getMetaFlags(base))['secondary-model']).toBe(true);
});

it('keeps an env-forced flag on when the config section disables it', async () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL', '1');
const base = await boot();

const res = await authedFetch(server as RunningServer, base, '/api/v1/config', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ experimental: { 'secondary-model': false } }),
});
expect(res.status).toBe(200);

// Env outranks the config section in FlagService resolution.
expect((await getMetaFlags(base))['secondary-model']).toBe(true);
});
});
Loading