@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.
- package/CHANGELOG.md +108 -0
- package/README.md +150 -57
- package/ciel-runtime-stop.cmd +1 -0
- package/ciel-runtime.cmd +1 -0
- package/ciel-runtimectl.cmd +1 -0
- package/ciel_runtime.py +56 -53
- package/ciel_runtime_support/anthropic_tool_turns.py +81 -0
- package/ciel_runtime_support/architecture.py +17 -0
- package/ciel_runtime_support/channel_injection.py +113 -3
- package/ciel_runtime_support/channel_message_prompt.py +15 -0
- package/ciel_runtime_support/channel_pending_injection.py +28 -4
- package/ciel_runtime_support/channel_pending_poll.py +2 -0
- package/ciel_runtime_support/channel_terminal_context.py +6 -0
- package/ciel_runtime_support/channel_terminal_dispatch.py +2 -0
- package/ciel_runtime_support/channel_terminal_proxy.py +8 -2
- package/ciel_runtime_support/channel_wake_claim_repository.py +7 -0
- package/ciel_runtime_support/channel_wake_context.py +7 -3
- package/ciel_runtime_support/cli_parser.py +6 -0
- package/ciel_runtime_support/codex_backend_context.py +4 -0
- package/ciel_runtime_support/codex_turn_recovery.py +75 -12
- package/ciel_runtime_support/config_migrations.py +102 -10
- package/ciel_runtime_support/config_repository.py +9 -0
- package/ciel_runtime_support/event_settings_cli.py +284 -2
- package/ciel_runtime_support/openai_chat_passthrough.py +2 -0
- package/ciel_runtime_support/protocols/openai_responses.py +22 -18
- package/ciel_runtime_support/provider_adapters.py +7 -0
- package/ciel_runtime_support/provider_policy.py +10 -1
- package/ciel_runtime_support/provider_request_builder.py +9 -2
- package/ciel_runtime_support/provider_responses_passthrough.py +4 -0
- package/ciel_runtime_support/providers/__init__.py +2 -0
- package/ciel_runtime_support/providers/alibaba.py +33 -10
- package/ciel_runtime_support/providers/constants.py +1 -0
- package/ciel_runtime_support/providers/opencode.py +25 -5
- package/ciel_runtime_support/providers/opencode_go.py +3 -3
- package/ciel_runtime_support/providers/openrouter.py +76 -3
- package/ciel_runtime_support/providers/tabitoken.py +130 -0
- package/ciel_runtime_support/remote_instructions.py +20 -6
- package/ciel_runtime_support/remote_memory.py +515 -55
- package/ciel_runtime_support/responses_input_compatibility.py +14 -0
- package/ciel_runtime_support/router_http.py +5 -0
- package/ciel_runtime_support/runtime_constants.py +1 -1
- package/ciel_runtime_support/runtime_launch.py +9 -2
- package/ciel_runtime_support/runtime_paths.py +2 -0
- package/ciel_runtime_support/tool_side_effect_dedupe.py +7 -4
- package/ciel_runtime_support/transcript_delta_delivery.py +333 -0
- package/ciel_runtime_support/usage_events.py +45 -3
- package/ciel_runtime_support/usage_service.py +871 -0
- package/ciel_runtime_support/windows_conpty.py +108 -2
- package/ciel_runtime_support/workspace_mcp.py +4 -0
- package/docs/CLI-Reference.md +11 -0
- package/docs/Configuration.md +7 -2
- package/docs/MCP-Channels.md +7 -3
- package/docs/Module-Map.md +4 -0
- package/docs/Providers.md +32 -0
- package/docs/Remote-Memory.md +29 -10
- package/docs/Test-Suite.md +1 -0
- package/docs/journal/2026/08/20/kevin-wake-orphan-tool-repair.md +161 -0
- package/docs/journal/2026/08/20/nightly-push-local-deployment.md +59 -2
- package/docs/journal/2026/08/20/wing-remote-memory-replication-verification.md +107 -0
- package/docs/journal/2026/08/21/alibaba-singapore-qwen38-official-alignment.md +64 -0
- package/docs/journal/2026/08/21/codex-visible-sse-wake-submit-regression.md +85 -0
- package/docs/journal/2026/08/21/external-sse-wake-only-change-analysis.md +131 -0
- package/docs/journal/2026/08/21/hyundai-invalid-replayed-tool-name-repair.okf +88 -0
- package/docs/journal/2026/08/21/local-stale-temporary-runtime-pin-repair.okf +118 -0
- package/docs/journal/2026/08/21/transcript-compaction-external-events.okf +131 -0
- package/docs/journal/2026/08/21/wing-ai-net-mcp-boundary-diagnosis.okf +129 -0
- package/docs/journal/2026/08/21/wing-cold-start-wake-submit-repair.okf +161 -0
- package/docs/journal/2026/08/21/wing-powershell-memory-runaway.okf +101 -0
- package/docs/journal/2026/08/21/wing-repeated-reasoning-tool-turn-repair.okf +112 -0
- package/docs/journal/2026/08/21/wing-repeated-tool-guard-finalization.okf +81 -0
- package/docs/journal/2026/08/21/wing-working-directory-memory-deployment.md +110 -0
- package/docs/journal/2026/08/21/wing-workspace-state-memory-prompt-injection.md +88 -0
- package/docs/journal/2026/08/22/alibaba-singapore-dual-endpoint-wire-alignment.okf +142 -0
- package/docs/journal/2026/08/22/delluhiold-claude-compaction-empty-tool-name.okf +168 -0
- package/docs/journal/2026/08/22/remote-memory-relative-prompt-paths.okf +79 -0
- package/docs/journal/2026/08/22/remote-memory-root-system-prompt.okf +121 -0
- package/docs/journal/2026/08/23/claude-web-chat-stateless-mcp-repair.okf +97 -0
- package/docs/journal/2026/08/23/cross-runtime-token-usage-interfaces-research.okf +378 -0
- package/docs/journal/2026/08/23/main-0.2.23-readme-release.okf +81 -0
- package/docs/journal/2026/08/23/mia-openrouter-ox-alpha-latency-diagnosis.okf +146 -0
- package/docs/journal/2026/08/23/openrouter-opencode-ox-alpha-support.okf +114 -0
- package/docs/journal/2026/08/23/tabitoken-provider-model-catalog.okf +97 -0
- package/docs/journal/2026/08/23/usage-stream-snapshot-implementation.okf +134 -0
- package/docs/usage-observability.md +141 -0
- package/install.ps1 +24 -3
- 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
|
+
]
|