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.
Files changed (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. 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"