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
tools/llm_selector.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import httpx
|
|
4
|
+
import config
|
|
5
|
+
|
|
6
|
+
CACHE_FILE = "openrouter_models_cache.json"
|
|
7
|
+
CONFIG_FILE = "models_config.json"
|
|
8
|
+
TIERS_FILE = "model_tiers.json"
|
|
9
|
+
|
|
10
|
+
DEFAULT_AGENTS = ["researcher", "editor", "reviewer"]
|
|
11
|
+
|
|
12
|
+
# Динамическая загрузка тиров
|
|
13
|
+
def _load_tiers() -> dict:
|
|
14
|
+
default_tiers = {
|
|
15
|
+
"high": ["anthropic/claude-3.5-sonnet", "openai/gpt-4o"],
|
|
16
|
+
"medium": ["deepseek/deepseek-chat", "google/gemini-2.5-flash"],
|
|
17
|
+
"low": ["meta-llama/llama-3-8b-instruct", "deepseek/deepseek-chat"],
|
|
18
|
+
}
|
|
19
|
+
if os.path.exists(TIERS_FILE):
|
|
20
|
+
try:
|
|
21
|
+
with open(TIERS_FILE, "r", encoding="utf-8") as f:
|
|
22
|
+
data = json.load(f)
|
|
23
|
+
if "high" in data and "medium" in data and "low" in data:
|
|
24
|
+
return data
|
|
25
|
+
except Exception:
|
|
26
|
+
pass
|
|
27
|
+
return default_tiers
|
|
28
|
+
|
|
29
|
+
def get_dynamic_routing_enabled() -> bool:
|
|
30
|
+
"""Возвращает True, если динамический роутинг включен."""
|
|
31
|
+
if os.path.exists(CONFIG_FILE):
|
|
32
|
+
try:
|
|
33
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
34
|
+
cfg = json.load(f)
|
|
35
|
+
return cfg.get("dynamic_routing", True)
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
def set_dynamic_routing_enabled(enabled: bool) -> None:
|
|
41
|
+
"""Сохраняет флаг динамического роутинга в models_config.json."""
|
|
42
|
+
cfg = {}
|
|
43
|
+
if os.path.exists(CONFIG_FILE):
|
|
44
|
+
try:
|
|
45
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
46
|
+
cfg = json.load(f)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
cfg["dynamic_routing"] = enabled
|
|
50
|
+
try:
|
|
51
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
52
|
+
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
print(f"[LLM Selector] Warning: Failed to save routing flag: {e}")
|
|
55
|
+
|
|
56
|
+
DEFAULT_BINDINGS = {
|
|
57
|
+
"researcher": "deepseek/deepseek-coder",
|
|
58
|
+
"editor": "deepseek/deepseek-coder",
|
|
59
|
+
"reviewer": "deepseek/deepseek-coder",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
DEFAULT_FALLBACKS = {
|
|
63
|
+
"researcher": ["google/gemini-3.8-flash", "deepseek/deepseek-v4-flash-latest"],
|
|
64
|
+
"editor": ["deepseek/deepseek-v4-flash-latest", "openai/gpt-4o-mini"],
|
|
65
|
+
"reviewer": ["z-ai/glm-4.7-flash", "google/gemini-3.8-flash"],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
DEFAULT_CATALOG = {
|
|
69
|
+
"anthropic/claude-3.5-sonnet:beta": {
|
|
70
|
+
"name": "Anthropic: Claude 3.5 Sonnet (Beta)",
|
|
71
|
+
"context_length": 200000,
|
|
72
|
+
"price_input_1m": 3.0,
|
|
73
|
+
"price_output_1m": 15.0,
|
|
74
|
+
},
|
|
75
|
+
"anthropic/claude-3.7-sonnet": {
|
|
76
|
+
"name": "Anthropic: Claude 3.7 Sonnet",
|
|
77
|
+
"context_length": 200000,
|
|
78
|
+
"price_input_1m": 3.0,
|
|
79
|
+
"price_output_1m": 15.0,
|
|
80
|
+
},
|
|
81
|
+
"google/gemini-2.5-pro": {
|
|
82
|
+
"name": "Google: Gemini 2.5 Pro",
|
|
83
|
+
"context_length": 1000000,
|
|
84
|
+
"price_input_1m": 1.25,
|
|
85
|
+
"price_output_1m": 5.0,
|
|
86
|
+
},
|
|
87
|
+
"ollama/qwen2.5-coder": {
|
|
88
|
+
"name": "Ollama: Qwen 2.5 Coder",
|
|
89
|
+
"context_length": 32768,
|
|
90
|
+
"price_input_1m": 0.0,
|
|
91
|
+
"price_output_1m": 0.0,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def fetch_openrouter_models(api_key: str = None) -> list[dict]:
|
|
97
|
+
"""Запрашивает сырой список моделей напрямую с OpenRouter API."""
|
|
98
|
+
key = api_key or getattr(config, "OPENROUTER_API_KEY", None) or os.getenv("OPENROUTER_API_KEY")
|
|
99
|
+
url = "https://openrouter.ai/api/v1/models"
|
|
100
|
+
headers = {}
|
|
101
|
+
if key and key != "dummy":
|
|
102
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
with httpx.Client(timeout=10.0) as client:
|
|
106
|
+
response = client.get(url, headers=headers)
|
|
107
|
+
if response.status_code == 200:
|
|
108
|
+
data = response.json()
|
|
109
|
+
return data.get("data", [])
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def sync_model_catalog() -> dict[str, dict]:
|
|
116
|
+
"""Загружает каталог OpenRouter и сохраняет кэш."""
|
|
117
|
+
raw_models = fetch_openrouter_models()
|
|
118
|
+
catalog = dict(DEFAULT_CATALOG)
|
|
119
|
+
|
|
120
|
+
if raw_models:
|
|
121
|
+
for item in raw_models:
|
|
122
|
+
m_id = item.get("id")
|
|
123
|
+
if not m_id:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
pricing = item.get("pricing", {})
|
|
127
|
+
try:
|
|
128
|
+
p_in = float(pricing.get("prompt", 0) or 0) * 1_000_000
|
|
129
|
+
except (ValueError, TypeError):
|
|
130
|
+
p_in = 0.0
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
p_out = float(pricing.get("completion", 0) or 0) * 1_000_000
|
|
134
|
+
except (ValueError, TypeError):
|
|
135
|
+
p_out = 0.0
|
|
136
|
+
|
|
137
|
+
catalog[m_id] = {
|
|
138
|
+
"name": item.get("name", m_id),
|
|
139
|
+
"context_length": item.get("context_length", 0) or 0,
|
|
140
|
+
"price_input_1m": p_in,
|
|
141
|
+
"price_output_1m": p_out,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
|
146
|
+
json.dump(catalog, f, indent=2, ensure_ascii=False)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
print(f"[LLM Selector] Warning: Failed to write catalog cache: {e}")
|
|
149
|
+
|
|
150
|
+
return catalog
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_cached_models(force_refresh: bool = False) -> dict[str, dict]:
|
|
154
|
+
"""Возвращает кэшированный словарь моделей."""
|
|
155
|
+
if not force_refresh and os.path.exists(CACHE_FILE):
|
|
156
|
+
try:
|
|
157
|
+
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
|
158
|
+
data = json.load(f)
|
|
159
|
+
if isinstance(data, dict) and len(data) > 0:
|
|
160
|
+
return data
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
return sync_model_catalog()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_model_details(model_id: str) -> dict:
|
|
168
|
+
"""Возвращает спецификацию конкретной модели."""
|
|
169
|
+
models = get_cached_models()
|
|
170
|
+
return models.get(
|
|
171
|
+
model_id,
|
|
172
|
+
{
|
|
173
|
+
"name": model_id,
|
|
174
|
+
"context_length": 0,
|
|
175
|
+
"price_input_1m": 0.0,
|
|
176
|
+
"price_output_1m": 0.0,
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_agent_bindings() -> dict[str, str]:
|
|
182
|
+
"""Возвращает привязки агентов к моделям, объединяя models_config.json и active_models.json."""
|
|
183
|
+
bindings = dict(DEFAULT_BINDINGS)
|
|
184
|
+
|
|
185
|
+
# First load legacy active_models.json if it exists
|
|
186
|
+
legacy_file = "active_models.json"
|
|
187
|
+
if os.path.exists(legacy_file):
|
|
188
|
+
try:
|
|
189
|
+
with open(legacy_file, "r", encoding="utf-8") as f:
|
|
190
|
+
data = json.load(f)
|
|
191
|
+
if isinstance(data, dict):
|
|
192
|
+
for agent in DEFAULT_AGENTS:
|
|
193
|
+
if agent in data:
|
|
194
|
+
bindings[agent] = data[agent]
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
# Then load models_config.json which takes precedence
|
|
199
|
+
if os.path.exists(CONFIG_FILE):
|
|
200
|
+
try:
|
|
201
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
202
|
+
data = json.load(f)
|
|
203
|
+
if isinstance(data, dict):
|
|
204
|
+
loaded_bindings = data.get("bindings", data)
|
|
205
|
+
if isinstance(loaded_bindings, dict):
|
|
206
|
+
bindings.update(loaded_bindings)
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
# Finally check for environment overrides
|
|
211
|
+
for agent in DEFAULT_AGENTS:
|
|
212
|
+
env_attr = f"MODEL_{agent.upper()}"
|
|
213
|
+
if hasattr(config, env_attr):
|
|
214
|
+
val = getattr(config, env_attr)
|
|
215
|
+
if val:
|
|
216
|
+
bindings[agent] = val
|
|
217
|
+
|
|
218
|
+
return bindings
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def save_agent_bindings(bindings: dict[str, str]) -> None:
|
|
222
|
+
"""Сохраняет привязки агентов в models_config.json."""
|
|
223
|
+
cfg = {}
|
|
224
|
+
if os.path.exists(CONFIG_FILE):
|
|
225
|
+
try:
|
|
226
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
227
|
+
cfg = json.load(f)
|
|
228
|
+
if not isinstance(cfg, dict):
|
|
229
|
+
cfg = {}
|
|
230
|
+
except Exception:
|
|
231
|
+
cfg = {}
|
|
232
|
+
|
|
233
|
+
cfg["bindings"] = bindings
|
|
234
|
+
try:
|
|
235
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
236
|
+
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
|
237
|
+
except Exception as e:
|
|
238
|
+
print(f"[LLM Selector] Warning: Failed to save agent bindings: {e}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def get_agent_fallback_chain(role_or_model: str) -> list[str]:
|
|
242
|
+
"""
|
|
243
|
+
Формирует цепочку фоллбэков для роли ИЛИ для конкретного имени модели.
|
|
244
|
+
Автоматически подменяет недействительный 'anthropic/claude-3.5-sonnet'.
|
|
245
|
+
"""
|
|
246
|
+
key = (role_or_model or "").lower()
|
|
247
|
+
bindings = get_agent_bindings()
|
|
248
|
+
|
|
249
|
+
if key in DEFAULT_AGENTS:
|
|
250
|
+
primary = bindings.get(key, "google/gemini-2.5-pro")
|
|
251
|
+
defaults = DEFAULT_FALLBACKS.get(key, ["google/gemini-2.5-pro"])
|
|
252
|
+
raw_chain = [primary] + [m for m in defaults if m != primary]
|
|
253
|
+
else:
|
|
254
|
+
# Если передано имя модели, делаем его первым, а затем добавляем общие фоллбэки
|
|
255
|
+
raw_chain = [role_or_model, "deepseek/deepseek-coder", "google/gemini-2.5-flash"]
|
|
256
|
+
|
|
257
|
+
clean_chain = []
|
|
258
|
+
for m in raw_chain:
|
|
259
|
+
if m == "anthropic/claude-3.5-sonnet":
|
|
260
|
+
m = "anthropic/claude-3.5-sonnet:beta"
|
|
261
|
+
if m not in clean_chain:
|
|
262
|
+
clean_chain.append(m)
|
|
263
|
+
|
|
264
|
+
return clean_chain
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def resolve_active_model(role: str, attempt: int = 0) -> str:
|
|
268
|
+
"""Выбирает модель для вызова с учетом номера попытки."""
|
|
269
|
+
chain = get_agent_fallback_chain(role)
|
|
270
|
+
if attempt < len(chain):
|
|
271
|
+
return chain[attempt]
|
|
272
|
+
return chain[-1]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def set_active_model(role: str, model_name: str) -> None:
|
|
276
|
+
"""Устанавливает модель для роли и сохраняет конфигурацию."""
|
|
277
|
+
bindings = get_agent_bindings()
|
|
278
|
+
bindings[role.lower()] = model_name
|
|
279
|
+
save_agent_bindings(bindings)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def get_manual_binding(role: str) -> str:
|
|
283
|
+
"""Returns the manually set binding for a role from models_config.json, or empty string if not set."""
|
|
284
|
+
if not os.path.exists(CONFIG_FILE):
|
|
285
|
+
return ""
|
|
286
|
+
try:
|
|
287
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
288
|
+
data = json.load(f)
|
|
289
|
+
bindings = data.get("bindings", data)
|
|
290
|
+
return bindings.get(role, "")
|
|
291
|
+
except Exception:
|
|
292
|
+
return ""
|
|
293
|
+
|
|
294
|
+
def get_active_models() -> dict[str, str]:
|
|
295
|
+
"""Возвращает активные модели для всех основных агентов."""
|
|
296
|
+
bindings = get_agent_bindings()
|
|
297
|
+
return {role: bindings.get(role, "google/gemini-2.5-pro") for role in DEFAULT_AGENTS}
|
|
298
|
+
|
|
299
|
+
def resolve_model_for_task(task_prompt: str, default_model: str = None) -> str:
|
|
300
|
+
"""
|
|
301
|
+
Integration entrypoint for dynamic LLM assignment.
|
|
302
|
+
"""
|
|
303
|
+
try:
|
|
304
|
+
complexity = assess_task_complexity(task_prompt)
|
|
305
|
+
model = select_model_for_task(complexity)
|
|
306
|
+
if model:
|
|
307
|
+
return model
|
|
308
|
+
except Exception as e:
|
|
309
|
+
print(f"[LLM Selector] Warning: resolve_model_for_task failed: {e}")
|
|
310
|
+
|
|
311
|
+
if default_model:
|
|
312
|
+
return default_model
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
return select_model_for_task("medium")
|
|
316
|
+
except Exception:
|
|
317
|
+
return "google/gemini-2.5-pro"
|
|
318
|
+
|
|
319
|
+
def assess_task_complexity(task_prompt: str) -> str:
|
|
320
|
+
"""
|
|
321
|
+
Assesses task complexity based on the prompt.
|
|
322
|
+
Returns 'low', 'medium', or 'high'.
|
|
323
|
+
"""
|
|
324
|
+
if not task_prompt:
|
|
325
|
+
return "low"
|
|
326
|
+
|
|
327
|
+
prompt_len = len(task_prompt)
|
|
328
|
+
lower_prompt = task_prompt.lower()
|
|
329
|
+
|
|
330
|
+
high_keywords = ["refactor", "implement", "add feature", "design", "architecture", "integrate", "from scratch"]
|
|
331
|
+
medium_keywords = ["fix", "debug", "update", "improve", "add test", "style", "enhance"]
|
|
332
|
+
|
|
333
|
+
if any(kw in lower_prompt for kw in high_keywords) or prompt_len > 400:
|
|
334
|
+
return "high"
|
|
335
|
+
|
|
336
|
+
if any(kw in lower_prompt for kw in medium_keywords) or prompt_len > 100:
|
|
337
|
+
return "medium"
|
|
338
|
+
|
|
339
|
+
return "low"
|
|
340
|
+
|
|
341
|
+
def select_model_for_task(complexity: str) -> str:
|
|
342
|
+
"""
|
|
343
|
+
Selects the best available model for a given complexity tier.
|
|
344
|
+
"""
|
|
345
|
+
tiers = _load_tiers()
|
|
346
|
+
tier = complexity if complexity in tiers else "medium"
|
|
347
|
+
preferred_models = tiers[tier]
|
|
348
|
+
all_models = get_cached_models()
|
|
349
|
+
|
|
350
|
+
for model_id in preferred_models:
|
|
351
|
+
if model_id in all_models:
|
|
352
|
+
return model_id
|
|
353
|
+
|
|
354
|
+
for fallback_tier in ["medium", "high", "low"]:
|
|
355
|
+
for model_id in tiers.get(fallback_tier, []):
|
|
356
|
+
if model_id in all_models:
|
|
357
|
+
return model_id
|
|
358
|
+
|
|
359
|
+
if all_models:
|
|
360
|
+
return next(iter(all_models))
|
|
361
|
+
|
|
362
|
+
return "google/gemini-2.5-pro"
|