M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
//! Per-response inference latency metrics.
|
||||
//!
|
||||
//! Captures token-level timing from streaming inference responses:
|
||||
//! TTFB, TTLB, and inter-token latency (ITL) statistics.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Compute percentiles from sorted intervals.
|
||||
///
|
||||
/// Returns (p50, p99, max, mean, sum) from a slice of sorted values.
|
||||
/// Panics if `sorted` is empty.
|
||||
pub fn compute_percentiles(sorted: &[u64]) -> (u64, u64, u64, u64, u64) {
|
||||
let len = sorted.len();
|
||||
assert!(len > 0, "Cannot compute percentiles from empty slice");
|
||||
|
||||
let p50 = sorted[len / 2];
|
||||
let p99_idx = ((len as f64 * 0.99).ceil() as usize)
|
||||
.saturating_sub(1)
|
||||
.min(len - 1);
|
||||
let p99 = sorted[p99_idx];
|
||||
let max = sorted[len - 1];
|
||||
let sum: u64 = sorted.iter().sum();
|
||||
let mean = sum / len as u64;
|
||||
|
||||
(p50, p99, max, mean, sum)
|
||||
}
|
||||
|
||||
/// Per-response inference latency metrics computed from chunk timestamps.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct InferenceLatencyStats {
|
||||
/// Time to first content token (ms)
|
||||
pub time_to_first_token_ms: Option<u64>,
|
||||
/// Time to last byte / stream end (ms). Measured at stream exhaustion,
|
||||
/// not at the last content chunk, so it includes trailing metadata chunks.
|
||||
pub time_to_last_byte_ms: u64,
|
||||
/// Number of content chunks received
|
||||
pub chunk_count: u32,
|
||||
/// Inter-token latency intervals (raw data for session aggregation)
|
||||
pub itl_intervals_ms: Vec<u64>,
|
||||
/// Inter-token latency: median (ms)
|
||||
pub itl_p50_ms: Option<u64>,
|
||||
/// Inter-token latency: 99th percentile (ms)
|
||||
pub itl_p99_ms: Option<u64>,
|
||||
/// Inter-token latency: maximum (ms)
|
||||
pub itl_max_ms: Option<u64>,
|
||||
/// Inter-token latency: mean (ms)
|
||||
pub itl_mean_ms: Option<u64>,
|
||||
/// Total request attempts (`1` = no retries); set by the retry loop on success.
|
||||
pub attempts: u32,
|
||||
}
|
||||
|
||||
impl InferenceLatencyStats {
|
||||
/// Record the computed stats as fields on a tracing span.
|
||||
pub fn record_on_span(&self, span: &tracing::Span) {
|
||||
if let Some(ttfb) = self.time_to_first_token_ms {
|
||||
span.record("ttfb_ms", ttfb);
|
||||
}
|
||||
span.record("ttlb_ms", self.time_to_last_byte_ms);
|
||||
span.record("chunk_count", self.chunk_count);
|
||||
if let Some(p50) = self.itl_p50_ms {
|
||||
span.record("itl_p50_ms", p50);
|
||||
}
|
||||
if let Some(p99) = self.itl_p99_ms {
|
||||
span.record("itl_p99_ms", p99);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute latency stats from chunk timestamps.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `stream_start` - `Instant::now()` captured before initiating the stream.
|
||||
/// * `chunk_timestamps` - `Instant` recorded on each content-bearing chunk.
|
||||
/// * `stream_end` - `Instant::now()` captured after the stream is fully exhausted
|
||||
/// (after trailing metadata/`[DONE]` chunks). Used for TTLB.
|
||||
pub fn from_timestamps(
|
||||
stream_start: Instant,
|
||||
chunk_timestamps: &[Instant],
|
||||
stream_end: Instant,
|
||||
) -> Self {
|
||||
let ttlb = stream_end.duration_since(stream_start).as_millis() as u64;
|
||||
|
||||
if chunk_timestamps.is_empty() {
|
||||
return Self {
|
||||
time_to_last_byte_ms: ttlb,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let ttfb = chunk_timestamps[0].duration_since(stream_start);
|
||||
|
||||
// Compute inter-token intervals
|
||||
let intervals: Vec<u64> = chunk_timestamps
|
||||
.windows(2)
|
||||
.map(|w| w[1].duration_since(w[0]).as_millis() as u64)
|
||||
.collect();
|
||||
|
||||
let (itl_p50, itl_p99, itl_max, itl_mean) = if intervals.is_empty() {
|
||||
(None, None, None, None)
|
||||
} else {
|
||||
let mut sorted = intervals.clone();
|
||||
sorted.sort_unstable();
|
||||
let (p50, p99, max, mean, _sum) = compute_percentiles(&sorted);
|
||||
(Some(p50), Some(p99), Some(max), Some(mean))
|
||||
};
|
||||
|
||||
Self {
|
||||
time_to_first_token_ms: Some(ttfb.as_millis() as u64),
|
||||
time_to_last_byte_ms: ttlb,
|
||||
chunk_count: u32::try_from(chunk_timestamps.len()).unwrap_or(u32::MAX),
|
||||
itl_intervals_ms: intervals,
|
||||
itl_p50_ms: itl_p50,
|
||||
itl_p99_ms: itl_p99,
|
||||
itl_max_ms: itl_max,
|
||||
itl_mean_ms: itl_mean,
|
||||
attempts: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Helper: create an Instant offset from a base by a given duration.
|
||||
fn offset(base: Instant, ms: u64) -> Instant {
|
||||
base + Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_timestamps() {
|
||||
let start = Instant::now();
|
||||
let end = start + Duration::from_millis(500);
|
||||
|
||||
let stats = InferenceLatencyStats::from_timestamps(start, &[], end);
|
||||
|
||||
assert_eq!(stats.time_to_first_token_ms, None);
|
||||
assert_eq!(stats.time_to_last_byte_ms, 500);
|
||||
assert_eq!(stats.chunk_count, 0);
|
||||
assert_eq!(stats.itl_p50_ms, None);
|
||||
assert_eq!(stats.itl_p99_ms, None);
|
||||
assert_eq!(stats.itl_max_ms, None);
|
||||
assert_eq!(stats.itl_mean_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_chunk() {
|
||||
let start = Instant::now();
|
||||
let chunks = vec![offset(start, 100)];
|
||||
let end = offset(start, 200);
|
||||
|
||||
let stats = InferenceLatencyStats::from_timestamps(start, &chunks, end);
|
||||
|
||||
assert_eq!(stats.time_to_first_token_ms, Some(100));
|
||||
assert_eq!(stats.time_to_last_byte_ms, 200);
|
||||
assert_eq!(stats.chunk_count, 1);
|
||||
// Single chunk => no intervals => no ITL stats
|
||||
assert_eq!(stats.itl_p50_ms, None);
|
||||
assert_eq!(stats.itl_p99_ms, None);
|
||||
assert_eq!(stats.itl_max_ms, None);
|
||||
assert_eq!(stats.itl_mean_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_two_chunks() {
|
||||
let start = Instant::now();
|
||||
let chunks = vec![offset(start, 100), offset(start, 150)];
|
||||
let end = offset(start, 200);
|
||||
|
||||
let stats = InferenceLatencyStats::from_timestamps(start, &chunks, end);
|
||||
|
||||
assert_eq!(stats.time_to_first_token_ms, Some(100));
|
||||
assert_eq!(stats.time_to_last_byte_ms, 200);
|
||||
assert_eq!(stats.chunk_count, 2);
|
||||
// One interval of 50ms => p50=p99=max=mean=50
|
||||
assert_eq!(stats.itl_p50_ms, Some(50));
|
||||
assert_eq!(stats.itl_p99_ms, Some(50));
|
||||
assert_eq!(stats.itl_max_ms, Some(50));
|
||||
assert_eq!(stats.itl_mean_ms, Some(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_many_chunks() {
|
||||
let start = Instant::now();
|
||||
// 11 chunks: intervals are [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
|
||||
let chunks: Vec<Instant> = (0..11)
|
||||
.scan(100u64, |acc, i| {
|
||||
let t = *acc;
|
||||
*acc += (i + 1) * 10; // intervals: 10, 20, 30, ...
|
||||
Some(offset(start, t))
|
||||
})
|
||||
.collect();
|
||||
let end = offset(start, 1000);
|
||||
|
||||
let stats = InferenceLatencyStats::from_timestamps(start, &chunks, end);
|
||||
|
||||
assert_eq!(stats.time_to_first_token_ms, Some(100));
|
||||
assert_eq!(stats.time_to_last_byte_ms, 1000);
|
||||
assert_eq!(stats.chunk_count, 11);
|
||||
|
||||
// 10 intervals: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
|
||||
// sorted: same
|
||||
// p50: intervals[5] = 60
|
||||
assert_eq!(stats.itl_p50_ms, Some(60));
|
||||
// p99_idx: ceil(10 * 0.99) - 1 = ceil(9.9) - 1 = 10 - 1 = 9, min(9, 9) = 9
|
||||
// intervals[9] = 100
|
||||
assert_eq!(stats.itl_p99_ms, Some(100));
|
||||
assert_eq!(stats.itl_max_ms, Some(100));
|
||||
// mean: (10+20+30+40+50+60+70+80+90+100) / 10 = 550 / 10 = 55
|
||||
assert_eq!(stats.itl_mean_ms, Some(55));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_p99_does_not_overflow() {
|
||||
let start = Instant::now();
|
||||
// 101 chunks => 100 intervals (indices 0..99)
|
||||
let chunks: Vec<Instant> = (0..101).map(|i| offset(start, 100 + i * 10)).collect();
|
||||
let end = offset(start, 2000);
|
||||
|
||||
let stats = InferenceLatencyStats::from_timestamps(start, &chunks, end);
|
||||
|
||||
assert_eq!(stats.chunk_count, 101);
|
||||
// 100 intervals, all 10ms
|
||||
// p99_idx: ceil(100 * 0.99) - 1 = 100 - 1 = 99, min(99, 99) = 99 -> in bounds
|
||||
assert_eq!(stats.itl_p99_ms, Some(10));
|
||||
assert_eq!(stats.itl_max_ms, Some(10));
|
||||
assert_eq!(stats.itl_p50_ms, Some(10));
|
||||
assert_eq!(stats.itl_mean_ms, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ttlb_uses_stream_end_not_last_chunk() {
|
||||
let start = Instant::now();
|
||||
let chunks = vec![offset(start, 100), offset(start, 200)];
|
||||
// stream_end is 500ms after start, well past the last chunk at 200ms
|
||||
let end = offset(start, 500);
|
||||
|
||||
let stats = InferenceLatencyStats::from_timestamps(start, &chunks, end);
|
||||
|
||||
// TTLB should be 500 (from stream_end), not 200 (from last chunk)
|
||||
assert_eq!(stats.time_to_last_byte_ms, 500);
|
||||
assert_eq!(stats.time_to_first_token_ms, Some(100));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user