mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-02 04:54:53 +00:00
* refactor(oauth): make X-Msh-Platform an explicit host identity field X-Msh-Platform was hardcoded to kimi_code_cli in createKimiDeviceHeaders, so non-CLI hosts could not state their own platform and the desktop had to patch the header after the fact. KimiHostIdentity now carries a required platform (every host declares its own value; the CLI constant stays the fallback only for direct createKimiDeviceHeaders callers), and userAgentProduct is renamed to productName so the transport identity uses one name everywhere. All in-repo identity constructions pass platform explicitly; the wire value for CLI and VS Code hosts is unchanged (kimi_code_cli). * feat(agent-core-v2): carry the host identity in the bootstrap snapshot Replace the flat clientVersion field with a required clientIdentity (KimiHostIdentity) so every consumer reads the same host identity object: OAuthToolkitService now passes it to the OAuth toolkit, which means the OAuth device-flow endpoints (device authorization, token polling, refresh) on the kap-server path finally send the full X-Msh-* device headers instead of none, and the telemetry cloud appender reads client_version from the same source. A built-in CLI fallback keeps bare bootstrap() calls in tests working; composition roots must pass their own identity. The session export manifest grows an optional desktopVersion field (payload plumbed through; filled by kap-server in a follow-up). * feat(agent-core): thread the host identity into the managed auth facades The v1 managed auth facade constructed its OAuth toolkit without an identity, so token refreshes from inside the core went out without any X-Msh-* device headers. createManagedAuthFacade now takes an optional KimiHostIdentity and every call site supplies one: CoreProcessService._defaultOAuthTokenResolver forwards the core process's options.identity (the same source _defaultKimiRequestHeaders uses), and the DI-held services (oauth / auth summary / model catalog) read it from a new optional identity field on IEnvironmentService. The library-level "no identity, no device headers" contract is unchanged. * feat(kap-server)!: require the host identity and derive request headers from it ServerStartOptions.hostIdentity is now a required ServerHostIdentity (KimiHostIdentity + optional prompt display fields), replacing both the old optional HostIdentityOverrides (renamed to PromptIdentityOverrides, its productName field now displayName) and the version option (renamed to serverVersion — it is the engine version reported as server_version, while the host product version travels in hostIdentity.version). The server now feeds bootstrap's clientIdentity from hostIdentity and derives the default outbound headers (User-Agent + X-Msh-*) from it via createKimiDefaultHeaders, so kap-server-hosted OAuth flows and model / WebSearch requests carry the real host identity instead of a hardcoded kimi-code-cli fallback UA. Explicit header seeds still win as an escape hatch. Session export manifests record the host product version: kimiCodeVersion now carries hostIdentity.version (the engine version no longer appears), and desktop exports (desktop: true) are additionally stamped with a desktopVersion field. The instance registry keeps its host_version wire field for compatibility (kimi-inspect reads it); only the in-memory name changed to serverVersion. * feat(cli): wire the CLI host identity into the kimi web server kimi web now passes createKimiCodeHostIdentity(version) as the server's hostIdentity, so web-UI OAuth flows and the engine's outbound requests carry the explicit CLI identity (productName + version + platform). The explicit hostRequestHeadersSeed is dropped — kap-server derives the same headers from hostIdentity — and buildKimiDefaultHeaders goes away with its only consumer. * test(klient): drop clientVersion from the bootstrap contract parity list * chore: add changesets for the host identity unification * feat(cli): tag kimi web requests with a (web) User-Agent suffix kimi web shares the CLI product token and platform, so its outbound requests were indistinguishable from direct CLI runs upstream. Its host identity now carries userAgentSuffix 'web', putting web-UI traffic at kimi-code-cli/<version> (web) while X-Msh-Platform stays kimi_code_cli. * fix(klient): keep the env() clientVersion wire field after the bootstrap identity switch The bootstrap snapshot replaced the flat clientVersion scalar with clientIdentity, which broke klient's env() fan-out (RPCError: method not found). The wire surface keeps clientVersion — now sourced from clientIdentity.version — and bootstrapService gains a clientIdentity read (registered in envContract with an object schema) for consumers that want the full identity. * feat(oauth): send the product User-Agent on OAuth requests The OAuth endpoints used to receive only the X-Msh-* device headers (undici's default UA otherwise), which left the OAuth host unable to distinguish runtime surfaces — notably kimi web, whose platform matches the CLI and whose only distinguishing mark is the (web) UA suffix. The toolkit now feeds the full identity headers (User-Agent + X-Msh-*) into every device authorization, token polling, and refresh request; the request-header type widens from DeviceHeaders to OAuthRequestHeaders. * feat(vscode): report kimi_code_vscode as the extension's platform The VS Code extension inherited the CLI's hardcoded X-Msh-Platform value; with platform now an explicit identity field it declares its own, so the managed endpoints and OAuth host can tell extension traffic apart from CLI runs. * refactor(agent-core-v2)!: require the client identity at the composition root The bootstrap fallback identity fabricated a kimi-code-cli/unknown host for any caller that forgot to pass one — the same silent-misreport pattern this series set out to remove, and it made "required" a lie. BootstrapInput.clientIdentity is now required, so a missing identity fails at compile time instead of being papered over. Test and example callers pass a shared fixture (klient examples and test engines get one each); the node-sdk v2 client asserts its host identity with the oauth helper. Also folds DeviceHeaders from an interface into a type alias so it stays assignable to the widened OAuthRequestHeaders record. * feat(oauth)!: require and validate the platform in device headers Drops the quiet CLI fallback in createKimiDeviceHeaders (the same silent-misreport pattern removed from the bootstrap identity): platform is now a required option, validated with the same required-ASCII rule as the version — empty or all-non-ASCII values throw instead of emitting a blank X-Msh-Platform, and header-unsafe characters are stripped rather than sent raw. * fix(node-sdk): seed the host request headers on the v2 client path The interactive v2 engine path (experimental flag) bootstrapped without a hostRequestHeaders seed, so managed vendor calls went out with the SDK's default User-Agent (OpenAI/JS) and no X-Msh-* at all — v1 passes the full identity headers on the same requests. The v2 client now seeds the headers from its asserted host identity, and a test pins the seed. * chore: simplify the CLI changeset wording
209 lines
8.8 KiB
TypeScript
209 lines
8.8 KiB
TypeScript
/**
|
|
* Trace how the reported "Context size" evolves on a brand-new session after
|
|
* a single "hi" prompt, against an in-process engine over the memory
|
|
* transport.
|
|
*
|
|
* What gets sampled, all through the klient facade:
|
|
* - `agent.getContext()` → `{ history, tokenCount }` — `tokenCount` is the
|
|
* last MEASURED exchange total (`contextSize.get().measured` engine-side);
|
|
* it is 0 until the first LLM response lands and stays flat between turns.
|
|
* - `agent.getUsage()` → accumulated token usage (`byModel` / `currentTurn`
|
|
* / `total`), recorded per request.
|
|
* - `agent.status.updated` events — the live `contextTokens` / `usage`
|
|
* slices that feed the TUI footer.
|
|
*
|
|
* A 250 ms poll diffs (history length, tokenCount, usage.total) and prints a
|
|
* line only when something changed, so the output is a timeline of exactly
|
|
* when the Context size reading moves — and when it does NOT.
|
|
*
|
|
* A throwaway model is seeded into the engine's temp home (an in-process
|
|
* engine has no default model), so both env vars are required. Run it (the
|
|
* engine sources need the decorators tsconfig + raw-text loader):
|
|
* KIMI_EXAMPLE_MODEL=... KIMI_EXAMPLE_API_KEY=... \
|
|
* pnpm -C packages/klient exec tsx --tsconfig ./tsconfig.examples.json \
|
|
* --import ../../build/register-raw-text-loader.mjs examples/context-usage.ts
|
|
*
|
|
* Env:
|
|
* KIMI_EXAMPLE_MODEL — gateway model id to seed (required)
|
|
* KIMI_EXAMPLE_API_KEY — API key for the seeded model (required)
|
|
* KIMI_EXAMPLE_BASE_URL — optional gateway base URL for the seeded model
|
|
* KIMI_EXAMPLE_PROTOCOL — optional wire protocol for the seeded model (default `openai`)
|
|
*/
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { EXAMPLE_CLIENT_IDENTITY } from './identity.js';
|
|
|
|
|
|
import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2';
|
|
import { createKlient } from '@moonshot-ai/klient/memory';
|
|
|
|
const SEEDED_MODEL_ID = 'klient-example-model';
|
|
|
|
interface TokenUsage {
|
|
inputOther: number;
|
|
output: number;
|
|
inputCacheRead: number;
|
|
inputCacheCreation: number;
|
|
}
|
|
|
|
function usageTotal(usage: TokenUsage | undefined): number | undefined {
|
|
if (usage === undefined) return undefined;
|
|
return usage.inputOther + usage.output + usage.inputCacheRead + usage.inputCacheCreation;
|
|
}
|
|
|
|
const tick = (ms: number): Promise<void> =>
|
|
new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
|
|
async function main(): Promise<void> {
|
|
const seedModel = process.env['KIMI_EXAMPLE_MODEL'];
|
|
const seedKey = process.env['KIMI_EXAMPLE_API_KEY'];
|
|
if (seedModel === undefined || seedKey === undefined) {
|
|
throw new Error('KIMI_EXAMPLE_MODEL and KIMI_EXAMPLE_API_KEY are required (see header)');
|
|
}
|
|
|
|
const homeDir = await mkdtemp(join(tmpdir(), 'klient-context-usage-'));
|
|
const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [
|
|
...logSeed(resolveLoggingConfig({ homeDir, env: process.env })),
|
|
]);
|
|
try {
|
|
const klient = createKlient({ scope: app });
|
|
|
|
const session = await klient.global.sessions.create({ workDir: process.cwd() });
|
|
console.log('[session] created ->', session.id);
|
|
const agent = klient.session(session.id).agent('main');
|
|
|
|
await klient.global.kosong.addProvider({
|
|
id: SEEDED_MODEL_ID,
|
|
model: seedModel,
|
|
protocol: (process.env['KIMI_EXAMPLE_PROTOCOL'] ?? 'openai'),
|
|
baseUrl: process.env['KIMI_EXAMPLE_BASE_URL'] ?? 'http://127.0.0.1:1',
|
|
auth: { method: 'api-key', apiKey: seedKey },
|
|
maxContextSize: 262_144,
|
|
});
|
|
await agent.setModel(SEEDED_MODEL_ID);
|
|
console.log('[model] bound ->', await agent.getModel());
|
|
|
|
const startedAt = Date.now();
|
|
const elapsed = (): string => `+${String(Date.now() - startedAt).padStart(6)}ms`;
|
|
|
|
// Live status slices (what the TUI footer consumes), as they arrive.
|
|
agent.events.on('agent.status.updated', (event) => {
|
|
const slice: Record<string, unknown> = {};
|
|
if ('contextTokens' in event) slice['contextTokens'] = event['contextTokens'];
|
|
if ('maxContextTokens' in event) slice['maxContextTokens'] = event['maxContextTokens'];
|
|
if ('contextUsage' in event) slice['contextUsage'] = event['contextUsage'];
|
|
if ('phase' in event) slice['phase'] = event['phase'];
|
|
const usage = event['usage'] as { total?: TokenUsage } | undefined;
|
|
if (usage !== undefined) slice['usage.total'] = usageTotal(usage.total);
|
|
console.log(`[event] ${elapsed()} agent.status.updated ->`, JSON.stringify(slice));
|
|
});
|
|
agent.events.on('turn.started', (event) => {
|
|
console.log(`[event] ${elapsed()} turn.started -> turnId=${String(event.turnId)}`);
|
|
});
|
|
agent.events.on('turn.ended', (event) => {
|
|
console.log(`[event] ${elapsed()} turn.ended -> reason=${event.reason}`);
|
|
});
|
|
agent.events.on('error', (event) => {
|
|
console.log(`[event] ${elapsed()} error ->`, JSON.stringify(event));
|
|
});
|
|
agent.events.onError((error) => {
|
|
console.log(`[event-err] ${elapsed()} ${error.message.split('\n')[0] ?? error.message}`);
|
|
});
|
|
|
|
const completed = new Promise<'completed' | 'failed' | 'timeout'>((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
sub.dispose();
|
|
resolve('timeout');
|
|
}, 120_000);
|
|
const sub = agent.events.on('prompt.completed', (event) => {
|
|
clearTimeout(timer);
|
|
sub.dispose();
|
|
console.log(
|
|
`[event] ${elapsed()} prompt.completed -> reason=${event.reason ?? 'unknown'}`,
|
|
);
|
|
resolve(event.reason === 'failed' ? 'failed' : 'completed');
|
|
});
|
|
});
|
|
|
|
// Diff-polled snapshot of the RPC-visible readings.
|
|
let lastKey = '';
|
|
const snapshot = async (tag: string): Promise<void> => {
|
|
const [ctx, usage] = await Promise.all([agent.getContext(), agent.getUsage()]);
|
|
const total = usageTotal(usage.total);
|
|
const turn = usageTotal(usage.currentTurn);
|
|
const key = `${String(ctx.history.length)}/${String(ctx.tokenCount)}/${String(total)}/${String(turn)}`;
|
|
if (key === lastKey) return;
|
|
lastKey = key;
|
|
console.log(
|
|
`[poll] ${elapsed()} ${tag}`.padEnd(46),
|
|
`history=${String(ctx.history.length)} tokenCount(measured)=${String(ctx.tokenCount)}` +
|
|
` usage.total=${String(total)} usage.currentTurn=${String(turn)}`,
|
|
);
|
|
};
|
|
|
|
let polling = true;
|
|
const pollLoop = (async (): Promise<void> => {
|
|
while (polling) {
|
|
try {
|
|
await snapshot('');
|
|
} catch {
|
|
// transient RPC failure during the turn — keep polling
|
|
}
|
|
await tick(250);
|
|
}
|
|
})();
|
|
|
|
await snapshot('created (pre-prompt)');
|
|
console.log(`[prompt] ${elapsed()} sending "hi"`);
|
|
await agent.prompt({ input: [{ type: 'text', text: 'hi' }] });
|
|
|
|
const outcome = await completed;
|
|
polling = false;
|
|
await pollLoop;
|
|
lastKey = ''; // force the final line even if nothing moved since the last poll tick
|
|
await snapshot('after prompt.completed');
|
|
|
|
const ctx = await agent.getContext();
|
|
const usage = await agent.getUsage();
|
|
const total = usageTotal(usage.total);
|
|
console.log('---');
|
|
console.log('[result] outcome ->', outcome);
|
|
console.log('[result] history messages ->', ctx.history.length);
|
|
console.log('[result] tokenCount (measured) ->', ctx.tokenCount);
|
|
console.log('[result] usage.total ->', JSON.stringify(usage.total));
|
|
console.log('[result] usage.byModel ->', JSON.stringify(usage.byModel));
|
|
console.log(
|
|
`[check] tokenCount vs usage.total -> ${String(ctx.tokenCount)} vs ${String(total)}`,
|
|
);
|
|
console.log(
|
|
'[note] reading guide:\n' +
|
|
' - tokenCount is 0 until the first measured exchange lands, then it\n' +
|
|
' should equal THAT exchange\'s total (input + output); new messages\n' +
|
|
' appended between turns are the unmeasured tail.\n' +
|
|
' - after one covered exchange on a fresh session, cumulative\n' +
|
|
' usage.total and tokenCount should roughly agree; a large gap means\n' +
|
|
' the measured total never made it onto the wire model and the reading\n' +
|
|
' silently fell back to per-message estimates.\n' +
|
|
' - outcome "timeout" means the turn finished its work but the\n' +
|
|
' prompt.completed event never reached the client.',
|
|
);
|
|
console.log('[note] session left in the (disposed) temp home ->', session.id);
|
|
|
|
await klient.close();
|
|
if (outcome === 'failed') process.exit(1);
|
|
} finally {
|
|
app.dispose();
|
|
await rm(homeDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
try {
|
|
await main();
|
|
} catch (error) {
|
|
console.error(error);
|
|
process.exit(1);
|
|
}
|