-
Notifications
You must be signed in to change notification settings - Fork 831
feat(tps): add show_tps_meter config and /tps command for TPS display #1759
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,9 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import time | ||
| import uuid | ||
| from collections import deque | ||
| from collections.abc import Awaitable, Callable, Sequence | ||
| from dataclasses import dataclass | ||
| from functools import partial | ||
|
|
@@ -17,6 +19,7 @@ | |
| APIStatusError, | ||
| APITimeoutError, | ||
| RetryableChatProvider, | ||
| StreamedMessagePart, | ||
| ) | ||
| from kosong.message import Message | ||
| from tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wait_exponential_jitter | ||
|
|
@@ -79,6 +82,7 @@ | |
| StepBegin, | ||
| StepInterrupted, | ||
| TextPart, | ||
| ThinkPart, | ||
| ToolResult, | ||
| TurnBegin, | ||
| TurnEnd, | ||
|
|
@@ -148,6 +152,10 @@ def __init__( | |
| self._steer_queue: asyncio.Queue[str | list[ContentPart]] = asyncio.Queue() | ||
| self._plan_mode: bool = self._runtime.session.state.plan_mode | ||
| self._plan_session_id: str | None = self._runtime.session.state.plan_session_id | ||
| # TPS tracking for streaming tokens | ||
| self._streaming_token_timestamps: deque[tuple[float, float]] = deque() | ||
| self._streaming_token_count: float = 0.0 | ||
| self._tps_window_seconds: float = 3.0 | ||
| # Pre-warm slug cache so the persisted slug survives process restarts | ||
| if self._plan_session_id is not None and self._runtime.session.state.plan_slug is not None: | ||
| from kimi_cli.tools.plan.heroes import seed_slug_cache | ||
|
|
@@ -380,6 +388,7 @@ def status(self) -> StatusSnapshot: | |
| context_tokens=token_count, | ||
| max_context_tokens=max_size, | ||
| mcp_status=self._mcp_status_snapshot(), | ||
| tps=self._calculate_tps(), | ||
| ) | ||
|
|
||
| @property | ||
|
|
@@ -428,6 +437,62 @@ def steer(self, content: str | list[ContentPart]) -> None: | |
| """Queue a steer message for injection into the current turn.""" | ||
| self._steer_queue.put_nowait(content) | ||
|
|
||
| def _track_streaming_tokens(self, token_count: float) -> None: | ||
| """Track tokens received during streaming for TPS calculation.""" | ||
| now = time.monotonic() | ||
| self._streaming_token_count += token_count | ||
| self._streaming_token_timestamps.append((now, self._streaming_token_count)) | ||
| # Prune old entries outside the rolling window | ||
| cutoff = now - self._tps_window_seconds | ||
| while self._streaming_token_timestamps and self._streaming_token_timestamps[0][0] < cutoff: | ||
| self._streaming_token_timestamps.popleft() | ||
|
|
||
| def _reset_streaming_tps(self) -> None: | ||
| """Reset TPS tracking when streaming ends or a new step begins.""" | ||
| self._streaming_token_timestamps.clear() | ||
| self._streaming_token_count = 0.0 | ||
|
|
||
| def _calculate_tps(self) -> float: | ||
| """Calculate current tokens-per-second over the rolling window.""" | ||
| if len(self._streaming_token_timestamps) < 2: | ||
| return 0.0 | ||
| first_time, first_tokens = self._streaming_token_timestamps[0] | ||
| last_time, last_tokens = self._streaming_token_timestamps[-1] | ||
| duration = last_time - first_time | ||
| if duration <= 0: | ||
| return 0.0 | ||
| tokens = last_tokens - first_tokens | ||
| return tokens / duration | ||
|
|
||
| @staticmethod | ||
| def _estimate_tokens_for_tps(text: str) -> float: | ||
| """Estimate token count for TPS calculation. | ||
|
|
||
| Uses simple heuristics for mixed CJK/Latin text: | ||
| - CJK characters: ~1.5 tokens each | ||
| - Other characters: ~1 token per 4 characters | ||
| """ | ||
| cjk_count = 0 | ||
| other_count = 0 | ||
| for ch in text: | ||
| cp = ord(ch) | ||
| if ( | ||
| 0x4E00 <= cp <= 0x9FFF # CJK Unified Ideographs | ||
| or 0x3400 <= cp <= 0x4DBF # CJK Extension A | ||
| or 0xF900 <= cp <= 0xFAFF # CJK Compatibility | ||
| or 0x3000 <= cp <= 0x303F # CJK Symbols | ||
| or 0xFF00 <= cp <= 0xFFEF # Fullwidth Forms | ||
| or 0x3040 <= cp <= 0x309F # Hiragana | ||
| or 0x30A0 <= cp <= 0x30FF # Katakana | ||
| or 0xAC00 <= cp <= 0xD7AF # Hangul Syllables | ||
| or 0x1100 <= cp <= 0x11FF # Hangul Jamo | ||
| or 0x3130 <= cp <= 0x318F # Hangul Compatibility Jamo | ||
| ): | ||
| cjk_count += 1 | ||
| else: | ||
| other_count += 1 | ||
| return cjk_count * 1.5 + other_count / 4 | ||
|
|
||
| async def _consume_pending_steers(self) -> bool: | ||
| """Drain the steer queue and inject as follow-up user messages. | ||
|
|
||
|
|
@@ -691,6 +756,8 @@ async def _agent_loop(self) -> TurnOutcome: | |
| raise MaxStepsReached(self._loop_control.max_steps_per_turn) | ||
|
|
||
| wire_send(StepBegin(n=step_no)) | ||
| # Reset TPS tracking at the start of each step | ||
| self._reset_streaming_tps() | ||
| back_to_the_future: BackToTheFuture | None = None | ||
| step_outcome: StepOutcome | None = None | ||
| try: | ||
|
|
@@ -806,14 +873,26 @@ async def _append_notification(view: NotificationView) -> None: | |
| # Normalize: merge adjacent user messages for clean API input | ||
| effective_history = normalize_history(self._context.history) | ||
|
|
||
| # Create a wrapped callback to track streaming tokens for TPS calculation | ||
| def _track_and_wire_send(part: StreamedMessagePart) -> None: | ||
| """Track tokens from streaming content and send to wire.""" | ||
| match part: | ||
| case TextPart(text=text) | ThinkPart(think=text): | ||
| if text: | ||
| # Estimate tokens for TPS calculation | ||
| self._track_streaming_tokens(self._estimate_tokens_for_tps(text)) | ||
| case _: | ||
| pass # Other parts don't contain tokens to track | ||
| wire_send(part) | ||
|
Comment on lines
+883
to
+886
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This callback updates TPS counters but only forwards the content part, so clients that render TPS from Useful? React with 👍 / 👎. |
||
|
|
||
| async def _run_step_once() -> StepResult: | ||
| # run an LLM step (may be interrupted) | ||
| return await kosong.step( | ||
| chat_provider, | ||
| self._agent.system_prompt, | ||
| self._agent.toolset, | ||
| effective_history, | ||
| on_message_part=wire_send, | ||
| on_message_part=_track_and_wire_send, | ||
| on_tool_result=wire_send, | ||
| ) | ||
|
|
||
|
|
@@ -843,6 +922,7 @@ async def _kosong_step_with_retry() -> StepResult: | |
| status_update.context_usage = snap.context_usage | ||
| status_update.context_tokens = snap.context_tokens | ||
| status_update.max_context_tokens = snap.max_context_tokens | ||
| status_update.tps = snap.tps | ||
| wire_send(status_update) | ||
|
Comment on lines
922
to
926
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 TPS value not sent in StatusUpdate when provider returns no token usage In (Refers to lines 915-926) Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| # wait for all tool results (may be interrupted) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| """TPS meter display preference - mirrors the theme pattern. | ||
|
|
||
| This module provides a global state for the TPS meter display setting, | ||
| similar to how theme.py manages the active color theme. | ||
| """ | ||
|
|
||
| # Module-level private state | ||
| _show_tps_meter: bool = False | ||
|
|
||
|
|
||
| def set_show_tps_meter(enabled: bool) -> None: | ||
| """Set whether the TPS meter should be displayed in the status bar. | ||
|
|
||
| Args: | ||
| enabled: True to show the TPS meter, False to hide it. | ||
| """ | ||
| global _show_tps_meter | ||
| _show_tps_meter = enabled | ||
|
|
||
|
|
||
| def get_show_tps_meter() -> bool: | ||
| """Get whether the TPS meter should be displayed. | ||
|
|
||
| Returns: | ||
| True if the TPS meter should be shown, False otherwise. | ||
| """ | ||
| return _show_tps_meter |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_reset_streaming_tps()is only called atStepBegin, so once the model finishes emitting parts the old samples remain andstatus.tpscan stay non-zero while the app is idle. That violates theStatusSnapshot.tpscontract (“0 when not streaming”) and leaves a stale tok/s value visible between turns until the next step starts. Please clear TPS state at step/turn completion (or expire samples against current time in_calculate_tps).Useful? React with 👍 / 👎.