differix 0.1.0a1__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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- tools/web_search.py +371 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/lesson_memory_bridge.py
|
|
3
|
+
------------------------------
|
|
4
|
+
Bridges the JSON-file lesson system with the SQLite-backed A-Score memory engine.
|
|
5
|
+
|
|
6
|
+
Design:
|
|
7
|
+
- lessons/*.json → source of truth (content, human-editable, git-tracked)
|
|
8
|
+
- .vegex/lessons.db → regenerable ranking index (vectors, T/H/V stats)
|
|
9
|
+
|
|
10
|
+
Embedding providers are selected via `build_embedding_provider(config)`.
|
|
11
|
+
On bridge initialization, `check_and_migrate` ensures the storage's vector
|
|
12
|
+
dimension and provider signature match the active provider. If they differ,
|
|
13
|
+
existing rows are re-embedded from their `content` field, preserving all
|
|
14
|
+
A-Score signals.
|
|
15
|
+
|
|
16
|
+
Public API:
|
|
17
|
+
- LessonMemoryBridge(lessons_root, db_path, ...)
|
|
18
|
+
.sync_all(force=False) -> SyncReport
|
|
19
|
+
.search(query, limit, stages, scope_type, scope_value, record_trigger) -> List[ScoredMemoryNode]
|
|
20
|
+
.record_helpful(node_ids) -> int
|
|
21
|
+
.record_violation(node_ids) -> int
|
|
22
|
+
.stats() -> dict
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import time
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
36
|
+
|
|
37
|
+
from memory import (
|
|
38
|
+
BaseEmbeddingProvider,
|
|
39
|
+
MemoryManager,
|
|
40
|
+
MemoryNode,
|
|
41
|
+
MemoryScope,
|
|
42
|
+
RankingWeights,
|
|
43
|
+
ScoredMemoryNode,
|
|
44
|
+
SQLiteMemoryStorage,
|
|
45
|
+
build_embedding_provider,
|
|
46
|
+
check_and_migrate,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Sync report
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
@dataclass
|
|
56
|
+
class SyncReport:
|
|
57
|
+
inserted: int = 0
|
|
58
|
+
updated: int = 0
|
|
59
|
+
skipped: int = 0
|
|
60
|
+
deleted: int = 0
|
|
61
|
+
errors: List[str] = field(default_factory=list)
|
|
62
|
+
elapsed_ms: float = 0.0
|
|
63
|
+
migration: str = ""
|
|
64
|
+
|
|
65
|
+
def as_dict(self) -> Dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"inserted": self.inserted,
|
|
68
|
+
"updated": self.updated,
|
|
69
|
+
"skipped": self.skipped,
|
|
70
|
+
"deleted": self.deleted,
|
|
71
|
+
"errors": list(self.errors),
|
|
72
|
+
"elapsed_ms": round(self.elapsed_ms, 2),
|
|
73
|
+
"migration": self.migration,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Bridge
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
class LessonMemoryBridge:
|
|
81
|
+
"""Synchronizes lessons/*/*.json into a MemoryManager SQLite index."""
|
|
82
|
+
|
|
83
|
+
STAGES: Tuple[str, ...] = ("observations", "candidates", "promoted", "deprecated")
|
|
84
|
+
|
|
85
|
+
STAGE_TRUST: Dict[str, float] = {
|
|
86
|
+
"observations": 0.3,
|
|
87
|
+
"candidates": 0.5,
|
|
88
|
+
"promoted": 1.0,
|
|
89
|
+
"deprecated": 0.1,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
STAGE_CRITICAL: Dict[str, bool] = {
|
|
93
|
+
"observations": False,
|
|
94
|
+
"candidates": False,
|
|
95
|
+
"promoted": True,
|
|
96
|
+
"deprecated": False,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
lessons_root: str | os.PathLike,
|
|
102
|
+
db_path: str | os.PathLike,
|
|
103
|
+
embedding_provider: Optional[BaseEmbeddingProvider] = None,
|
|
104
|
+
ranking_weights: Optional[RankingWeights] = None,
|
|
105
|
+
config_module: Optional[Any] = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
self.lessons_root = Path(lessons_root).resolve()
|
|
108
|
+
self.db_path = Path(db_path).resolve()
|
|
109
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
|
|
111
|
+
# Provider selection: explicit override, else factory.
|
|
112
|
+
if embedding_provider is not None:
|
|
113
|
+
self.embedder = embedding_provider
|
|
114
|
+
else:
|
|
115
|
+
if config_module is None:
|
|
116
|
+
import config as config_module # type: ignore
|
|
117
|
+
self.embedder = build_embedding_provider(config_module)
|
|
118
|
+
|
|
119
|
+
# Storage is created with the active provider's dim.
|
|
120
|
+
storage = SQLiteMemoryStorage(
|
|
121
|
+
db_path=str(self.db_path),
|
|
122
|
+
expected_dim=self.embedder.dim,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Migrate if the on-disk signature differs (re-embeds all rows).
|
|
126
|
+
self._migration_report = check_and_migrate(storage, self.embedder)
|
|
127
|
+
if self._migration_report.action == "migrated":
|
|
128
|
+
logger.info("Bridge migration: %s", self._migration_report)
|
|
129
|
+
elif self._migration_report.action == "aborted":
|
|
130
|
+
logger.error("Bridge migration aborted: %s", self._migration_report.error)
|
|
131
|
+
|
|
132
|
+
self.manager = MemoryManager(
|
|
133
|
+
storage=storage,
|
|
134
|
+
default_weights=ranking_weights or RankingWeights(),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------ #
|
|
138
|
+
# Sync
|
|
139
|
+
# ------------------------------------------------------------------ #
|
|
140
|
+
def sync_all(self, force: bool = False) -> SyncReport:
|
|
141
|
+
t0 = time.perf_counter()
|
|
142
|
+
report = SyncReport()
|
|
143
|
+
report.migration = str(self._migration_report)
|
|
144
|
+
|
|
145
|
+
seen_ids: set = set()
|
|
146
|
+
json_by_stage: Dict[str, List[Path]] = {s: [] for s in self.STAGES}
|
|
147
|
+
|
|
148
|
+
for stage in self.STAGES:
|
|
149
|
+
stage_dir = self.lessons_root / stage
|
|
150
|
+
if not stage_dir.is_dir():
|
|
151
|
+
continue
|
|
152
|
+
for fp in sorted(stage_dir.glob("*.json")):
|
|
153
|
+
json_by_stage[stage].append(fp)
|
|
154
|
+
|
|
155
|
+
for stage, paths in json_by_stage.items():
|
|
156
|
+
for path in paths:
|
|
157
|
+
try:
|
|
158
|
+
node = self._sync_one(stage, path, force=force, report=report)
|
|
159
|
+
if node is not None:
|
|
160
|
+
seen_ids.add(node.node_id)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
report.errors.append(f"{stage}/{path.name}: {exc}")
|
|
163
|
+
|
|
164
|
+
report.deleted = self._purge_orphans(seen_ids)
|
|
165
|
+
report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
|
166
|
+
return report
|
|
167
|
+
|
|
168
|
+
def _sync_one(
|
|
169
|
+
self,
|
|
170
|
+
stage: str,
|
|
171
|
+
path: Path,
|
|
172
|
+
force: bool,
|
|
173
|
+
report: SyncReport,
|
|
174
|
+
) -> Optional[MemoryNode]:
|
|
175
|
+
raw = path.read_bytes()
|
|
176
|
+
content_hash = hashlib.sha256(raw).hexdigest()
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
data = json.loads(raw.decode("utf-8"))
|
|
180
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
181
|
+
report.errors.append(f"{stage}/{path.name}: invalid JSON ({exc})")
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
lesson_id = str(data.get("id") or path.stem).strip()
|
|
185
|
+
if not lesson_id:
|
|
186
|
+
report.errors.append(f"{stage}/{path.name}: missing 'id'")
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
node_id = f"lesson:{lesson_id}"
|
|
190
|
+
|
|
191
|
+
existing: Optional[MemoryNode] = None
|
|
192
|
+
try:
|
|
193
|
+
existing = self.manager.get(node_id)
|
|
194
|
+
except Exception:
|
|
195
|
+
existing = None
|
|
196
|
+
|
|
197
|
+
# Fast path: unchanged content + same stage → skip
|
|
198
|
+
if existing is not None and not force:
|
|
199
|
+
prev_hash = (existing.metadata or {}).get("content_hash")
|
|
200
|
+
prev_stage = (existing.metadata or {}).get("stage")
|
|
201
|
+
if prev_hash == content_hash and prev_stage == stage:
|
|
202
|
+
report.skipped += 1
|
|
203
|
+
return existing
|
|
204
|
+
|
|
205
|
+
node = self._build_node(stage, path, data, content_hash, existing)
|
|
206
|
+
if existing is None:
|
|
207
|
+
self.manager.add(node)
|
|
208
|
+
report.inserted += 1
|
|
209
|
+
else:
|
|
210
|
+
# Delete+reinsert to preserve runtime signals from `node`
|
|
211
|
+
self.manager.storage.delete(node.node_id)
|
|
212
|
+
self.manager.add(node)
|
|
213
|
+
report.updated += 1
|
|
214
|
+
return node
|
|
215
|
+
|
|
216
|
+
def _build_node(
|
|
217
|
+
self,
|
|
218
|
+
stage: str,
|
|
219
|
+
path: Path,
|
|
220
|
+
data: Dict[str, Any],
|
|
221
|
+
content_hash: str,
|
|
222
|
+
existing: Optional[MemoryNode],
|
|
223
|
+
) -> MemoryNode:
|
|
224
|
+
lesson_id = str(data.get("id") or path.stem).strip()
|
|
225
|
+
content_text = self._render_content_text(data)
|
|
226
|
+
vector = self.embedder.embed(content_text)
|
|
227
|
+
|
|
228
|
+
now = time.time()
|
|
229
|
+
trigger = existing.trigger_count if existing else 0
|
|
230
|
+
helpful = existing.helpful_count if existing else 0
|
|
231
|
+
violation = existing.violation_count if existing else 0
|
|
232
|
+
last_used = existing.last_used_at if existing else now
|
|
233
|
+
created_at = existing.created_at if existing else now
|
|
234
|
+
|
|
235
|
+
rel_path = path.relative_to(self.lessons_root).as_posix()
|
|
236
|
+
|
|
237
|
+
metadata: Dict[str, Any] = {
|
|
238
|
+
"stage": stage,
|
|
239
|
+
"lesson_id": lesson_id,
|
|
240
|
+
"lesson_file": rel_path,
|
|
241
|
+
"content_hash": content_hash,
|
|
242
|
+
"root_cause": str(data.get("root_cause", "")),
|
|
243
|
+
"details": data.get("details", {}),
|
|
244
|
+
"tags": list(data.get("tags", []) or []),
|
|
245
|
+
"timestamp": data.get("timestamp"),
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return MemoryNode(
|
|
249
|
+
node_id=f"lesson:{lesson_id}",
|
|
250
|
+
vector=vector,
|
|
251
|
+
content=content_text,
|
|
252
|
+
metadata=metadata,
|
|
253
|
+
base_trust=self.STAGE_TRUST.get(stage, 0.3),
|
|
254
|
+
trigger_count=trigger,
|
|
255
|
+
helpful_count=helpful,
|
|
256
|
+
violation_count=violation,
|
|
257
|
+
is_critical=self.STAGE_CRITICAL.get(stage, False),
|
|
258
|
+
scope_type=MemoryScope.PROJECT,
|
|
259
|
+
scope_value="vegex",
|
|
260
|
+
created_at=created_at,
|
|
261
|
+
last_used_at=last_used,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _render_content_text(data: Dict[str, Any]) -> str:
|
|
266
|
+
parts: List[str] = []
|
|
267
|
+
root_cause = str(data.get("root_cause", "")).strip()
|
|
268
|
+
if root_cause:
|
|
269
|
+
parts.append(f"Root cause: {root_cause}")
|
|
270
|
+
|
|
271
|
+
details = data.get("details", {})
|
|
272
|
+
if isinstance(details, dict):
|
|
273
|
+
for k, v in details.items():
|
|
274
|
+
sv = str(v).strip()
|
|
275
|
+
if sv:
|
|
276
|
+
parts.append(f"{k}: {sv}")
|
|
277
|
+
elif details:
|
|
278
|
+
parts.append(str(details))
|
|
279
|
+
|
|
280
|
+
tags = data.get("tags", [])
|
|
281
|
+
if tags:
|
|
282
|
+
parts.append("Tags: " + ", ".join(str(t) for t in tags))
|
|
283
|
+
|
|
284
|
+
text = "\n".join(parts).strip()
|
|
285
|
+
return text or str(data.get("id", "empty lesson"))
|
|
286
|
+
|
|
287
|
+
def _purge_orphans(self, live_node_ids: set) -> int:
|
|
288
|
+
deleted = 0
|
|
289
|
+
try:
|
|
290
|
+
all_nodes = self.manager.storage.list_all()
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
logger.warning("Failed to list nodes during purge: %s", exc)
|
|
293
|
+
return 0
|
|
294
|
+
for node in all_nodes:
|
|
295
|
+
if not node.node_id.startswith("lesson:"):
|
|
296
|
+
continue
|
|
297
|
+
if node.node_id not in live_node_ids:
|
|
298
|
+
try:
|
|
299
|
+
self.manager.storage.delete(node.node_id)
|
|
300
|
+
deleted += 1
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
logger.warning("Failed to purge node %s: %s", node.node_id, exc)
|
|
303
|
+
return deleted
|
|
304
|
+
|
|
305
|
+
# ------------------------------------------------------------------ #
|
|
306
|
+
# Retrieval
|
|
307
|
+
# ------------------------------------------------------------------ #
|
|
308
|
+
def search(
|
|
309
|
+
self,
|
|
310
|
+
query: str,
|
|
311
|
+
limit: int = 5,
|
|
312
|
+
stages: Optional[Sequence[str]] = None,
|
|
313
|
+
scope_type: MemoryScope = MemoryScope.PROJECT,
|
|
314
|
+
scope_value: str = "vegex",
|
|
315
|
+
record_trigger: bool = True,
|
|
316
|
+
) -> List[ScoredMemoryNode]:
|
|
317
|
+
if not query or not query.strip():
|
|
318
|
+
return []
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
qvec = self.embedder.embed(query)
|
|
322
|
+
except Exception as exc:
|
|
323
|
+
logger.warning("Query embedding failed: %s", exc)
|
|
324
|
+
return []
|
|
325
|
+
|
|
326
|
+
all_nodes = self.manager.storage.list_all()
|
|
327
|
+
if stages is not None:
|
|
328
|
+
stage_set = set(stages)
|
|
329
|
+
candidates = [
|
|
330
|
+
n for n in all_nodes
|
|
331
|
+
if n.node_id.startswith("lesson:")
|
|
332
|
+
and (n.metadata or {}).get("stage") in stage_set
|
|
333
|
+
]
|
|
334
|
+
else:
|
|
335
|
+
candidates = [n for n in all_nodes if n.node_id.startswith("lesson:")]
|
|
336
|
+
|
|
337
|
+
if not candidates:
|
|
338
|
+
return []
|
|
339
|
+
|
|
340
|
+
from memory import rank_memory_nodes_scored
|
|
341
|
+
|
|
342
|
+
scored = rank_memory_nodes_scored(
|
|
343
|
+
nodes=candidates,
|
|
344
|
+
query_vector=qvec,
|
|
345
|
+
weights=self.manager.weights,
|
|
346
|
+
query_scope=scope_type,
|
|
347
|
+
query_scope_value=scope_value,
|
|
348
|
+
top_k=limit,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if record_trigger and scored:
|
|
352
|
+
node_ids = [s.node.node_id for s in scored]
|
|
353
|
+
try:
|
|
354
|
+
self.manager.record_trigger(node_ids)
|
|
355
|
+
for s in scored:
|
|
356
|
+
s.node.trigger_count += 1
|
|
357
|
+
s.node.last_used_at = time.time()
|
|
358
|
+
except Exception as exc:
|
|
359
|
+
logger.warning("Failed to persist trigger feedback: %s", exc)
|
|
360
|
+
|
|
361
|
+
return scored
|
|
362
|
+
|
|
363
|
+
# ------------------------------------------------------------------ #
|
|
364
|
+
# Feedback loop
|
|
365
|
+
# ------------------------------------------------------------------ #
|
|
366
|
+
def record_helpful(self, node_ids: Iterable[str]) -> int:
|
|
367
|
+
ids = [i for i in node_ids if i]
|
|
368
|
+
return self.manager.record_helpful(ids) if ids else 0
|
|
369
|
+
|
|
370
|
+
def record_violation(self, node_ids: Iterable[str]) -> int:
|
|
371
|
+
ids = [i for i in node_ids if i]
|
|
372
|
+
return self.manager.record_violation(ids) if ids else 0
|
|
373
|
+
|
|
374
|
+
# ------------------------------------------------------------------ #
|
|
375
|
+
# Stats
|
|
376
|
+
# ------------------------------------------------------------------ #
|
|
377
|
+
def stats(self) -> Dict[str, Any]:
|
|
378
|
+
all_nodes = self.manager.storage.list_all()
|
|
379
|
+
by_stage: Dict[str, int] = {s: 0 for s in self.STAGES}
|
|
380
|
+
for n in all_nodes:
|
|
381
|
+
if not n.node_id.startswith("lesson:"):
|
|
382
|
+
continue
|
|
383
|
+
stage = (n.metadata or {}).get("stage", "unknown")
|
|
384
|
+
by_stage[stage] = by_stage.get(stage, 0) + 1
|
|
385
|
+
return {
|
|
386
|
+
"db_path": str(self.db_path),
|
|
387
|
+
"embedding_signature": list(self.embedder.signature),
|
|
388
|
+
"embedding_dim": self.embedder.dim,
|
|
389
|
+
"migration_status": str(self._migration_report),
|
|
390
|
+
"total_lesson_nodes": sum(by_stage.values()),
|
|
391
|
+
"by_stage": by_stage,
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
def close(self) -> None:
|
|
395
|
+
self.manager.close()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
__all__ = ["LessonMemoryBridge", "SyncReport"]
|
tools/llm_client.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import json
|
|
4
|
+
import httpx
|
|
5
|
+
import openai
|
|
6
|
+
from openai import OpenAI
|
|
7
|
+
import config
|
|
8
|
+
from tools.llm_selector import (
|
|
9
|
+
get_agent_fallback_chain,
|
|
10
|
+
resolve_model_for_task,
|
|
11
|
+
CONFIG_FILE,
|
|
12
|
+
get_dynamic_routing_enabled,
|
|
13
|
+
get_agent_bindings
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
RECOVERABLE_ERRORS = (
|
|
17
|
+
openai.APIConnectionError,
|
|
18
|
+
openai.APITimeoutError,
|
|
19
|
+
openai.InternalServerError,
|
|
20
|
+
openai.RateLimitError,
|
|
21
|
+
httpx.RequestError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def safe_chat_completion(
|
|
25
|
+
role_or_model: str,
|
|
26
|
+
messages: list,
|
|
27
|
+
temperature: float = 0.2,
|
|
28
|
+
max_tokens: int = 2000,
|
|
29
|
+
extra_body: dict = None,
|
|
30
|
+
retries: int = 3,
|
|
31
|
+
model: str = None,
|
|
32
|
+
task_spec: str = None,
|
|
33
|
+
):
|
|
34
|
+
api_key = getattr(config, "OPENROUTER_API_KEY", None) or os.getenv("OPENROUTER_API_KEY", "dummy")
|
|
35
|
+
base_url = getattr(config, "OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
|
|
36
|
+
|
|
37
|
+
client = OpenAI(base_url=base_url, api_key=api_key, timeout=120.0)
|
|
38
|
+
|
|
39
|
+
primary_model = None
|
|
40
|
+
is_dynamic = get_dynamic_routing_enabled()
|
|
41
|
+
bindings = get_agent_bindings()
|
|
42
|
+
|
|
43
|
+
if model:
|
|
44
|
+
primary_model = model
|
|
45
|
+
elif role_or_model in bindings and os.path.exists(CONFIG_FILE):
|
|
46
|
+
# Manual binding exists in config file
|
|
47
|
+
primary_model = bindings[role_or_model]
|
|
48
|
+
elif is_dynamic and task_spec and str(task_spec).strip() and role_or_model in ["researcher", "editor", "reviewer"]:
|
|
49
|
+
# Dynamic routing is enabled and we have task spec
|
|
50
|
+
try:
|
|
51
|
+
primary_model = resolve_model_for_task(task_spec, role_or_model)
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
print(f"[LLM Client] Dynamic routing failed ({exc}); falling back to bindings.")
|
|
54
|
+
primary_model = bindings.get(role_or_model, role_or_model)
|
|
55
|
+
else:
|
|
56
|
+
# Fallback to bindings
|
|
57
|
+
primary_model = bindings.get(role_or_model, role_or_model)
|
|
58
|
+
|
|
59
|
+
if primary_model:
|
|
60
|
+
models_to_try = [primary_model]
|
|
61
|
+
for fallback in get_agent_fallback_chain(role_or_model):
|
|
62
|
+
if fallback not in models_to_try:
|
|
63
|
+
models_to_try.append(fallback)
|
|
64
|
+
else:
|
|
65
|
+
models_to_try = get_agent_fallback_chain(role_or_model)
|
|
66
|
+
|
|
67
|
+
last_exception = None
|
|
68
|
+
|
|
69
|
+
for model_name in models_to_try:
|
|
70
|
+
for attempt in range(retries):
|
|
71
|
+
try:
|
|
72
|
+
kwargs = {
|
|
73
|
+
"model": model_name,
|
|
74
|
+
"messages": messages,
|
|
75
|
+
"temperature": temperature,
|
|
76
|
+
"max_tokens": max_tokens,
|
|
77
|
+
}
|
|
78
|
+
if extra_body:
|
|
79
|
+
kwargs["extra_body"] = extra_body
|
|
80
|
+
|
|
81
|
+
print(
|
|
82
|
+
f"[LLM Router] role={role_or_model} "
|
|
83
|
+
f"task_spec={(task_spec or '')[:40]!r} "
|
|
84
|
+
f"dynamic={is_dynamic} primary={primary_model} trying={model_name}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
response = client.chat.completions.create(**kwargs)
|
|
88
|
+
provider = "OpenRouter" if "openrouter" in base_url.lower() else "Local/Ollama"
|
|
89
|
+
return response, provider
|
|
90
|
+
|
|
91
|
+
except RECOVERABLE_ERRORS as e:
|
|
92
|
+
last_exception = e
|
|
93
|
+
time.sleep(2 ** attempt)
|
|
94
|
+
except openai.APIStatusError as e:
|
|
95
|
+
last_exception = e
|
|
96
|
+
print(f"[LLM Client] Model '{model_name}' returned HTTP {e.status_code}. Switching to fallback...")
|
|
97
|
+
break
|
|
98
|
+
except Exception as e:
|
|
99
|
+
last_exception = e
|
|
100
|
+
print(f"[LLM Client] Unexpected error with '{model_name}': {e}")
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
raise RuntimeError(f"LLM Chat Completion failed for all models {models_to_try}: {last_exception}")
|