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