voidaccess 1.3.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.
Files changed (142) hide show
  1. analysis/__init__.py +49 -0
  2. analysis/opsec.py +454 -0
  3. analysis/patterns.py +202 -0
  4. analysis/temporal.py +201 -0
  5. api/__init__.py +1 -0
  6. api/auth.py +163 -0
  7. api/main.py +509 -0
  8. api/routes/__init__.py +1 -0
  9. api/routes/admin.py +214 -0
  10. api/routes/auth.py +157 -0
  11. api/routes/entities.py +871 -0
  12. api/routes/export.py +359 -0
  13. api/routes/investigations.py +2567 -0
  14. api/routes/monitors.py +405 -0
  15. api/routes/search.py +157 -0
  16. api/routes/settings.py +851 -0
  17. auth/__init__.py +1 -0
  18. auth/token_blacklist.py +108 -0
  19. cli/__init__.py +3 -0
  20. cli/adapters/__init__.py +1 -0
  21. cli/adapters/sqlite.py +273 -0
  22. cli/browser.py +376 -0
  23. cli/commands/__init__.py +1 -0
  24. cli/commands/configure.py +185 -0
  25. cli/commands/enrich.py +154 -0
  26. cli/commands/export.py +158 -0
  27. cli/commands/investigate.py +601 -0
  28. cli/commands/show.py +87 -0
  29. cli/config.py +180 -0
  30. cli/display.py +212 -0
  31. cli/main.py +154 -0
  32. cli/tor_detect.py +71 -0
  33. config.py +180 -0
  34. crawler/__init__.py +28 -0
  35. crawler/dedup.py +97 -0
  36. crawler/frontier.py +115 -0
  37. crawler/spider.py +462 -0
  38. crawler/utils.py +122 -0
  39. db/__init__.py +47 -0
  40. db/migrations/__init__.py +0 -0
  41. db/migrations/env.py +80 -0
  42. db/migrations/versions/0001_initial_schema.py +270 -0
  43. db/migrations/versions/0002_add_investigation_status_column.py +27 -0
  44. db/migrations/versions/0002_add_missing_tables.py +33 -0
  45. db/migrations/versions/0003_add_canonical_value_and_entity_links.py +61 -0
  46. db/migrations/versions/0004_add_page_posted_at.py +41 -0
  47. db/migrations/versions/0005_add_extraction_method.py +32 -0
  48. db/migrations/versions/0006_add_monitor_alerts.py +26 -0
  49. db/migrations/versions/0007_add_actor_style_profiles.py +23 -0
  50. db/migrations/versions/0008_add_users_table.py +47 -0
  51. db/migrations/versions/0009_add_investigation_id_to_relationships.py +29 -0
  52. db/migrations/versions/0010_add_composite_index_entity_relationships.py +22 -0
  53. db/migrations/versions/0011_add_page_extraction_cache.py +52 -0
  54. db/migrations/versions/0013_add_graph_status.py +31 -0
  55. db/migrations/versions/0015_add_progress_fields.py +41 -0
  56. db/migrations/versions/0016_backfill_graph_status.py +33 -0
  57. db/migrations/versions/0017_add_user_api_keys.py +44 -0
  58. db/migrations/versions/0018_add_user_id_to_investigations.py +33 -0
  59. db/migrations/versions/0019_add_content_safety_log.py +46 -0
  60. db/migrations/versions/0020_add_entity_source_tracking.py +50 -0
  61. db/models.py +618 -0
  62. db/queries.py +841 -0
  63. db/session.py +270 -0
  64. export/__init__.py +34 -0
  65. export/misp.py +257 -0
  66. export/sigma.py +342 -0
  67. export/stix.py +418 -0
  68. extractor/__init__.py +21 -0
  69. extractor/llm_extract.py +372 -0
  70. extractor/ner.py +512 -0
  71. extractor/normalizer.py +638 -0
  72. extractor/pipeline.py +401 -0
  73. extractor/regex_patterns.py +325 -0
  74. fingerprint/__init__.py +33 -0
  75. fingerprint/profiler.py +240 -0
  76. fingerprint/stylometry.py +249 -0
  77. graph/__init__.py +73 -0
  78. graph/builder.py +894 -0
  79. graph/export.py +225 -0
  80. graph/model.py +83 -0
  81. graph/queries.py +297 -0
  82. graph/visualize.py +178 -0
  83. i18n/__init__.py +24 -0
  84. i18n/detect.py +76 -0
  85. i18n/query_expand.py +72 -0
  86. i18n/translate.py +210 -0
  87. monitor/__init__.py +27 -0
  88. monitor/_db.py +74 -0
  89. monitor/alerts.py +345 -0
  90. monitor/config.py +118 -0
  91. monitor/diff.py +75 -0
  92. monitor/jobs.py +247 -0
  93. monitor/scheduler.py +184 -0
  94. scraper/__init__.py +0 -0
  95. scraper/scrape.py +857 -0
  96. scraper/scrape_js.py +272 -0
  97. search/__init__.py +318 -0
  98. search/circuit_breaker.py +240 -0
  99. search/search.py +334 -0
  100. sources/__init__.py +96 -0
  101. sources/blockchain.py +444 -0
  102. sources/cache.py +93 -0
  103. sources/cisa.py +108 -0
  104. sources/dns_enrichment.py +557 -0
  105. sources/domain_reputation.py +643 -0
  106. sources/email_reputation.py +635 -0
  107. sources/engines.py +244 -0
  108. sources/enrichment.py +1244 -0
  109. sources/github_scraper.py +589 -0
  110. sources/gitlab_scraper.py +624 -0
  111. sources/hash_reputation.py +856 -0
  112. sources/historical_intel.py +253 -0
  113. sources/ip_reputation.py +521 -0
  114. sources/paste_scraper.py +484 -0
  115. sources/pastes.py +278 -0
  116. sources/rss_scraper.py +576 -0
  117. sources/seed_manager.py +373 -0
  118. sources/seeds.py +368 -0
  119. sources/shodan.py +103 -0
  120. sources/telegram.py +199 -0
  121. sources/virustotal.py +113 -0
  122. utils/__init__.py +0 -0
  123. utils/async_utils.py +89 -0
  124. utils/content_safety.py +193 -0
  125. utils/defang.py +94 -0
  126. utils/encryption.py +34 -0
  127. utils/ioc_freshness.py +124 -0
  128. utils/user_keys.py +33 -0
  129. vector/__init__.py +39 -0
  130. vector/embedder.py +100 -0
  131. vector/model_singleton.py +49 -0
  132. vector/search.py +87 -0
  133. vector/store.py +514 -0
  134. voidaccess/__init__.py +0 -0
  135. voidaccess/llm.py +717 -0
  136. voidaccess/llm_utils.py +696 -0
  137. voidaccess-1.3.0.dist-info/METADATA +395 -0
  138. voidaccess-1.3.0.dist-info/RECORD +142 -0
  139. voidaccess-1.3.0.dist-info/WHEEL +5 -0
  140. voidaccess-1.3.0.dist-info/entry_points.txt +2 -0
  141. voidaccess-1.3.0.dist-info/licenses/LICENSE +21 -0
  142. voidaccess-1.3.0.dist-info/top_level.txt +19 -0
@@ -0,0 +1,696 @@
1
+ import logging
2
+ import requests
3
+ from urllib.parse import urljoin
4
+ from langchain_openai import ChatOpenAI
5
+ from langchain_ollama import ChatOllama
6
+ from typing import Callable, Optional, List
7
+ from langchain_anthropic import ChatAnthropic
8
+ from langchain_google_genai import ChatGoogleGenerativeAI
9
+ from langchain_core.callbacks.base import BaseCallbackHandler
10
+ import os
11
+ from config import (
12
+ OLLAMA_BASE_URL,
13
+ OPENROUTER_BASE_URL,
14
+ OPENROUTER_API_KEY,
15
+ GOOGLE_API_KEY,
16
+ OPENAI_API_KEY,
17
+ ANTHROPIC_API_KEY,
18
+ LLAMA_CPP_BASE_URL,
19
+ GROQ_API_KEY,
20
+ DEFAULT_MODELS,
21
+ DEFAULT_MODEL,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class BufferedStreamingHandler(BaseCallbackHandler):
28
+ def __init__(self, buffer_limit: int = 60, ui_callback: Optional[Callable[[str], None]] = None):
29
+ self.buffer = ""
30
+ self.buffer_limit = buffer_limit
31
+ self.ui_callback = ui_callback
32
+
33
+ def on_llm_new_token(self, token: str, **kwargs) -> None:
34
+ self.buffer += token
35
+ if "\n" in token or len(self.buffer) >= self.buffer_limit:
36
+ print(self.buffer, end="", flush=True)
37
+ if self.ui_callback:
38
+ self.ui_callback(self.buffer)
39
+ self.buffer = ""
40
+
41
+ def on_llm_end(self, response, **kwargs) -> None:
42
+ if self.buffer:
43
+ print(self.buffer, end="", flush=True)
44
+ if self.ui_callback:
45
+ self.ui_callback(self.buffer)
46
+ self.buffer = ""
47
+
48
+
49
+ # --- Configuration Data ---
50
+ # Instantiate common dependencies once
51
+ _common_callbacks = [BufferedStreamingHandler(buffer_limit=60)]
52
+
53
+ # Define common parameters for most LLMs
54
+ _common_llm_params = {
55
+ "temperature": 0,
56
+ "streaming": True,
57
+ "callbacks": _common_callbacks,
58
+ "timeout": 30.0,
59
+ }
60
+
61
+ RECOMMENDED_MODELS = [
62
+ {
63
+ "id": "openrouter/deepseek/deepseek-chat",
64
+ "name": "DeepSeek Chat",
65
+ "provider": "OpenRouter",
66
+ "free_tier": False,
67
+ "recommended": True,
68
+ "default": True,
69
+ "note": "Recommended default — fast, cheap, no refusals"
70
+ },
71
+ {
72
+ "id": "openrouter/meta-llama/llama-3.3-70b-instruct:free",
73
+ "name": "Llama 3.3 70B (Free)",
74
+ "provider": "OpenRouter",
75
+ "free_tier": True,
76
+ "recommended": True,
77
+ "default": False,
78
+ "note": "Free via OpenRouter — rate limited"
79
+ },
80
+ {
81
+ "id": "groq/llama-3.3-70b-versatile",
82
+ "name": "Llama 3.3 70B",
83
+ "provider": "Groq",
84
+ "free_tier": True,
85
+ "recommended": True,
86
+ "default": False,
87
+ "note": "Free via Groq — fastest inference"
88
+ },
89
+ {
90
+ "id": "groq/llama-3.1-8b-instant",
91
+ "name": "Llama 3.1 8B Instant",
92
+ "provider": "Groq",
93
+ "free_tier": True,
94
+ "recommended": False,
95
+ "default": False,
96
+ "note": "Free via Groq — fastest, lower quality"
97
+ },
98
+ {
99
+ "id": "openrouter/deepseek/deepseek-r1",
100
+ "name": "DeepSeek R1",
101
+ "provider": "OpenRouter",
102
+ "free_tier": False,
103
+ "recommended": False,
104
+ "default": False,
105
+ "note": "Reasoning model — slower but thorough"
106
+ },
107
+ {
108
+ "id": "openrouter/google/gemini-2.0-flash-001",
109
+ "name": "Gemini 2.0 Flash",
110
+ "provider": "OpenRouter",
111
+ "free_tier": False,
112
+ "recommended": False,
113
+ "default": False,
114
+ "note": "Fast, large context"
115
+ },
116
+ {
117
+ "id": "openrouter/anthropic/claude-haiku-4-5",
118
+ "name": "Claude Haiku",
119
+ "provider": "OpenRouter",
120
+ "free_tier": False,
121
+ "recommended": False,
122
+ "default": False,
123
+ "note": "Fast Anthropic model via OpenRouter"
124
+ },
125
+ {
126
+ "id": "gpt-4o-mini",
127
+ "name": "GPT-4o Mini",
128
+ "provider": "OpenAI",
129
+ "free_tier": False,
130
+ "recommended": True,
131
+ "default": False,
132
+ "note": "Best OpenAI price/performance"
133
+ },
134
+ {
135
+ "id": "claude-haiku-4-5-20251001",
136
+ "name": "Claude Haiku",
137
+ "provider": "Anthropic",
138
+ "free_tier": False,
139
+ "recommended": True,
140
+ "default": False,
141
+ "note": "Fastest Claude model"
142
+ },
143
+ {
144
+ "id": "gemini-1.5-flash",
145
+ "name": "Gemini 1.5 Flash",
146
+ "provider": "Google",
147
+ "free_tier": True,
148
+ "recommended": True,
149
+ "default": False,
150
+ "note": "Free tier via Google AI Studio"
151
+ },
152
+ ]
153
+
154
+ # Map input model choices (lowercased) to their configuration
155
+ # Each config includes the class and any model-specific constructor parameters
156
+ _llm_config_map = {
157
+ 'gpt-4.1': {
158
+ 'class': ChatOpenAI,
159
+ 'constructor_params': {'model_name': 'gpt-4.1'}
160
+ },
161
+ 'gpt-5.2': {
162
+ 'class': ChatOpenAI,
163
+ 'constructor_params': {'model_name': 'gpt-5.2'}
164
+ },
165
+ 'gpt-5.1': {
166
+ 'class': ChatOpenAI,
167
+ 'constructor_params': {'model_name': 'gpt-5.1'}
168
+ },
169
+ 'gpt-5-mini': {
170
+ 'class': ChatOpenAI,
171
+ 'constructor_params': {'model_name': 'gpt-5-mini'}
172
+ },
173
+ 'gpt-5-nano': {
174
+ 'class': ChatOpenAI,
175
+ 'constructor_params': {'model_name': 'gpt-5-nano'}
176
+ },
177
+ 'claude-sonnet-4-5': {
178
+ 'class': ChatAnthropic,
179
+ 'constructor_params': {'model': 'claude-sonnet-4-5'}
180
+ },
181
+ 'claude-sonnet-4-0': {
182
+ 'class': ChatAnthropic,
183
+ 'constructor_params': {'model': 'claude-sonnet-4-0'}
184
+ },
185
+ 'gemini-2.5-flash': {
186
+ 'class': ChatGoogleGenerativeAI,
187
+ 'constructor_params': {'model': 'gemini-2.5-flash', 'google_api_key': GOOGLE_API_KEY }
188
+ },
189
+ 'gemini-2.5-flash-lite': {
190
+ 'class': ChatGoogleGenerativeAI,
191
+ 'constructor_params': {'model': 'gemini-2.5-flash-lite', 'google_api_key': GOOGLE_API_KEY}
192
+ },
193
+ 'gemini-2.5-pro': {
194
+ 'class': ChatGoogleGenerativeAI,
195
+ 'constructor_params': {'model': 'gemini-2.5-pro', 'google_api_key': GOOGLE_API_KEY}
196
+ },
197
+ 'deepseek-v3-openrouter': {
198
+ 'class': ChatOpenAI,
199
+ 'constructor_params': {
200
+ 'model_name': 'deepseek/deepseek-chat-v3-0324',
201
+ 'base_url': OPENROUTER_BASE_URL,
202
+ 'api_key': OPENROUTER_API_KEY,
203
+ }
204
+ },
205
+ 'minimax-m2.5-openrouter': {
206
+ 'class': ChatOpenAI,
207
+ 'constructor_params': {
208
+ 'model_name': 'minimax/minimax-m2.5',
209
+ 'base_url': OPENROUTER_BASE_URL,
210
+ 'api_key': OPENROUTER_API_KEY,
211
+ }
212
+ },
213
+ 'qwen3-80b-openrouter': {
214
+ 'class': ChatOpenAI,
215
+ 'constructor_params': {
216
+ 'model_name': 'qwen/qwen3-next-80b-a3b-instruct:free',
217
+ 'base_url': OPENROUTER_BASE_URL,
218
+ 'api_key': OPENROUTER_API_KEY
219
+ }
220
+ },
221
+ 'nemotron-nano-9b-openrouter': {
222
+ 'class': ChatOpenAI,
223
+ 'constructor_params': {
224
+ 'model_name': 'nvidia/nemotron-nano-9b-v2:free',
225
+ 'base_url': OPENROUTER_BASE_URL,
226
+ 'api_key': OPENROUTER_API_KEY # Use OpenRouter API key
227
+ }
228
+ },
229
+ 'gpt-oss-120b-openrouter': {
230
+ 'class': ChatOpenAI,
231
+ 'constructor_params': {
232
+ 'model_name': 'openai/gpt-oss-120b:free',
233
+ 'base_url': OPENROUTER_BASE_URL,
234
+ 'api_key': OPENROUTER_API_KEY # Use OpenRouter API key
235
+ }
236
+ },
237
+ 'gpt-5.1-openrouter': {
238
+ 'class': ChatOpenAI,
239
+ 'constructor_params': {
240
+ 'model_name': 'openai/gpt-5.1',
241
+ 'base_url': OPENROUTER_BASE_URL,
242
+ 'api_key': OPENROUTER_API_KEY # Use OpenRouter API key
243
+ }
244
+ },
245
+ 'gpt-5-mini-openrouter': {
246
+ 'class': ChatOpenAI,
247
+ 'constructor_params': {
248
+ 'model_name': 'openai/gpt-5-mini',
249
+ 'base_url': OPENROUTER_BASE_URL,
250
+ 'api_key': OPENROUTER_API_KEY # Use OpenRouter API key
251
+ }
252
+ },
253
+ 'claude-sonnet-4.5-openrouter': {
254
+ 'class': ChatOpenAI,
255
+ 'constructor_params': {
256
+ 'model_name': 'anthropic/claude-sonnet-4.5',
257
+ 'base_url': OPENROUTER_BASE_URL,
258
+ 'api_key': OPENROUTER_API_KEY # Use OpenRouter API key
259
+ }
260
+ },
261
+ 'grok-4.1-fast-openrouter': {
262
+ 'class': ChatOpenAI,
263
+ 'constructor_params': {
264
+ 'model_name': 'x-ai/grok-4.1-fast',
265
+ 'base_url': OPENROUTER_BASE_URL,
266
+ 'api_key': OPENROUTER_API_KEY # Use OpenRouter API key
267
+ }
268
+ },
269
+ 'groq/llama-3.3-70b': {
270
+ 'class': ChatOpenAI,
271
+ 'constructor_params': {
272
+ 'model_name': 'llama-3.3-70b-versatile',
273
+ 'base_url': 'https://api.groq.com/openai/v1',
274
+ 'api_key': GROQ_API_KEY,
275
+ },
276
+ 'label': 'Llama 3.3 70B (Groq — Free tier)',
277
+ 'recommended': True,
278
+ 'free_tier': True,
279
+ },
280
+ 'groq/llama-3.1-8b': {
281
+ 'class': ChatOpenAI,
282
+ 'constructor_params': {
283
+ 'model_name': 'llama-3.1-8b-instant',
284
+ 'base_url': 'https://api.groq.com/openai/v1',
285
+ 'api_key': GROQ_API_KEY,
286
+ },
287
+ 'label': 'Llama 3.1 8B Instant (Groq — Free tier, fast)',
288
+ 'recommended': False,
289
+ 'free_tier': True,
290
+ },
291
+ 'minimax-m2.1-openrouter': {
292
+ 'class': ChatOpenAI,
293
+ 'constructor_params': {
294
+ 'model_name': 'minimax/minimax-m2.1',
295
+ 'base_url': OPENROUTER_BASE_URL,
296
+ 'api_key': OPENROUTER_API_KEY,
297
+ }
298
+ },
299
+ # 'llama3.2': {
300
+ # 'class': ChatOllama,
301
+ # 'constructor_params': {'model': 'llama3.2:latest', 'base_url': OLLAMA_BASE_URL}
302
+ # },
303
+ # 'llama3.1': {
304
+ # 'class': ChatOllama,
305
+ # 'constructor_params': {'model': 'llama3.1:latest', 'base_url': OLLAMA_BASE_URL}
306
+ # },
307
+ # 'gemma3': {
308
+ # 'class': ChatOllama,
309
+ # 'constructor_params': {'model': 'gemma3:latest', 'base_url': OLLAMA_BASE_URL}
310
+ # },
311
+ # 'deepseek-r1': {
312
+ # 'class': ChatOllama,
313
+ # 'constructor_params': {'model': 'deepseek-r1:latest', 'base_url': OLLAMA_BASE_URL}
314
+ # },
315
+
316
+ # Add more models here easily:
317
+ # 'mistral7b': {
318
+ # 'class': ChatOllama,
319
+ # 'constructor_params': {'model': 'mistral:7b', 'base_url': OLLAMA_BASE_URL}
320
+ # },
321
+ # 'gpt3.5': {
322
+ # 'class': ChatOpenAI,
323
+ # 'constructor_params': {'model_name': 'gpt-3.5-turbo', 'base_url': OLLAMA_BASE_URL}
324
+ # }
325
+ }
326
+
327
+
328
+ def _normalize_model_name(name: str) -> str:
329
+ return name.strip().lower()
330
+
331
+
332
+ def _get_ollama_base_url() -> Optional[str]:
333
+ if not OLLAMA_BASE_URL:
334
+ return None
335
+ return OLLAMA_BASE_URL.rstrip("/") + "/"
336
+
337
+
338
+ def fetch_ollama_models() -> List[str]:
339
+ """
340
+ Retrieve the list of locally available Ollama models by querying the Ollama HTTP API.
341
+ Returns an empty list if the API isn't reachable or the base URL is not defined.
342
+ """
343
+ base_url = _get_ollama_base_url()
344
+ if not base_url:
345
+ return []
346
+
347
+ try:
348
+ resp = requests.get(urljoin(base_url, "api/tags"), timeout=3)
349
+ resp.raise_for_status()
350
+ models = resp.json().get("models", [])
351
+ available = []
352
+ for m in models:
353
+ name = m.get("name") or m.get("model")
354
+ if name:
355
+ available.append(name)
356
+ return available
357
+ except (requests.RequestException, ValueError):
358
+ return []
359
+
360
+
361
+ # Added Support for llama.cpp models since they use OpenAI-compatible API
362
+ def fetch_llama_cpp_models() -> List[str]:
363
+ """
364
+ Retrieve available models from an OpenAI-compatible llama.cpp server.
365
+ Uses /v1/models.
366
+ """
367
+ if not LLAMA_CPP_BASE_URL:
368
+ return []
369
+
370
+ base = LLAMA_CPP_BASE_URL.rstrip("/")
371
+ try:
372
+ resp = requests.get(f"{base}/v1/models", timeout=3)
373
+ resp.raise_for_status()
374
+ data = resp.json().get("data", [])
375
+ return [m["id"] for m in data if "id" in m]
376
+ except (requests.RequestException, ValueError, KeyError):
377
+ return []
378
+
379
+
380
+
381
+ def _is_set(v: Optional[str]) -> bool:
382
+ return bool(v and str(v).strip() and "your_" not in str(v))
383
+
384
+
385
+ # Changed it so the GUI only loaded available models
386
+ def get_model_choices() -> List[str]:
387
+ """
388
+ Combine configured cloud models with locally available Ollama models.
389
+ Cloud models are shown only if required API keys are present.
390
+ """
391
+ gated_base_models: List[str] = []
392
+
393
+ openai_ok = _is_set(OPENAI_API_KEY)
394
+ anthropic_ok = _is_set(ANTHROPIC_API_KEY)
395
+ google_ok = _is_set(GOOGLE_API_KEY)
396
+ openrouter_ok = _is_set(OPENROUTER_API_KEY) and _is_set(OPENROUTER_BASE_URL)
397
+ groq_ok = _is_set(GROQ_API_KEY)
398
+
399
+ for k, cfg in _llm_config_map.items():
400
+ cls = cfg.get("class")
401
+ ctor = cfg.get("constructor_params", {}) or {}
402
+
403
+ # OpenRouter models (ChatOpenAI with base_url set to OpenRouter)
404
+ if cls is ChatOpenAI and (ctor.get("base_url") == OPENROUTER_BASE_URL or "openrouter" in k):
405
+ if openrouter_ok:
406
+ gated_base_models.append(k)
407
+ continue
408
+
409
+ # Groq models (ChatOpenAI with base_url set to Groq)
410
+ if cls is ChatOpenAI and ctor.get("base_url") == "https://api.groq.com/openai/v1":
411
+ if groq_ok:
412
+ gated_base_models.append(k)
413
+ continue
414
+
415
+ # Direct OpenAI models
416
+ if cls is ChatOpenAI:
417
+ if openai_ok:
418
+ gated_base_models.append(k)
419
+ continue
420
+
421
+ # Anthropic
422
+ if cls is ChatAnthropic:
423
+ if anthropic_ok:
424
+ gated_base_models.append(k)
425
+ continue
426
+
427
+ # Google Gemini
428
+ if cls is ChatGoogleGenerativeAI:
429
+ if google_ok:
430
+ gated_base_models.append(k)
431
+ continue
432
+
433
+ # Anything else: keep
434
+ gated_base_models.append(k)
435
+
436
+ # Local Models
437
+ dynamic_models = []
438
+
439
+ # Dynamic local models via Ollama-style API (/api/tags)
440
+ dynamic_models += fetch_ollama_models()
441
+
442
+ # Dynamic local models via llama.cpp which uses OpenAI style API
443
+ dynamic_models += fetch_llama_cpp_models()
444
+
445
+ normalized = {_normalize_model_name(m): m for m in gated_base_models}
446
+ for dm in dynamic_models:
447
+ key = _normalize_model_name(dm)
448
+ if key not in normalized:
449
+ normalized[key] = dm
450
+
451
+ ordered_dynamic = sorted(
452
+ [name for key, name in normalized.items() if name not in gated_base_models],
453
+ key=_normalize_model_name,
454
+ )
455
+ return gated_base_models + ordered_dynamic
456
+
457
+
458
+
459
+
460
+ def resolve_model_config(model_choice: str):
461
+ """
462
+ Resolve a model choice (case-insensitive) to the corresponding configuration.
463
+ Supports both the predefined remote models and any locally installed Ollama models.
464
+ """
465
+ model_choice_lower = _normalize_model_name(model_choice)
466
+ config = _llm_config_map.get(model_choice_lower)
467
+ if config:
468
+ return config
469
+
470
+ # llama.cpp (OpenAI-compatible)
471
+ for llama_model in fetch_llama_cpp_models():
472
+ if _normalize_model_name(llama_model) == model_choice_lower:
473
+ return {
474
+ "class": ChatOpenAI,
475
+ "constructor_params": {
476
+ "model_name": llama_model,
477
+ "base_url": LLAMA_CPP_BASE_URL,
478
+ "api_key": OPENAI_API_KEY or "sk-local",
479
+ },
480
+ }
481
+
482
+ for ollama_model in fetch_ollama_models():
483
+ if _normalize_model_name(ollama_model) == model_choice_lower:
484
+ return {
485
+ "class": ChatOllama,
486
+ "constructor_params": {"model": ollama_model, "base_url": OLLAMA_BASE_URL},
487
+ }
488
+
489
+ return None
490
+
491
+
492
+ # ---------------------------------------------------------------------------
493
+ # _make_*_llm helpers — used by the model-validate endpoint and as
494
+ # fallback routing inside resolve_model_config() for dynamic model IDs.
495
+ # Each helper raises ValueError with a clear message if the key is absent.
496
+ # ---------------------------------------------------------------------------
497
+
498
+ def _resolve_key(key_name: str, api_keys: Optional[dict]) -> Optional[str]:
499
+ """Return user-override key if available, else fall back to server config."""
500
+ if api_keys:
501
+ v = api_keys.get(key_name)
502
+ if v and str(v).strip():
503
+ return str(v).strip()
504
+ return globals().get(key_name) or None
505
+
506
+
507
+ def _make_openrouter_llm(model_id: str, api_keys: Optional[dict] = None):
508
+ """Build a ChatOpenAI instance pointed at OpenRouter for *model_id*."""
509
+ if not model_id:
510
+ model_id = DEFAULT_MODELS["openrouter"]
511
+ key = _resolve_key("OPENROUTER_API_KEY", api_keys)
512
+ if not key:
513
+ raise ValueError(
514
+ "No API key configured for OpenRouter. "
515
+ "Add OPENROUTER_API_KEY in Settings."
516
+ )
517
+ base = (OPENROUTER_BASE_URL or "https://openrouter.ai/api/v1").rstrip("/")
518
+ return ChatOpenAI(
519
+ **{**_common_llm_params, "model_name": model_id, "base_url": base, "api_key": key}
520
+ )
521
+
522
+
523
+ def _make_groq_llm(model_id: str, api_keys: Optional[dict] = None):
524
+ """Build a ChatOpenAI instance pointed at Groq for *model_id*."""
525
+ if not model_id:
526
+ model_id = DEFAULT_MODELS["groq"]
527
+ key = _resolve_key("GROQ_API_KEY", api_keys)
528
+ if not key:
529
+ raise ValueError(
530
+ "No API key configured for Groq. "
531
+ "Add GROQ_API_KEY in Settings."
532
+ )
533
+ return ChatOpenAI(
534
+ **{**_common_llm_params, "model_name": model_id,
535
+ "base_url": "https://api.groq.com/openai/v1", "api_key": key}
536
+ )
537
+
538
+
539
+ def _make_openai_llm(model_id: str, api_keys: Optional[dict] = None):
540
+ """Build a ChatOpenAI instance for a native OpenAI model."""
541
+ if not model_id:
542
+ model_id = DEFAULT_MODELS["openai"]
543
+ key = _resolve_key("OPENAI_API_KEY", api_keys)
544
+ if not key:
545
+ raise ValueError(
546
+ "No API key configured for OpenAI. "
547
+ "Add OPENAI_API_KEY in Settings."
548
+ )
549
+ return ChatOpenAI(**{**_common_llm_params, "model_name": model_id, "api_key": key})
550
+
551
+
552
+ def _make_anthropic_llm(model_id: str, api_keys: Optional[dict] = None):
553
+ """Build a ChatAnthropic instance."""
554
+ if not model_id:
555
+ model_id = DEFAULT_MODELS["anthropic"]
556
+ key = _resolve_key("ANTHROPIC_API_KEY", api_keys)
557
+ if not key:
558
+ raise ValueError(
559
+ "No API key configured for Anthropic. "
560
+ "Add ANTHROPIC_API_KEY in Settings."
561
+ )
562
+ base = {k: v for k, v in _common_llm_params.items() if k != "streaming"}
563
+ return ChatAnthropic(**{**base, "model": model_id, "anthropic_api_key": key})
564
+
565
+
566
+ def _make_google_llm(model_id: str, api_keys: Optional[dict] = None):
567
+ """Build a ChatGoogleGenerativeAI instance."""
568
+ if not model_id:
569
+ model_id = DEFAULT_MODELS["google"]
570
+ key = _resolve_key("GOOGLE_API_KEY", api_keys)
571
+ if not key:
572
+ raise ValueError(
573
+ "No API key configured for Google Gemini. "
574
+ "Add GOOGLE_API_KEY in Settings."
575
+ )
576
+ base = {k: v for k, v in _common_llm_params.items() if k != "streaming"}
577
+ return ChatGoogleGenerativeAI(**{**base, "model": model_id, "google_api_key": key})
578
+
579
+
580
+ def _make_ollama_llm(model_id: str, api_keys: Optional[dict] = None):
581
+ """Build a ChatOllama instance for a locally running model."""
582
+ if not model_id:
583
+ model_id = DEFAULT_MODELS["ollama"]
584
+ base_url = OLLAMA_BASE_URL or "http://localhost:11434"
585
+ return ChatOllama(**{**_common_llm_params, "model": model_id, "base_url": base_url})
586
+
587
+
588
+ # ---------------------------------------------------------------------------
589
+ # Dynamic resolve — prefix routing appended as final fallback so that any
590
+ # model ID like "openrouter/x/y", "groq/llama-3.3-70b-versatile",
591
+ # "gpt-4o", "claude-3-5-sonnet-20241022", etc. resolves correctly even if
592
+ # it isn't in _llm_config_map. Returns a synthetic config dict that
593
+ # llm.py's get_llm() can consume via the normal code-path.
594
+ # ---------------------------------------------------------------------------
595
+
596
+ def _resolve_model_config_by_prefix(model_choice: str) -> Optional[dict]:
597
+ """
598
+ Detect provider from model ID prefix and return a synthetic config dict.
599
+
600
+ Model ID conventions:
601
+ - "openrouter/..." → OpenRouter (strip the prefix → actual model ID)
602
+ - "groq/..." → Groq (strip the prefix)
603
+ - "gpt-..." → OpenAI direct
604
+ - "claude-..." → Anthropic
605
+ - "gemini-..." → Google
606
+ - "ollama/..." → Ollama (strip the prefix)
607
+ - anything else → OpenRouter fallback (with warning)
608
+ """
609
+ mc = model_choice.strip()
610
+
611
+ if mc.startswith("openrouter/"):
612
+ actual = mc[len("openrouter/"):]
613
+ base = (OPENROUTER_BASE_URL or "https://openrouter.ai/api/v1").rstrip("/")
614
+ return {
615
+ "class": ChatOpenAI,
616
+ "constructor_params": {
617
+ "model_name": actual,
618
+ "base_url": base,
619
+ "api_key": OPENROUTER_API_KEY,
620
+ },
621
+ }
622
+
623
+ if mc.startswith("groq/"):
624
+ actual = mc[len("groq/"):]
625
+ return {
626
+ "class": ChatOpenAI,
627
+ "constructor_params": {
628
+ "model_name": actual,
629
+ "base_url": "https://api.groq.com/openai/v1",
630
+ "api_key": GROQ_API_KEY,
631
+ },
632
+ }
633
+
634
+ if mc.startswith("gpt-"):
635
+ return {
636
+ "class": ChatOpenAI,
637
+ "constructor_params": {"model_name": mc, "api_key": OPENAI_API_KEY},
638
+ }
639
+
640
+ if mc.startswith("claude-"):
641
+ return {
642
+ "class": ChatAnthropic,
643
+ "constructor_params": {"model": mc, "anthropic_api_key": ANTHROPIC_API_KEY},
644
+ }
645
+
646
+ if mc.startswith("gemini-"):
647
+ return {
648
+ "class": ChatGoogleGenerativeAI,
649
+ "constructor_params": {"model": mc, "google_api_key": GOOGLE_API_KEY},
650
+ }
651
+
652
+ if mc.startswith("ollama/"):
653
+ actual = mc[len("ollama/"):]
654
+ return {
655
+ "class": ChatOllama,
656
+ "constructor_params": {
657
+ "model": actual,
658
+ "base_url": OLLAMA_BASE_URL or "http://localhost:11434",
659
+ },
660
+ }
661
+
662
+ # Unknown prefix — attempt OpenRouter as fallback
663
+ logger.warning(
664
+ "Unknown model prefix for '%s' — attempting OpenRouter fallback", model_choice
665
+ )
666
+ base = (OPENROUTER_BASE_URL or "https://openrouter.ai/api/v1").rstrip("/")
667
+ return {
668
+ "class": ChatOpenAI,
669
+ "constructor_params": {
670
+ "model_name": mc,
671
+ "base_url": base,
672
+ "api_key": OPENROUTER_API_KEY,
673
+ },
674
+ }
675
+
676
+
677
+ # Patch resolve_model_config to also use prefix routing as its last-resort fallback.
678
+ # We keep the original function body intact and append an extra branch here.
679
+ _original_resolve_model_config = resolve_model_config
680
+
681
+
682
+ def resolve_model_config(model_choice: str): # type: ignore[redefined-outer-name]
683
+ """
684
+ Resolve a model choice to the corresponding configuration.
685
+
686
+ Lookup order:
687
+ 1. Predefined _llm_config_map (case-insensitive, includes all hardcoded models).
688
+ 2. llama.cpp dynamic list (/v1/models).
689
+ 3. Ollama dynamic list (/api/tags).
690
+ 4. Prefix-based routing for dynamic model IDs (openrouter/, groq/, gpt-*, …).
691
+ """
692
+ result = _original_resolve_model_config(model_choice)
693
+ if result is not None:
694
+ return result
695
+ # Fall through to prefix-based dynamic routing
696
+ return _resolve_model_config_by_prefix(model_choice)