contextos-memory-runtime 1.0.0rc2__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.
- contextos/__init__.py +3 -0
- contextos/__main__.py +6 -0
- contextos/api/__init__.py +1 -0
- contextos/api/routes/__init__.py +1 -0
- contextos/api/routes/desktop.py +322 -0
- contextos/api/routes/ingest.py +17 -0
- contextos/api/routes/memories.py +84 -0
- contextos/api/routes/models.py +81 -0
- contextos/api/routes/retrieval.py +89 -0
- contextos/api/routes/system.py +216 -0
- contextos/api/server.py +195 -0
- contextos/benchmarks/__init__.py +1 -0
- contextos/benchmarks/compilation.py +245 -0
- contextos/benchmarks/connectors.py +423 -0
- contextos/benchmarks/explainability.py +103 -0
- contextos/benchmarks/final.py +406 -0
- contextos/benchmarks/graph.py +310 -0
- contextos/benchmarks/graph_adversarial.py +525 -0
- contextos/benchmarks/mcp.py +324 -0
- contextos/benchmarks/model_routing.py +203 -0
- contextos/benchmarks/optimization.py +305 -0
- contextos/benchmarks/rescue_integration.py +127 -0
- contextos/benchmarks/retrieval.py +266 -0
- contextos/benchmarks/temporal.py +377 -0
- contextos/benchmarks/temporal_hotpath.py +76 -0
- contextos/benchmarks/terminal.py +62 -0
- contextos/cli/__init__.py +1 -0
- contextos/cli/app.py +932 -0
- contextos/cli/dashboard.py +174 -0
- contextos/cli/formatters.py +299 -0
- contextos/config/__init__.py +1 -0
- contextos/config/settings.py +160 -0
- contextos/connectors/__init__.py +6 -0
- contextos/connectors/fake.py +11 -0
- contextos/connectors/json_import.py +125 -0
- contextos/connectors/local_files.py +102 -0
- contextos/connectors/manager.py +293 -0
- contextos/connectors/models.py +62 -0
- contextos/connectors/protocols.py +11 -0
- contextos/core/__init__.py +103 -0
- contextos/core/enums.py +489 -0
- contextos/core/exceptions.py +293 -0
- contextos/core/models.py +1147 -0
- contextos/core/protocols.py +549 -0
- contextos/daemon/__init__.py +1 -0
- contextos/daemon/manager.py +510 -0
- contextos/daemon/state.py +127 -0
- contextos/daemon/wiring.py +296 -0
- contextos/demo.py +217 -0
- contextos/embedding/__init__.py +1 -0
- contextos/embedding/deterministic.py +76 -0
- contextos/embedding/sentence_transformers.py +80 -0
- contextos/mcp/__init__.py +5 -0
- contextos/mcp/server.py +269 -0
- contextos/providers/__init__.py +13 -0
- contextos/providers/fake.py +217 -0
- contextos/providers/ollama.py +297 -0
- contextos/providers/openai_compatible.py +337 -0
- contextos/services/__init__.py +1 -0
- contextos/services/compilation.py +535 -0
- contextos/services/explainability.py +553 -0
- contextos/services/extraction.py +311 -0
- contextos/services/graph.py +524 -0
- contextos/services/graph_retrieval.py +143 -0
- contextos/services/ingestion.py +143 -0
- contextos/services/inspection.py +174 -0
- contextos/services/memory.py +291 -0
- contextos/services/model_service.py +409 -0
- contextos/services/optimization.py +426 -0
- contextos/services/privacy.py +331 -0
- contextos/services/retrieval.py +302 -0
- contextos/services/retrieval_index.py +88 -0
- contextos/services/router.py +302 -0
- contextos/services/secret_scanner.py +207 -0
- contextos/services/telemetry_query.py +102 -0
- contextos/services/temporal.py +500 -0
- contextos/services/token_counter.py +222 -0
- contextos/storage/__init__.py +1 -0
- contextos/storage/connector_repo.py +67 -0
- contextos/storage/database.py +497 -0
- contextos/storage/event_repo.py +137 -0
- contextos/storage/graph_repo.py +228 -0
- contextos/storage/lexical/__init__.py +1 -0
- contextos/storage/lexical/bm25.py +134 -0
- contextos/storage/memory_repo.py +589 -0
- contextos/storage/relation_repo.py +80 -0
- contextos/storage/telemetry_repo.py +481 -0
- contextos/storage/vector/__init__.py +1 -0
- contextos/storage/vector/in_memory.py +162 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Explicit synchronization of ephemeral retrieval indexes with SQLite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from contextos.core.enums import MemoryStatus
|
|
8
|
+
from contextos.core.models import Memory, MemoryFilters
|
|
9
|
+
from contextos.core.protocols import EmbeddingService, LexicalIndex, MemoryRepository, VectorStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
INDEXED_STATUSES = (
|
|
13
|
+
MemoryStatus.ACTIVE,
|
|
14
|
+
MemoryStatus.HISTORICAL,
|
|
15
|
+
MemoryStatus.SUPERSEDED,
|
|
16
|
+
MemoryStatus.CONTRADICTED,
|
|
17
|
+
MemoryStatus.EXPIRED,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RetrievalIndexSynchronizer:
|
|
22
|
+
"""Rebuild both indexes when the persisted memory fingerprint changes."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
memory_repo: MemoryRepository,
|
|
28
|
+
lexical_index: LexicalIndex,
|
|
29
|
+
vector_store: VectorStore,
|
|
30
|
+
embedding_service: EmbeddingService,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._memory_repo = memory_repo
|
|
33
|
+
self._lexical_index = lexical_index
|
|
34
|
+
self._vector_store = vector_store
|
|
35
|
+
self._embedding_service = embedding_service
|
|
36
|
+
self._fingerprint: tuple[tuple[str, int, str, str], ...] | None = None
|
|
37
|
+
self._lock = asyncio.Lock()
|
|
38
|
+
|
|
39
|
+
async def ensure_current(self, *, force: bool = False) -> bool:
|
|
40
|
+
"""Synchronize indexes; return whether a rebuild occurred."""
|
|
41
|
+
async with self._lock:
|
|
42
|
+
memories = await self._load_indexable_memories()
|
|
43
|
+
fingerprint = tuple(
|
|
44
|
+
sorted(
|
|
45
|
+
(str(memory.id), memory.version, memory.content_hash, memory.status.value)
|
|
46
|
+
for memory in memories
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
if not force and fingerprint == self._fingerprint:
|
|
50
|
+
return False
|
|
51
|
+
await self._rebuild(memories)
|
|
52
|
+
self._fingerprint = fingerprint
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
async def _load_indexable_memories(self) -> list[Memory]:
|
|
56
|
+
memories: list[Memory] = []
|
|
57
|
+
for status in INDEXED_STATUSES:
|
|
58
|
+
offset = 0
|
|
59
|
+
while True:
|
|
60
|
+
page = await self._memory_repo.list(
|
|
61
|
+
MemoryFilters(status=status, limit=500, offset=offset)
|
|
62
|
+
)
|
|
63
|
+
memories.extend(page)
|
|
64
|
+
if len(page) < 500:
|
|
65
|
+
break
|
|
66
|
+
offset += len(page)
|
|
67
|
+
return memories
|
|
68
|
+
|
|
69
|
+
async def _rebuild(self, memories: list[Memory]) -> None:
|
|
70
|
+
documents = {str(memory.id): memory.content for memory in memories}
|
|
71
|
+
ids = [str(memory.id) for memory in memories]
|
|
72
|
+
vectors = (
|
|
73
|
+
await self._embedding_service.embed([memory.content for memory in memories])
|
|
74
|
+
if memories
|
|
75
|
+
else []
|
|
76
|
+
)
|
|
77
|
+
metadata = [
|
|
78
|
+
{
|
|
79
|
+
"status": memory.status.value,
|
|
80
|
+
"type": memory.type.value,
|
|
81
|
+
"source_type": memory.source_type,
|
|
82
|
+
}
|
|
83
|
+
for memory in memories
|
|
84
|
+
]
|
|
85
|
+
# Generate and validate the more failure-prone dense corpus before
|
|
86
|
+
# replacing either live index.
|
|
87
|
+
await self._vector_store.rebuild(ids, vectors, metadata)
|
|
88
|
+
await self._lexical_index.rebuild(documents)
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Deterministic Model Router for ContextOS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from contextos.core.enums import RoutingPolicy, TokenMeasurementSource
|
|
10
|
+
from contextos.core.exceptions import (
|
|
11
|
+
ContextWindowExceededError,
|
|
12
|
+
ModelUnavailableError,
|
|
13
|
+
ProviderUnavailableError,
|
|
14
|
+
RoutingFailureError,
|
|
15
|
+
)
|
|
16
|
+
from contextos.core.models import (
|
|
17
|
+
ModelCapabilities,
|
|
18
|
+
ModelRequest,
|
|
19
|
+
RouteDecision,
|
|
20
|
+
)
|
|
21
|
+
from contextos.core.protocols import ModelProvider
|
|
22
|
+
from contextos.services.token_counter import TokenCounter, get_token_counter_for_model
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DeterministicModelRouter:
|
|
28
|
+
"""Selects provider and model deterministically based on policy and constraints."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
default_provider_id: str = "fake",
|
|
33
|
+
default_model_id: str = "fake-default",
|
|
34
|
+
default_policy: RoutingPolicy = RoutingPolicy.LOCAL_FIRST,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._default_provider_id = default_provider_id
|
|
37
|
+
self._default_model_id = default_model_id
|
|
38
|
+
self._default_policy = default_policy
|
|
39
|
+
|
|
40
|
+
async def route(
|
|
41
|
+
self,
|
|
42
|
+
request: ModelRequest,
|
|
43
|
+
providers: dict[str, ModelProvider],
|
|
44
|
+
policy: RoutingPolicy | None = None,
|
|
45
|
+
) -> RouteDecision:
|
|
46
|
+
"""Route request to the most appropriate healthy provider and model."""
|
|
47
|
+
start_time = time.perf_counter()
|
|
48
|
+
chosen_policy = policy or request.routing_policy or self._default_policy
|
|
49
|
+
|
|
50
|
+
if not providers:
|
|
51
|
+
raise RoutingFailureError(chosen_policy.value, "No providers registered in system")
|
|
52
|
+
|
|
53
|
+
candidates_evaluated: list[str] = []
|
|
54
|
+
|
|
55
|
+
# -------------------------------------------------------------------
|
|
56
|
+
# Policy: EXPLICIT
|
|
57
|
+
# -------------------------------------------------------------------
|
|
58
|
+
if chosen_policy == RoutingPolicy.EXPLICIT or (request.provider and chosen_policy != RoutingPolicy.FIXED_DEFAULT):
|
|
59
|
+
prov_id = request.provider or self._default_provider_id
|
|
60
|
+
if prov_id not in providers:
|
|
61
|
+
raise ProviderUnavailableError(prov_id, f"Provider '{prov_id}' not found in registry")
|
|
62
|
+
|
|
63
|
+
provider = providers[prov_id]
|
|
64
|
+
is_healthy = await provider.health()
|
|
65
|
+
if not is_healthy:
|
|
66
|
+
raise ProviderUnavailableError(prov_id, f"Provider '{prov_id}' is unhealthy")
|
|
67
|
+
|
|
68
|
+
models = await provider.list_models()
|
|
69
|
+
target_model_id = request.model or (models[0].model_id if models else self._default_model_id)
|
|
70
|
+
candidates_evaluated.append(f"{prov_id}/{target_model_id}")
|
|
71
|
+
|
|
72
|
+
matched_model = next((m for m in models if m.model_id == target_model_id), None)
|
|
73
|
+
if not matched_model:
|
|
74
|
+
raise ModelUnavailableError(target_model_id, prov_id)
|
|
75
|
+
|
|
76
|
+
self._validate_context_window(request, matched_model)
|
|
77
|
+
|
|
78
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
79
|
+
return RouteDecision(
|
|
80
|
+
policy=chosen_policy,
|
|
81
|
+
reason="Explicit provider/model selection requested and verified",
|
|
82
|
+
candidates_evaluated=candidates_evaluated,
|
|
83
|
+
selected_provider=prov_id,
|
|
84
|
+
selected_model=target_model_id,
|
|
85
|
+
fallback_used=False,
|
|
86
|
+
routing_latency_ms=elapsed_ms,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# -------------------------------------------------------------------
|
|
90
|
+
# Policy: FIXED_DEFAULT
|
|
91
|
+
# -------------------------------------------------------------------
|
|
92
|
+
if chosen_policy == RoutingPolicy.FIXED_DEFAULT:
|
|
93
|
+
prov_id = self._default_provider_id
|
|
94
|
+
if prov_id not in providers:
|
|
95
|
+
raise ProviderUnavailableError(prov_id, "Default provider not registered")
|
|
96
|
+
|
|
97
|
+
provider = providers[prov_id]
|
|
98
|
+
if not await provider.health():
|
|
99
|
+
raise ProviderUnavailableError(prov_id, "Default provider is unhealthy")
|
|
100
|
+
|
|
101
|
+
models = await provider.list_models()
|
|
102
|
+
matched_model = next((m for m in models if m.model_id == self._default_model_id), None)
|
|
103
|
+
if not matched_model and models:
|
|
104
|
+
matched_model = models[0]
|
|
105
|
+
if not matched_model:
|
|
106
|
+
raise ModelUnavailableError(self._default_model_id, prov_id)
|
|
107
|
+
|
|
108
|
+
candidates_evaluated.append(f"{prov_id}/{matched_model.model_id}")
|
|
109
|
+
self._validate_context_window(request, matched_model)
|
|
110
|
+
|
|
111
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
112
|
+
return RouteDecision(
|
|
113
|
+
policy=chosen_policy,
|
|
114
|
+
reason="Fixed default policy selected configured default model",
|
|
115
|
+
candidates_evaluated=candidates_evaluated,
|
|
116
|
+
selected_provider=prov_id,
|
|
117
|
+
selected_model=matched_model.model_id,
|
|
118
|
+
fallback_used=False,
|
|
119
|
+
routing_latency_ms=elapsed_ms,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# -------------------------------------------------------------------
|
|
123
|
+
# Policy: LOCAL_FIRST
|
|
124
|
+
# -------------------------------------------------------------------
|
|
125
|
+
if chosen_policy == RoutingPolicy.LOCAL_FIRST:
|
|
126
|
+
local_providers = [p for p in providers.values() if p.is_local]
|
|
127
|
+
selected_local: tuple[ModelProvider, ModelCapabilities] | None = None
|
|
128
|
+
initial_provider_tried: str | None = None
|
|
129
|
+
|
|
130
|
+
for prov in local_providers:
|
|
131
|
+
initial_provider_tried = prov.provider_id
|
|
132
|
+
try:
|
|
133
|
+
if not await prov.health():
|
|
134
|
+
candidates_evaluated.append(f"{prov.provider_id} (unhealthy)")
|
|
135
|
+
continue
|
|
136
|
+
prov_models = await prov.list_models()
|
|
137
|
+
except Exception:
|
|
138
|
+
candidates_evaluated.append(f"{prov.provider_id} (health check failed)")
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
for model in prov_models:
|
|
142
|
+
candidates_evaluated.append(f"{prov.provider_id}/{model.model_id}")
|
|
143
|
+
if self._fits_context_and_capabilities(request, model):
|
|
144
|
+
selected_local = (prov, model)
|
|
145
|
+
break
|
|
146
|
+
if selected_local:
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
if selected_local:
|
|
150
|
+
prov, model = selected_local
|
|
151
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
152
|
+
return RouteDecision(
|
|
153
|
+
policy=chosen_policy,
|
|
154
|
+
reason="Local-first policy found healthy local provider and model fitting context window",
|
|
155
|
+
candidates_evaluated=candidates_evaluated,
|
|
156
|
+
selected_provider=prov.provider_id,
|
|
157
|
+
selected_model=model.model_id,
|
|
158
|
+
fallback_used=False,
|
|
159
|
+
routing_latency_ms=elapsed_ms,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Local provider not found or unhealthy
|
|
163
|
+
if not request.allow_fallback:
|
|
164
|
+
raise ProviderUnavailableError(
|
|
165
|
+
initial_provider_tried or "local",
|
|
166
|
+
"Local providers unavailable and fallback not allowed by request policy",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Fallback to remote provider
|
|
170
|
+
remote_providers = [p for p in providers.values() if not p.is_local]
|
|
171
|
+
selected_remote: tuple[ModelProvider, ModelCapabilities] | None = None
|
|
172
|
+
for prov in remote_providers:
|
|
173
|
+
if not await prov.health():
|
|
174
|
+
candidates_evaluated.append(f"{prov.provider_id} (unhealthy)")
|
|
175
|
+
continue
|
|
176
|
+
prov_models = await prov.list_models()
|
|
177
|
+
for model in prov_models:
|
|
178
|
+
candidates_evaluated.append(f"{prov.provider_id}/{model.model_id}")
|
|
179
|
+
if self._fits_context_and_capabilities(request, model):
|
|
180
|
+
selected_remote = (prov, model)
|
|
181
|
+
break
|
|
182
|
+
if selected_remote:
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
if selected_remote:
|
|
186
|
+
prov, model = selected_remote
|
|
187
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
188
|
+
return RouteDecision(
|
|
189
|
+
policy=chosen_policy,
|
|
190
|
+
reason="Local provider unavailable; successfully fell back to remote provider",
|
|
191
|
+
candidates_evaluated=candidates_evaluated,
|
|
192
|
+
selected_provider=prov.provider_id,
|
|
193
|
+
selected_model=model.model_id,
|
|
194
|
+
fallback_used=True,
|
|
195
|
+
initial_provider=initial_provider_tried,
|
|
196
|
+
fallback_reason="Local provider unavailable or failed health check",
|
|
197
|
+
routing_latency_ms=elapsed_ms,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
raise RoutingFailureError(
|
|
201
|
+
chosen_policy.value,
|
|
202
|
+
"Neither local nor remote fallback providers could satisfy the request",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# -------------------------------------------------------------------
|
|
206
|
+
# Policy: CAPABILITY_AWARE
|
|
207
|
+
# -------------------------------------------------------------------
|
|
208
|
+
if chosen_policy == RoutingPolicy.CAPABILITY_AWARE:
|
|
209
|
+
all_candidates: list[tuple[ModelProvider, ModelCapabilities]] = []
|
|
210
|
+
for prov in providers.values():
|
|
211
|
+
if not await prov.health():
|
|
212
|
+
continue
|
|
213
|
+
models = await prov.list_models()
|
|
214
|
+
for model in models:
|
|
215
|
+
candidates_evaluated.append(f"{prov.provider_id}/{model.model_id}")
|
|
216
|
+
if self._fits_context_and_capabilities(request, model):
|
|
217
|
+
all_candidates.append((prov, model))
|
|
218
|
+
|
|
219
|
+
if not all_candidates:
|
|
220
|
+
raise RoutingFailureError(
|
|
221
|
+
chosen_policy.value,
|
|
222
|
+
f"No model satisfied required capabilities: {request.required_capabilities}",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Prefer local if available, then by largest context window
|
|
226
|
+
all_candidates.sort(key=lambda item: (not item[0].is_local, -item[1].context_window))
|
|
227
|
+
best_prov, best_model = all_candidates[0]
|
|
228
|
+
|
|
229
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000.0
|
|
230
|
+
return RouteDecision(
|
|
231
|
+
policy=chosen_policy,
|
|
232
|
+
reason="Capability-aware policy matched required capabilities and context fit",
|
|
233
|
+
candidates_evaluated=candidates_evaluated,
|
|
234
|
+
selected_provider=best_prov.provider_id,
|
|
235
|
+
selected_model=best_model.model_id,
|
|
236
|
+
fallback_used=False,
|
|
237
|
+
routing_latency_ms=elapsed_ms,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
raise RoutingFailureError(str(chosen_policy), f"Unsupported routing policy '{chosen_policy}'")
|
|
241
|
+
|
|
242
|
+
def _compute_safety_margin(self, counter: TokenCounter) -> int:
|
|
243
|
+
"""Conservative buffer for chat framing (roles, formatting) and approximation variance."""
|
|
244
|
+
base_framing = 16
|
|
245
|
+
if counter.measurement_source == TokenMeasurementSource.APPROXIMATED:
|
|
246
|
+
return base_framing + 32
|
|
247
|
+
return base_framing
|
|
248
|
+
|
|
249
|
+
def _fits_context_and_capabilities(
|
|
250
|
+
self, request: ModelRequest, model: ModelCapabilities
|
|
251
|
+
) -> bool:
|
|
252
|
+
"""Check capability constraints and context window fit."""
|
|
253
|
+
if not model.enabled:
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
# Capability checks
|
|
257
|
+
if "tools" in request.required_capabilities and not model.supports_tools:
|
|
258
|
+
return False
|
|
259
|
+
if "json" in request.required_capabilities and not model.supports_json:
|
|
260
|
+
return False
|
|
261
|
+
if "vision" in request.required_capabilities and not model.supports_vision:
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
# Context window fit with safety margin for framing/approximation
|
|
265
|
+
context_text = request.compiled_context.context_text if request.compiled_context else ""
|
|
266
|
+
counter = get_token_counter_for_model(model.model_id, model.tokenizer_family)
|
|
267
|
+
prompt_tokens = counter.count(request.user_prompt)
|
|
268
|
+
if request.system_prompt:
|
|
269
|
+
prompt_tokens += counter.count(request.system_prompt)
|
|
270
|
+
context_tokens = counter.count(context_text) if context_text else 0
|
|
271
|
+
reserved_output = request.max_output_tokens or 1024
|
|
272
|
+
margin = self._compute_safety_margin(counter)
|
|
273
|
+
|
|
274
|
+
total_needed = prompt_tokens + context_tokens + reserved_output + margin
|
|
275
|
+
return total_needed <= model.context_window
|
|
276
|
+
|
|
277
|
+
def _validate_context_window(
|
|
278
|
+
self, request: ModelRequest, model: ModelCapabilities
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Ensure total needed tokens fit within the model context window.
|
|
281
|
+
|
|
282
|
+
Raises ContextWindowExceededError if it does not fit.
|
|
283
|
+
"""
|
|
284
|
+
context_text = request.compiled_context.context_text if request.compiled_context else ""
|
|
285
|
+
counter = get_token_counter_for_model(model.model_id, model.tokenizer_family)
|
|
286
|
+
prompt_tokens = counter.count(request.user_prompt)
|
|
287
|
+
if request.system_prompt:
|
|
288
|
+
prompt_tokens += counter.count(request.system_prompt)
|
|
289
|
+
context_tokens = counter.count(context_text) if context_text else 0
|
|
290
|
+
reserved_output = request.max_output_tokens or 1024
|
|
291
|
+
margin = self._compute_safety_margin(counter)
|
|
292
|
+
|
|
293
|
+
total_needed = prompt_tokens + context_tokens + reserved_output + margin
|
|
294
|
+
if total_needed > model.context_window:
|
|
295
|
+
raise ContextWindowExceededError(
|
|
296
|
+
model_id=model.model_id,
|
|
297
|
+
required_tokens=total_needed,
|
|
298
|
+
context_window=model.context_window,
|
|
299
|
+
prompt_tokens=prompt_tokens,
|
|
300
|
+
compiled_context_tokens=context_tokens,
|
|
301
|
+
reserved_output_tokens=reserved_output,
|
|
302
|
+
)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Local structured credential detection and overlap-safe redaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from contextos.core.enums import SecretType
|
|
10
|
+
from contextos.core.models import ScanResult, SecretMatch
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class SecretPattern:
|
|
15
|
+
secret_type: SecretType
|
|
16
|
+
pattern: re.Pattern[str]
|
|
17
|
+
confidence: float
|
|
18
|
+
detector: str
|
|
19
|
+
secret_group: int = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _pattern(
|
|
23
|
+
secret_type: SecretType,
|
|
24
|
+
expression: str,
|
|
25
|
+
confidence: float,
|
|
26
|
+
detector: str,
|
|
27
|
+
*,
|
|
28
|
+
flags: int = 0,
|
|
29
|
+
secret_group: int = 0,
|
|
30
|
+
) -> SecretPattern:
|
|
31
|
+
return SecretPattern(
|
|
32
|
+
secret_type, re.compile(expression, flags), confidence, detector, secret_group
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
SECRET_PATTERNS: list[SecretPattern] = [
|
|
37
|
+
_pattern(SecretType.PRIVATE_KEY,
|
|
38
|
+
r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----[\s\S]*?"
|
|
39
|
+
r"-----END (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
|
|
40
|
+
0.99, "private-key-block", flags=re.MULTILINE),
|
|
41
|
+
_pattern(SecretType.AUTHORIZATION_HEADER,
|
|
42
|
+
r"\bauthorization\s*:\s*(?:bearer|basic)\s+([^\s,;]+)",
|
|
43
|
+
0.99, "authorization-header", flags=re.I, secret_group=1),
|
|
44
|
+
_pattern(SecretType.AWS_ACCESS_KEY, r"(?<![A-Z0-9])AKIA[0-9A-Z]{16}(?![A-Z0-9])",
|
|
45
|
+
0.98, "aws-access-key"),
|
|
46
|
+
_pattern(SecretType.GITHUB_TOKEN, r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}",
|
|
47
|
+
0.98, "github-token"),
|
|
48
|
+
_pattern(SecretType.OPENAI_API_KEY,
|
|
49
|
+
r"sk-[A-Za-z0-9_-]{20,}T3BlbkFJ[A-Za-z0-9_-]{20,}",
|
|
50
|
+
0.99, "openai-legacy-key"),
|
|
51
|
+
_pattern(SecretType.OPENAI_API_KEY, r"sk-proj-[A-Za-z0-9_-]{40,}",
|
|
52
|
+
0.98, "openai-project-key"),
|
|
53
|
+
_pattern(SecretType.ANTHROPIC_API_KEY, r"sk-ant-[A-Za-z0-9_-]{40,}",
|
|
54
|
+
0.98, "anthropic-key"),
|
|
55
|
+
_pattern(SecretType.GOOGLE_API_KEY, r"AIza[0-9A-Za-z_-]{35}",
|
|
56
|
+
0.96, "google-api-key"),
|
|
57
|
+
_pattern(SecretType.SLACK_TOKEN,
|
|
58
|
+
r"xox[baprs]-[0-9a-zA-Z]{10,}(?:-[0-9a-zA-Z]{10,})*",
|
|
59
|
+
0.96, "slack-token"),
|
|
60
|
+
_pattern(SecretType.STRIPE_KEY, r"(?:sk|pk)_(?:test|live)_[0-9a-zA-Z]{24,}",
|
|
61
|
+
0.98, "stripe-key"),
|
|
62
|
+
_pattern(SecretType.JWT,
|
|
63
|
+
r"eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}",
|
|
64
|
+
0.96, "jwt-token"),
|
|
65
|
+
_pattern(SecretType.CONNECTION_STRING,
|
|
66
|
+
r"(?:mongodb|postgres|postgresql|mysql|redis|amqp)(?:\+[a-z]+)?://"
|
|
67
|
+
r"[^\s:/]+:[^\s@]+@[^\s]+",
|
|
68
|
+
0.96, "credential-connection-string", flags=re.I),
|
|
69
|
+
_pattern(SecretType.CONNECTION_STRING,
|
|
70
|
+
r"(?:https?|ftp)://[^\s:/]+:[^\s@]+@[^\s/]+(?:/[^\s]*)?",
|
|
71
|
+
0.94, "credential-url-userinfo", flags=re.I),
|
|
72
|
+
_pattern(SecretType.BEARER_TOKEN, r"\bbearer\s+([A-Za-z0-9._~+/=-]{12,})",
|
|
73
|
+
0.94, "bearer-token", flags=re.I, secret_group=1),
|
|
74
|
+
_pattern(SecretType.GENERIC_API_KEY,
|
|
75
|
+
r"\b(?:api[_ -]?key|apikey|api[_ -]?secret)\b\s*(?:is|=|:)\s*"
|
|
76
|
+
r"['\"]?([A-Za-z0-9_./+=-]{11,}[A-Za-z0-9_+=-])['\"]?",
|
|
77
|
+
0.92, "credential-assignment", flags=re.I, secret_group=1),
|
|
78
|
+
_pattern(SecretType.ACCESS_TOKEN,
|
|
79
|
+
r"\b(?:access[_ -]?token|refresh[_ -]?token)\b\s*(?:is|=|:)\s*"
|
|
80
|
+
r"['\"]?([A-Za-z0-9_./+=-]{12,})['\"]?",
|
|
81
|
+
0.93, "token-assignment", flags=re.I, secret_group=1),
|
|
82
|
+
_pattern(SecretType.ACCESS_TOKEN,
|
|
83
|
+
r"[?&](?:x-amz-signature|x-goog-signature|signature|sig)="
|
|
84
|
+
r"([^&#\s]{16,})",
|
|
85
|
+
0.95, "signed-url-signature", flags=re.I, secret_group=1),
|
|
86
|
+
_pattern(SecretType.SESSION_COOKIE,
|
|
87
|
+
r"\b(?:session(?:[_ -]?(?:id|token))?|cookie)\b\s*(?:is|=|:)\s*"
|
|
88
|
+
r"['\"]?([A-Za-z0-9_./+=-]{12,})['\"]?",
|
|
89
|
+
0.91, "session-cookie", flags=re.I, secret_group=1),
|
|
90
|
+
_pattern(SecretType.PASSWORD,
|
|
91
|
+
r"\b(?:password|passwd|pwd|pass)\b\s*(?:is|=|:)\s*"
|
|
92
|
+
r"['\"]?([^\s'\"]{6,})['\"]?",
|
|
93
|
+
0.91, "password-assignment", flags=re.I, secret_group=1),
|
|
94
|
+
_pattern(SecretType.OTP,
|
|
95
|
+
r"\b(?:otp|one[ -]?time (?:password|code)|verification code|pin)\b"
|
|
96
|
+
r"\s*(?:is|=|:)\s*([0-9]{4,8})\b",
|
|
97
|
+
0.95, "contextual-otp", flags=re.I, secret_group=1),
|
|
98
|
+
_pattern(SecretType.AWS_SECRET_KEY,
|
|
99
|
+
r"\baws[_ -]?secret(?:[_ -]?access)?[_ -]?key\b\s*(?:is|=|:)\s*"
|
|
100
|
+
r"['\"]?([A-Za-z0-9/+=]{40})['\"]?",
|
|
101
|
+
0.96, "aws-secret-key", flags=re.I, secret_group=1),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _shannon_entropy(value: str) -> float:
|
|
106
|
+
if not value:
|
|
107
|
+
return 0.0
|
|
108
|
+
frequencies: dict[str, int] = {}
|
|
109
|
+
for character in value:
|
|
110
|
+
frequencies[character] = frequencies.get(character, 0) + 1
|
|
111
|
+
length = len(value)
|
|
112
|
+
return -sum(
|
|
113
|
+
(count / length) * math.log2(count / length) for count in frequencies.values()
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _find_high_entropy_strings(
|
|
118
|
+
text: str,
|
|
119
|
+
min_length: int = 20,
|
|
120
|
+
max_length: int = 200,
|
|
121
|
+
entropy_threshold: float = 4.5,
|
|
122
|
+
) -> list[SecretMatch]:
|
|
123
|
+
"""Optional advisory detector; disabled by default to control false positives."""
|
|
124
|
+
matches: list[SecretMatch] = []
|
|
125
|
+
token_pattern = re.compile(
|
|
126
|
+
r"[A-Za-z0-9+/=_-]{" + str(min_length) + "," + str(max_length) + "}"
|
|
127
|
+
)
|
|
128
|
+
for match in token_pattern.finditer(text):
|
|
129
|
+
entropy = _shannon_entropy(match.group())
|
|
130
|
+
if entropy >= entropy_threshold:
|
|
131
|
+
matches.append(SecretMatch(
|
|
132
|
+
secret_type=SecretType.HIGH_ENTROPY,
|
|
133
|
+
start=match.start(),
|
|
134
|
+
end=match.end(),
|
|
135
|
+
matched_text="[REDACTED:high_entropy]",
|
|
136
|
+
confidence=min(0.89, (entropy - entropy_threshold) / 2.0 + 0.5),
|
|
137
|
+
))
|
|
138
|
+
return matches
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class PatternSecretScanner:
|
|
142
|
+
"""Replaceable local scanner implementing the existing SecretScanner protocol."""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
*,
|
|
147
|
+
min_confidence: float = 0.5,
|
|
148
|
+
enable_entropy: bool = False,
|
|
149
|
+
entropy_threshold: float = 4.5,
|
|
150
|
+
) -> None:
|
|
151
|
+
self._min_confidence = min_confidence
|
|
152
|
+
self._enable_entropy = enable_entropy
|
|
153
|
+
self._entropy_threshold = entropy_threshold
|
|
154
|
+
|
|
155
|
+
def scan(self, text: str) -> ScanResult:
|
|
156
|
+
if not text:
|
|
157
|
+
return ScanResult(scanned_length=0)
|
|
158
|
+
matches: list[SecretMatch] = []
|
|
159
|
+
for secret_pattern in SECRET_PATTERNS:
|
|
160
|
+
if secret_pattern.confidence < self._min_confidence:
|
|
161
|
+
continue
|
|
162
|
+
for match in secret_pattern.pattern.finditer(text):
|
|
163
|
+
start, end = match.span(secret_pattern.secret_group)
|
|
164
|
+
matches.append(SecretMatch(
|
|
165
|
+
secret_type=secret_pattern.secret_type,
|
|
166
|
+
start=start,
|
|
167
|
+
end=end,
|
|
168
|
+
matched_text=f"[REDACTED:{secret_pattern.secret_type.value}]",
|
|
169
|
+
confidence=secret_pattern.confidence,
|
|
170
|
+
))
|
|
171
|
+
if self._enable_entropy:
|
|
172
|
+
matches.extend(_find_high_entropy_strings(
|
|
173
|
+
text, entropy_threshold=self._entropy_threshold
|
|
174
|
+
))
|
|
175
|
+
matches = self._deduplicate_overlapping(matches)
|
|
176
|
+
return ScanResult(
|
|
177
|
+
has_secrets=bool(matches), matches=matches, scanned_length=len(text)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def redact(self, text: str) -> tuple[str, ScanResult]:
|
|
181
|
+
result = self.scan(text)
|
|
182
|
+
redacted = text
|
|
183
|
+
for match in sorted(result.matches, key=lambda item: item.start, reverse=True):
|
|
184
|
+
redacted = (
|
|
185
|
+
redacted[:match.start]
|
|
186
|
+
+ f"[REDACTED:{match.secret_type.value}]"
|
|
187
|
+
+ redacted[match.end:]
|
|
188
|
+
)
|
|
189
|
+
return redacted, result
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _deduplicate_overlapping(matches: list[SecretMatch]) -> list[SecretMatch]:
|
|
193
|
+
selected: list[SecretMatch] = []
|
|
194
|
+
for current in sorted(matches, key=lambda item: (item.start, -item.confidence, -item.end)):
|
|
195
|
+
if selected and current.start < selected[-1].end:
|
|
196
|
+
previous = selected[-1]
|
|
197
|
+
winner = current if current.confidence > previous.confidence else previous
|
|
198
|
+
selected[-1] = SecretMatch(
|
|
199
|
+
secret_type=winner.secret_type,
|
|
200
|
+
start=min(previous.start, current.start),
|
|
201
|
+
end=max(previous.end, current.end),
|
|
202
|
+
matched_text=f"[REDACTED:{winner.secret_type.value}]",
|
|
203
|
+
confidence=max(previous.confidence, current.confidence),
|
|
204
|
+
)
|
|
205
|
+
continue
|
|
206
|
+
selected.append(current)
|
|
207
|
+
return selected
|