@oneciel-ai/ciel-runtime 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/ciel_runtime.py +2553 -9635
  2. package/ciel_runtime_support/advisor_request_builder.py +8 -21
  3. package/ciel_runtime_support/anthropic_tool_turns.py +13 -8
  4. package/ciel_runtime_support/architecture.py +68 -0
  5. package/ciel_runtime_support/architecture_budget.py +1 -1
  6. package/ciel_runtime_support/channel_connection_context.py +233 -0
  7. package/ciel_runtime_support/channel_delivery_context.py +332 -0
  8. package/ciel_runtime_support/channel_mcp_context.py +313 -0
  9. package/ciel_runtime_support/channel_mcp_discovery.py +47 -0
  10. package/ciel_runtime_support/channel_mcp_transport.py +5 -1
  11. package/ciel_runtime_support/channel_message_context.py +212 -0
  12. package/ciel_runtime_support/channel_message_repository.py +14 -3
  13. package/ciel_runtime_support/channel_pending_injection.py +9 -0
  14. package/ciel_runtime_support/channel_probe_launch_context.py +213 -0
  15. package/ciel_runtime_support/channel_replay_policy.py +38 -0
  16. package/ciel_runtime_support/channel_runtime_environment.py +8 -0
  17. package/ciel_runtime_support/channel_session_context.py +236 -0
  18. package/ciel_runtime_support/channel_terminal_context.py +350 -0
  19. package/ciel_runtime_support/channel_wake_context.py +532 -0
  20. package/ciel_runtime_support/claude_environment.py +60 -0
  21. package/ciel_runtime_support/claude_launch_assembly.py +249 -0
  22. package/ciel_runtime_support/claude_router.py +62 -12
  23. package/ciel_runtime_support/cli_application_context.py +132 -0
  24. package/ciel_runtime_support/cli_assembly.py +50 -0
  25. package/ciel_runtime_support/codex_backend_context.py +363 -0
  26. package/ciel_runtime_support/codex_config.py +13 -1
  27. package/ciel_runtime_support/codex_launch_assembly.py +213 -0
  28. package/ciel_runtime_support/codex_launch_configuration.py +30 -1
  29. package/ciel_runtime_support/codex_mcp_integration.py +90 -8
  30. package/ciel_runtime_support/codex_model_catalog.py +4 -1
  31. package/ciel_runtime_support/codex_reasoning_rejects.py +225 -0
  32. package/ciel_runtime_support/codex_router.py +38 -8
  33. package/ciel_runtime_support/codex_turn_recovery.py +154 -0
  34. package/ciel_runtime_support/config_migrations.py +103 -0
  35. package/ciel_runtime_support/configuration_cli.py +38 -0
  36. package/ciel_runtime_support/context_compaction.py +9 -4
  37. package/ciel_runtime_support/credential_management.py +12 -0
  38. package/ciel_runtime_support/credentials.py +12 -0
  39. package/ciel_runtime_support/github_copilot_oauth.py +2 -2
  40. package/ciel_runtime_support/hosted_formula_tools.py +216 -0
  41. package/ciel_runtime_support/kimi_runtime_context.py +208 -0
  42. package/ciel_runtime_support/llm_preset_context.py +338 -0
  43. package/ciel_runtime_support/managed_mcp_config.py +8 -4
  44. package/ciel_runtime_support/mcp_configuration_context.py +291 -0
  45. package/ciel_runtime_support/mcp_http_proxy.py +14 -8
  46. package/ciel_runtime_support/mcp_probe_transport.py +47 -15
  47. package/ciel_runtime_support/mcp_transport.py +14 -1
  48. package/ciel_runtime_support/native_context_recovery.py +72 -0
  49. package/ciel_runtime_support/ollama_catalog_context.py +213 -0
  50. package/ciel_runtime_support/ollama_stream_collection.py +103 -0
  51. package/ciel_runtime_support/ollama_thinking.py +6 -1
  52. package/ciel_runtime_support/ollama_wire_projection.py +157 -0
  53. package/ciel_runtime_support/openai_forwarding.py +32 -10
  54. package/ciel_runtime_support/openai_responses_router.py +12 -0
  55. package/ciel_runtime_support/package_lifecycle.py +39 -0
  56. package/ciel_runtime_support/prelaunch_assembly.py +37 -0
  57. package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
  58. package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
  59. package/ciel_runtime_support/prompt_compaction.py +144 -0
  60. package/ciel_runtime_support/prompt_injection.py +45 -0
  61. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
  62. package/ciel_runtime_support/protocols/chat_projection.py +85 -5
  63. package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
  64. package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
  65. package/ciel_runtime_support/protocols/ollama_response.py +57 -5
  66. package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
  67. package/ciel_runtime_support/protocols/openai_responses.py +61 -15
  68. package/ciel_runtime_support/provider_adapters.py +26 -0
  69. package/ciel_runtime_support/provider_administration_context.py +207 -0
  70. package/ciel_runtime_support/provider_config_mutations.py +3 -0
  71. package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
  72. package/ciel_runtime_support/provider_model_context.py +107 -0
  73. package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
  74. package/ciel_runtime_support/provider_model_selection.py +10 -3
  75. package/ciel_runtime_support/provider_models.py +45 -2
  76. package/ciel_runtime_support/provider_option_cli.py +19 -0
  77. package/ciel_runtime_support/provider_policy.py +1 -1
  78. package/ciel_runtime_support/provider_readiness_context.py +189 -0
  79. package/ciel_runtime_support/provider_request_builder.py +64 -28
  80. package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
  81. package/ciel_runtime_support/provider_timeout_policy.py +54 -0
  82. package/ciel_runtime_support/provider_tool_policy.py +9 -1
  83. package/ciel_runtime_support/providers/__init__.py +6 -0
  84. package/ciel_runtime_support/providers/alibaba.py +634 -0
  85. package/ciel_runtime_support/providers/catalog.py +24 -16
  86. package/ciel_runtime_support/providers/deepseek.py +73 -0
  87. package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
  88. package/ciel_runtime_support/providers/kimi.py +69 -9
  89. package/ciel_runtime_support/providers/ollama.py +8 -0
  90. package/ciel_runtime_support/providers/ollama_context.py +21 -2
  91. package/ciel_runtime_support/providers/vllm.py +7 -1
  92. package/ciel_runtime_support/response_collection.py +68 -18
  93. package/ciel_runtime_support/response_collection_context.py +391 -0
  94. package/ciel_runtime_support/response_stream_context.py +555 -0
  95. package/ciel_runtime_support/responses_input_compatibility.py +121 -0
  96. package/ciel_runtime_support/responses_usage_observer.py +83 -0
  97. package/ciel_runtime_support/router_client_lifecycle.py +1 -0
  98. package/ciel_runtime_support/router_http.py +239 -3
  99. package/ciel_runtime_support/router_observability_context.py +251 -0
  100. package/ciel_runtime_support/router_process_context.py +200 -0
  101. package/ciel_runtime_support/router_process_lifecycle.py +2 -0
  102. package/ciel_runtime_support/router_request_assembly.py +399 -0
  103. package/ciel_runtime_support/router_request_context.py +215 -0
  104. package/ciel_runtime_support/router_server_context.py +82 -0
  105. package/ciel_runtime_support/runaway_output_guard.py +488 -0
  106. package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
  107. package/ciel_runtime_support/runtime_asset_context.py +297 -0
  108. package/ciel_runtime_support/runtime_constants.py +16 -1
  109. package/ciel_runtime_support/runtime_launch.py +9 -5
  110. package/ciel_runtime_support/runtime_launch_context.py +130 -0
  111. package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
  112. package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
  113. package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
  114. package/ciel_runtime_support/runtime_paths.py +60 -40
  115. package/ciel_runtime_support/runtime_primitives.py +78 -0
  116. package/ciel_runtime_support/sse_stream_collection.py +236 -0
  117. package/ciel_runtime_support/statusline_script.py +57 -8
  118. package/ciel_runtime_support/streaming_anthropic.py +361 -24
  119. package/ciel_runtime_support/tool_schema.py +40 -2
  120. package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
  121. package/ciel_runtime_support/upstream_dump.py +68 -0
  122. package/ciel_runtime_support/upstream_retry_context.py +259 -0
  123. package/ciel_runtime_support/workspace_router_selection.py +86 -0
  124. package/docs/Configuration.md +50 -0
  125. package/docs/Test-Suite.md +1 -0
  126. package/package.json +1 -1
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Mapping
6
+
7
+
8
+ def source_fingerprint(path: Path) -> str:
9
+ try:
10
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
11
+ except Exception:
12
+ try:
13
+ stat = path.stat()
14
+ return f"{int(stat.st_mtime_ns)}-{int(stat.st_size)}"
15
+ except Exception:
16
+ return "unknown"
17
+
18
+
19
+ def positive_environment_int(environment: Mapping[str, str], name: str, default: int) -> int:
20
+ raw = str(environment.get(name) or "").strip()
21
+ if raw:
22
+ try:
23
+ value = int(raw)
24
+ if value > 0:
25
+ return value
26
+ except ValueError:
27
+ pass
28
+ return default
29
+
30
+
31
+ def model_preset(
32
+ model_id: str,
33
+ presets: Mapping[str, dict[str, Any]],
34
+ lookup_ids: Callable[[str], tuple[str, ...] | list[str]],
35
+ ) -> dict[str, Any]:
36
+ for candidate in lookup_ids(model_id):
37
+ if candidate in presets:
38
+ return presets[candidate]
39
+ candidate_base = candidate.split(":", 1)[0]
40
+ for key, value in presets.items():
41
+ if candidate.startswith(key) or (":" not in candidate and key.startswith(candidate_base)):
42
+ return value
43
+ return {}
44
+
45
+
46
+ def join_url(base: str, path: str) -> str:
47
+ base = base.rstrip("/")
48
+ if base.endswith("/v1") and path.startswith("/v1/"):
49
+ return base + path[3:]
50
+ return base + path
51
+
52
+
53
+ def url_is_up(url: str, request_json: Callable[..., Any]) -> bool:
54
+ try:
55
+ request_json(url, timeout=1.5)
56
+ return True
57
+ except Exception:
58
+ return False
59
+
60
+
61
+ def colorize_status_text(
62
+ text: str,
63
+ *,
64
+ enabled: bool,
65
+ palette: tuple[int, ...],
66
+ monotonic: Callable[[], float],
67
+ ) -> str:
68
+ if not enabled:
69
+ return text
70
+ parts: list[str] = []
71
+ phase = int(monotonic() * 8)
72
+ for index, char in enumerate(text):
73
+ if char.isspace():
74
+ parts.append(char)
75
+ continue
76
+ color = palette[(phase + index) % len(palette)]
77
+ parts.append(f"\033[1;38;5;{color}m{char}\033[0m")
78
+ return "".join(parts)
@@ -0,0 +1,236 @@
1
+ """Collect SSE chat responses into one message, cutting loops short.
2
+
3
+ The Ollama collection path reads NDJSON; the other two protocols the
4
+ Codex-facing collector speaks are SSE. DeepSeek publishes both formats -- an
5
+ OpenAI-compatible endpoint at ``https://api.deepseek.com`` and an
6
+ Anthropic-compatible one at ``https://api.deepseek.com/anthropic`` -- and
7
+ ciel-runtime uses the Anthropic one, so a repetition loop on deepseek.com
8
+ arrives through :func:`collect_anthropic_message_stream`. Switching endpoints
9
+ would not have helped: both collectors used one blocking POST, which is what
10
+ let a loop run to completion before anything could look at it.
11
+
12
+ Each collector assembles exactly the payload the matching decoder already
13
+ expects, so nothing downstream changes.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Iterable
21
+
22
+ from .runaway_output_guard import (
23
+ RunawayOutputDetector,
24
+ RunawayOutputPolicy,
25
+ RunawayVerdict,
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class SseStreamCollection:
31
+ response: dict[str, Any]
32
+ verdict: RunawayVerdict | None = None
33
+ chunks: int = 0
34
+
35
+
36
+ def iter_sse_payloads(lines: Iterable[Any]) -> Iterable[dict[str, Any]]:
37
+ """Yield decoded ``data:`` payloads, ignoring framing and keepalives."""
38
+
39
+ for raw in lines:
40
+ line = (
41
+ raw.decode("utf-8", errors="ignore")
42
+ if isinstance(raw, (bytes, bytearray))
43
+ else str(raw)
44
+ ).strip()
45
+ if not line or not line.startswith("data:"):
46
+ continue
47
+ body = line[5:].strip()
48
+ if not body or body == "[DONE]":
49
+ continue
50
+ try:
51
+ payload = json.loads(body)
52
+ except ValueError:
53
+ continue
54
+ if isinstance(payload, dict):
55
+ yield payload
56
+
57
+
58
+ @dataclass
59
+ class _ToolFragment:
60
+ call_id: str = ""
61
+ name: str = ""
62
+ arguments: str = ""
63
+
64
+
65
+ def collect_openai_chat_stream(
66
+ lines: Iterable[Any], policy: RunawayOutputPolicy | None = None
67
+ ) -> SseStreamCollection:
68
+ """Merge OpenAI chat-completion chunks into one non-streaming response."""
69
+
70
+ text_runaway = RunawayOutputDetector(policy)
71
+ reasoning_runaway = RunawayOutputDetector(policy)
72
+ verdict: RunawayVerdict | None = None
73
+ content: list[str] = []
74
+ reasoning: list[str] = []
75
+ fragments: dict[int, _ToolFragment] = {}
76
+ finish_reason = ""
77
+ usage: dict[str, Any] = {}
78
+ envelope: dict[str, Any] = {}
79
+ chunks = 0
80
+ for payload in iter_sse_payloads(lines):
81
+ chunks += 1
82
+ for key in ("id", "model", "created", "system_fingerprint"):
83
+ if payload.get(key) is not None:
84
+ envelope[key] = payload[key]
85
+ if isinstance(payload.get("usage"), dict):
86
+ usage = payload["usage"]
87
+ choices = payload.get("choices")
88
+ choice = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
89
+ if choice.get("finish_reason"):
90
+ finish_reason = str(choice["finish_reason"])
91
+ delta = choice.get("delta") if isinstance(choice.get("delta"), dict) else {}
92
+ reasoning_chunk = str(delta.get("reasoning_content") or "")
93
+ if reasoning_chunk:
94
+ reasoning.append(reasoning_chunk)
95
+ verdict = verdict or reasoning_runaway.feed(reasoning_chunk)
96
+ text_chunk = str(delta.get("content") or "")
97
+ if text_chunk:
98
+ content.append(text_chunk)
99
+ verdict = verdict or text_runaway.feed(text_chunk)
100
+ for call in delta.get("tool_calls") or []:
101
+ if not isinstance(call, dict):
102
+ continue
103
+ try:
104
+ index = int(call.get("index") or 0)
105
+ except (TypeError, ValueError):
106
+ index = 0
107
+ fragment = fragments.setdefault(index, _ToolFragment())
108
+ if call.get("id"):
109
+ fragment.call_id = str(call["id"])
110
+ function = call.get("function") if isinstance(call.get("function"), dict) else {}
111
+ if function.get("name"):
112
+ fragment.name = str(function["name"])
113
+ if function.get("arguments"):
114
+ fragment.arguments += str(function["arguments"])
115
+ if verdict is not None:
116
+ break
117
+ message: dict[str, Any] = {"role": "assistant", "content": "".join(content)}
118
+ if reasoning:
119
+ message["reasoning_content"] = "".join(reasoning)
120
+ if fragments:
121
+ message["tool_calls"] = [
122
+ {
123
+ "id": fragment.call_id or f"call_{index + 1}",
124
+ "type": "function",
125
+ "function": {"name": fragment.name, "arguments": fragment.arguments},
126
+ }
127
+ for index, fragment in sorted(fragments.items())
128
+ ]
129
+ response = {
130
+ **envelope,
131
+ "choices": [{"index": 0, "message": message, "finish_reason": finish_reason or "stop"}],
132
+ }
133
+ if usage:
134
+ response["usage"] = usage
135
+ return SseStreamCollection(response=response, verdict=verdict, chunks=chunks)
136
+
137
+
138
+ @dataclass
139
+ class _ContentBlock:
140
+ block: dict[str, Any] = field(default_factory=dict)
141
+ text: list[str] = field(default_factory=list)
142
+ thinking: list[str] = field(default_factory=list)
143
+ signature: str = ""
144
+ partial_json: str = ""
145
+
146
+ def finish(self) -> dict[str, Any]:
147
+ block = dict(self.block)
148
+ kind = str(block.get("type") or "")
149
+ if kind == "text":
150
+ block["text"] = str(block.get("text") or "") + "".join(self.text)
151
+ elif kind in ("thinking", "redacted_thinking"):
152
+ block["thinking"] = str(block.get("thinking") or "") + "".join(self.thinking)
153
+ if self.signature:
154
+ block["signature"] = self.signature
155
+ elif kind == "tool_use" and self.partial_json:
156
+ try:
157
+ parsed = json.loads(self.partial_json)
158
+ except ValueError:
159
+ parsed = None
160
+ block["input"] = parsed if isinstance(parsed, dict) else block.get("input") or {}
161
+ return block
162
+
163
+
164
+ def collect_anthropic_message_stream(
165
+ lines: Iterable[Any], policy: RunawayOutputPolicy | None = None
166
+ ) -> SseStreamCollection:
167
+ """Merge Anthropic Messages SSE events into one non-streaming message."""
168
+
169
+ text_runaway = RunawayOutputDetector(policy)
170
+ thinking_runaway = RunawayOutputDetector(policy)
171
+ verdict: RunawayVerdict | None = None
172
+ message: dict[str, Any] = {
173
+ "type": "message",
174
+ "role": "assistant",
175
+ "content": [],
176
+ "stop_reason": None,
177
+ }
178
+ blocks: dict[int, _ContentBlock] = {}
179
+ chunks = 0
180
+ for payload in iter_sse_payloads(lines):
181
+ chunks += 1
182
+ event_type = str(payload.get("type") or "")
183
+ if event_type == "message_start":
184
+ started = payload.get("message")
185
+ if isinstance(started, dict):
186
+ message.update({key: value for key, value in started.items() if key != "content"})
187
+ continue
188
+ if event_type == "content_block_start":
189
+ index = payload.get("index")
190
+ block = payload.get("content_block")
191
+ if isinstance(index, int) and isinstance(block, dict):
192
+ blocks[index] = _ContentBlock(block=dict(block))
193
+ continue
194
+ if event_type == "content_block_delta":
195
+ index = payload.get("index")
196
+ delta = payload.get("delta") if isinstance(payload.get("delta"), dict) else {}
197
+ if not isinstance(index, int):
198
+ continue
199
+ state = blocks.setdefault(index, _ContentBlock(block={"type": "text", "text": ""}))
200
+ delta_type = str(delta.get("type") or "")
201
+ if delta_type == "text_delta":
202
+ chunk = str(delta.get("text") or "")
203
+ state.text.append(chunk)
204
+ verdict = verdict or text_runaway.feed(chunk)
205
+ elif delta_type == "thinking_delta":
206
+ chunk = str(delta.get("thinking") or "")
207
+ state.thinking.append(chunk)
208
+ verdict = verdict or thinking_runaway.feed(chunk)
209
+ elif delta_type == "signature_delta":
210
+ state.signature += str(delta.get("signature") or "")
211
+ elif delta_type == "input_json_delta":
212
+ state.partial_json += str(delta.get("partial_json") or "")
213
+ if verdict is not None:
214
+ break
215
+ continue
216
+ if event_type == "message_delta":
217
+ delta = payload.get("delta") if isinstance(payload.get("delta"), dict) else {}
218
+ for key in ("stop_reason", "stop_sequence"):
219
+ if delta.get(key) is not None:
220
+ message[key] = delta[key]
221
+ usage = payload.get("usage")
222
+ if isinstance(usage, dict):
223
+ message["usage"] = {**(message.get("usage") or {}), **usage}
224
+ continue
225
+ message["content"] = [state.finish() for _index, state in sorted(blocks.items())]
226
+ if verdict is not None and not message.get("stop_reason"):
227
+ message["stop_reason"] = "max_tokens"
228
+ return SseStreamCollection(response=message, verdict=verdict, chunks=chunks)
229
+
230
+
231
+ __all__ = [
232
+ "SseStreamCollection",
233
+ "collect_anthropic_message_stream",
234
+ "collect_openai_chat_stream",
235
+ "iter_sse_payloads",
236
+ ]
@@ -25,14 +25,15 @@ def default_config_dir():
25
25
 
26
26
 
27
27
  CONFIG_DIR = default_config_dir()
28
+ STATE_DIR = Path(os.environ.get("CIEL_RUNTIME_STATE_DIR") or CONFIG_DIR)
28
29
  CONFIG_PATH = CONFIG_DIR / "config.json"
29
- STATE_PATH = CONFIG_DIR / "rate-limit-state.json"
30
- ACTIVITY_PATH = CONFIG_DIR / "router-activity.json"
31
- COMPACT_ACTIVITY_PATH = CONFIG_DIR / "context-compact-activity.json"
32
- CONTEXT_PATH = CONFIG_DIR / "context-usage.json"
33
- CHAT_MESSAGES_PATH = CONFIG_DIR / "chat-messages.jsonl"
34
- CHANNEL_LLM_CURSOR_PATH = CONFIG_DIR / "channel-llm-cursor.json"
35
- CHANNEL_LLM_CLEAR_FLOOR_PATH = CONFIG_DIR / "channel-llm-clear-floor.json"
30
+ STATE_PATH = STATE_DIR / "rate-limit-state.json"
31
+ ACTIVITY_PATH = STATE_DIR / "router-activity.json"
32
+ COMPACT_ACTIVITY_PATH = STATE_DIR / "context-compact-activity.json"
33
+ CONTEXT_PATH = STATE_DIR / "context-usage.json"
34
+ CHAT_MESSAGES_PATH = STATE_DIR / "chat-messages.jsonl"
35
+ CHANNEL_LLM_CURSOR_PATH = STATE_DIR / "channel-llm-cursor.json"
36
+ CHANNEL_LLM_CLEAR_FLOOR_PATH = STATE_DIR / "channel-llm-clear-floor.json"
36
37
  PALETTE = (203, 209, 215, 221, 229, 187, 151, 116, 111, 147, 183, 219)
37
38
 
38
39
 
@@ -247,6 +248,42 @@ def session_context_status_text(session):
247
248
  return f"ctx {tokens:,} tok"
248
249
 
249
250
 
251
+ def session_cache_status_text(session):
252
+ if not isinstance(session, dict):
253
+ return ""
254
+ context_window = session.get("context_window")
255
+ if not isinstance(context_window, dict):
256
+ return ""
257
+ usage = context_window.get("current_usage")
258
+ if not isinstance(usage, dict):
259
+ return ""
260
+ details = usage.get("input_tokens_details")
261
+ details = details if isinstance(details, dict) else {}
262
+ cache_read = _as_int(
263
+ usage.get("cache_read_input_tokens"),
264
+ _as_int(details.get("cached_tokens")),
265
+ )
266
+ cache_write = _as_int(
267
+ usage.get("cache_creation_input_tokens"),
268
+ _as_int(details.get("cache_write_tokens")),
269
+ )
270
+ input_tokens = max(0, _as_int(usage.get("input_tokens")))
271
+ uncached = (
272
+ max(0, input_tokens - cache_read - cache_write)
273
+ if details
274
+ else input_tokens
275
+ )
276
+ total = uncached + cache_read + cache_write
277
+ if total <= 0 or (cache_read <= 0 and cache_write <= 0):
278
+ return ""
279
+ hit_pct = (cache_read / total) * 100.0
280
+ parts = [f"cache {hit_pct:.1f}%", f"hit {cache_read:,}"]
281
+ if cache_write > 0:
282
+ parts.append(f"write {cache_write:,}")
283
+ parts.append(f"new {uncached:,}")
284
+ return " ".join(parts)
285
+
286
+
250
287
  def _status_as_string_list(value):
251
288
  if value is None:
252
289
  return []
@@ -472,6 +509,9 @@ def main():
472
509
  ctx_text = router_ctx_text or session_ctx_text
473
510
  if ctx_text:
474
511
  status_parts.append(gray(ctx_text))
512
+ cache_text = session_cache_status_text(session)
513
+ if cache_text:
514
+ status_parts.append(color(cache_text))
475
515
  channel_pending = channel_pending_status_count()
476
516
  if channel_pending > 0:
477
517
  status_parts.append(color(f"channel queue {channel_pending}"))
@@ -554,6 +594,16 @@ def main():
554
594
  activity_text += " " + color(f"({chunks} chunks)")
555
595
  elif event in ("success", "error"):
556
596
  activity_text = color(f"{event} {age:.0f}s")
597
+ cache_read = max(0, _as_int(activity.get("cache_read_tokens")))
598
+ cache_write = max(0, _as_int(activity.get("cache_creation_tokens")))
599
+ uncached = max(0, _as_int(activity.get("uncached_input_tokens")))
600
+ cache_total = cache_read + cache_write + uncached
601
+ if not cache_text and cache_total > 0 and (cache_read > 0 or cache_write > 0):
602
+ cache_pct = (cache_read / cache_total) * 100.0
603
+ activity_text += " " + color(
604
+ f"cache {cache_pct:.1f}% hit {cache_read:,} "
605
+ f"write {cache_write:,} new {uncached:,}"
606
+ )
557
607
  if activity_text:
558
608
  status_parts.append(activity_text)
559
609
  compact_text = ""
@@ -590,4 +640,3 @@ if __name__ == "__main__":
590
640
  main()
591
641
 
592
642
  '''
593
-