File size: 7,610 Bytes
f0634fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 | /**
* Kimi host and device identity header factories.
*
* The caller owns the host identity (product name + host app version)
* and the `homeDir` where the stable device id is stored. This module
* intentionally keeps no global CLI version or environment-derived
* production state.
*/
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { arch, hostname, release, type } from 'node:os';
import { join } from 'node:path';
import type { DeviceHeaders } from './types';
export const KIMI_CODE_PLATFORM = 'kimi_code_cli';
export interface KimiHostIdentity {
readonly productName: string;
readonly version: string;
/**
* `X-Msh-Platform` value reported to the OAuth host and managed endpoints
* (e.g. `kimi_code_cli`, `kimi_code_desktop`). Every host must state its own
* explicitly — `KIMI_CODE_PLATFORM` is the CLI's value, not a default to
* inherit silently.
*/
readonly platform: string;
readonly userAgentSuffix?: string | undefined;
}
export interface KimiIdentityOptions extends KimiHostIdentity {
readonly homeDir: string;
}
export interface CreateKimiDeviceIdOptions {
/** Invoked synchronously the first time a device id is minted on this machine. */
readonly onFirstLaunch?: ((id: string) => void) | undefined;
}
export function readKimiDeviceId(homeDir: string): string | null {
const deviceIdPath = join(homeDir, 'device_id');
if (!existsSync(deviceIdPath)) return null;
try {
const text = readFileSync(deviceIdPath, 'utf-8').trim();
return text.length > 0 ? text : null;
} catch {
return null;
}
}
export function createKimiDeviceId(
homeDir: string,
options: CreateKimiDeviceIdOptions = {},
): string {
const existing = readKimiDeviceId(homeDir);
if (existing !== null) return existing;
const id = randomUUID();
try {
mkdirSync(homeDir, { recursive: true, mode: 0o700 });
writeFileSync(join(homeDir, 'device_id'), id, { encoding: 'utf-8', mode: 0o600 });
} catch {
// Best-effort: requests can still use the in-memory id.
}
if (options.onFirstLaunch !== undefined) {
try {
options.onFirstLaunch(id);
} catch {
// Telemetry callback must not affect device id creation.
}
}
return id;
}
export function createKimiDeviceHeaders(options: {
readonly homeDir: string;
readonly version: string;
/** Required and validated like the version: non-empty ASCII, no fallback —
a blank or fabricated platform would silently misreport the host. */
readonly platform: string;
}): DeviceHeaders {
return {
'X-Msh-Platform': requiredAsciiHeader(options.platform, 'Kimi identity platform'),
'X-Msh-Version': requiredAsciiHeader(options.version, 'Kimi identity version'),
'X-Msh-Device-Name': asciiHeader(hostname()),
'X-Msh-Device-Model': asciiHeader(deviceModel()),
'X-Msh-Os-Version': asciiHeader(release()),
'X-Msh-Device-Id': createKimiDeviceId(options.homeDir),
};
}
export function createKimiUserAgent(options: {
readonly productName: string;
readonly version: string;
readonly userAgentSuffix?: string | undefined;
}): string {
const product = requiredAsciiHeader(options.productName, 'Kimi identity product');
const version = requiredAsciiHeader(options.version, 'Kimi identity version');
const suffix =
options.userAgentSuffix === undefined ? undefined : asciiHeader(options.userAgentSuffix, '');
return suffix === undefined || suffix.length === 0
? `${product}/${version}`
: `${product}/${version} (${suffix})`;
}
/**
* Swap the product token of a User-Agent produced by
* {@link createKimiUserAgent}, keeping the version and optional suffix intact
* (`kimi-code-cli/1.2.3 (web)` → `acme/1.2.3 (web)`).
*
* Lives next to the builder on purpose: the format knowledge — product token,
* `/`, version, parenthesized suffix — must exist in exactly one place, so a
* change to the builder cannot silently desynchronize the rewriter. Callers
* pass an already-normalized ASCII token; a blank or non-ASCII product still
* throws rather than emitting an invalid header.
*
* A value that does not carry a `/` is treated as a bare product token and
* replaced wholesale.
*/
export function replaceUserAgentProduct(userAgent: string, product: string): string {
const cleaned = requiredAsciiHeader(product, 'Kimi identity product');
const separator = userAgent.indexOf('/');
return separator < 0 ? cleaned : `${cleaned}${userAgent.slice(separator)}`;
}
export function createKimiDefaultHeaders(options: KimiIdentityOptions): Record<string, string> {
return {
'User-Agent': createKimiUserAgent(options),
...createKimiDeviceHeaders({
homeDir: options.homeDir,
version: options.version,
platform: options.platform,
}),
};
}
/**
* Env var carrying extra headers applied to every outbound provider request
* (LLM chat and `/models` listing). Mirrors `ANTHROPIC_CUSTOM_HEADERS`:
* newline-separated `Name: Value` lines; lines without a colon are skipped;
* names and values are trimmed.
*
* These headers form the lowest-precedence layer — the Kimi identity headers
* (User-Agent, X-Msh-*), per-provider `customHeaders`, and request auth
* (Authorization) all override them.
*
* Unlike the device identity headers above, this is intentionally
* environment-derived and stateless (re-read on every call) so callers can
* apply it uniformly without plumbing the value through every host layer.
*/
export const KIMI_CODE_CUSTOM_HEADERS_ENV = 'KIMI_CODE_CUSTOM_HEADERS';
export function parseKimiCodeCustomHeaders(
env: NodeJS.ProcessEnv = process.env,
): Record<string, string> {
const raw = env[KIMI_CODE_CUSTOM_HEADERS_ENV]?.trim();
if (raw === undefined || raw.length === 0) return {};
const headers: Record<string, string> = {};
for (const line of raw.split('\n')) {
const colon = line.indexOf(':');
if (colon < 0) continue;
const name = line.slice(0, colon).trim();
if (name.length === 0) continue;
headers[name] = line.slice(colon + 1).trim();
}
return headers;
}
export function assertKimiHostIdentity(identity: KimiHostIdentity | undefined): KimiHostIdentity {
if (identity === undefined) {
throw new Error('Kimi host identity is required. Pass the host product name and version.');
}
requiredAsciiHeader(identity.productName, 'Kimi identity product');
requiredAsciiHeader(identity.version, 'Kimi identity version');
return identity;
}
function deviceModel(): string {
const os = type();
const version = release();
const osArch = arch();
if (os === 'Darwin') return `macOS ${macOsProductVersion() ?? version} ${osArch}`;
if (os === 'Windows_NT') return `Windows ${version} ${osArch}`;
return `${os} ${version} ${osArch}`.trim();
}
function macOsProductVersion(): string | undefined {
try {
const version = execFileSync('/usr/bin/sw_vers', ['-productVersion'], {
encoding: 'utf-8',
timeout: 1000,
}).trim();
return version.length > 0 ? version : undefined;
} catch {
return undefined;
}
}
function asciiHeader(value: string, fallback = 'unknown'): string {
const cleaned = value.replaceAll(/[^\u0020-\u007E]/g, '').trim();
return cleaned.length > 0 ? cleaned : fallback;
}
function requiredAsciiHeader(value: string, fieldName: string): string {
const cleaned = asciiHeader(value, '');
if (cleaned.length === 0) {
throw new Error(`${fieldName} must be a non-empty ASCII string.`);
}
return cleaned;
}
|