@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
@@ -0,0 +1,871 @@
1
+ """Workspace usage ledger, authenticated HTTP API, and reliable push delivery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from contextlib import contextmanager
7
+ from dataclasses import asdict, dataclass, field, replace
8
+ from datetime import datetime, timezone
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import secrets
15
+ import sqlite3
16
+ import threading
17
+ import time
18
+ from typing import Any, Callable
19
+ import urllib.error
20
+ import urllib.request
21
+
22
+ from .remote_instructions import expand_environment_references
23
+ from .router_access import router_request_bearer_token
24
+ from .usage_events import UsageEvent
25
+
26
+
27
+ def _utc_text(epoch: float) -> str:
28
+ return datetime.fromtimestamp(epoch, timezone.utc).isoformat().replace("+00:00", "Z")
29
+
30
+
31
+ def _parse_time(value: str | None, default: float) -> float:
32
+ text = str(value or "").strip()
33
+ if not text:
34
+ return default
35
+ try:
36
+ return float(text)
37
+ except ValueError:
38
+ pass
39
+ try:
40
+ return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
41
+ except ValueError as exc:
42
+ raise ValueError(f"invalid time value: {text}") from exc
43
+
44
+
45
+ class SqliteUsageLedger:
46
+ """Restart-safe workspace ledger and delivery cursor repository."""
47
+
48
+ def __init__(self, path: Path, workspace_id: str, clock: Callable[[], float] = time.time) -> None:
49
+ self.path = path
50
+ self.workspace_id = workspace_id
51
+ self.clock = clock
52
+ self._lock = threading.RLock()
53
+ self._condition = threading.Condition(self._lock)
54
+ self._initialized = False
55
+
56
+ def _connect(self) -> sqlite3.Connection:
57
+ self.path.parent.mkdir(parents=True, exist_ok=True)
58
+ connection = sqlite3.connect(self.path, timeout=10.0)
59
+ connection.row_factory = sqlite3.Row
60
+ connection.execute("PRAGMA journal_mode=WAL")
61
+ connection.execute("PRAGMA busy_timeout=10000")
62
+ return connection
63
+
64
+ @contextmanager
65
+ def _database(self):
66
+ connection = self._connect()
67
+ try:
68
+ with connection:
69
+ yield connection
70
+ finally:
71
+ connection.close()
72
+
73
+ def _ensure(self) -> None:
74
+ if self._initialized:
75
+ return
76
+ with self._lock:
77
+ if self._initialized:
78
+ return
79
+ with self._database() as db:
80
+ db.executescript(
81
+ """
82
+ CREATE TABLE IF NOT EXISTS usage_events (
83
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
84
+ event_id TEXT NOT NULL UNIQUE,
85
+ timestamp REAL NOT NULL,
86
+ request_started_at REAL NOT NULL,
87
+ request_completed_at REAL NOT NULL,
88
+ duration_ms REAL NOT NULL,
89
+ workspace_id TEXT NOT NULL,
90
+ runtime TEXT NOT NULL,
91
+ session_id TEXT NOT NULL,
92
+ turn_id TEXT NOT NULL,
93
+ request_id TEXT NOT NULL,
94
+ provider TEXT NOT NULL,
95
+ model TEXT NOT NULL,
96
+ protocol TEXT NOT NULL,
97
+ status TEXT NOT NULL,
98
+ input_tokens INTEGER NOT NULL,
99
+ output_tokens INTEGER NOT NULL,
100
+ cache_read_input_tokens INTEGER NOT NULL,
101
+ cache_write_input_tokens INTEGER NOT NULL,
102
+ input_tokens_total INTEGER NOT NULL,
103
+ reasoning_output_tokens INTEGER NOT NULL,
104
+ usage_source TEXT NOT NULL,
105
+ is_estimated INTEGER NOT NULL,
106
+ is_incomplete INTEGER NOT NULL
107
+ );
108
+ CREATE INDEX IF NOT EXISTS usage_events_completed_idx
109
+ ON usage_events(request_completed_at, seq);
110
+ CREATE INDEX IF NOT EXISTS usage_events_route_idx
111
+ ON usage_events(provider, model, request_completed_at);
112
+ CREATE INDEX IF NOT EXISTS usage_events_runtime_idx
113
+ ON usage_events(runtime, request_completed_at);
114
+ CREATE TABLE IF NOT EXISTS usage_delivery_cursors (
115
+ destination_key TEXT PRIMARY KEY,
116
+ endpoint_id TEXT NOT NULL,
117
+ event_cursor INTEGER NOT NULL,
118
+ audit_start REAL NOT NULL,
119
+ audit_end REAL NOT NULL,
120
+ updated_at REAL NOT NULL
121
+ );
122
+ CREATE TABLE IF NOT EXISTS usage_api_keys (
123
+ key_id TEXT PRIMARY KEY,
124
+ name TEXT NOT NULL,
125
+ secret_hash TEXT NOT NULL UNIQUE,
126
+ scopes TEXT NOT NULL,
127
+ created_at REAL NOT NULL,
128
+ expires_at REAL NOT NULL,
129
+ revoked_at REAL NOT NULL,
130
+ last_used_at REAL NOT NULL
131
+ );
132
+ CREATE TABLE IF NOT EXISTS usage_import_cursors (
133
+ source_path TEXT PRIMARY KEY,
134
+ source_head_sha256 TEXT NOT NULL,
135
+ byte_offset INTEGER NOT NULL,
136
+ updated_at REAL NOT NULL
137
+ );
138
+ """
139
+ )
140
+ self._initialized = True
141
+
142
+ def record(self, event: UsageEvent) -> None:
143
+ normalized = event.normalized(self.clock)
144
+ if not normalized.workspace_id:
145
+ normalized = replace(normalized, workspace_id=self.workspace_id)
146
+ with self._condition:
147
+ self._ensure()
148
+ values = asdict(normalized)
149
+ with self._database() as db:
150
+ db.execute(
151
+ """
152
+ INSERT OR IGNORE INTO usage_events (
153
+ event_id,timestamp,request_started_at,request_completed_at,duration_ms,
154
+ workspace_id,runtime,session_id,turn_id,request_id,provider,model,
155
+ protocol,status,input_tokens,output_tokens,cache_read_input_tokens,
156
+ cache_write_input_tokens,input_tokens_total,reasoning_output_tokens,
157
+ usage_source,is_estimated,is_incomplete
158
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
159
+ """,
160
+ (
161
+ values["event_id"], values["timestamp"], values["request_started_at"],
162
+ values["request_completed_at"], values["duration_ms"], values["workspace_id"],
163
+ values["runtime"], values["session_id"], values["turn_id"], values["request_id"],
164
+ values["provider"], values["model"], values["protocol"], values["status"],
165
+ values["input_tokens"], values["output_tokens"],
166
+ values["cache_read_input_tokens"], values["cache_write_input_tokens"],
167
+ values["input_tokens_total"], values["reasoning_output_tokens"],
168
+ values["usage_source"],
169
+ int(values["is_estimated"]), int(values["is_incomplete"]),
170
+ ),
171
+ )
172
+ self._condition.notify_all()
173
+
174
+ @staticmethod
175
+ def _row(row: sqlite3.Row) -> dict[str, Any]:
176
+ value = dict(row)
177
+ value["is_estimated"] = bool(value["is_estimated"])
178
+ value["is_incomplete"] = bool(value["is_incomplete"])
179
+ value["observed_at"] = _utc_text(float(value["timestamp"]))
180
+ value["request_started_at_iso"] = _utc_text(float(value["request_started_at"]))
181
+ value["request_completed_at_iso"] = _utc_text(float(value["request_completed_at"]))
182
+ value["total_tokens"] = value["input_tokens_total"] + int(value["output_tokens"])
183
+ return value
184
+
185
+ def events(
186
+ self,
187
+ *,
188
+ start: float = 0.0,
189
+ end: float | None = None,
190
+ after: int = 0,
191
+ limit: int = 200,
192
+ runtime: str = "",
193
+ provider: str = "",
194
+ model: str = "",
195
+ ) -> list[dict[str, Any]]:
196
+ self._ensure()
197
+ clauses = ["seq > ?", "request_completed_at >= ?", "request_completed_at <= ?"]
198
+ values: list[Any] = [max(0, int(after)), float(start), float(end or self.clock())]
199
+ for column, value in (("runtime", runtime), ("provider", provider), ("model", model)):
200
+ if value:
201
+ clauses.append(f"{column} = ?")
202
+ values.append(value)
203
+ values.append(max(1, min(1000, int(limit))))
204
+ with self._lock, self._database() as db:
205
+ rows = db.execute(
206
+ "SELECT * FROM usage_events WHERE " + " AND ".join(clauses) + " ORDER BY seq LIMIT ?",
207
+ values,
208
+ ).fetchall()
209
+ return [self._row(row) for row in rows]
210
+
211
+ def max_sequence(self) -> int:
212
+ self._ensure()
213
+ with self._lock, self._database() as db:
214
+ row = db.execute("SELECT COALESCE(MAX(seq), 0) AS value FROM usage_events").fetchone()
215
+ return int(row["value"])
216
+
217
+ def wait_after(self, after: int, timeout: float) -> list[dict[str, Any]]:
218
+ events = self.events(after=after, limit=200)
219
+ if events:
220
+ return events
221
+ with self._condition:
222
+ self._condition.wait(max(0.0, timeout))
223
+ return self.events(after=after, limit=200)
224
+
225
+ def summary(self, start: float, end: float) -> dict[str, Any]:
226
+ self._ensure()
227
+ with self._lock, self._database() as db:
228
+ totals = db.execute(
229
+ """
230
+ SELECT COUNT(*) AS requests, COALESCE(MIN(seq),0) AS first_sequence,
231
+ COALESCE(MAX(seq),0) AS last_sequence,
232
+ COALESCE(SUM(input_tokens),0) AS input_tokens,
233
+ COALESCE(SUM(output_tokens),0) AS output_tokens,
234
+ COALESCE(SUM(cache_read_input_tokens),0) AS cache_read_input_tokens,
235
+ COALESCE(SUM(cache_write_input_tokens),0) AS cache_write_input_tokens,
236
+ COALESCE(SUM(input_tokens_total),0) AS input_tokens_total,
237
+ COALESCE(SUM(reasoning_output_tokens),0) AS reasoning_output_tokens,
238
+ COALESCE(SUM(is_estimated),0) AS estimated_events,
239
+ COALESCE(SUM(is_incomplete),0) AS incomplete_events
240
+ FROM usage_events WHERE request_completed_at >= ? AND request_completed_at <= ?
241
+ """,
242
+ (start, end),
243
+ ).fetchone()
244
+ groups = db.execute(
245
+ """
246
+ SELECT runtime,provider,model,COUNT(*) AS requests,
247
+ COALESCE(SUM(input_tokens),0) AS input_tokens,
248
+ COALESCE(SUM(output_tokens),0) AS output_tokens,
249
+ COALESCE(SUM(cache_read_input_tokens),0) AS cache_read_input_tokens,
250
+ COALESCE(SUM(cache_write_input_tokens),0) AS cache_write_input_tokens,
251
+ COALESCE(SUM(input_tokens_total),0) AS input_tokens_total,
252
+ COALESCE(SUM(reasoning_output_tokens),0) AS reasoning_output_tokens
253
+ FROM usage_events WHERE request_completed_at >= ? AND request_completed_at <= ?
254
+ GROUP BY runtime,provider,model ORDER BY runtime,provider,model
255
+ """,
256
+ (start, end),
257
+ ).fetchall()
258
+ total = dict(totals)
259
+ total["total_tokens"] = total["input_tokens_total"] + total["output_tokens"]
260
+ duration_seconds = max(0.0, end - start)
261
+ hours = duration_seconds / 3600.0
262
+ cache_denominator = max(0, int(total["input_tokens_total"]))
263
+ rates = {
264
+ "duration_seconds": duration_seconds,
265
+ "requests_per_hour": (float(total["requests"]) / hours) if hours else 0.0,
266
+ "tokens_per_hour": (float(total["total_tokens"]) / hours) if hours else 0.0,
267
+ "cache_read_ratio": (
268
+ float(total["cache_read_input_tokens"]) / cache_denominator
269
+ if cache_denominator
270
+ else 0.0
271
+ ),
272
+ }
273
+ projected_groups = []
274
+ for raw in groups:
275
+ group = dict(raw)
276
+ group["total_tokens"] = group["input_tokens_total"] + group["output_tokens"]
277
+ projected_groups.append(group)
278
+ return {
279
+ "period": {"from": _utc_text(start), "to": _utc_text(end), "from_epoch": start, "to_epoch": end},
280
+ "workspace_id": self.workspace_id,
281
+ "totals": total,
282
+ "rates": rates,
283
+ "groups": projected_groups,
284
+ }
285
+
286
+ def delivery_cursor(self, key: str, endpoint_id: str, *, tail: bool, now: float) -> dict[str, Any]:
287
+ self._ensure()
288
+ with self._lock, self._database() as db:
289
+ row = db.execute(
290
+ "SELECT * FROM usage_delivery_cursors WHERE destination_key = ?", (key,)
291
+ ).fetchone()
292
+ if row is not None:
293
+ return dict(row)
294
+ cursor = self.max_sequence() if tail else 0
295
+ db.execute(
296
+ "INSERT INTO usage_delivery_cursors VALUES (?,?,?,?,?,?)",
297
+ (key, endpoint_id, cursor, now, now, now),
298
+ )
299
+ return {"destination_key": key, "endpoint_id": endpoint_id, "event_cursor": cursor,
300
+ "audit_start": now, "audit_end": now, "updated_at": now}
301
+
302
+ def update_delivery_cursor(self, key: str, *, event_cursor: int | None = None,
303
+ audit_start: float | None = None, audit_end: float | None = None) -> None:
304
+ self._ensure()
305
+ assignments = ["updated_at = ?"]
306
+ values: list[Any] = [self.clock()]
307
+ for column, value in (("event_cursor", event_cursor), ("audit_start", audit_start), ("audit_end", audit_end)):
308
+ if value is not None:
309
+ assignments.append(f"{column} = ?")
310
+ values.append(value)
311
+ values.append(key)
312
+ with self._lock, self._database() as db:
313
+ db.execute("UPDATE usage_delivery_cursors SET " + ",".join(assignments) + " WHERE destination_key = ?", values)
314
+
315
+ def import_cursor(self, source_path: str) -> dict[str, Any] | None:
316
+ self._ensure()
317
+ with self._lock, self._database() as db:
318
+ row = db.execute(
319
+ "SELECT * FROM usage_import_cursors WHERE source_path = ?",
320
+ (source_path,),
321
+ ).fetchone()
322
+ return dict(row) if row is not None else None
323
+
324
+ def update_import_cursor(self, source_path: str, head_sha256: str, byte_offset: int) -> None:
325
+ self._ensure()
326
+ with self._lock, self._database() as db:
327
+ db.execute(
328
+ """INSERT INTO usage_import_cursors
329
+ (source_path,source_head_sha256,byte_offset,updated_at)
330
+ VALUES (?,?,?,?)
331
+ ON CONFLICT(source_path) DO UPDATE SET
332
+ source_head_sha256=excluded.source_head_sha256,
333
+ byte_offset=excluded.byte_offset,updated_at=excluded.updated_at""",
334
+ (source_path, head_sha256, max(0, int(byte_offset)), self.clock()),
335
+ )
336
+
337
+
338
+ class LegacyUsageBackfillService:
339
+ """Incrementally import complete legacy usage JSONL records into the ledger."""
340
+
341
+ def __init__(self, ledger: SqliteUsageLedger, log: Callable[[str, str], None]) -> None:
342
+ self.ledger = ledger
343
+ self.log = log
344
+
345
+ @staticmethod
346
+ def discover(config_dir: Path, workspace_state_dir: Path, workspace_id: str,
347
+ current_path: Path) -> list[Path]:
348
+ candidates = {current_path, current_path.with_suffix(".jsonl.1")}
349
+ roots = (
350
+ config_dir / "router-instances",
351
+ workspace_state_dir / "router-instances",
352
+ )
353
+ for root in roots:
354
+ if not root.is_dir():
355
+ continue
356
+ for path in root.glob("*/usage-events.jsonl*"):
357
+ parent = path.parent.name
358
+ if root == config_dir / "router-instances" and not parent.endswith("-" + workspace_id):
359
+ continue
360
+ candidates.add(path)
361
+ return sorted((path for path in candidates if path.is_file()), key=lambda item: str(item))
362
+
363
+ def run(self, paths: list[Path]) -> dict[str, int]:
364
+ result = {"files": 0, "records": 0, "skipped": 0}
365
+ for path in paths:
366
+ imported, skipped = self._import_file(path)
367
+ result["files"] += 1
368
+ result["records"] += imported
369
+ result["skipped"] += skipped
370
+ if result["files"]:
371
+ self.log(
372
+ "INFO",
373
+ "usage_backfill_complete "
374
+ f"files={result['files']} records={result['records']} skipped={result['skipped']}",
375
+ )
376
+ return result
377
+
378
+ def _import_file(self, path: Path) -> tuple[int, int]:
379
+ try:
380
+ resolved = path.resolve()
381
+ size = resolved.stat().st_size
382
+ with resolved.open("rb") as stream:
383
+ head = stream.readline(4096)
384
+ except OSError:
385
+ return 0, 0
386
+ source = str(resolved)
387
+ head_digest = hashlib.sha256(head).hexdigest()
388
+ cursor = self.ledger.import_cursor(source)
389
+ offset = int(cursor.get("byte_offset") or 0) if cursor else 0
390
+ if cursor and (str(cursor.get("source_head_sha256") or "") != head_digest or size < offset):
391
+ offset = 0
392
+ imported = 0
393
+ skipped = 0
394
+ committed_offset = offset
395
+ try:
396
+ with resolved.open("rb") as stream:
397
+ stream.seek(offset)
398
+ while True:
399
+ line = stream.readline()
400
+ if not line:
401
+ break
402
+ if not line.endswith(b"\n"):
403
+ break
404
+ committed_offset = stream.tell()
405
+ try:
406
+ raw = json.loads(line)
407
+ if not isinstance(raw, dict):
408
+ raise ValueError("usage row is not an object")
409
+ event = self._event(raw)
410
+ before = self.ledger.max_sequence()
411
+ self.ledger.record(event)
412
+ imported += int(self.ledger.max_sequence() > before)
413
+ except (TypeError, ValueError, json.JSONDecodeError):
414
+ skipped += 1
415
+ except OSError as exc:
416
+ self.log("WARN", f"usage_backfill_read_failed path={resolved} error={type(exc).__name__}: {exc}")
417
+ return imported, skipped
418
+ self.ledger.update_import_cursor(source, head_digest, committed_offset)
419
+ return imported, skipped
420
+
421
+ def _event(self, raw: dict[str, Any]) -> UsageEvent:
422
+ event_id = str(raw.get("event_id") or "").strip()
423
+ if not event_id:
424
+ canonical = json.dumps(raw, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
425
+ event_id = "legacy_" + hashlib.sha256(canonical).hexdigest()
426
+ timestamp = float(raw.get("timestamp") or 0)
427
+ cache_read = int(raw.get("cache_read_input_tokens") or 0)
428
+ cache_write = int(raw.get("cache_write_input_tokens") or 0)
429
+ input_tokens = int(raw.get("input_tokens") or 0)
430
+ detailed = any(
431
+ key in raw
432
+ for key in (
433
+ "cache_read_input_tokens", "cache_write_input_tokens",
434
+ "reasoning_output_tokens", "input_tokens_total",
435
+ )
436
+ )
437
+ return UsageEvent(
438
+ provider=str(raw.get("provider") or ""), model=str(raw.get("model") or ""),
439
+ input_tokens=input_tokens, output_tokens=int(raw.get("output_tokens") or 0),
440
+ request_id=str(raw.get("request_id") or ""), protocol=str(raw.get("protocol") or ""),
441
+ status=str(raw.get("status") or "completed"), timestamp=timestamp, event_id=event_id,
442
+ runtime=str(raw.get("runtime") or ""),
443
+ workspace_id=str(raw.get("workspace_id") or self.ledger.workspace_id),
444
+ session_id=str(raw.get("session_id") or ""), turn_id=str(raw.get("turn_id") or ""),
445
+ cache_read_input_tokens=cache_read, cache_write_input_tokens=cache_write,
446
+ input_tokens_total=int(raw.get("input_tokens_total") or (input_tokens + cache_read + cache_write)),
447
+ reasoning_output_tokens=int(raw.get("reasoning_output_tokens") or 0),
448
+ request_started_at=float(raw.get("request_started_at") or timestamp),
449
+ request_completed_at=float(raw.get("request_completed_at") or timestamp),
450
+ duration_ms=float(raw.get("duration_ms") or 0),
451
+ usage_source=str(raw.get("usage_source") or "legacy_ciel_jsonl"),
452
+ is_estimated=bool(raw.get("is_estimated", False)),
453
+ is_incomplete=bool(raw.get("is_incomplete", not detailed)),
454
+ )
455
+
456
+
457
+ class UsageApiKeyRepository:
458
+ def __init__(self, ledger: SqliteUsageLedger, pepper_path: Path, environ: Mapping[str, str] = os.environ,
459
+ clock: Callable[[], float] = time.time) -> None:
460
+ self.ledger = ledger
461
+ self.pepper_path = pepper_path
462
+ self.environ = environ
463
+ self.clock = clock
464
+ self._lock = threading.Lock()
465
+
466
+ def _pepper(self) -> bytes:
467
+ try:
468
+ return self.pepper_path.read_bytes()
469
+ except OSError:
470
+ with self._lock:
471
+ try:
472
+ return self.pepper_path.read_bytes()
473
+ except OSError:
474
+ self.pepper_path.parent.mkdir(parents=True, exist_ok=True)
475
+ value = secrets.token_bytes(32)
476
+ temporary = self.pepper_path.with_name(f"{self.pepper_path.name}.{os.getpid()}.tmp")
477
+ temporary.write_bytes(value)
478
+ os.chmod(temporary, 0o600)
479
+ temporary.replace(self.pepper_path)
480
+ return value
481
+
482
+ def _hash(self, secret: str) -> str:
483
+ return hmac.new(self._pepper(), secret.encode("utf-8"), hashlib.sha256).hexdigest()
484
+
485
+ def issue(self, name: str, scopes: list[str] | tuple[str, ...] = ("usage:read", "usage:stream"),
486
+ expires_at: float = 0.0, *, secret: str = "", key_id: str = "") -> dict[str, Any]:
487
+ self.ledger._ensure()
488
+ value = secret or f"cu_{secrets.token_urlsafe(32)}"
489
+ identifier = key_id or f"uk_{secrets.token_hex(8)}"
490
+ normalized_scopes = sorted({scope for scope in scopes if scope in {"usage:read", "usage:stream"}})
491
+ if not normalized_scopes:
492
+ raise ValueError("at least one usage scope is required")
493
+ now = self.clock()
494
+ with self.ledger._lock, self.ledger._database() as db:
495
+ db.execute(
496
+ """INSERT INTO usage_api_keys
497
+ (key_id,name,secret_hash,scopes,created_at,expires_at,revoked_at,last_used_at)
498
+ VALUES (?,?,?,?,?,?,0,0)
499
+ ON CONFLICT(key_id) DO UPDATE SET name=excluded.name,
500
+ secret_hash=excluded.secret_hash,scopes=excluded.scopes,
501
+ expires_at=excluded.expires_at,revoked_at=0""",
502
+ (identifier, name or identifier, self._hash(value), json.dumps(normalized_scopes), now, max(0.0, expires_at)),
503
+ )
504
+ return {"key_id": identifier, "name": name or identifier, "api_key": value,
505
+ "scopes": normalized_scopes, "created_at": now, "expires_at": max(0.0, expires_at)}
506
+
507
+ def authenticate(self, handler: Any, required_scope: str) -> dict[str, Any] | None:
508
+ supplied = router_request_bearer_token(handler)
509
+ if not supplied:
510
+ try:
511
+ supplied = str(handler.headers.get("x-ciel-usage-key") or "").strip()
512
+ except Exception:
513
+ supplied = ""
514
+ if not supplied:
515
+ return None
516
+ digest = self._hash(supplied)
517
+ self.ledger._ensure()
518
+ now = self.clock()
519
+ with self.ledger._lock, self.ledger._database() as db:
520
+ row = db.execute("SELECT * FROM usage_api_keys WHERE secret_hash = ?", (digest,)).fetchone()
521
+ if row is None or float(row["revoked_at"]) > 0 or (float(row["expires_at"]) > 0 and float(row["expires_at"]) <= now):
522
+ return None
523
+ scopes = json.loads(str(row["scopes"]))
524
+ if required_scope not in scopes:
525
+ return None
526
+ db.execute("UPDATE usage_api_keys SET last_used_at = ? WHERE key_id = ?", (now, row["key_id"]))
527
+ return {"key_id": row["key_id"], "name": row["name"], "scopes": scopes}
528
+
529
+ def list(self) -> list[dict[str, Any]]:
530
+ self.ledger._ensure()
531
+ with self.ledger._lock, self.ledger._database() as db:
532
+ rows = db.execute("SELECT key_id,name,scopes,created_at,expires_at,revoked_at,last_used_at FROM usage_api_keys ORDER BY created_at").fetchall()
533
+ return [{**dict(row), "scopes": json.loads(str(row["scopes"]))} for row in rows]
534
+
535
+ def revoke(self, key_id: str) -> bool:
536
+ self.ledger._ensure()
537
+ with self.ledger._lock, self.ledger._database() as db:
538
+ result = db.execute("UPDATE usage_api_keys SET revoked_at = ? WHERE key_id = ? AND revoked_at = 0", (self.clock(), key_id))
539
+ return result.rowcount > 0
540
+
541
+ def bootstrap_environment(self) -> int:
542
+ raw = str(self.environ.get("CIEL_RUNTIME_USAGE_API_KEYS") or "").strip()
543
+ single = str(self.environ.get("CIEL_RUNTIME_USAGE_API_KEY") or "").strip()
544
+ entries: list[dict[str, Any]] = []
545
+ if raw:
546
+ try:
547
+ parsed = json.loads(raw)
548
+ if isinstance(parsed, dict):
549
+ entries = [{"id": key, "key": value} for key, value in parsed.items()]
550
+ elif isinstance(parsed, list):
551
+ entries = [item for item in parsed if isinstance(item, dict)]
552
+ except json.JSONDecodeError:
553
+ for part in raw.split(","):
554
+ key_id, separator, value = part.partition("=")
555
+ if separator and key_id.strip() and value.strip():
556
+ entries.append({"id": key_id.strip(), "key": value.strip()})
557
+ if single:
558
+ entries.append({"id": "environment", "key": single})
559
+ count = 0
560
+ for item in entries:
561
+ secret = str(item.get("key") or item.get("secret") or "").strip()
562
+ if not secret:
563
+ continue
564
+ scopes = item.get("scopes") or ["usage:read", "usage:stream"]
565
+ self.issue(str(item.get("name") or item.get("id") or "environment"), list(scopes),
566
+ float(item.get("expires_at") or 0), secret=secret,
567
+ key_id=str(item.get("id") or f"env_{hashlib.sha256(secret.encode()).hexdigest()[:12]}"))
568
+ count += 1
569
+ return count
570
+
571
+
572
+ @dataclass(frozen=True, slots=True)
573
+ class UsagePushEndpoint:
574
+ endpoint_id: str
575
+ url: str
576
+ authorization: str
577
+ timeout_seconds: float = 5.0
578
+ poll_interval_seconds: float = 1.0
579
+ start_mode: str = "tail"
580
+ audit_interval_seconds: float = 86400.0
581
+ audit_emit_on_start: bool = True
582
+
583
+
584
+ def usage_push_endpoints(config: dict[str, Any], environ: Mapping[str, str]) -> list[UsagePushEndpoint]:
585
+ usage = config.get("usage") if isinstance(config.get("usage"), dict) else {}
586
+ raw_items = usage.get("push_endpoints") if isinstance(usage, dict) else []
587
+ items = list(raw_items) if isinstance(raw_items, list) else []
588
+ environment_json = str(environ.get("CIEL_RUNTIME_USAGE_PUSH_ENDPOINTS") or "").strip()
589
+ if environment_json:
590
+ parsed = json.loads(environment_json)
591
+ items = parsed if isinstance(parsed, list) else [parsed]
592
+ elif str(environ.get("CIEL_RUNTIME_USAGE_PUSH_URL") or "").strip():
593
+ items = [{
594
+ "id": str(environ.get("CIEL_RUNTIME_USAGE_PUSH_ID") or "environment"),
595
+ "url": str(environ.get("CIEL_RUNTIME_USAGE_PUSH_URL") or ""),
596
+ "authorization": str(environ.get("CIEL_RUNTIME_USAGE_PUSH_AUTHORIZATION") or ""),
597
+ "api_key": str(environ.get("CIEL_RUNTIME_USAGE_PUSH_API_KEY") or ""),
598
+ "audit_interval_seconds": str(environ.get("CIEL_RUNTIME_USAGE_AUDIT_INTERVAL_SECONDS") or "86400"),
599
+ }]
600
+ endpoints: list[UsagePushEndpoint] = []
601
+ for index, raw in enumerate(items):
602
+ if not isinstance(raw, dict) or raw.get("enabled", True) is False:
603
+ continue
604
+ url = str(raw.get("url") or "").strip()
605
+ if not url:
606
+ continue
607
+ authorization = str(raw.get("authorization") or "").strip()
608
+ if not authorization and str(raw.get("api_key") or "").strip():
609
+ authorization = "Bearer " + str(raw["api_key"]).strip()
610
+ endpoints.append(UsagePushEndpoint(
611
+ endpoint_id=str(raw.get("id") or f"endpoint-{index + 1}"), url=url,
612
+ authorization=authorization,
613
+ timeout_seconds=max(0.1, min(30.0, float(raw.get("timeout_seconds") or 5))),
614
+ poll_interval_seconds=max(0.1, min(60.0, float(raw.get("poll_interval_seconds") or 1))),
615
+ start_mode="beginning" if str(raw.get("start_mode") or "tail").lower() == "beginning" else "tail",
616
+ audit_interval_seconds=max(1.0, float(raw.get("audit_interval_seconds") or 86400)),
617
+ audit_emit_on_start=bool(raw.get("audit_emit_on_start", True)),
618
+ ))
619
+ return endpoints
620
+
621
+
622
+ def usage_jsonl_enabled(config: dict[str, Any], environ: Mapping[str, str]) -> bool:
623
+ usage = config.get("usage") if isinstance(config.get("usage"), dict) else {}
624
+ configured = bool(usage.get("jsonl_enabled", True))
625
+ raw = str(environ.get("CIEL_RUNTIME_USAGE_LOG", str(configured))).strip().lower()
626
+ return raw not in {"0", "false", "off", "no", ""}
627
+
628
+
629
+ class UsagePushDeliveryService:
630
+ def __init__(self, ledger: SqliteUsageLedger, load_config: Callable[[], dict[str, Any]],
631
+ environ: Mapping[str, str], log: Callable[[str, str], None],
632
+ clock: Callable[[], float] = time.time) -> None:
633
+ self.ledger = ledger
634
+ self.load_config = load_config
635
+ self.environ = environ
636
+ self.log = log
637
+ self.clock = clock
638
+ self._stop = threading.Event()
639
+ self._thread: threading.Thread | None = None
640
+
641
+ def start(self) -> None:
642
+ if self._thread is not None and self._thread.is_alive():
643
+ return
644
+ self._stop.clear()
645
+ self._thread = threading.Thread(target=self._run, name="ciel-usage-push", daemon=True)
646
+ self._thread.start()
647
+
648
+ def stop(self, timeout: float = 2.0) -> None:
649
+ self._stop.set()
650
+ if self._thread is not None and self._thread is not threading.current_thread():
651
+ self._thread.join(max(0.0, timeout))
652
+
653
+ def _run(self) -> None:
654
+ while not self._stop.is_set():
655
+ try:
656
+ endpoints = usage_push_endpoints(self.load_config(), self.environ)
657
+ self.poll_once(endpoints)
658
+ delay = min((endpoint.poll_interval_seconds for endpoint in endpoints), default=1.0)
659
+ except Exception as exc:
660
+ self.log("WARN", f"usage_push_poll_failed error={type(exc).__name__}: {exc}")
661
+ delay = 1.0
662
+ self._stop.wait(delay)
663
+
664
+ def poll_once(self, endpoints: list[UsagePushEndpoint] | None = None) -> int:
665
+ delivered = 0
666
+ for endpoint in endpoints if endpoints is not None else usage_push_endpoints(self.load_config(), self.environ):
667
+ authorization, missing = expand_environment_references(endpoint.authorization)
668
+ if missing:
669
+ self.log("WARN", "usage_push_authorization_missing " + ",".join(missing))
670
+ continue
671
+ key = hashlib.sha256(f"{endpoint.endpoint_id}\0{endpoint.url}\0{authorization}".encode()).hexdigest()
672
+ now = self.clock()
673
+ cursor = self.ledger.delivery_cursor(key, endpoint.endpoint_id, tail=endpoint.start_mode == "tail", now=now)
674
+ events = self.ledger.events(after=int(cursor["event_cursor"]), limit=1)
675
+ if events:
676
+ event = events[0]
677
+ cloud = self._cloud_event("ai.oneciel.ciel-runtime.usage.recorded", event["event_id"], event)
678
+ if self._post(endpoint, authorization, cloud):
679
+ self.ledger.update_delivery_cursor(key, event_cursor=int(event["seq"]))
680
+ delivered += 1
681
+ audit_start = float(cursor["audit_start"])
682
+ audit_end = float(cursor["audit_end"])
683
+ if endpoint.audit_emit_on_start and audit_end == audit_start:
684
+ audit_end = now - endpoint.audit_interval_seconds
685
+ audit_start = audit_end - endpoint.audit_interval_seconds
686
+ self.ledger.update_delivery_cursor(
687
+ key,
688
+ audit_start=audit_start,
689
+ audit_end=audit_end,
690
+ )
691
+ due = now - audit_end >= endpoint.audit_interval_seconds
692
+ if due:
693
+ start = audit_end
694
+ end = audit_end + endpoint.audit_interval_seconds
695
+ snapshot = self.ledger.summary(start, end)
696
+ audit_id = hashlib.sha256(f"{key}\0{start:.6f}\0{end:.6f}".encode()).hexdigest()
697
+ cloud = self._cloud_event("ai.oneciel.ciel-runtime.usage.audit", audit_id, snapshot)
698
+ if self._post(endpoint, authorization, cloud):
699
+ self.ledger.update_delivery_cursor(key, audit_start=start, audit_end=end)
700
+ delivered += 1
701
+ return delivered
702
+
703
+ def _cloud_event(self, event_type: str, event_id: str, data: dict[str, Any]) -> dict[str, Any]:
704
+ return {"specversion": "1.0", "id": event_id, "source": f"urn:ciel-runtime:workspace:{self.ledger.workspace_id}",
705
+ "type": event_type, "time": _utc_text(self.clock()), "datacontenttype": "application/json", "data": data}
706
+
707
+ def _post(self, endpoint: UsagePushEndpoint, authorization: str, event: dict[str, Any]) -> bool:
708
+ payload = json.dumps(event, ensure_ascii=False, separators=(",", ":")).encode()
709
+ headers = {"Content-Type": "application/cloudevents+json", "Accept": "application/json",
710
+ "User-Agent": "ciel-runtime-usage-delivery/1", "Idempotency-Key": str(event["id"])}
711
+ if authorization:
712
+ headers["Authorization"] = authorization
713
+ request = urllib.request.Request(endpoint.url, data=payload, headers=headers, method="POST")
714
+ try:
715
+ with urllib.request.urlopen(request, timeout=endpoint.timeout_seconds) as response:
716
+ status = int(getattr(response, "status", 200) or 200)
717
+ response.read(4096)
718
+ except urllib.error.HTTPError as exc:
719
+ self.log("WARN", f"usage_push_failed endpoint={endpoint.endpoint_id} status={exc.code}")
720
+ return False
721
+ except (OSError, urllib.error.URLError, TimeoutError) as exc:
722
+ self.log("WARN", f"usage_push_failed endpoint={endpoint.endpoint_id} error={type(exc).__name__}: {exc}")
723
+ return False
724
+ return 200 <= status < 300
725
+
726
+
727
+ @dataclass(slots=True)
728
+ class UsageRuntimeServices:
729
+ ledger: SqliteUsageLedger
730
+ keys: UsageApiKeyRepository
731
+ push: UsagePushDeliveryService
732
+ config_dir: Path
733
+ workspace_state_dir: Path
734
+ workspace_id: str
735
+ current_jsonl: Path
736
+ log: Callable[[str, str], None]
737
+ environ: Mapping[str, str] = field(default_factory=lambda: os.environ)
738
+
739
+ def start(self) -> None:
740
+ backfill = LegacyUsageBackfillService(self.ledger, self.log)
741
+ paths = backfill.discover(
742
+ self.config_dir,
743
+ self.workspace_state_dir,
744
+ self.workspace_id,
745
+ self.current_jsonl,
746
+ )
747
+ config = self.push.load_config()
748
+ usage_config = config.get("usage") if isinstance(config.get("usage"), dict) else {}
749
+ configured_paths = usage_config.get("backfill_paths") if isinstance(usage_config, dict) else []
750
+ if isinstance(configured_paths, list):
751
+ paths = sorted({*paths, *(Path(str(value)) for value in configured_paths if str(value).strip())}, key=lambda item: str(item))
752
+ configured = str(self.environ.get("CIEL_RUNTIME_USAGE_BACKFILL_PATHS") or "").strip()
753
+ if configured:
754
+ try:
755
+ raw_paths = json.loads(configured)
756
+ values = raw_paths if isinstance(raw_paths, list) else [raw_paths]
757
+ except json.JSONDecodeError:
758
+ values = configured.split(os.pathsep)
759
+ paths = sorted({*paths, *(Path(str(value)) for value in values if str(value).strip())}, key=lambda item: str(item))
760
+ backfill.run(paths)
761
+ try:
762
+ imported = self.keys.bootstrap_environment()
763
+ if imported:
764
+ self.log("INFO", f"usage_api_keys_bootstrapped count={imported}")
765
+ except Exception as exc:
766
+ self.log("ERROR", f"usage_api_key_bootstrap_failed error={type(exc).__name__}: {exc}")
767
+ self.push.start()
768
+
769
+ def stop(self) -> None:
770
+ self.push.stop()
771
+
772
+
773
+ class UsageHttpAdapter:
774
+ def __init__(self, ledger: SqliteUsageLedger, keys: UsageApiKeyRepository,
775
+ write_json: Callable[..., Any], admin_reject: Callable[[Any, dict[str, Any] | None], bool],
776
+ load_config: Callable[[], dict[str, Any]], log: Callable[[str, str], None]) -> None:
777
+ self.ledger = ledger
778
+ self.keys = keys
779
+ self.write_json = write_json
780
+ self.admin_reject = admin_reject
781
+ self.load_config = load_config
782
+ self.log = log
783
+
784
+ def _unauthorized(self, handler: Any) -> None:
785
+ self.write_json(handler, {"ok": False, "error": "usage_api_key_required"}, 401)
786
+
787
+ def handle_get(self, handler: Any, path: str, query: dict[str, list[str]]) -> bool:
788
+ if path not in {"/ca/usage/events", "/ca/usage/snapshot", "/ca/usage/stream", "/ca/usage/keys"}:
789
+ return False
790
+ if path == "/ca/usage/keys":
791
+ if self.admin_reject(handler, self.load_config()):
792
+ return True
793
+ self.write_json(handler, {"ok": True, "keys": self.keys.list()})
794
+ return True
795
+ scope = "usage:stream" if path == "/ca/usage/stream" else "usage:read"
796
+ identity = self.keys.authenticate(handler, scope)
797
+ if identity is None:
798
+ self._unauthorized(handler)
799
+ return True
800
+ try:
801
+ now = time.time()
802
+ start = _parse_time((query.get("from") or [""])[0], now - 86400)
803
+ end = _parse_time((query.get("to") or [""])[0], now)
804
+ if end < start:
805
+ raise ValueError("to must be greater than or equal to from")
806
+ after = int((query.get("after") or ["0"])[0])
807
+ limit = int((query.get("limit") or ["200"])[0])
808
+ except ValueError as exc:
809
+ self.write_json(handler, {"ok": False, "error": "invalid_usage_query", "message": str(exc)}, 400)
810
+ return True
811
+ if path == "/ca/usage/snapshot":
812
+ self.write_json(handler, {"ok": True, "consumer_key_id": identity["key_id"], "snapshot": self.ledger.summary(start, end)})
813
+ return True
814
+ if path == "/ca/usage/events":
815
+ events = self.ledger.events(start=start, end=end, after=after, limit=limit,
816
+ runtime=(query.get("runtime") or [""])[0],
817
+ provider=(query.get("provider") or [""])[0],
818
+ model=(query.get("model") or [""])[0])
819
+ self.write_json(handler, {"ok": True, "consumer_key_id": identity["key_id"], "events": events,
820
+ "next_after": events[-1]["seq"] if events else after})
821
+ return True
822
+ return self._stream(handler, after)
823
+
824
+ def _stream(self, handler: Any, after: int) -> bool:
825
+ handler.send_response(200)
826
+ handler.send_header("content-type", "text/event-stream")
827
+ handler.send_header("cache-control", "no-cache")
828
+ handler.send_header("connection", "close")
829
+ handler.end_headers()
830
+ try:
831
+ while True:
832
+ events = self.ledger.wait_after(after, 15.0)
833
+ if not events:
834
+ handler.wfile.write(b": keepalive\n\n")
835
+ handler.wfile.flush()
836
+ continue
837
+ for event in events:
838
+ after = max(after, int(event["seq"]))
839
+ handler.wfile.write(f"id: {after}\nevent: usage\ndata: {json.dumps(event, ensure_ascii=False)}\n\n".encode())
840
+ handler.wfile.flush()
841
+ except (BrokenPipeError, ConnectionResetError):
842
+ return True
843
+ except Exception as exc:
844
+ self.log("DEBUG", f"usage stream closed: {type(exc).__name__}: {exc}")
845
+ return True
846
+
847
+ def handle_post(self, handler: Any, path: str, body: dict[str, Any]) -> bool:
848
+ if path != "/ca/usage/keys":
849
+ return False
850
+ action = str(body.get("action") or "issue").strip().lower()
851
+ if action == "revoke":
852
+ key_id = str(body.get("key_id") or "").strip()
853
+ if not key_id:
854
+ self.write_json(handler, {"ok": False, "error": "key_id_required"}, 400)
855
+ else:
856
+ self.write_json(handler, {"ok": self.keys.revoke(key_id), "key_id": key_id})
857
+ return True
858
+ try:
859
+ result = self.keys.issue(str(body.get("name") or "usage-consumer"), list(body.get("scopes") or ["usage:read", "usage:stream"]),
860
+ _parse_time(str(body.get("expires_at") or ""), 0.0) if body.get("expires_at") else 0.0)
861
+ except (TypeError, ValueError) as exc:
862
+ self.write_json(handler, {"ok": False, "error": "invalid_usage_key", "message": str(exc)}, 400)
863
+ return True
864
+ self.write_json(handler, {"ok": True, **result}, 201)
865
+ return True
866
+
867
+
868
+ __all__ = [
869
+ "LegacyUsageBackfillService", "SqliteUsageLedger", "UsageApiKeyRepository", "UsageHttpAdapter",
870
+ "UsagePushDeliveryService", "UsagePushEndpoint", "UsageRuntimeServices", "usage_jsonl_enabled", "usage_push_endpoints",
871
+ ]