crprotocol 2.0.0__py3-none-any.whl
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.
- crp/__init__.py +126 -0
- crp/__main__.py +8 -0
- crp/_typing.py +27 -0
- crp/_version.py +5 -0
- crp/adapters.py +31 -0
- crp/advanced/__init__.py +40 -0
- crp/advanced/auto_ingest.py +400 -0
- crp/advanced/cqs.py +235 -0
- crp/advanced/cross_window.py +477 -0
- crp/advanced/curator.py +265 -0
- crp/advanced/feedback.py +146 -0
- crp/advanced/hierarchical.py +211 -0
- crp/advanced/meta_learning.py +401 -0
- crp/advanced/parallel.py +98 -0
- crp/advanced/review_cycle.py +329 -0
- crp/advanced/scale_mode.py +129 -0
- crp/advanced/source_grounding.py +207 -0
- crp/ckf/__init__.py +35 -0
- crp/ckf/community.py +377 -0
- crp/ckf/fabric.py +445 -0
- crp/ckf/gc.py +175 -0
- crp/ckf/graph_walk.py +87 -0
- crp/ckf/merge.py +133 -0
- crp/ckf/pattern_query.py +122 -0
- crp/ckf/pubsub.py +128 -0
- crp/ckf/semantic.py +207 -0
- crp/cli/__init__.py +7 -0
- crp/cli/main.py +329 -0
- crp/cli/sidecar.py +929 -0
- crp/cli/startup.py +272 -0
- crp/continuation/__init__.py +103 -0
- crp/continuation/completion.py +348 -0
- crp/continuation/degradation.py +157 -0
- crp/continuation/document_map.py +160 -0
- crp/continuation/flow.py +109 -0
- crp/continuation/gap.py +419 -0
- crp/continuation/manager.py +484 -0
- crp/continuation/quality_monitor.py +179 -0
- crp/continuation/stitch.py +419 -0
- crp/continuation/trigger.py +142 -0
- crp/continuation/voice.py +157 -0
- crp/core/__init__.py +69 -0
- crp/core/batch.py +77 -0
- crp/core/circuit_breaker.py +116 -0
- crp/core/config.py +377 -0
- crp/core/context_tools.py +540 -0
- crp/core/dispatch_router.py +3977 -0
- crp/core/errors.py +128 -0
- crp/core/extraction_facade.py +384 -0
- crp/core/facilitator.py +713 -0
- crp/core/idempotency.py +215 -0
- crp/core/orchestrator.py +1435 -0
- crp/core/relay_strategies.py +613 -0
- crp/core/security_manager.py +140 -0
- crp/core/session.py +134 -0
- crp/core/task_intent.py +36 -0
- crp/core/window.py +363 -0
- crp/envelope/__init__.py +30 -0
- crp/envelope/builder.py +288 -0
- crp/envelope/decomposer.py +236 -0
- crp/envelope/formatter.py +168 -0
- crp/envelope/packer.py +211 -0
- crp/envelope/reranker.py +209 -0
- crp/envelope/scoring.py +310 -0
- crp/extraction/__init__.py +45 -0
- crp/extraction/complexity.py +96 -0
- crp/extraction/contradiction.py +132 -0
- crp/extraction/pipeline.py +360 -0
- crp/extraction/quality_gate.py +237 -0
- crp/extraction/stage1_regex.py +173 -0
- crp/extraction/stage2_statistical.py +244 -0
- crp/extraction/stage3_gliner.py +210 -0
- crp/extraction/stage4_uie.py +183 -0
- crp/extraction/stage5_discourse.py +175 -0
- crp/extraction/stage6_llm.py +178 -0
- crp/extraction/structured_output.py +219 -0
- crp/extraction/types.py +299 -0
- crp/license_guard.py +722 -0
- crp/observability/__init__.py +30 -0
- crp/observability/audit.py +118 -0
- crp/observability/events.py +233 -0
- crp/observability/metrics.py +264 -0
- crp/observability/quality.py +135 -0
- crp/observability/structured_logging.py +81 -0
- crp/observability/telemetry.py +117 -0
- crp/provenance/__init__.py +314 -0
- crp/provenance/_embeddings.py +97 -0
- crp/provenance/_types.py +378 -0
- crp/provenance/attribution_scorer.py +252 -0
- crp/provenance/claim_detector.py +229 -0
- crp/provenance/contradiction_detector.py +243 -0
- crp/provenance/distortion_detector.py +397 -0
- crp/provenance/entailment_verifier.py +358 -0
- crp/provenance/fabrication_detector.py +203 -0
- crp/provenance/hallucination_scorer.py +320 -0
- crp/provenance/omission_analyzer.py +106 -0
- crp/provenance/provenance_chain.py +205 -0
- crp/provenance/report_generator.py +440 -0
- crp/providers/__init__.py +43 -0
- crp/providers/anthropic.py +270 -0
- crp/providers/base.py +135 -0
- crp/providers/custom.py +63 -0
- crp/providers/diagnostic.py +251 -0
- crp/providers/llamacpp.py +224 -0
- crp/providers/manager.py +139 -0
- crp/providers/ollama.py +243 -0
- crp/providers/openai.py +628 -0
- crp/providers/tokenizers.py +48 -0
- crp/py.typed +0 -0
- crp/resources/__init__.py +53 -0
- crp/resources/adaptive_allocator.py +525 -0
- crp/resources/cost_model.py +388 -0
- crp/resources/overhead_manager.py +217 -0
- crp/resources/resource_manager.py +262 -0
- crp/schemas/__init__.py +20 -0
- crp/schemas/cost-estimate.json +33 -0
- crp/schemas/crp-error.json +43 -0
- crp/schemas/envelope-preview.json +40 -0
- crp/schemas/persisted-state-header.json +27 -0
- crp/schemas/quality-report.json +94 -0
- crp/schemas/session-handle.json +33 -0
- crp/schemas/session-status.json +57 -0
- crp/schemas/stream-event.json +18 -0
- crp/schemas/task-intent.json +42 -0
- crp/security/__init__.py +93 -0
- crp/security/audit_trail.py +392 -0
- crp/security/binding.py +192 -0
- crp/security/compliance.py +813 -0
- crp/security/consent.py +593 -0
- crp/security/embedding_defense.py +161 -0
- crp/security/encryption.py +202 -0
- crp/security/injection.py +335 -0
- crp/security/integrity.py +267 -0
- crp/security/privacy.py +662 -0
- crp/security/quarantine.py +249 -0
- crp/security/rbac.py +221 -0
- crp/security/validation.py +164 -0
- crp/state/__init__.py +31 -0
- crp/state/cold_storage.py +258 -0
- crp/state/compaction.py +263 -0
- crp/state/critical_state.py +104 -0
- crp/state/event_log.py +313 -0
- crp/state/fact.py +189 -0
- crp/state/serialization.py +189 -0
- crp/state/session_cleanup.py +77 -0
- crp/state/snapshot.py +290 -0
- crp/state/warm_store.py +346 -0
- crprotocol-2.0.0.dist-info/METADATA +1295 -0
- crprotocol-2.0.0.dist-info/RECORD +153 -0
- crprotocol-2.0.0.dist-info/WHEEL +4 -0
- crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
- crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
- crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
crp/cli/sidecar.py
ADDED
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""CRP HTTP sidecar — lightweight REST API for inter-process context sharing (§9.3).
|
|
4
|
+
|
|
5
|
+
Architecture
|
|
6
|
+
~~~~~~~~~~~~
|
|
7
|
+
The sidecar exposes CRP sessions over HTTP, enabling:
|
|
8
|
+
|
|
9
|
+
1. **Inter-LLM fact sharing** — two applications using different LLMs can
|
|
10
|
+
share extracted knowledge. Application A (Claude) extracts facts,
|
|
11
|
+
Application B (GPT-4) receives them via ``/facts/share`` — both benefit
|
|
12
|
+
from the other's knowledge without direct LLM-to-LLM communication.
|
|
13
|
+
|
|
14
|
+
2. **Full protocol surface** — every CRP dispatch variant (basic, tools,
|
|
15
|
+
reflexive, progressive, stream-augmented, agentic) is available over
|
|
16
|
+
HTTP. Feedback loops, cost estimation, and provider registration are
|
|
17
|
+
also exposed.
|
|
18
|
+
|
|
19
|
+
3. **Language-agnostic integration** — any language/framework can interact
|
|
20
|
+
with CRP via HTTP (TypeScript frontend, Rust service, Python backend).
|
|
21
|
+
|
|
22
|
+
4. **Dashboard & monitoring** — query session status, inspect facts,
|
|
23
|
+
preview envelopes, and track event history.
|
|
24
|
+
|
|
25
|
+
Endpoints
|
|
26
|
+
~~~~~~~~~
|
|
27
|
+
Session lifecycle::
|
|
28
|
+
|
|
29
|
+
POST /sessions Create a new CRP session
|
|
30
|
+
GET /sessions List active sessions
|
|
31
|
+
GET /sessions/:id/status Session status / metrics
|
|
32
|
+
POST /sessions/:id/close Close session
|
|
33
|
+
|
|
34
|
+
Dispatch (all 6 variants)::
|
|
35
|
+
|
|
36
|
+
POST /sessions/:id/dispatch Basic dispatch
|
|
37
|
+
POST /sessions/:id/dispatch/tools Tool-mediated dispatch
|
|
38
|
+
POST /sessions/:id/dispatch/reflexive Reflexive (verify) dispatch
|
|
39
|
+
POST /sessions/:id/dispatch/progressive Progressive dispatch
|
|
40
|
+
POST /sessions/:id/dispatch/stream-augmented Stream-augmented dispatch
|
|
41
|
+
POST /sessions/:id/dispatch/agentic Agentic dispatch
|
|
42
|
+
|
|
43
|
+
Knowledge::
|
|
44
|
+
|
|
45
|
+
POST /sessions/:id/ingest Ingest raw text
|
|
46
|
+
GET /sessions/:id/facts Query extracted facts
|
|
47
|
+
POST /sessions/:id/facts/share Share facts TO another session
|
|
48
|
+
POST /sessions/:id/facts/feedback Boost / penalize / reject facts
|
|
49
|
+
GET /sessions/:id/envelope Preview envelope
|
|
50
|
+
|
|
51
|
+
Provider::
|
|
52
|
+
|
|
53
|
+
POST /sessions/:id/providers Register fallback provider
|
|
54
|
+
|
|
55
|
+
Admin::
|
|
56
|
+
|
|
57
|
+
GET /health Health check
|
|
58
|
+
POST /sessions/:id/estimate Cost estimation
|
|
59
|
+
|
|
60
|
+
Security
|
|
61
|
+
~~~~~~~~
|
|
62
|
+
- **Off by default** — ``crp serve`` must be explicitly invoked; the sidecar
|
|
63
|
+
is never started automatically by the library.
|
|
64
|
+
- Binds to ``127.0.0.1`` (loopback only) by default.
|
|
65
|
+
- Optional bearer-token authentication (``--auth-token``).
|
|
66
|
+
- Per-session RBAC enforced through the orchestrator's existing security layer.
|
|
67
|
+
- Request body size capped at 10 MB (configurable).
|
|
68
|
+
- Rate limiting: configurable per-IP burst window.
|
|
69
|
+
- Session ownership: sessions are bound to the token hash that created them.
|
|
70
|
+
- ``--bind-all`` requires explicit ``--auth-token`` or ``--allow-unauthenticated``.
|
|
71
|
+
- No HTTPS built-in — deploy behind a TLS-terminating reverse proxy for
|
|
72
|
+
production use (nginx, Caddy, etc.).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
from __future__ import annotations
|
|
76
|
+
|
|
77
|
+
import hashlib
|
|
78
|
+
import json
|
|
79
|
+
import logging
|
|
80
|
+
import secrets
|
|
81
|
+
import threading
|
|
82
|
+
import time
|
|
83
|
+
import uuid
|
|
84
|
+
from collections import defaultdict, deque
|
|
85
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
86
|
+
from typing import Any
|
|
87
|
+
from urllib.parse import urlparse, parse_qs
|
|
88
|
+
|
|
89
|
+
logger = logging.getLogger("crp.sidecar")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _sanitize_error(exc: Exception) -> str:
|
|
93
|
+
"""Sanitize internal error details before sending to client (§audit M11).
|
|
94
|
+
|
|
95
|
+
Strips stack traces, file paths, and internal module names.
|
|
96
|
+
Returns a generic message for unexpected errors that might leak internals.
|
|
97
|
+
"""
|
|
98
|
+
from crp.core.errors import CRPError
|
|
99
|
+
if isinstance(exc, (ValueError, TypeError, KeyError)):
|
|
100
|
+
# Return generic message — str(exc) can leak internal paths/secrets
|
|
101
|
+
return f"Invalid input: {type(exc).__name__}"
|
|
102
|
+
if isinstance(exc, CRPError):
|
|
103
|
+
# CRP errors have structured codes — truncate message to prevent leakage (§audit3 SEC-H3)
|
|
104
|
+
msg = (exc.message or "")[:ERROR_MSG_MAX_LEN].replace("\n", " ")
|
|
105
|
+
return f"CRP-{exc.code}: {msg}"
|
|
106
|
+
# Generic error — don't leak internals
|
|
107
|
+
return "Internal server error"
|
|
108
|
+
|
|
109
|
+
# ── Constants (§audit4 CQ-M1) ────────────────────────────────────────
|
|
110
|
+
MAX_BODY_BYTES_DEFAULT = 10 * 1024 * 1024 # 10 MB body limit
|
|
111
|
+
MAX_SESSIONS_DEFAULT = 64 # Max concurrent sessions
|
|
112
|
+
RATE_WINDOW_DEFAULT = 60 # Rate window in seconds
|
|
113
|
+
RATE_MAX_REQUESTS_DEFAULT = 120 # Max requests per IP per window
|
|
114
|
+
DISPATCH_RATE_WINDOW_DEFAULT = 60 # Dispatch rate window
|
|
115
|
+
DISPATCH_RATE_MAX_DEFAULT = 30 # Max dispatch requests per caller
|
|
116
|
+
RATE_COUNTER_MAX_KEYS = 10_000 # Max tracked IPs before eviction
|
|
117
|
+
RATE_COUNTER_EVICT_COUNT = 2_000 # Number of oldest IPs to evict
|
|
118
|
+
ERROR_MSG_MAX_LEN = 80 # Error message truncation length
|
|
119
|
+
|
|
120
|
+
# ── Server state ─────────────────────────────────────────────────────
|
|
121
|
+
_active_sessions: dict[str, Any] = {}
|
|
122
|
+
_session_owners: dict[str, str] = {} # session_id → token_hash that created it
|
|
123
|
+
_sessions_lock = threading.Lock()
|
|
124
|
+
_auth_token: str | None = None # Optional bearer token (set via start_sidecar)
|
|
125
|
+
_max_body_bytes: int = MAX_BODY_BYTES_DEFAULT
|
|
126
|
+
_max_sessions: int = MAX_SESSIONS_DEFAULT
|
|
127
|
+
|
|
128
|
+
# ── Rate limiting state ──────────────────────────────────────────────
|
|
129
|
+
_rate_window_seconds: int = RATE_WINDOW_DEFAULT
|
|
130
|
+
_rate_max_requests: int = RATE_MAX_REQUESTS_DEFAULT
|
|
131
|
+
_rate_counters: dict[str, deque] = defaultdict(lambda: deque(maxlen=RATE_MAX_REQUESTS_DEFAULT))
|
|
132
|
+
_rate_lock = threading.Lock()
|
|
133
|
+
|
|
134
|
+
# Dispatch-specific rate limit (per API-key / per-IP, more expensive)
|
|
135
|
+
_dispatch_rate_window: int = DISPATCH_RATE_WINDOW_DEFAULT
|
|
136
|
+
_dispatch_rate_max: int = DISPATCH_RATE_MAX_DEFAULT
|
|
137
|
+
_dispatch_counters: dict[str, list[float]] = defaultdict(list)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_rate_limit(client_ip: str) -> bool:
|
|
141
|
+
"""Return True if request is allowed, False if rate-limited."""
|
|
142
|
+
now = time.monotonic()
|
|
143
|
+
with _rate_lock:
|
|
144
|
+
window = _rate_counters[client_ip]
|
|
145
|
+
# Prune old entries (deque keeps bounded maxlen)
|
|
146
|
+
cutoff = now - _rate_window_seconds
|
|
147
|
+
while window and window[0] <= cutoff:
|
|
148
|
+
window.popleft()
|
|
149
|
+
if len(window) >= _rate_max_requests:
|
|
150
|
+
return False
|
|
151
|
+
window.append(now)
|
|
152
|
+
# Evict oldest IP keys to prevent unbounded growth (§audit2 SEC-H3, §audit3 SEC-C1)
|
|
153
|
+
if len(_rate_counters) > RATE_COUNTER_MAX_KEYS:
|
|
154
|
+
oldest = sorted(_rate_counters.items(),
|
|
155
|
+
key=lambda kv: max(kv[1]) if kv[1] else 0.0)
|
|
156
|
+
for ip, _ in oldest[:RATE_COUNTER_EVICT_COUNT]:
|
|
157
|
+
del _rate_counters[ip]
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _check_dispatch_rate(caller_key: str) -> bool:
|
|
162
|
+
"""Return True if dispatch request is allowed for this caller."""
|
|
163
|
+
now = time.monotonic()
|
|
164
|
+
with _rate_lock:
|
|
165
|
+
window = _dispatch_counters[caller_key]
|
|
166
|
+
cutoff = now - _dispatch_rate_window
|
|
167
|
+
_dispatch_counters[caller_key] = [t for t in window if t > cutoff]
|
|
168
|
+
if len(_dispatch_counters[caller_key]) >= _dispatch_rate_max:
|
|
169
|
+
return False
|
|
170
|
+
_dispatch_counters[caller_key].append(now)
|
|
171
|
+
# Evict oldest keys to prevent unbounded growth (§audit3 SEC-H5)
|
|
172
|
+
if len(_dispatch_counters) > RATE_COUNTER_MAX_KEYS:
|
|
173
|
+
oldest = sorted(_dispatch_counters.items(),
|
|
174
|
+
key=lambda kv: max(kv[1]) if kv[1] else 0.0)
|
|
175
|
+
for key, _ in oldest[:RATE_COUNTER_EVICT_COUNT]:
|
|
176
|
+
del _dispatch_counters[key]
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _token_hash(token: str) -> str:
|
|
181
|
+
"""One-way hash of bearer token for ownership tracking."""
|
|
182
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _json_response(handler: BaseHTTPRequestHandler, status: int, data: Any) -> None:
|
|
186
|
+
"""Write a JSON response with security headers."""
|
|
187
|
+
body = json.dumps(data, default=str).encode("utf-8")
|
|
188
|
+
handler.send_response(status)
|
|
189
|
+
handler.send_header("Content-Type", "application/json")
|
|
190
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
191
|
+
handler.send_header("X-Content-Type-Options", "nosniff")
|
|
192
|
+
handler.send_header("Cache-Control", "no-store")
|
|
193
|
+
handler.end_headers()
|
|
194
|
+
handler.wfile.write(body)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _read_body(handler: BaseHTTPRequestHandler) -> dict:
|
|
198
|
+
"""Read and parse JSON request body with size limit."""
|
|
199
|
+
content_length = int(handler.headers.get("Content-Length", 0))
|
|
200
|
+
if content_length <= 0:
|
|
201
|
+
return {}
|
|
202
|
+
if content_length > _max_body_bytes:
|
|
203
|
+
raise ValueError(f"Request body too large ({content_length} bytes, max {_max_body_bytes})")
|
|
204
|
+
raw = handler.rfile.read(content_length)
|
|
205
|
+
return json.loads(raw.decode("utf-8"))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _check_auth(handler: BaseHTTPRequestHandler) -> bool:
|
|
209
|
+
"""Check bearer token if authentication is configured.
|
|
210
|
+
|
|
211
|
+
When no auth_token is set, access is permitted only because
|
|
212
|
+
start_sidecar() enforces auth for non-loopback binds (§audit3 SEC-H4).
|
|
213
|
+
"""
|
|
214
|
+
if _auth_token is None:
|
|
215
|
+
return True
|
|
216
|
+
auth = handler.headers.get("Authorization", "")
|
|
217
|
+
if not auth.startswith("Bearer "):
|
|
218
|
+
_json_response(handler, 401, {"error": "Unauthorized", "detail": "Bearer token required"})
|
|
219
|
+
return False
|
|
220
|
+
provided = auth[7:]
|
|
221
|
+
if secrets.compare_digest(provided, _auth_token):
|
|
222
|
+
return True
|
|
223
|
+
_json_response(handler, 401, {"error": "Unauthorized", "detail": "Invalid bearer token"})
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _get_caller_token(handler: BaseHTTPRequestHandler) -> str:
|
|
228
|
+
"""Extract and hash the caller's bearer token (for ownership tracking)."""
|
|
229
|
+
auth = handler.headers.get("Authorization", "")
|
|
230
|
+
if auth.startswith("Bearer "):
|
|
231
|
+
return _token_hash(auth[7:])
|
|
232
|
+
return "anonymous"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _check_session_access(handler: BaseHTTPRequestHandler, session_id: str) -> bool:
|
|
236
|
+
"""Verify caller owns (or has access to) the session."""
|
|
237
|
+
if _auth_token is None:
|
|
238
|
+
return True # No auth = no ownership enforcement
|
|
239
|
+
caller = _get_caller_token(handler)
|
|
240
|
+
owner = _session_owners.get(session_id, caller)
|
|
241
|
+
if caller == owner:
|
|
242
|
+
return True
|
|
243
|
+
_json_response(handler, 403, {"error": "Forbidden", "detail": "You do not own this session"})
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class CRPSidecarHandler(BaseHTTPRequestHandler):
|
|
248
|
+
"""HTTP request handler for CRP sidecar endpoints.
|
|
249
|
+
|
|
250
|
+
Security enforced at every layer:
|
|
251
|
+
1. Rate limiting (per-IP)
|
|
252
|
+
2. Bearer token authentication
|
|
253
|
+
3. Session ownership verification
|
|
254
|
+
4. Request body size limits
|
|
255
|
+
5. Input validation on all endpoints
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
259
|
+
"""Route access logs through Python logging."""
|
|
260
|
+
logger.info(format, *args)
|
|
261
|
+
|
|
262
|
+
# ── Pre-flight checks ────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
def _pre_flight(self) -> bool:
|
|
265
|
+
"""Run rate-limit + auth checks. Return True if request may proceed."""
|
|
266
|
+
client_ip = self.client_address[0]
|
|
267
|
+
if not _check_rate_limit(client_ip):
|
|
268
|
+
_json_response(self, 429, {"error": "Too Many Requests",
|
|
269
|
+
"detail": f"Rate limit: {_rate_max_requests} req/{_rate_window_seconds}s"})
|
|
270
|
+
return False
|
|
271
|
+
if not _check_auth(self):
|
|
272
|
+
return False
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
def _get_session(self, session_id: str) -> Any | None:
|
|
276
|
+
"""Look up session with ownership check. Returns orchestrator or None (after sending error)."""
|
|
277
|
+
with _sessions_lock:
|
|
278
|
+
orch = _active_sessions.get(session_id)
|
|
279
|
+
if orch is None:
|
|
280
|
+
# Generic 404 prevents session-ID enumeration (§audit5 SEC-L5)
|
|
281
|
+
_json_response(self, 404, {"error": "Not found"})
|
|
282
|
+
return None
|
|
283
|
+
if not _check_session_access(self, session_id):
|
|
284
|
+
return None
|
|
285
|
+
return orch
|
|
286
|
+
|
|
287
|
+
# ── GET routing ──────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
def do_GET(self) -> None:
|
|
290
|
+
if not self._pre_flight():
|
|
291
|
+
return
|
|
292
|
+
parsed = urlparse(self.path)
|
|
293
|
+
parts = [p for p in parsed.path.strip("/").split("/") if p]
|
|
294
|
+
|
|
295
|
+
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "status":
|
|
296
|
+
self._handle_session_status(parts[1])
|
|
297
|
+
elif len(parts) == 3 and parts[0] == "sessions" and parts[2] == "facts":
|
|
298
|
+
self._handle_get_facts(parts[1], parse_qs(parsed.query))
|
|
299
|
+
elif len(parts) == 3 and parts[0] == "sessions" and parts[2] == "envelope":
|
|
300
|
+
self._handle_preview_envelope(parts[1], parse_qs(parsed.query))
|
|
301
|
+
elif len(parts) == 1 and parts[0] == "health":
|
|
302
|
+
self._handle_health()
|
|
303
|
+
elif len(parts) == 1 and parts[0] == "ready":
|
|
304
|
+
self._handle_ready()
|
|
305
|
+
elif len(parts) == 1 and parts[0] == "metrics":
|
|
306
|
+
self._handle_metrics()
|
|
307
|
+
elif len(parts) == 1 and parts[0] == "sessions":
|
|
308
|
+
self._handle_list_sessions()
|
|
309
|
+
else:
|
|
310
|
+
_json_response(self, 404, {"error": "Not found"})
|
|
311
|
+
|
|
312
|
+
# ── POST routing ─────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
def do_POST(self) -> None:
|
|
315
|
+
if not self._pre_flight():
|
|
316
|
+
return
|
|
317
|
+
parsed = urlparse(self.path)
|
|
318
|
+
parts = [p for p in parsed.path.strip("/").split("/") if p]
|
|
319
|
+
|
|
320
|
+
# POST /sessions
|
|
321
|
+
if len(parts) == 1 and parts[0] == "sessions":
|
|
322
|
+
self._handle_create_session()
|
|
323
|
+
|
|
324
|
+
# POST /sessions/:id/dispatch[/variant]
|
|
325
|
+
elif len(parts) >= 3 and parts[0] == "sessions" and parts[2] == "dispatch":
|
|
326
|
+
variant = parts[3] if len(parts) == 4 else "basic"
|
|
327
|
+
self._handle_dispatch(parts[1], variant)
|
|
328
|
+
|
|
329
|
+
# POST /sessions/:id/ingest
|
|
330
|
+
elif len(parts) == 3 and parts[0] == "sessions" and parts[2] == "ingest":
|
|
331
|
+
self._handle_ingest(parts[1])
|
|
332
|
+
|
|
333
|
+
# POST /sessions/:id/facts/share
|
|
334
|
+
elif len(parts) == 4 and parts[0] == "sessions" and parts[2] == "facts" and parts[3] == "share":
|
|
335
|
+
self._handle_share_facts(parts[1])
|
|
336
|
+
|
|
337
|
+
# POST /sessions/:id/facts/feedback
|
|
338
|
+
elif len(parts) == 4 and parts[0] == "sessions" and parts[2] == "facts" and parts[3] == "feedback":
|
|
339
|
+
self._handle_feedback(parts[1])
|
|
340
|
+
|
|
341
|
+
# POST /sessions/:id/providers
|
|
342
|
+
elif len(parts) == 3 and parts[0] == "sessions" and parts[2] == "providers":
|
|
343
|
+
self._handle_register_provider(parts[1])
|
|
344
|
+
|
|
345
|
+
# POST /sessions/:id/estimate
|
|
346
|
+
elif len(parts) == 3 and parts[0] == "sessions" and parts[2] == "estimate":
|
|
347
|
+
self._handle_estimate(parts[1])
|
|
348
|
+
|
|
349
|
+
# POST /sessions/:id/close
|
|
350
|
+
elif len(parts) == 3 and parts[0] == "sessions" and parts[2] == "close":
|
|
351
|
+
self._handle_close(parts[1])
|
|
352
|
+
|
|
353
|
+
else:
|
|
354
|
+
_json_response(self, 404, {"error": "Not found"})
|
|
355
|
+
|
|
356
|
+
# ── Health & list ────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
def _handle_health(self) -> None:
|
|
359
|
+
with _sessions_lock:
|
|
360
|
+
count = len(_active_sessions)
|
|
361
|
+
_json_response(self, 200, {
|
|
362
|
+
"status": "ok",
|
|
363
|
+
"active_sessions": count,
|
|
364
|
+
"max_sessions": _max_sessions,
|
|
365
|
+
"auth_required": _auth_token is not None,
|
|
366
|
+
"rate_limit": f"{_rate_max_requests}/{_rate_window_seconds}s",
|
|
367
|
+
"version": "2.0.0",
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
def _handle_ready(self) -> None:
|
|
371
|
+
"""Readiness probe for orchestration platforms (K8s, ECS) (§audit H8)."""
|
|
372
|
+
with _sessions_lock:
|
|
373
|
+
count = len(_active_sessions)
|
|
374
|
+
can_accept = count < _max_sessions
|
|
375
|
+
status = 200 if can_accept else 503
|
|
376
|
+
_json_response(self, status, {
|
|
377
|
+
"ready": can_accept,
|
|
378
|
+
"active_sessions": count,
|
|
379
|
+
"max_sessions": _max_sessions,
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
def _handle_metrics(self) -> None:
|
|
383
|
+
"""Expose Prometheus-compatible metrics (§audit M14)."""
|
|
384
|
+
try:
|
|
385
|
+
from crp.observability.metrics import MetricsExporter, ExportFormat
|
|
386
|
+
exporter = MetricsExporter()
|
|
387
|
+
# Collect live session metrics
|
|
388
|
+
with _sessions_lock:
|
|
389
|
+
exporter.gauge("sidecar.active_sessions", len(_active_sessions))
|
|
390
|
+
for sid, orch in _active_sessions.items():
|
|
391
|
+
try:
|
|
392
|
+
st = orch.session_status()
|
|
393
|
+
exporter.gauge("sidecar.facts_in_warm_store", st.facts_in_warm_state)
|
|
394
|
+
except Exception:
|
|
395
|
+
pass
|
|
396
|
+
text = exporter.export(ExportFormat.PROMETHEUS)
|
|
397
|
+
self.send_response(200)
|
|
398
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
399
|
+
encoded = text.encode("utf-8")
|
|
400
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
401
|
+
self.end_headers()
|
|
402
|
+
self.wfile.write(encoded)
|
|
403
|
+
except Exception:
|
|
404
|
+
logger.exception("Metrics export failed")
|
|
405
|
+
_json_response(self, 500, {"error": "Metrics export failed"})
|
|
406
|
+
|
|
407
|
+
def _handle_list_sessions(self) -> None:
|
|
408
|
+
caller = _get_caller_token(self)
|
|
409
|
+
with _sessions_lock:
|
|
410
|
+
snapshot = list(_active_sessions.items())
|
|
411
|
+
sessions = []
|
|
412
|
+
for sid, orch in snapshot:
|
|
413
|
+
# Only show sessions the caller owns (or all if no auth)
|
|
414
|
+
if _auth_token is not None and _session_owners.get(sid) != caller:
|
|
415
|
+
continue
|
|
416
|
+
try:
|
|
417
|
+
st = orch.session_status()
|
|
418
|
+
sessions.append({
|
|
419
|
+
"session_id": sid,
|
|
420
|
+
"windows_completed": st.windows_completed,
|
|
421
|
+
"facts_in_warm_state": st.facts_in_warm_state,
|
|
422
|
+
})
|
|
423
|
+
except Exception:
|
|
424
|
+
sessions.append({"session_id": sid, "status": "error"})
|
|
425
|
+
_json_response(self, 200, {"sessions": sessions})
|
|
426
|
+
|
|
427
|
+
# ── Session lifecycle ────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
def _handle_create_session(self) -> None:
|
|
430
|
+
with _sessions_lock:
|
|
431
|
+
if len(_active_sessions) >= _max_sessions:
|
|
432
|
+
_json_response(self, 503, {"error": "Max sessions reached",
|
|
433
|
+
"detail": f"Limit: {_max_sessions}"})
|
|
434
|
+
return
|
|
435
|
+
try:
|
|
436
|
+
body = _read_body(self)
|
|
437
|
+
except ValueError as exc:
|
|
438
|
+
_json_response(self, 413, {"error": _sanitize_error(exc)})
|
|
439
|
+
return
|
|
440
|
+
try:
|
|
441
|
+
from crp.providers.custom import CustomProvider
|
|
442
|
+
from crp.core.orchestrator import CRPOrchestrator
|
|
443
|
+
|
|
444
|
+
context_window = body.get("context_window", 128_000)
|
|
445
|
+
model_name = body.get("model", "sidecar-custom")
|
|
446
|
+
|
|
447
|
+
provider = CustomProvider(
|
|
448
|
+
generate_fn=lambda msgs, **kw: ("", "stop"),
|
|
449
|
+
count_tokens_fn=lambda t: max(1, len(t) // 4),
|
|
450
|
+
context_size=context_window,
|
|
451
|
+
name=model_name,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
orch = CRPOrchestrator(provider=provider)
|
|
455
|
+
session_id = orch._session.session_id
|
|
456
|
+
caller = _get_caller_token(self)
|
|
457
|
+
with _sessions_lock:
|
|
458
|
+
_active_sessions[session_id] = orch
|
|
459
|
+
_session_owners[session_id] = caller
|
|
460
|
+
|
|
461
|
+
_json_response(self, 201, {
|
|
462
|
+
"session_id": session_id,
|
|
463
|
+
"model": model_name,
|
|
464
|
+
"context_window": context_window,
|
|
465
|
+
})
|
|
466
|
+
except Exception as exc:
|
|
467
|
+
logger.exception("Failed to create session")
|
|
468
|
+
_json_response(self, 500, {"error": _sanitize_error(exc)})
|
|
469
|
+
|
|
470
|
+
def _handle_session_status(self, session_id: str) -> None:
|
|
471
|
+
orch = self._get_session(session_id)
|
|
472
|
+
if orch is None:
|
|
473
|
+
return
|
|
474
|
+
try:
|
|
475
|
+
st = orch.session_status()
|
|
476
|
+
_json_response(self, 200, {
|
|
477
|
+
"session_id": st.session_id,
|
|
478
|
+
"windows_completed": st.windows_completed,
|
|
479
|
+
"total_input_tokens": st.total_input_tokens,
|
|
480
|
+
"total_output_tokens": st.total_output_tokens,
|
|
481
|
+
"facts_in_warm_state": st.facts_in_warm_state,
|
|
482
|
+
"overhead_ratio": st.overhead_ratio,
|
|
483
|
+
})
|
|
484
|
+
except Exception as exc:
|
|
485
|
+
logger.exception("Failed to get session status for %s", session_id)
|
|
486
|
+
_json_response(self, 500, {"error": _sanitize_error(exc)})
|
|
487
|
+
|
|
488
|
+
def _handle_close(self, session_id: str) -> None:
|
|
489
|
+
orch = self._get_session(session_id)
|
|
490
|
+
if orch is None:
|
|
491
|
+
return
|
|
492
|
+
try:
|
|
493
|
+
orch.close()
|
|
494
|
+
with _sessions_lock:
|
|
495
|
+
_active_sessions.pop(session_id, None)
|
|
496
|
+
_session_owners.pop(session_id, None)
|
|
497
|
+
_json_response(self, 200, {"closed": True, "session_id": session_id})
|
|
498
|
+
except Exception as exc:
|
|
499
|
+
logger.exception("Failed to close session %s", session_id)
|
|
500
|
+
_json_response(self, 500, {"error": _sanitize_error(exc)})
|
|
501
|
+
|
|
502
|
+
# ── Dispatch (all 6 variants) ────────────────────────────
|
|
503
|
+
|
|
504
|
+
def _handle_dispatch(self, session_id: str, variant: str) -> None:
|
|
505
|
+
orch = self._get_session(session_id)
|
|
506
|
+
if orch is None:
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
# Per-caller dispatch rate limit (expensive operation)
|
|
510
|
+
caller_key = _get_caller_token(self) + ":" + self.client_address[0]
|
|
511
|
+
if not _check_dispatch_rate(caller_key):
|
|
512
|
+
_json_response(self, 429, {
|
|
513
|
+
"error": "Dispatch rate limit exceeded",
|
|
514
|
+
"detail": f"Max {_dispatch_rate_max} dispatches/{_dispatch_rate_window}s per caller",
|
|
515
|
+
})
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
body = _read_body(self)
|
|
520
|
+
except ValueError as exc:
|
|
521
|
+
_json_response(self, 413, {"error": _sanitize_error(exc)})
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
system_prompt = body.get("system_prompt", "You are a helpful assistant.")
|
|
525
|
+
task_input = body.get("task_input", "")
|
|
526
|
+
if not task_input:
|
|
527
|
+
_json_response(self, 400, {"error": "task_input is required"})
|
|
528
|
+
return
|
|
529
|
+
|
|
530
|
+
valid_variants = {"basic", "tools", "reflexive", "progressive",
|
|
531
|
+
"stream-augmented", "agentic"}
|
|
532
|
+
if variant not in valid_variants:
|
|
533
|
+
_json_response(self, 400, {"error": f"Unknown dispatch variant '{variant}'",
|
|
534
|
+
"valid": sorted(valid_variants)})
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
try:
|
|
538
|
+
if variant == "basic":
|
|
539
|
+
output, report = orch.dispatch(system_prompt, task_input)
|
|
540
|
+
elif variant == "tools":
|
|
541
|
+
tools = body.get("tools", [])
|
|
542
|
+
if not tools:
|
|
543
|
+
_json_response(self, 400, {"error": "tools list required for tools dispatch"})
|
|
544
|
+
return
|
|
545
|
+
output, report = orch.dispatch_with_tools(
|
|
546
|
+
system_prompt, task_input, tools=tools,
|
|
547
|
+
)
|
|
548
|
+
elif variant == "reflexive":
|
|
549
|
+
depth = body.get("depth", 1)
|
|
550
|
+
output, report = orch.dispatch_reflexive(
|
|
551
|
+
system_prompt, task_input, depth=depth,
|
|
552
|
+
)
|
|
553
|
+
elif variant == "progressive":
|
|
554
|
+
output, report = orch.dispatch_progressive(
|
|
555
|
+
system_prompt, task_input,
|
|
556
|
+
)
|
|
557
|
+
elif variant == "stream-augmented":
|
|
558
|
+
# Collect streamed output into final string
|
|
559
|
+
chunks = []
|
|
560
|
+
for chunk in orch.dispatch_stream_augmented(system_prompt, task_input):
|
|
561
|
+
chunks.append(chunk.text if hasattr(chunk, "text") else str(chunk))
|
|
562
|
+
output = "".join(chunks)
|
|
563
|
+
report = orch.session_status() # Best-effort report
|
|
564
|
+
_json_response(self, 200, {
|
|
565
|
+
"output": output,
|
|
566
|
+
"variant": "stream-augmented",
|
|
567
|
+
"session_id": session_id,
|
|
568
|
+
})
|
|
569
|
+
return
|
|
570
|
+
elif variant == "agentic":
|
|
571
|
+
output, report = orch.dispatch_agentic(
|
|
572
|
+
system_prompt, task_input,
|
|
573
|
+
)
|
|
574
|
+
else:
|
|
575
|
+
_json_response(self, 400, {"error": f"Unhandled variant: {variant}"})
|
|
576
|
+
return
|
|
577
|
+
|
|
578
|
+
_json_response(self, 200, {
|
|
579
|
+
"output": output,
|
|
580
|
+
"variant": variant,
|
|
581
|
+
"quality_tier": report.quality_tier,
|
|
582
|
+
"facts_extracted": report.facts_extracted,
|
|
583
|
+
"continuation_windows": report.continuation_windows,
|
|
584
|
+
"session_id": report.session_id,
|
|
585
|
+
"window_id": report.window_id,
|
|
586
|
+
})
|
|
587
|
+
except Exception as exc:
|
|
588
|
+
logger.exception("Dispatch error (variant=%s)", variant)
|
|
589
|
+
_json_response(self, 500, {"error": _sanitize_error(exc)})
|
|
590
|
+
|
|
591
|
+
# ── Ingest ───────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
def _handle_ingest(self, session_id: str) -> None:
|
|
594
|
+
orch = self._get_session(session_id)
|
|
595
|
+
if orch is None:
|
|
596
|
+
return
|
|
597
|
+
try:
|
|
598
|
+
body = _read_body(self)
|
|
599
|
+
except ValueError as exc:
|
|
600
|
+
_json_response(self, 413, {"error": _sanitize_error(exc)})
|
|
601
|
+
return
|
|
602
|
+
text = body.get("text", "")
|
|
603
|
+
label = body.get("label", "sidecar-ingest")
|
|
604
|
+
if not text:
|
|
605
|
+
_json_response(self, 400, {"error": "text is required"})
|
|
606
|
+
return
|
|
607
|
+
try:
|
|
608
|
+
result = orch.ingest(text, source_label=label)
|
|
609
|
+
_json_response(self, 200, {
|
|
610
|
+
"facts_extracted": result.facts_extracted,
|
|
611
|
+
"source_label": result.source_label,
|
|
612
|
+
"fact_ids": result.fact_ids,
|
|
613
|
+
})
|
|
614
|
+
except Exception as exc:
|
|
615
|
+
logger.exception("Ingest failed for session %s", session_id)
|
|
616
|
+
_json_response(self, 500, {"error": _sanitize_error(exc)})
|
|
617
|
+
|
|
618
|
+
# ── Facts: query, share, feedback ────────────────────────
|
|
619
|
+
|
|
620
|
+
def _handle_get_facts(self, session_id: str, params: dict) -> None:
|
|
621
|
+
orch = self._get_session(session_id)
|
|
622
|
+
if orch is None:
|
|
623
|
+
return
|
|
624
|
+
try:
|
|
625
|
+
limit = min(int(params.get("limit", ["50"])[0]), 500)
|
|
626
|
+
ranked_facts = orch._warm_store.get_ranked_facts(limit=limit)
|
|
627
|
+
facts = [
|
|
628
|
+
{
|
|
629
|
+
"id": f.id,
|
|
630
|
+
"text": f.text,
|
|
631
|
+
"confidence": round(f.confidence, 3),
|
|
632
|
+
"source_window_id": f.source_window_id,
|
|
633
|
+
"extraction_stage": f.extraction_stage,
|
|
634
|
+
}
|
|
635
|
+
for f in ranked_facts
|
|
636
|
+
]
|
|
637
|
+
_json_response(self, 200, {"facts": facts, "total": len(facts)})
|
|
638
|
+
except Exception as exc:
|
|
639
|
+
logger.exception("Failed to get facts for session %s", session_id)
|
|
640
|
+
_json_response(self, 500, {"error": _sanitize_error(exc)})
|
|
641
|
+
|
|
642
|
+
def _handle_share_facts(self, source_session_id: str) -> None:
|
|
643
|
+
"""Share facts FROM source session TO target session.
|
|
644
|
+
|
|
645
|
+
This is the **core inter-LLM context sharing** endpoint:
|
|
646
|
+
- Session A (Claude) extracts facts about code architecture
|
|
647
|
+
- Session B (GPT-4) receives those facts via this endpoint
|
|
648
|
+
- Session B's next dispatch envelope automatically includes
|
|
649
|
+
Session A's knowledge — no LLM-to-LLM communication needed
|
|
650
|
+
|
|
651
|
+
Request body::
|
|
652
|
+
|
|
653
|
+
{
|
|
654
|
+
"target_session_id": "...",
|
|
655
|
+
"limit": 50,
|
|
656
|
+
"min_confidence": 0.5
|
|
657
|
+
}
|
|
658
|
+
"""
|
|
659
|
+
source_orch = self._get_session(source_session_id)
|
|
660
|
+
if source_orch is None:
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
try:
|
|
664
|
+
body = _read_body(self)
|
|
665
|
+
except ValueError as exc:
|
|
666
|
+
_json_response(self, 413, {"error": _sanitize_error(exc)})
|
|
667
|
+
return
|
|
668
|
+
|
|
669
|
+
target_session_id = body.get("target_session_id", "")
|
|
670
|
+
if not target_session_id:
|
|
671
|
+
_json_response(self, 400, {"error": "target_session_id is required"})
|
|
672
|
+
return
|
|
673
|
+
|
|
674
|
+
# Verify caller also owns target session
|
|
675
|
+
target_orch = self._get_session(target_session_id)
|
|
676
|
+
if target_orch is None:
|
|
677
|
+
return
|
|
678
|
+
|
|
679
|
+
limit = min(int(body.get("limit", 50)), 500)
|
|
680
|
+
min_confidence = max(0.0, min(1.0, float(body.get("min_confidence", 0.3))))
|
|
681
|
+
|
|
682
|
+
try:
|
|
683
|
+
source_facts = source_orch._warm_store.get_ranked_facts(limit=limit)
|
|
684
|
+
shared_facts = [f for f in source_facts if f.confidence >= min_confidence]
|
|
685
|
+
|
|
686
|
+
if shared_facts:
|
|
687
|
+
target_orch._warm_store.add_facts(shared_facts)
|
|
688
|
+
target_orch._ckf.store(
|
|
689
|
+
shared_facts,
|
|
690
|
+
window_id=f"shared-from-{source_session_id[:8]}",
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
shared_count = len(shared_facts)
|
|
694
|
+
logger.info(
|
|
695
|
+
"Shared %d facts from session %s → %s",
|
|
696
|
+
shared_count, source_session_id[:8], target_session_id[:8],
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
source_orch._emitter.emit("fact.shared", {
|
|
700
|
+
"target_session_id": target_session_id,
|
|
701
|
+
"facts_shared": shared_count,
|
|
702
|
+
})
|
|
703
|
+
target_orch._emitter.emit("fact.received", {
|
|
704
|
+
"source_session_id": source_session_id,
|
|
705
|
+
"facts_received": shared_count,
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
_json_response(self, 200, {
|
|
709
|
+
"facts_shared": shared_count,
|
|
710
|
+
"source_session_id": source_session_id,
|
|
711
|
+
"target_session_id": target_session_id,
|
|
712
|
+
})
|
|
713
|
+
except Exception as exc:
|
|
714
|
+
logger.exception("Share facts failed from %s", source_session_id)
|
|
715
|
+
_json_response(self, 500, {"error": _sanitize_error(exc)})
|
|
716
|
+
|
|
717
|
+
def _handle_feedback(self, session_id: str) -> None:
|
|
718
|
+
"""Adjust fact confidence or reject a fact.
|
|
719
|
+
|
|
720
|
+
Request body::
|
|
721
|
+
|
|
722
|
+
{
|
|
723
|
+
"fact_id": "...",
|
|
724
|
+
"action": "boost" | "penalize" | "reject",
|
|
725
|
+
"delta": 0.1,
|
|
726
|
+
"reason": "User confirmed this fact"
|
|
727
|
+
}
|
|
728
|
+
"""
|
|
729
|
+
orch = self._get_session(session_id)
|
|
730
|
+
if orch is None:
|
|
731
|
+
return
|
|
732
|
+
try:
|
|
733
|
+
body = _read_body(self)
|
|
734
|
+
except ValueError as exc:
|
|
735
|
+
_json_response(self, 413, {"error": _sanitize_error(exc)})
|
|
736
|
+
return
|
|
737
|
+
|
|
738
|
+
fact_id = body.get("fact_id", "")
|
|
739
|
+
action = body.get("action", "")
|
|
740
|
+
if not fact_id or action not in ("boost", "penalize", "reject"):
|
|
741
|
+
_json_response(self, 400, {
|
|
742
|
+
"error": "fact_id and action (boost|penalize|reject) required",
|
|
743
|
+
})
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
delta = max(0.0, min(1.0, float(body.get("delta", 0.1))))
|
|
747
|
+
reason = body.get("reason", "sidecar-feedback")
|
|
748
|
+
|
|
749
|
+
try:
|
|
750
|
+
if action == "boost":
|
|
751
|
+
orch.boost_fact(fact_id, delta=delta, reason=reason)
|
|
752
|
+
elif action == "penalize":
|
|
753
|
+
orch.penalize_fact(fact_id, delta=delta, reason=reason)
|
|
754
|
+
elif action == "reject":
|
|
755
|
+
orch.reject_fact(fact_id, reason=reason)
|
|
756
|
+
|
|
757
|
+
_json_response(self, 200, {
|
|
758
|
+
"fact_id": fact_id,
|
|
759
|
+
"action": action,
|
|
760
|
+
"applied": True,
|
|
761
|
+
})
|
|
762
|
+
except Exception as exc:
|
|
763
|
+
logger.exception("Feedback failed for fact %s in session %s", fact_id, session_id)
|
|
764
|
+
_json_response(self, 500, {"error": _sanitize_error(exc)})
|
|
765
|
+
|
|
766
|
+
# ── Envelope preview ─────────────────────────────────────
|
|
767
|
+
|
|
768
|
+
def _handle_preview_envelope(self, session_id: str, params: dict) -> None:
|
|
769
|
+
orch = self._get_session(session_id)
|
|
770
|
+
if orch is None:
|
|
771
|
+
return
|
|
772
|
+
try:
|
|
773
|
+
task = params.get("task", ["Preview task"])[0]
|
|
774
|
+
system = params.get("system", ["You are a helpful assistant."])[0]
|
|
775
|
+
ep = orch.preview_envelope(system, task)
|
|
776
|
+
_json_response(self, 200, {
|
|
777
|
+
"total_tokens": ep.total_tokens,
|
|
778
|
+
"envelope_tokens": ep.envelope_tokens,
|
|
779
|
+
"generation_reserve": ep.generation_reserve,
|
|
780
|
+
"facts_included": ep.facts_included,
|
|
781
|
+
"facts_available": ep.facts_available,
|
|
782
|
+
"saturation": round(ep.saturation, 4),
|
|
783
|
+
})
|
|
784
|
+
except Exception as exc:
|
|
785
|
+
logger.exception("Envelope preview failed for session %s", session_id)
|
|
786
|
+
_json_response(self, 500, {"error": _sanitize_error(exc)})
|
|
787
|
+
|
|
788
|
+
# ── Cost estimation ──────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
def _handle_estimate(self, session_id: str) -> None:
|
|
791
|
+
orch = self._get_session(session_id)
|
|
792
|
+
if orch is None:
|
|
793
|
+
return
|
|
794
|
+
try:
|
|
795
|
+
body = _read_body(self)
|
|
796
|
+
except ValueError as exc:
|
|
797
|
+
_json_response(self, 413, {"error": _sanitize_error(exc)})
|
|
798
|
+
return
|
|
799
|
+
system_prompt = body.get("system_prompt", "You are a helpful assistant.")
|
|
800
|
+
task_input = body.get("task_input", "")
|
|
801
|
+
planned = body.get("planned_dispatches", 1)
|
|
802
|
+
if not task_input:
|
|
803
|
+
_json_response(self, 400, {"error": "task_input is required"})
|
|
804
|
+
return
|
|
805
|
+
try:
|
|
806
|
+
est = orch.estimate_session(system_prompt, task_input,
|
|
807
|
+
planned_dispatches=planned)
|
|
808
|
+
_json_response(self, 200, {
|
|
809
|
+
"estimated_input_tokens": est.estimated_input_tokens,
|
|
810
|
+
"estimated_output_tokens": est.estimated_output_tokens,
|
|
811
|
+
"estimated_windows": est.estimated_windows,
|
|
812
|
+
"estimated_cost_usd": est.estimated_cost_usd
|
|
813
|
+
if hasattr(est, "estimated_cost_usd") else None,
|
|
814
|
+
})
|
|
815
|
+
except Exception as exc:
|
|
816
|
+
logger.exception("Cost estimation failed for session %s", session_id)
|
|
817
|
+
_json_response(self, 500, {"error": _sanitize_error(exc)})
|
|
818
|
+
|
|
819
|
+
# ── Provider registration ────────────────────────────────
|
|
820
|
+
|
|
821
|
+
def _handle_register_provider(self, session_id: str) -> None:
|
|
822
|
+
"""Register a fallback provider for this session.
|
|
823
|
+
|
|
824
|
+
Request body::
|
|
825
|
+
|
|
826
|
+
{
|
|
827
|
+
"model": "gpt-4",
|
|
828
|
+
"context_window": 128000
|
|
829
|
+
}
|
|
830
|
+
"""
|
|
831
|
+
orch = self._get_session(session_id)
|
|
832
|
+
if orch is None:
|
|
833
|
+
return
|
|
834
|
+
try:
|
|
835
|
+
body = _read_body(self)
|
|
836
|
+
except ValueError as exc:
|
|
837
|
+
_json_response(self, 413, {"error": _sanitize_error(exc)})
|
|
838
|
+
return
|
|
839
|
+
|
|
840
|
+
model_name = body.get("model", "")
|
|
841
|
+
context_window = body.get("context_window", 128_000)
|
|
842
|
+
if not model_name:
|
|
843
|
+
_json_response(self, 400, {"error": "model name required"})
|
|
844
|
+
return
|
|
845
|
+
try:
|
|
846
|
+
from crp.providers.custom import CustomProvider
|
|
847
|
+
|
|
848
|
+
provider = CustomProvider(
|
|
849
|
+
generate_fn=lambda msgs, **kw: ("", "stop"),
|
|
850
|
+
count_tokens_fn=lambda t: max(1, len(t) // 4),
|
|
851
|
+
context_size=context_window,
|
|
852
|
+
name=model_name,
|
|
853
|
+
)
|
|
854
|
+
orch.register_provider(provider)
|
|
855
|
+
_json_response(self, 200, {
|
|
856
|
+
"registered": True,
|
|
857
|
+
"model": model_name,
|
|
858
|
+
"context_window": context_window,
|
|
859
|
+
})
|
|
860
|
+
except Exception as exc:
|
|
861
|
+
logger.exception("Provider registration failed for session %s", session_id)
|
|
862
|
+
_json_response(self, 500, {"error": _sanitize_error(exc)})
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
# ── Server factory ───────────────────────────────────────────────────
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def start_sidecar(
|
|
869
|
+
host: str = "127.0.0.1",
|
|
870
|
+
port: int = 9470,
|
|
871
|
+
auth_token: str | None = None,
|
|
872
|
+
max_body_bytes: int = 10 * 1024 * 1024,
|
|
873
|
+
max_sessions: int = 64,
|
|
874
|
+
rate_limit: int = 120,
|
|
875
|
+
rate_window: int = 60,
|
|
876
|
+
dispatch_rate_limit: int = 30,
|
|
877
|
+
dispatch_rate_window: int = 60,
|
|
878
|
+
) -> HTTPServer:
|
|
879
|
+
"""Start the CRP HTTP sidecar.
|
|
880
|
+
|
|
881
|
+
The sidecar is **optional** — it is never started automatically by the
|
|
882
|
+
library. Users must explicitly invoke ``crp serve`` or call this
|
|
883
|
+
function from their own code.
|
|
884
|
+
|
|
885
|
+
Args:
|
|
886
|
+
host: Bind address. Default ``127.0.0.1`` (loopback only).
|
|
887
|
+
port: Port number. Default ``9470``.
|
|
888
|
+
auth_token: Bearer token for authentication. **Strongly
|
|
889
|
+
recommended** when binding to non-loopback addresses.
|
|
890
|
+
max_body_bytes: Maximum request body size (default 10 MB).
|
|
891
|
+
max_sessions: Maximum concurrent sessions (default 64).
|
|
892
|
+
rate_limit: Maximum requests per IP per rate window (default 120).
|
|
893
|
+
rate_window: Rate-limit window in seconds (default 60).
|
|
894
|
+
dispatch_rate_limit: Max dispatch requests per caller per window (default 30).
|
|
895
|
+
dispatch_rate_window: Dispatch rate-limit window in seconds (default 60).
|
|
896
|
+
|
|
897
|
+
Returns:
|
|
898
|
+
HTTPServer instance (call ``.serve_forever()`` to start).
|
|
899
|
+
"""
|
|
900
|
+
global _auth_token, _max_body_bytes, _max_sessions
|
|
901
|
+
global _rate_max_requests, _rate_window_seconds
|
|
902
|
+
global _dispatch_rate_max, _dispatch_rate_window
|
|
903
|
+
|
|
904
|
+
# Enforce auth when binding to non-loopback addresses (§audit3 SEC-H4)
|
|
905
|
+
if host not in ("127.0.0.1", "localhost", "::1") and auth_token is None:
|
|
906
|
+
raise ValueError(
|
|
907
|
+
f"Binding to '{host}' without auth_token is insecure. "
|
|
908
|
+
"Pass auth_token or bind to 127.0.0.1 for local-only access."
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
if auth_token is None:
|
|
912
|
+
logger.warning(
|
|
913
|
+
"Sidecar starting without auth_token on %s:%d — "
|
|
914
|
+
"unauthenticated access allowed (§audit4 SEC-H1)",
|
|
915
|
+
host, port,
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
_auth_token = auth_token
|
|
919
|
+
_max_body_bytes = max_body_bytes
|
|
920
|
+
_max_sessions = max_sessions
|
|
921
|
+
_rate_max_requests = rate_limit
|
|
922
|
+
_rate_window_seconds = rate_window
|
|
923
|
+
_dispatch_rate_max = dispatch_rate_limit
|
|
924
|
+
_dispatch_rate_window = dispatch_rate_window
|
|
925
|
+
|
|
926
|
+
server = HTTPServer((host, port), CRPSidecarHandler)
|
|
927
|
+
logger.info("CRP sidecar started on %s:%d (auth=%s, max_sessions=%d)",
|
|
928
|
+
host, port, "enabled" if auth_token else "disabled", max_sessions)
|
|
929
|
+
return server
|