@oneciel-ai/ciel-runtime 0.2.22 → 0.2.23

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 (86) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +150 -57
  3. package/ciel-runtime-stop.cmd +1 -0
  4. package/ciel-runtime.cmd +1 -0
  5. package/ciel-runtimectl.cmd +1 -0
  6. package/ciel_runtime.py +56 -53
  7. package/ciel_runtime_support/anthropic_tool_turns.py +81 -0
  8. package/ciel_runtime_support/architecture.py +17 -0
  9. package/ciel_runtime_support/channel_injection.py +113 -3
  10. package/ciel_runtime_support/channel_message_prompt.py +15 -0
  11. package/ciel_runtime_support/channel_pending_injection.py +28 -4
  12. package/ciel_runtime_support/channel_pending_poll.py +2 -0
  13. package/ciel_runtime_support/channel_terminal_context.py +6 -0
  14. package/ciel_runtime_support/channel_terminal_dispatch.py +2 -0
  15. package/ciel_runtime_support/channel_terminal_proxy.py +8 -2
  16. package/ciel_runtime_support/channel_wake_claim_repository.py +7 -0
  17. package/ciel_runtime_support/channel_wake_context.py +7 -3
  18. package/ciel_runtime_support/cli_parser.py +6 -0
  19. package/ciel_runtime_support/codex_backend_context.py +4 -0
  20. package/ciel_runtime_support/codex_turn_recovery.py +75 -12
  21. package/ciel_runtime_support/config_migrations.py +102 -10
  22. package/ciel_runtime_support/config_repository.py +9 -0
  23. package/ciel_runtime_support/event_settings_cli.py +284 -2
  24. package/ciel_runtime_support/openai_chat_passthrough.py +2 -0
  25. package/ciel_runtime_support/protocols/openai_responses.py +22 -18
  26. package/ciel_runtime_support/provider_adapters.py +7 -0
  27. package/ciel_runtime_support/provider_policy.py +10 -1
  28. package/ciel_runtime_support/provider_request_builder.py +9 -2
  29. package/ciel_runtime_support/provider_responses_passthrough.py +4 -0
  30. package/ciel_runtime_support/providers/__init__.py +2 -0
  31. package/ciel_runtime_support/providers/alibaba.py +33 -10
  32. package/ciel_runtime_support/providers/constants.py +1 -0
  33. package/ciel_runtime_support/providers/opencode.py +25 -5
  34. package/ciel_runtime_support/providers/opencode_go.py +3 -3
  35. package/ciel_runtime_support/providers/openrouter.py +76 -3
  36. package/ciel_runtime_support/providers/tabitoken.py +130 -0
  37. package/ciel_runtime_support/remote_instructions.py +20 -6
  38. package/ciel_runtime_support/remote_memory.py +515 -55
  39. package/ciel_runtime_support/responses_input_compatibility.py +14 -0
  40. package/ciel_runtime_support/router_http.py +5 -0
  41. package/ciel_runtime_support/runtime_constants.py +1 -1
  42. package/ciel_runtime_support/runtime_launch.py +9 -2
  43. package/ciel_runtime_support/runtime_paths.py +2 -0
  44. package/ciel_runtime_support/tool_side_effect_dedupe.py +7 -4
  45. package/ciel_runtime_support/transcript_delta_delivery.py +333 -0
  46. package/ciel_runtime_support/usage_events.py +45 -3
  47. package/ciel_runtime_support/usage_service.py +871 -0
  48. package/ciel_runtime_support/windows_conpty.py +108 -2
  49. package/ciel_runtime_support/workspace_mcp.py +4 -0
  50. package/docs/CLI-Reference.md +11 -0
  51. package/docs/Configuration.md +7 -2
  52. package/docs/MCP-Channels.md +7 -3
  53. package/docs/Module-Map.md +4 -0
  54. package/docs/Providers.md +32 -0
  55. package/docs/Remote-Memory.md +29 -10
  56. package/docs/Test-Suite.md +1 -0
  57. package/docs/journal/2026/08/20/kevin-wake-orphan-tool-repair.md +161 -0
  58. package/docs/journal/2026/08/20/nightly-push-local-deployment.md +59 -2
  59. package/docs/journal/2026/08/20/wing-remote-memory-replication-verification.md +107 -0
  60. package/docs/journal/2026/08/21/alibaba-singapore-qwen38-official-alignment.md +64 -0
  61. package/docs/journal/2026/08/21/codex-visible-sse-wake-submit-regression.md +85 -0
  62. package/docs/journal/2026/08/21/external-sse-wake-only-change-analysis.md +131 -0
  63. package/docs/journal/2026/08/21/hyundai-invalid-replayed-tool-name-repair.okf +88 -0
  64. package/docs/journal/2026/08/21/local-stale-temporary-runtime-pin-repair.okf +118 -0
  65. package/docs/journal/2026/08/21/transcript-compaction-external-events.okf +131 -0
  66. package/docs/journal/2026/08/21/wing-ai-net-mcp-boundary-diagnosis.okf +129 -0
  67. package/docs/journal/2026/08/21/wing-cold-start-wake-submit-repair.okf +161 -0
  68. package/docs/journal/2026/08/21/wing-powershell-memory-runaway.okf +101 -0
  69. package/docs/journal/2026/08/21/wing-repeated-reasoning-tool-turn-repair.okf +112 -0
  70. package/docs/journal/2026/08/21/wing-repeated-tool-guard-finalization.okf +81 -0
  71. package/docs/journal/2026/08/21/wing-working-directory-memory-deployment.md +110 -0
  72. package/docs/journal/2026/08/21/wing-workspace-state-memory-prompt-injection.md +88 -0
  73. package/docs/journal/2026/08/22/alibaba-singapore-dual-endpoint-wire-alignment.okf +142 -0
  74. package/docs/journal/2026/08/22/delluhiold-claude-compaction-empty-tool-name.okf +168 -0
  75. package/docs/journal/2026/08/22/remote-memory-relative-prompt-paths.okf +79 -0
  76. package/docs/journal/2026/08/22/remote-memory-root-system-prompt.okf +121 -0
  77. package/docs/journal/2026/08/23/claude-web-chat-stateless-mcp-repair.okf +97 -0
  78. package/docs/journal/2026/08/23/cross-runtime-token-usage-interfaces-research.okf +378 -0
  79. package/docs/journal/2026/08/23/main-0.2.23-readme-release.okf +81 -0
  80. package/docs/journal/2026/08/23/mia-openrouter-ox-alpha-latency-diagnosis.okf +146 -0
  81. package/docs/journal/2026/08/23/openrouter-opencode-ox-alpha-support.okf +114 -0
  82. package/docs/journal/2026/08/23/tabitoken-provider-model-catalog.okf +97 -0
  83. package/docs/journal/2026/08/23/usage-stream-snapshot-implementation.okf +134 -0
  84. package/docs/usage-observability.md +141 -0
  85. package/install.ps1 +24 -3
  86. package/package.json +2 -1
@@ -26,6 +26,7 @@ _MISSING_TOOL_OUTPUT = re.compile(
26
26
  r"No tool output found for (?:custom|function) tool call ([A-Za-z0-9_-]+)",
27
27
  re.IGNORECASE,
28
28
  )
29
+ _VALID_TOOL_NAME = re.compile(r"^[a-zA-Z0-9_-]+$")
29
30
  _TOOL_CALL_TYPES = frozenset({"function_call", "custom_tool_call"})
30
31
  _TOOL_OUTPUT_TYPES = frozenset(
31
32
  {"function_call_output", "custom_tool_call_output"}
@@ -68,6 +69,11 @@ def repair_replayed_response_items(body: dict[str, Any]) -> dict[str, Any]:
68
69
  keep ``call_id``, the tool name, arguments, output, and message content so
69
70
  the transcript remains usable.
70
71
 
72
+ Historical clients can also persist function names that the Responses API
73
+ does not accept, such as ``multi_tool_use.parallel``. Drop that call rather
74
+ than inventing a different tool name. The call/output balancing below then
75
+ drops its matching output by ``call_id``.
76
+
71
77
  IDs the router minted itself are treated the same way. They carry the right
72
78
  prefix, so the type check alone accepts them, but no backend ever stored
73
79
  them: OpenAI answers a replayed one with ``Item with id 'rs_..._0' not
@@ -94,6 +100,14 @@ def repair_replayed_response_items(body: dict[str, Any]) -> dict[str, Any]:
94
100
  continue
95
101
  item = value_item
96
102
  item_type = item.get("type")
103
+ item_name = item.get("name")
104
+ if (
105
+ item_type in _TOOL_CALL_TYPES
106
+ and item_name is not None
107
+ and not _VALID_TOOL_NAME.fullmatch(str(item_name))
108
+ ):
109
+ changed = True
110
+ continue
97
111
  item_id = str(item.get("id") or "")
98
112
  expected_prefix = OPENAI_RESPONSES_ITEM_ID_PREFIXES.get(str(item_type or ""))
99
113
  invalid_item_id = bool(item_id) and (
@@ -117,6 +117,7 @@ class RouterHttpPostEndpoints:
117
117
  runtime: Callable[..., bool]
118
118
  external_events_raw: Callable[[Any, str, bytes], bool] | None = None
119
119
  external_events_config: Callable[[Any, str, dict[str, Any]], bool] | None = None
120
+ usage: Callable[[Any, str, dict[str, Any]], bool] | None = None
120
121
 
121
122
 
122
123
  @dataclass(frozen=True, slots=True)
@@ -711,6 +712,8 @@ class RouterHttpHandler(BaseHTTPRequestHandler):
711
712
  path = parsed.path
712
713
  query = urllib.parse.parse_qs(parsed.query)
713
714
  cfg = services.core.load_config()
715
+ if path.startswith("/ca/usage/") and services.get.events(self, path, query):
716
+ return
714
717
  if services.core.reject_external(self, cfg):
715
718
  return
716
719
  endpoints = services.get
@@ -964,6 +967,8 @@ class RouterHttpHandler(BaseHTTPRequestHandler):
964
967
  length,
965
968
  body,
966
969
  )
970
+ if endpoints.usage is not None and endpoints.usage(self, path, body):
971
+ return
967
972
  if endpoints.external_events_config is not None and endpoints.external_events_config(self, path, body):
968
973
  return
969
974
  if endpoints.llm_config(self, path, body):
@@ -87,7 +87,7 @@ OPENCODE_ENDPOINT_ALIASES = {
87
87
  }
88
88
 
89
89
  APP_NAME = "Ciel Runtime"
90
- VERSION = "0.2.22"
90
+ VERSION = "0.2.23"
91
91
  CREDITS = "Credits: One Ciel LLC"
92
92
  PRELAUNCH_CANCEL = 10
93
93
  PRELAUNCH_LAUNCH_CODEX = 11
@@ -18,7 +18,7 @@ from ciel_runtime_support.runtime_constants import (
18
18
  PRELAUNCH_LAUNCH_CODEX_APP_SERVER,
19
19
  ROUTED_COMPAT_PROMPT,
20
20
  )
21
- from ciel_runtime_support.runtime_paths import CONFIG_DIR, LOG_PATH, ROUTER_INSTANCE_DIR, WORKSPACE_STATE_DIR
21
+ from ciel_runtime_support.runtime_paths import CONFIG_DIR, LOG_PATH, ROUTER_BASE, ROUTER_INSTANCE_DIR, WORKSPACE_STATE_DIR
22
22
  from ciel_runtime_support.web_endpoints import (
23
23
  current_web_workspace,
24
24
  web_backend_owned_by_workspace,
@@ -457,7 +457,13 @@ def run_claude(
457
457
  if should_attach_web_search(provider, cfg, web_search_override):
458
458
  mcp_config_paths.append(str(write_duckduckgo_mcp_config(cfg)))
459
459
  workspace_mcp_launch = (
460
- workspace_mcp.prepare("claude", cfg, env) if workspace_mcp is not None else None
460
+ workspace_mcp.prepare(
461
+ "claude", cfg, env,
462
+ injected_servers={"ciel-runtime-router": {
463
+ "transport": "streamable_http", "url": f"{ROUTER_BASE}/ca/mcp",
464
+ "runtimes": ["claude"],
465
+ }} if llm_channel_delivery and web_backend_start_requested(cfg) else None,
466
+ ) if workspace_mcp is not None else None
461
467
  )
462
468
  if workspace_mcp_launch is not None:
463
469
  mcp_config_paths.extend(str(path) for path in workspace_mcp_launch.claude_config_paths)
@@ -961,6 +967,7 @@ def run_codex(
961
967
  cmd,
962
968
  env,
963
969
  wake_for_llm_delivery=channel_delivery_mode(cfg) == "llm",
970
+ channel_wake_display_body=True,
964
971
  synthetic_enter_bytes=codex_synthetic_enter,
965
972
  normalize_bare_cr_for_synthetic_enter=False,
966
973
  channel_wake_submit_retries=_codex_channel_wake_submit_retries(),
@@ -155,6 +155,8 @@ LOG_LEVEL_PATH = CONFIG_DIR / "log-level"
155
155
  REQUEST_DUMP_PATH = ROUTER_INSTANCE_DIR / "requests.jsonl"
156
156
  RESPONSE_DUMP_PATH = ROUTER_INSTANCE_DIR / "responses.jsonl"
157
157
  USAGE_EVENTS_PATH = ROUTER_INSTANCE_DIR / "usage-events.jsonl"
158
+ USAGE_LEDGER_PATH = WORKSPACE_STATE_DIR / "usage" / "usage.sqlite3"
159
+ USAGE_API_KEY_PEPPER_PATH = WORKSPACE_STATE_DIR / "usage" / "api-key.pepper"
158
160
  SSE_TRACE_PATH = ROUTER_INSTANCE_DIR / "router-sse-trace.jsonl"
159
161
  SSE_LAST_PATH = ROUTER_INSTANCE_DIR / "router-last-sse.json"
160
162
  TOOL_CALL_LOG_PATH = ROUTER_INSTANCE_DIR / "tool-calls.jsonl"
@@ -143,10 +143,13 @@ class ToolSideEffectDedupeService:
143
143
  for block in blocks
144
144
  if isinstance(block, dict) and block.get("type") == "tool_result"
145
145
  ]
146
- has_new_intent = isinstance(content, str) and bool(content.strip())
147
- has_new_intent = has_new_intent or any(
148
- not isinstance(block, dict) or block.get("type") != "tool_result"
149
- for block in blocks
146
+ runtime_control = bool(message.get("ciel_runtime_control"))
147
+ has_new_intent = not runtime_control and (
148
+ (isinstance(content, str) and bool(content.strip()))
149
+ or any(
150
+ not isinstance(block, dict) or block.get("type") != "tool_result"
151
+ for block in blocks
152
+ )
150
153
  )
151
154
  if has_new_intent:
152
155
  completed.clear()
@@ -0,0 +1,333 @@
1
+ """Incremental transcript delivery to one configured outbound HTTP endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timezone
8
+ import hashlib
9
+ import json
10
+ from pathlib import Path
11
+ import threading
12
+ import time
13
+ from typing import Any
14
+ import urllib.error
15
+ import urllib.request
16
+
17
+ from .remote_instructions import expand_environment_references
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class TranscriptDeliverySettings:
22
+ enabled: bool
23
+ url: str
24
+ authorization: str
25
+ timeout_seconds: float
26
+ poll_interval_seconds: float
27
+ max_batch_bytes: int
28
+ start_mode: str
29
+
30
+ @classmethod
31
+ def from_config(cls, config: dict[str, Any]) -> "TranscriptDeliverySettings":
32
+ raw = config.get("transcript_events")
33
+ values = raw if isinstance(raw, dict) else {}
34
+ start_mode = str(values.get("start_mode") or "tail").strip().lower()
35
+ if start_mode not in {"tail", "beginning"}:
36
+ start_mode = "tail"
37
+ return cls(
38
+ enabled=bool(values.get("enabled", False)),
39
+ url=str(values.get("url") or "").strip(),
40
+ authorization=str(values.get("authorization") or "").strip(),
41
+ timeout_seconds=max(0.1, min(30.0, float(values.get("timeout_seconds") or 5))),
42
+ poll_interval_seconds=max(
43
+ 0.1, min(60.0, float(values.get("poll_interval_ms") or 1000) / 1000.0)
44
+ ),
45
+ max_batch_bytes=max(
46
+ 1024, min(16_777_216, int(values.get("max_batch_bytes") or 1_048_576))
47
+ ),
48
+ start_mode=start_mode,
49
+ )
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class TranscriptDeliveryPorts:
54
+ load_config: Callable[[], dict[str, Any]]
55
+ latest_transcript: Callable[[], Path | None]
56
+ scope: Callable[[], dict[str, Any]]
57
+ log: Callable[[str, str], None]
58
+ epoch: Callable[[], float] = time.time
59
+
60
+
61
+ class TranscriptDeltaDeliveryService:
62
+ """Tail complete JSONL records and commit offsets only after HTTP success."""
63
+
64
+ def __init__(
65
+ self,
66
+ cursor_path: Path,
67
+ workspace_id: str,
68
+ ports: TranscriptDeliveryPorts,
69
+ ) -> None:
70
+ self.cursor_path = cursor_path
71
+ self.workspace_id = str(workspace_id or "workspace")
72
+ self.ports = ports
73
+ self._stop = threading.Event()
74
+ self._thread: threading.Thread | None = None
75
+ self._lock = threading.Lock()
76
+ self._last_error = ""
77
+ self._last_error_at = 0.0
78
+
79
+ def start(self) -> None:
80
+ with self._lock:
81
+ if self._thread is not None and self._thread.is_alive():
82
+ return
83
+ self._stop.clear()
84
+ self._thread = threading.Thread(
85
+ target=self._run,
86
+ name="ciel-transcript-delivery",
87
+ daemon=True,
88
+ )
89
+ self._thread.start()
90
+
91
+ def stop(self, timeout: float = 2.0) -> None:
92
+ self._stop.set()
93
+ thread = self._thread
94
+ if thread is not None and thread is not threading.current_thread():
95
+ thread.join(max(0.0, timeout))
96
+
97
+ def poll_once(self) -> bool:
98
+ settings = TranscriptDeliverySettings.from_config(self.ports.load_config())
99
+ if not settings.enabled or not settings.url:
100
+ return False
101
+ path = self.ports.latest_transcript()
102
+ if path is None:
103
+ return False
104
+ try:
105
+ path = path.resolve()
106
+ size = path.stat().st_size
107
+ except OSError:
108
+ return False
109
+ scope = self.ports.scope()
110
+ session_id = str(scope.get("session_id") or path.stem)
111
+ runtime = str(scope.get("runtime") or "runtime")
112
+ destination_key = self._destination_key(settings.url, path)
113
+ cursors = self._load_cursors()
114
+ destinations = cursors.setdefault("destinations", {})
115
+ current = destinations.get(destination_key)
116
+ if not isinstance(current, dict):
117
+ offset = self._initial_offset(settings, scope, path)
118
+ destinations[destination_key] = self._cursor_record(path, offset)
119
+ self._save_cursors(cursors)
120
+ if offset >= size:
121
+ return False
122
+ current = destinations[destination_key]
123
+ offset = max(0, int(current.get("offset") or 0))
124
+ rotated = size < offset
125
+ if rotated:
126
+ offset = 0
127
+ payload = self._read_complete_batch(path, offset, settings.max_batch_bytes)
128
+ if not payload:
129
+ if rotated:
130
+ destinations[destination_key] = self._cursor_record(path, offset)
131
+ self._save_cursors(cursors)
132
+ return False
133
+ end_offset = offset + len(payload)
134
+ event = self._cloud_event(
135
+ runtime=runtime,
136
+ session_id=session_id,
137
+ path=path,
138
+ start_offset=offset,
139
+ end_offset=end_offset,
140
+ content=payload,
141
+ rotated=rotated,
142
+ )
143
+ if not self._post(settings, event):
144
+ return False
145
+ destinations[destination_key] = self._cursor_record(path, end_offset)
146
+ self._save_cursors(cursors)
147
+ self.ports.log(
148
+ "INFO",
149
+ "transcript_delta_delivered "
150
+ f"runtime={runtime} session={session_id} bytes={len(payload)} "
151
+ f"offset={offset}->{end_offset}",
152
+ )
153
+ self._last_error = ""
154
+ return True
155
+
156
+ @staticmethod
157
+ def _initial_offset(
158
+ settings: TranscriptDeliverySettings, scope: dict[str, Any], path: Path
159
+ ) -> int:
160
+ if settings.start_mode == "beginning":
161
+ return 0
162
+ boundary_path = scope.get("turn_scan_path")
163
+ if boundary_path is None:
164
+ return 0
165
+ try:
166
+ if Path(boundary_path).resolve() != path:
167
+ return 0
168
+ except (OSError, TypeError, ValueError):
169
+ return 0
170
+ return max(0, int(scope.get("turn_scan_offset") or 0))
171
+
172
+ def _run(self) -> None:
173
+ while not self._stop.is_set():
174
+ interval = 1.0
175
+ try:
176
+ config = self.ports.load_config()
177
+ settings = TranscriptDeliverySettings.from_config(config)
178
+ interval = settings.poll_interval_seconds
179
+ if settings.enabled and settings.url:
180
+ self.poll_once()
181
+ except Exception as exc:
182
+ self._report_error(f"poll {type(exc).__name__}: {exc}")
183
+ self._stop.wait(interval)
184
+
185
+ def _load_cursors(self) -> dict[str, Any]:
186
+ try:
187
+ value = json.loads(self.cursor_path.read_text(encoding="utf-8"))
188
+ except (OSError, ValueError, TypeError):
189
+ value = {}
190
+ if not isinstance(value, dict):
191
+ value = {}
192
+ if not isinstance(value.get("destinations"), dict):
193
+ value["destinations"] = {}
194
+ value["version"] = 1
195
+ return value
196
+
197
+ def _save_cursors(self, cursors: dict[str, Any]) -> None:
198
+ self.cursor_path.parent.mkdir(parents=True, exist_ok=True)
199
+ temporary = self.cursor_path.with_name(
200
+ f"{self.cursor_path.name}.{threading.get_ident()}.tmp"
201
+ )
202
+ temporary.write_text(
203
+ json.dumps(cursors, ensure_ascii=False, separators=(",", ":")),
204
+ encoding="utf-8",
205
+ )
206
+ temporary.replace(self.cursor_path)
207
+
208
+ def _cursor_record(self, path: Path, offset: int) -> dict[str, Any]:
209
+ return {
210
+ "path": str(path),
211
+ "offset": max(0, int(offset)),
212
+ "updated_at": self.ports.epoch(),
213
+ }
214
+
215
+ @staticmethod
216
+ def _destination_key(url: str, path: Path) -> str:
217
+ return hashlib.sha256(f"{url}\0{path}".encode("utf-8")).hexdigest()
218
+
219
+ def _read_complete_batch(self, path: Path, offset: int, limit: int) -> bytes:
220
+ try:
221
+ with path.open("rb") as stream:
222
+ stream.seek(offset)
223
+ data = stream.read(limit + 1)
224
+ except OSError as exc:
225
+ self._report_error(f"read {type(exc).__name__}: {exc}")
226
+ return b""
227
+ if not data:
228
+ return b""
229
+ bounded = data[:limit]
230
+ complete_end = bounded.rfind(b"\n")
231
+ if complete_end < 0:
232
+ if len(data) > limit:
233
+ self._report_error(
234
+ f"record exceeds max_batch_bytes={limit} path={path.name} offset={offset}"
235
+ )
236
+ return b""
237
+ return bounded[: complete_end + 1]
238
+
239
+ def _cloud_event(
240
+ self,
241
+ *,
242
+ runtime: str,
243
+ session_id: str,
244
+ path: Path,
245
+ start_offset: int,
246
+ end_offset: int,
247
+ content: bytes,
248
+ rotated: bool,
249
+ ) -> dict[str, Any]:
250
+ digest = hashlib.sha256(content).hexdigest()
251
+ event_id = hashlib.sha256(
252
+ (
253
+ f"{self.workspace_id}\0{session_id}\0{path}\0"
254
+ f"{start_offset}\0{end_offset}\0{digest}"
255
+ ).encode("utf-8")
256
+ ).hexdigest()
257
+ return {
258
+ "specversion": "1.0",
259
+ "id": event_id,
260
+ "source": f"urn:ciel-runtime:workspace:{self.workspace_id}",
261
+ "type": "ai.oneciel.ciel-runtime.transcript.delta",
262
+ "subject": session_id,
263
+ "time": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
264
+ "datacontenttype": "application/json",
265
+ "data": {
266
+ "workspace_id": self.workspace_id,
267
+ "runtime": runtime,
268
+ "session_id": session_id,
269
+ "transcript_name": path.name,
270
+ "start_offset": start_offset,
271
+ "end_offset": end_offset,
272
+ "content_sha256": digest,
273
+ "record_count": content.count(b"\n"),
274
+ "rotated": rotated,
275
+ "format": "jsonl",
276
+ "content": content.decode("utf-8", errors="replace"),
277
+ },
278
+ }
279
+
280
+ def _post(
281
+ self, settings: TranscriptDeliverySettings, event: dict[str, Any]
282
+ ) -> bool:
283
+ body = json.dumps(event, ensure_ascii=False, separators=(",", ":")).encode(
284
+ "utf-8"
285
+ )
286
+ headers = {
287
+ "Content-Type": "application/cloudevents+json",
288
+ "Accept": "application/json",
289
+ "User-Agent": "ciel-runtime-transcript-delivery/1",
290
+ "Idempotency-Key": str(event["id"]),
291
+ }
292
+ authorization, missing = expand_environment_references(settings.authorization)
293
+ if missing:
294
+ self._report_error(
295
+ "authorization environment variable(s) missing: " + ", ".join(missing)
296
+ )
297
+ return False
298
+ if authorization:
299
+ headers["Authorization"] = authorization
300
+ request = urllib.request.Request(
301
+ settings.url, data=body, headers=headers, method="POST"
302
+ )
303
+ try:
304
+ with urllib.request.urlopen(
305
+ request, timeout=settings.timeout_seconds
306
+ ) as response:
307
+ status = int(getattr(response, "status", 200) or 200)
308
+ response.read(4096)
309
+ except urllib.error.HTTPError as exc:
310
+ self._report_error(f"HTTP {exc.code} from transcript endpoint")
311
+ return False
312
+ except (OSError, urllib.error.URLError, TimeoutError) as exc:
313
+ self._report_error(f"delivery {type(exc).__name__}: {exc}")
314
+ return False
315
+ if not 200 <= status < 300:
316
+ self._report_error(f"HTTP {status} from transcript endpoint")
317
+ return False
318
+ return True
319
+
320
+ def _report_error(self, message: str) -> None:
321
+ now = self.ports.epoch()
322
+ if message == self._last_error and now - self._last_error_at < 30.0:
323
+ return
324
+ self._last_error = message
325
+ self._last_error_at = now
326
+ self.ports.log("WARN", f"transcript_delta_delivery_failed {message}")
327
+
328
+
329
+ __all__ = [
330
+ "TranscriptDeliveryPorts",
331
+ "TranscriptDeliverySettings",
332
+ "TranscriptDeltaDeliveryService",
333
+ ]
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import threading
7
7
  import time
8
+ import uuid
8
9
  from dataclasses import asdict, dataclass
9
10
  from pathlib import Path
10
11
  from typing import Callable, Protocol
@@ -20,17 +21,57 @@ class UsageEvent:
20
21
  protocol: str = ""
21
22
  status: str = "completed"
22
23
  timestamp: float = 0.0
24
+ event_id: str = ""
25
+ runtime: str = ""
26
+ workspace_id: str = ""
27
+ session_id: str = ""
28
+ turn_id: str = ""
29
+ cache_read_input_tokens: int = 0
30
+ cache_write_input_tokens: int = 0
31
+ input_tokens_total: int = 0
32
+ reasoning_output_tokens: int = 0
33
+ request_started_at: float = 0.0
34
+ request_completed_at: float = 0.0
35
+ duration_ms: float = 0.0
36
+ usage_source: str = "router_response"
37
+ is_estimated: bool = False
38
+ is_incomplete: bool = False
23
39
 
24
40
  def normalized(self, clock: Callable[[], float] = time.time) -> UsageEvent:
41
+ timestamp = float(self.timestamp or clock())
42
+ completed_at = float(self.request_completed_at or timestamp)
43
+ started_at = float(self.request_started_at or completed_at)
44
+ input_tokens = max(0, int(self.input_tokens or 0))
45
+ cache_read = max(0, int(self.cache_read_input_tokens or 0))
46
+ cache_write = max(0, int(self.cache_write_input_tokens or 0))
25
47
  return UsageEvent(
26
48
  provider=str(self.provider or "").strip(),
27
49
  model=str(self.model or "").strip(),
28
- input_tokens=max(0, int(self.input_tokens or 0)),
50
+ input_tokens=input_tokens,
29
51
  output_tokens=max(0, int(self.output_tokens or 0)),
30
52
  request_id=str(self.request_id or "").strip(),
31
53
  protocol=str(self.protocol or "").strip(),
32
54
  status=str(self.status or "completed").strip() or "completed",
33
- timestamp=float(self.timestamp or clock()),
55
+ timestamp=timestamp,
56
+ event_id=str(self.event_id or uuid.uuid4().hex).strip(),
57
+ runtime=str(self.runtime or "").strip(),
58
+ workspace_id=str(self.workspace_id or "").strip(),
59
+ session_id=str(self.session_id or "").strip(),
60
+ turn_id=str(self.turn_id or "").strip(),
61
+ cache_read_input_tokens=cache_read,
62
+ cache_write_input_tokens=cache_write,
63
+ input_tokens_total=max(
64
+ 0,
65
+ int(self.input_tokens_total or (input_tokens + cache_read + cache_write)),
66
+ ),
67
+ reasoning_output_tokens=max(0, int(self.reasoning_output_tokens or 0)),
68
+ request_started_at=started_at,
69
+ request_completed_at=completed_at,
70
+ duration_ms=max(0.0, float(self.duration_ms or 0.0)),
71
+ usage_source=str(self.usage_source or "router_response").strip()
72
+ or "router_response",
73
+ is_estimated=bool(self.is_estimated),
74
+ is_incomplete=bool(self.is_incomplete),
34
75
  )
35
76
 
36
77
 
@@ -78,8 +119,9 @@ class CompositeUsageEventSink:
78
119
  self._sinks = sinks
79
120
 
80
121
  def record(self, event: UsageEvent) -> None:
122
+ normalized = event.normalized()
81
123
  for sink in self._sinks:
82
- sink.record(event)
124
+ sink.record(normalized)
83
125
 
84
126
 
85
127
  def summarize_usage(events: list[UsageEvent]) -> dict[tuple[str, str], dict[str, int]]: