feat(gateway): show per-category context breakdown in /usage (#55204)

Channel users get the same context split the desktop popover shows
(PR #54907) — system prompt, tools, rules, skills, MCP, subagents,
memory, conversation — under the existing Context line in /usage.

Reuses agent.context_breakdown.compute_session_context_breakdown, so
there is no new tool and no new engine. The slices are estimates
(chars/4) and the block is labelled _(estimated)_; the headline
Context line keeps using the provider-measured last_prompt_tokens.
Rendering is fail-open: any engine error returns no breakdown and the
rest of /usage is unaffected.

- gateway/slash_commands.py: _context_breakdown_lines() helper + wire
  into _handle_usage_command
- locales/*.yaml: breakdown_header, breakdown_line, and 8 category
  labels across all 16 locales (parity gate)
- tests/gateway/test_usage_command.py: render + fail-open coverage
This commit is contained in:
Teknium 2026-06-29 20:42:19 -07:00 committed by GitHub
parent 53a75f147f
commit 6aefc9d925
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 272 additions and 0 deletions

View file

@ -3455,6 +3455,47 @@ class GatewaySlashCommandsMixin:
lines.append("Complete your top-up in the browser — credits will appear in /credits shortly.")
return "\n".join(lines)
def _context_breakdown_lines(self, agent, source) -> list[str]:
"""Render the per-category context breakdown for /usage.
Estimated (chars/4) same engine the desktop popover uses. Returns an
empty list and never raises on failure so /usage stays robust.
"""
try:
from agent.context_breakdown import compute_session_context_breakdown
history: list[dict] = []
try:
entry = self.session_store.get_or_create_session(source)
history = self.session_store.load_transcript(entry.session_id) or []
except Exception:
history = []
payload = compute_session_context_breakdown(agent, history)
categories = payload.get("categories") or []
if not categories:
return []
total = payload.get("estimated_total") or 0
out = [t("gateway.usage.breakdown_header")]
for cat in categories:
tokens = int(cat.get("tokens") or 0)
if tokens <= 0:
continue
cat_id = str(cat.get("id") or "")
label = t(f"gateway.usage.breakdown_cat_{cat_id}")
# Missing key → t() echoes the key back; fall back to the
# English label the engine already provides.
if label.endswith(f"breakdown_cat_{cat_id}"):
label = str(cat.get("label") or cat_id)
pct = round(tokens / total * 100) if total else 0
out.append(
t("gateway.usage.breakdown_line", label=label, count=f"{tokens:,}", pct=pct)
)
return out if len(out) > 1 else []
except Exception:
return []
async def _handle_usage_command(self, event: MessageEvent) -> str:
"""Handle /usage command -- show token usage for the current session.
@ -3554,6 +3595,15 @@ class GatewaySlashCommandsMixin:
if ctx.compression_count:
lines.append(t("gateway.usage.label_compressions", count=ctx.compression_count))
# Per-category context breakdown (estimated — chars/4 heuristic).
# Same engine the desktop popover uses (PR #54907). The system
# prompt / tools / skills / memory slices read off the live agent;
# the conversation slice is estimated from the session transcript.
breakdown_lines = self._context_breakdown_lines(agent, source)
if breakdown_lines:
lines.append("")
lines.extend(breakdown_lines)
if account_lines:
lines.append("")
lines.extend(account_lines)

View file

@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Koste: ingesluit"
label_context: "Konteks: {used} / {total} ({pct}%)"
label_compressions: "Saamperserings: {count}"
breakdown_header: "🧩 **Konteksverdeling** _(geskat)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Stelselopdrag"
breakdown_cat_tool_definitions: "Nutsmiddeldefinisies"
breakdown_cat_rules: "Reëls"
breakdown_cat_skills: "Vaardighede"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Subagent-definisies"
breakdown_cat_memory: "Geheue"
breakdown_cat_conversation: "Gesprek"
header_session_info: "📊 **Sessie-inligting**"
label_messages: "Boodskappe: {count}"
label_estimated_context: "Geskatte konteks: ~{count} tokens"

View file

@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Kosten: inbegriffen"
label_context: "Kontext: {used} / {total} ({pct}%)"
label_compressions: "Kompressionen: {count}"
breakdown_header: "🧩 **Kontextaufschlüsselung** _(geschätzt)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "System-Prompt"
breakdown_cat_tool_definitions: "Tool-Definitionen"
breakdown_cat_rules: "Regeln"
breakdown_cat_skills: "Fähigkeiten"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Subagent-Definitionen"
breakdown_cat_memory: "Speicher"
breakdown_cat_conversation: "Konversation"
header_session_info: "📊 **Sitzungsinfo**"
label_messages: "Nachrichten: {count}"
label_estimated_context: "Geschätzter Kontext: ~{count} Tokens"

View file

@ -344,6 +344,16 @@ gateway:
label_cost_included: "Cost: included"
label_context: "Context: {used} / {total} ({pct}%)"
label_compressions: "Compressions: {count}"
breakdown_header: "🧩 **Context breakdown** _(estimated)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "System prompt"
breakdown_cat_tool_definitions: "Tool definitions"
breakdown_cat_rules: "Rules"
breakdown_cat_skills: "Skills"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Subagent definitions"
breakdown_cat_memory: "Memory"
breakdown_cat_conversation: "Conversation"
header_session_info: "📊 **Session Info**"
label_messages: "Messages: {count}"
label_estimated_context: "Estimated context: ~{count} tokens"

View file

@ -329,6 +329,16 @@ gateway:
label_cost_included: "Costo: incluido"
label_context: "Contexto: {used} / {total} ({pct}%)"
label_compressions: "Compresiones: {count}"
breakdown_header: "🧩 **Desglose del contexto** _(estimado)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Prompt del sistema"
breakdown_cat_tool_definitions: "Definiciones de herramientas"
breakdown_cat_rules: "Reglas"
breakdown_cat_skills: "Habilidades"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Definiciones de subagentes"
breakdown_cat_memory: "Memoria"
breakdown_cat_conversation: "Conversación"
header_session_info: "📊 **Información de la sesión**"
label_messages: "Mensajes: {count}"
label_estimated_context: "Contexto estimado: ~{count} tokens"

View file

@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Coût : inclus"
label_context: "Contexte : {used} / {total} ({pct}%)"
label_compressions: "Compressions : {count}"
breakdown_header: "🧩 **Répartition du contexte** _(estimée)_"
breakdown_line: "• {label} : ~{count} ({pct} %)"
breakdown_cat_system_prompt: "Invite système"
breakdown_cat_tool_definitions: "Définitions des outils"
breakdown_cat_rules: "Règles"
breakdown_cat_skills: "Compétences"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Définitions des sous-agents"
breakdown_cat_memory: "Mémoire"
breakdown_cat_conversation: "Conversation"
header_session_info: "📊 **Infos de session**"
label_messages: "Messages : {count}"
label_estimated_context: "Contexte estimé : ~{count} jetons"

View file

@ -336,6 +336,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Costas: san áireamh"
label_context: "Comhthéacs: {used} / {total} ({pct}%)"
label_compressions: "Dlúthuithe: {count}"
breakdown_header: "🧩 **Miondealú comhthéacs** _(measta)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Leid an chórais"
breakdown_cat_tool_definitions: "Sainmhínithe uirlisí"
breakdown_cat_rules: "Rialacha"
breakdown_cat_skills: "Scileanna"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Sainmhínithe fo-ghníomhairí"
breakdown_cat_memory: "Cuimhne"
breakdown_cat_conversation: "Comhrá"
header_session_info: "📊 **Eolas Seisiúin**"
label_messages: "Teachtaireachtaí: {count}"
label_estimated_context: "Comhthéacs measta: ~{count} comhartha"

View file

@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Költség: belefoglalva"
label_context: "Kontextus: {used} / {total} ({pct}%)"
label_compressions: "Tömörítések: {count}"
breakdown_header: "🧩 **Kontextus-bontás** _(becsült)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Rendszerüzenet"
breakdown_cat_tool_definitions: "Eszközdefiníciók"
breakdown_cat_rules: "Szabályok"
breakdown_cat_skills: "Készségek"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Alügynök-definíciók"
breakdown_cat_memory: "Memória"
breakdown_cat_conversation: "Beszélgetés"
header_session_info: "📊 **Munkamenet-információ**"
label_messages: "Üzenetek: {count}"
label_estimated_context: "Becsült kontextus: ~{count} token"

View file

@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Costo: incluso"
label_context: "Contesto: {used} / {total} ({pct}%)"
label_compressions: "Compressioni: {count}"
breakdown_header: "🧩 **Suddivisione del contesto** _(stimata)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Prompt di sistema"
breakdown_cat_tool_definitions: "Definizioni degli strumenti"
breakdown_cat_rules: "Regole"
breakdown_cat_skills: "Competenze"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Definizioni dei subagenti"
breakdown_cat_memory: "Memoria"
breakdown_cat_conversation: "Conversazione"
header_session_info: "📊 **Info sessione**"
label_messages: "Messaggi: {count}"
label_estimated_context: "Contesto stimato: ~{count} token"

View file

@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "コスト: 含まれています"
label_context: "コンテキスト: {used} / {total} ({pct}%)"
label_compressions: "圧縮回数: {count}"
breakdown_header: "🧩 **コンテキスト内訳** _(推定)_"
breakdown_line: "• {label}: 約{count} ({pct}%)"
breakdown_cat_system_prompt: "システムプロンプト"
breakdown_cat_tool_definitions: "ツール定義"
breakdown_cat_rules: "ルール"
breakdown_cat_skills: "スキル"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "サブエージェント定義"
breakdown_cat_memory: "メモリ"
breakdown_cat_conversation: "会話"
header_session_info: "📊 **セッション情報**"
label_messages: "メッセージ数: {count}"
label_estimated_context: "推定コンテキスト: ~{count} トークン"

View file

@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "비용: 포함됨"
label_context: "컨텍스트: {used} / {total} ({pct}%)"
label_compressions: "압축: {count}"
breakdown_header: "🧩 **컨텍스트 분석** _(추정치)_"
breakdown_line: "• {label}: 약 {count} ({pct}%)"
breakdown_cat_system_prompt: "시스템 프롬프트"
breakdown_cat_tool_definitions: "도구 정의"
breakdown_cat_rules: "규칙"
breakdown_cat_skills: "스킬"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "서브에이전트 정의"
breakdown_cat_memory: "메모리"
breakdown_cat_conversation: "대화"
header_session_info: "📊 **세션 정보**"
label_messages: "메시지: {count}"
label_estimated_context: "예상 컨텍스트: 약 {count} 토큰"

View file

@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Custo: incluído"
label_context: "Contexto: {used} / {total} ({pct}%)"
label_compressions: "Compressões: {count}"
breakdown_header: "🧩 **Detalhamento do contexto** _(estimado)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Prompt do sistema"
breakdown_cat_tool_definitions: "Definições de ferramentas"
breakdown_cat_rules: "Regras"
breakdown_cat_skills: "Habilidades"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Definições de subagentes"
breakdown_cat_memory: "Memória"
breakdown_cat_conversation: "Conversa"
header_session_info: "📊 **Informações da sessão**"
label_messages: "Mensagens: {count}"
label_estimated_context: "Contexto estimado: ~{count} tokens"

View file

@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Стоимость: включено"
label_context: "Контекст: {used} / {total} ({pct}%)"
label_compressions: "Сжатий: {count}"
breakdown_header: "🧩 **Разбивка контекста** _(оценка)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Системный промпт"
breakdown_cat_tool_definitions: "Определения инструментов"
breakdown_cat_rules: "Правила"
breakdown_cat_skills: "Навыки"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Определения субагентов"
breakdown_cat_memory: "Память"
breakdown_cat_conversation: "Разговор"
header_session_info: "📊 **Информация о сеансе**"
label_messages: "Сообщений: {count}"
label_estimated_context: "Ориентировочный контекст: ~{count} токенов"

View file

@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Maliyet: dahil"
label_context: "Bağlam: {used} / {total} ({pct}%)"
label_compressions: "Sıkıştırmalar: {count}"
breakdown_header: "🧩 **Bağlam dökümü** _(tahmini)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Sistem istemi"
breakdown_cat_tool_definitions: "Araç tanımları"
breakdown_cat_rules: "Kurallar"
breakdown_cat_skills: "Beceriler"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Alt aracı tanımları"
breakdown_cat_memory: "Bellek"
breakdown_cat_conversation: "Konuşma"
header_session_info: "📊 **Oturum Bilgisi**"
label_messages: "Mesajlar: {count}"
label_estimated_context: "Tahmini bağlam: ~{count} token"

View file

@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Вартість: включено"
label_context: "Контекст: {used} / {total} ({pct}%)"
label_compressions: "Стиснень: {count}"
breakdown_header: "🧩 **Розбивка контексту** _(оцінка)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Системний промпт"
breakdown_cat_tool_definitions: "Визначення інструментів"
breakdown_cat_rules: "Правила"
breakdown_cat_skills: "Навички"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Визначення субагентів"
breakdown_cat_memory: "Пам'ять"
breakdown_cat_conversation: "Розмова"
header_session_info: "📊 **Інформація про сеанс**"
label_messages: "Повідомлень: {count}"
label_estimated_context: "Орієнтовний контекст: ~{count} токенів"

View file

@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "費用:已包含"
label_context: "上下文:{used} / {total}{pct}%"
label_compressions: "壓縮次數:{count}"
breakdown_header: "🧩 **上下文明細** _(估算)_"
breakdown_line: "• {label}:約 {count} ({pct}%)"
breakdown_cat_system_prompt: "系統提示詞"
breakdown_cat_tool_definitions: "工具定義"
breakdown_cat_rules: "規則"
breakdown_cat_skills: "技能"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "子代理定義"
breakdown_cat_memory: "記憶"
breakdown_cat_conversation: "對話"
header_session_info: "📊 **工作階段資訊**"
label_messages: "訊息數:{count}"
label_estimated_context: "預估上下文:~{count} 個 token"

View file

@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "费用:已包含"
label_context: "上下文:{used} / {total}{pct}%"
label_compressions: "压缩次数:{count}"
breakdown_header: "🧩 **上下文明细** _(估算)_"
breakdown_line: "• {label}:约 {count} ({pct}%)"
breakdown_cat_system_prompt: "系统提示词"
breakdown_cat_tool_definitions: "工具定义"
breakdown_cat_rules: "规则"
breakdown_cat_skills: "技能"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "子代理定义"
breakdown_cat_memory: "记忆"
breakdown_cat_conversation: "对话"
header_session_info: "📊 **会话信息**"
label_messages: "消息数:{count}"
label_estimated_context: "估计上下文:~{count} 个令牌"

View file

@ -243,3 +243,65 @@ class TestUsageAccountSection:
assert account_call["kwargs"]["base_url"] == "https://chatgpt.com/backend-api/codex"
assert "📊 **Session Info**" in result
assert "📈 **Account limits**" in result
class TestUsageContextBreakdown:
"""The /usage output includes the per-category context breakdown."""
@pytest.mark.asyncio
async def test_breakdown_lines_rendered_for_live_agent(self):
agent = _make_mock_agent()
runner = _make_runner(SK, cached_agent=agent)
session_entry = MagicMock()
session_entry.session_id = "sess-bd"
runner.session_store.get_or_create_session.return_value = session_entry
runner.session_store.load_transcript.return_value = [
{"role": "user", "content": "hi"},
]
event = MagicMock()
fake_payload = {
"categories": [
{"id": "system_prompt", "label": "System prompt", "tokens": 4000, "color": "x"},
{"id": "tool_definitions", "label": "Tool definitions", "tokens": 6000, "color": "x"},
{"id": "conversation", "label": "Conversation", "tokens": 0, "color": "x"},
],
"estimated_total": 10000,
"context_max": 200000,
"context_percent": 5,
"context_used": 30000,
"model": "anthropic/claude-sonnet-4.6",
}
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
patch("agent.context_breakdown.compute_session_context_breakdown", return_value=fake_payload):
result = await runner._handle_usage_command(event)
# Localized header + at least the two non-zero category labels appear,
# each labelled as a percentage of the estimated total.
assert "Context breakdown" in result
assert "System prompt" in result
assert "Tool definitions" in result
assert "4,000" in result # system prompt tokens, comma-formatted
assert "40%" in result # 4000 / 10000
assert "60%" in result # 6000 / 10000
# Zero-token category is dropped, not rendered.
assert "Conversation" not in result
@pytest.mark.asyncio
async def test_breakdown_failure_is_non_fatal(self):
"""A breakdown engine error must not break the rest of /usage."""
agent = _make_mock_agent()
runner = _make_runner(SK, cached_agent=agent)
runner.session_store.get_or_create_session.side_effect = RuntimeError("boom")
event = MagicMock()
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
patch("agent.context_breakdown.compute_session_context_breakdown",
side_effect=RuntimeError("engine down")):
result = await runner._handle_usage_command(event)
# Core usage lines still render; no breakdown header.
assert "📊 **Session Token Usage**" in result
assert "50,000" in result # total tokens
assert "Context breakdown" not in result