osintengine 1.0.2__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.
- osintengine-1.0.2.dist-info/METADATA +311 -0
- osintengine-1.0.2.dist-info/RECORD +103 -0
- osintengine-1.0.2.dist-info/WHEEL +5 -0
- osintengine-1.0.2.dist-info/entry_points.txt +2 -0
- osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
- osintengine-1.0.2.dist-info/top_level.txt +2 -0
- src/watson/__init__.py +10 -0
- src/watson/agent/__init__.py +96 -0
- src/watson/agents/__init__.py +101 -0
- src/watson/agents/protocol.py +196 -0
- src/watson/auth/__init__.py +68 -0
- src/watson/auth/store.py +145 -0
- src/watson/conversation.py +68 -0
- src/watson/core/__init__.py +0 -0
- src/watson/core/models.py +96 -0
- src/watson/ethics.py +205 -0
- src/watson/exports.py +182 -0
- src/watson/graph/__init__.py +69 -0
- src/watson/graph/entities.py +207 -0
- src/watson/graph/graph.py +489 -0
- src/watson/graph/osint_framework.py +266 -0
- src/watson/graph/relationships.py +116 -0
- src/watson/graph/transforms.py +787 -0
- src/watson/infra/__init__.py +43 -0
- src/watson/infra/cache.py +171 -0
- src/watson/infra/ratelimit.py +125 -0
- src/watson/infra/resilience.py +239 -0
- src/watson/infra/retry.py +186 -0
- src/watson/metrics.py +128 -0
- src/watson/opsec/__init__.py +290 -0
- src/watson/orchestration/__init__.py +23 -0
- src/watson/orchestration/engine.py +5587 -0
- src/watson/orchestration/executor.py +96 -0
- src/watson/orchestration/intent.py +139 -0
- src/watson/orchestration/intent_classifier.py +45 -0
- src/watson/orchestration/llm_config.py +160 -0
- src/watson/orchestration/resolution.py +555 -0
- src/watson/orchestration/scraper.py +94 -0
- src/watson/orchestration/synthesis.py +726 -0
- src/watson/orchestration/target_profile.py +748 -0
- src/watson/persistence/__init__.py +18 -0
- src/watson/persistence/models.py +161 -0
- src/watson/persistence/store.py +230 -0
- src/watson/pipeline/__init__.py +16 -0
- src/watson/pipeline/pre_synthesis.py +621 -0
- src/watson/search.py +103 -0
- src/watson/serializers/stix.py +451 -0
- src/watson/tools/__init__.py +31 -0
- src/watson/tools/base.py +97 -0
- src/watson/tools/blockchain.py +359 -0
- src/watson/tools/captcha.py +276 -0
- src/watson/tools/conflict.py +107 -0
- src/watson/tools/corporate.py +287 -0
- src/watson/tools/darkweb.py +144 -0
- src/watson/tools/geolocation.py +347 -0
- src/watson/tools/image_video.py +108 -0
- src/watson/tools/marinetraffic.py +142 -0
- src/watson/tools/people.py +476 -0
- src/watson/tools/registry.py +56 -0
- src/watson/tools/satellite.py +118 -0
- src/watson/tools/scraper.py +745 -0
- src/watson/tools/shodan.py +129 -0
- src/watson/tools/social_media.py +160 -0
- src/watson/tools/websites.py +291 -0
- src/watson/tools/wikidata.py +398 -0
- src/watson/utils/__init__.py +1 -0
- src/watson/utils/helpers.py +49 -0
- src/watson/utils/http.py +119 -0
- src/watson/verification/__init__.py +245 -0
- watson/__init__.py +6 -0
- watson/agents/__init__.py +20 -0
- watson/agents/base.py +103 -0
- watson/agents/direct.py +225 -0
- watson/agents/hermes.py +275 -0
- watson/agents/hermes_mcp.py +292 -0
- watson/api_keys.py +176 -0
- watson/browser_scraper.py +481 -0
- watson/cli.py +836 -0
- watson/crossref.py +175 -0
- watson/ethics.py +20 -0
- watson/graph.py +406 -0
- watson/mcp_server.py +601 -0
- watson/memory.py +552 -0
- watson/neo4j_graph.py +124 -0
- watson/opsec/__init__.py +307 -0
- watson/reporter.py +537 -0
- watson/scheduler.py +486 -0
- watson/serializers/stix.py +451 -0
- watson/terminal.py +410 -0
- watson/toolkit.py +252 -0
- watson/toolkit_api.py +347 -0
- watson/toolkit_automation.py +95 -0
- watson/toolkit_registry.py +345 -0
- watson/verification/__init__.py +251 -0
- watson/web/__init__.py +1 -0
- watson/web/app.py +1650 -0
- watson/web/middleware.py +301 -0
- watson/web/static/assets/index-B7hPOc0z.js +278 -0
- watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
- watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
- watson/web/static/index.html +14 -0
- watson/web/templates/chat.html +2 -0
- watson/web/templates/investigation-map.html +429 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Tool executor — retry wrappers, circuit breakers, graceful degradation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("watson.executor")
|
|
10
|
+
|
|
11
|
+
# ── Circuit breaker ───────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
class CircuitBreaker:
|
|
14
|
+
"""Prevent hammering a failing API."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, name: str, failure_threshold: int = 3, cooldown: float = 60.0):
|
|
17
|
+
self.name = name
|
|
18
|
+
self.failure_threshold = failure_threshold
|
|
19
|
+
self.cooldown = cooldown
|
|
20
|
+
self._failures = 0
|
|
21
|
+
self._last_failure = 0.0
|
|
22
|
+
self._open = False
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def is_open(self) -> bool:
|
|
26
|
+
if not self._open:
|
|
27
|
+
return False
|
|
28
|
+
if time.time() - self._last_failure > self.cooldown:
|
|
29
|
+
self._open = False
|
|
30
|
+
self._failures = 0
|
|
31
|
+
return False
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
def record_failure(self):
|
|
35
|
+
self._failures += 1
|
|
36
|
+
self._last_failure = time.time()
|
|
37
|
+
if self._failures >= self.failure_threshold:
|
|
38
|
+
self._open = True
|
|
39
|
+
logger.warning("circuit_breaker_open: %s", self.name)
|
|
40
|
+
|
|
41
|
+
def record_success(self):
|
|
42
|
+
self._failures = 0
|
|
43
|
+
self._open = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Global circuit breakers
|
|
47
|
+
_circuits: dict[str, CircuitBreaker] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_circuit(name: str) -> CircuitBreaker:
|
|
51
|
+
if name not in _circuits:
|
|
52
|
+
_circuits[name] = CircuitBreaker(name)
|
|
53
|
+
return _circuits[name]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Retry wrapper ─────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
async def with_retry(
|
|
59
|
+
fn: Callable,
|
|
60
|
+
*args,
|
|
61
|
+
tool_name: str = "unknown",
|
|
62
|
+
max_retries: int = 2,
|
|
63
|
+
backoff: float = 2.0,
|
|
64
|
+
**kwargs,
|
|
65
|
+
) -> Any:
|
|
66
|
+
"""Call fn with retry + circuit breaker. Returns result or error finding."""
|
|
67
|
+
circuit = get_circuit(tool_name)
|
|
68
|
+
|
|
69
|
+
if circuit.is_open:
|
|
70
|
+
return _error_finding(tool_name, "Circuit breaker open — API unavailable")
|
|
71
|
+
|
|
72
|
+
last_error = None
|
|
73
|
+
for attempt in range(max_retries + 1):
|
|
74
|
+
try:
|
|
75
|
+
result = await fn(*args, **kwargs) if asyncio.iscoroutinefunction(fn) else fn(*args, **kwargs)
|
|
76
|
+
circuit.record_success()
|
|
77
|
+
return result
|
|
78
|
+
except Exception as e:
|
|
79
|
+
last_error = e
|
|
80
|
+
logger.warning("tool_attempt_failed: %s attempt %d: %s", tool_name, attempt + 1, e)
|
|
81
|
+
if attempt < max_retries:
|
|
82
|
+
await asyncio.sleep(backoff * (attempt + 1))
|
|
83
|
+
|
|
84
|
+
circuit.record_failure()
|
|
85
|
+
return _error_finding(tool_name, str(last_error)[:200])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _error_finding(tool_name: str, error_msg: str) -> dict:
|
|
89
|
+
"""Return a structured error finding — NOT None, so the pipeline knows the tool ran."""
|
|
90
|
+
return {
|
|
91
|
+
"title": f"⚠️ {tool_name}: API error",
|
|
92
|
+
"description": f"Tool '{tool_name}' failed: {error_msg}",
|
|
93
|
+
"source_type": "error",
|
|
94
|
+
"confidence": 0.0,
|
|
95
|
+
"evidence": [],
|
|
96
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Intent classification — understand what the user is investigating."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
# ── Data model ────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Intent:
|
|
11
|
+
"""What Watson understood about the investigation target."""
|
|
12
|
+
focus: str # natural-language focus area
|
|
13
|
+
target_type: str # person | company | domain | topic | wallet
|
|
14
|
+
confidence: float # classification confidence 0-1
|
|
15
|
+
entities: list[str] = field(default_factory=list)
|
|
16
|
+
categories: list[str] = field(default_factory=list)
|
|
17
|
+
raw_query: str = ""
|
|
18
|
+
|
|
19
|
+
# ── LLM prompt for classification ─────────────────────────────
|
|
20
|
+
|
|
21
|
+
_INTENT_PROMPT = """You are an OSINT target classifier. Given a search query, determine what is being investigated.
|
|
22
|
+
|
|
23
|
+
Return STRICT JSON with these fields:
|
|
24
|
+
{
|
|
25
|
+
"target_type": "person|company|domain|topic|wallet|ip|email",
|
|
26
|
+
"focus": "short description of what aspect to investigate (1 sentence)",
|
|
27
|
+
"confidence": 0.0-1.0,
|
|
28
|
+
"categories": ["list of tool categories to use: social, corporate, dark, crypto, geo, media, recon, vision"],
|
|
29
|
+
"entities": ["list of named entities found in the query"]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ENTITY TYPE RULES:
|
|
33
|
+
- Classify each entity independently based on context
|
|
34
|
+
- People have names, DOBs, nationalities, aliases, professions
|
|
35
|
+
- Companies have suffixes (Inc, LLC, Ltd, Corp, GmbH, SA, AG, Group, Holdings) or are known orgs
|
|
36
|
+
- Entities with version numbers (e.g., "LockBit 3.0") are NEVER typed as person
|
|
37
|
+
- Email addresses, crypto wallets, IPs, domains have their own types
|
|
38
|
+
- If ambiguous, prefer the more specific type
|
|
39
|
+
|
|
40
|
+
Query: {query}
|
|
41
|
+
|
|
42
|
+
JSON:"""
|
|
43
|
+
|
|
44
|
+
# ── Deterministic fallback — no LLM needed for obvious cases ──
|
|
45
|
+
|
|
46
|
+
def _deterministic_classify(query: str) -> Optional[Intent]:
|
|
47
|
+
"""Quick classification without LLM for obvious cases."""
|
|
48
|
+
q = query.strip().lower()
|
|
49
|
+
|
|
50
|
+
# Email
|
|
51
|
+
if "@" in q and "." in q.split("@")[-1]:
|
|
52
|
+
return Intent(focus=f"Email investigation of {q}", target_type="email",
|
|
53
|
+
confidence=0.95, entities=[query.strip()],
|
|
54
|
+
categories=["social", "recon"], raw_query=query)
|
|
55
|
+
|
|
56
|
+
# Crypto wallet
|
|
57
|
+
if q.startswith("0x") and len(q) >= 40:
|
|
58
|
+
return Intent(focus=f"Cryptocurrency wallet {q[:10]}...", target_type="wallet",
|
|
59
|
+
confidence=0.95, entities=[q], categories=["crypto", "dark"],
|
|
60
|
+
raw_query=query)
|
|
61
|
+
|
|
62
|
+
# IP address
|
|
63
|
+
parts = q.split(".")
|
|
64
|
+
if len(parts) == 4 and all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
|
|
65
|
+
return Intent(focus=f"IP address investigation of {q}", target_type="ip",
|
|
66
|
+
confidence=0.95, entities=[q], categories=["geo", "dark"],
|
|
67
|
+
raw_query=query)
|
|
68
|
+
|
|
69
|
+
# Domain
|
|
70
|
+
if "." in q and not " " in q and not q.startswith("http"):
|
|
71
|
+
if any(q.endswith(t) for t in [".com", ".org", ".net", ".io", ".gov", ".ru", ".cn", ".de"]):
|
|
72
|
+
return Intent(focus=f"Domain investigation of {q}", target_type="domain",
|
|
73
|
+
confidence=0.85, entities=[q],
|
|
74
|
+
categories=["recon", "dark", "corporate"],
|
|
75
|
+
raw_query=query)
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
# ── LLM fallback classifier ──────────────────────────────────
|
|
80
|
+
|
|
81
|
+
async def _llm_classify(query: str, call_llm) -> Intent:
|
|
82
|
+
"""Use LLM to classify the query intent."""
|
|
83
|
+
prompt = _INTENT_PROMPT.format(query=query[:500])
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
raw = await call_llm(prompt, timeout=30)
|
|
87
|
+
except Exception:
|
|
88
|
+
raw = None
|
|
89
|
+
|
|
90
|
+
if not raw:
|
|
91
|
+
return Intent(focus=query, target_type="topic", confidence=0.3,
|
|
92
|
+
entities=[], categories=["recon", "social"],
|
|
93
|
+
raw_query=query)
|
|
94
|
+
|
|
95
|
+
import json, re
|
|
96
|
+
# Strip markdown fences
|
|
97
|
+
raw = raw.strip()
|
|
98
|
+
if raw.startswith("```"):
|
|
99
|
+
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
|
100
|
+
raw = re.sub(r"\s*```$", "", raw)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
parsed = json.loads(raw)
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
m = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
106
|
+
if m:
|
|
107
|
+
try:
|
|
108
|
+
parsed = json.loads(m.group(0))
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
parsed = {}
|
|
111
|
+
else:
|
|
112
|
+
parsed = {}
|
|
113
|
+
|
|
114
|
+
target_type = str(parsed.get("target_type", "topic")).lower()
|
|
115
|
+
if target_type not in ("person", "company", "domain", "topic", "wallet", "ip", "email"):
|
|
116
|
+
target_type = "topic"
|
|
117
|
+
|
|
118
|
+
return Intent(
|
|
119
|
+
focus=str(parsed.get("focus", query)),
|
|
120
|
+
target_type=target_type,
|
|
121
|
+
confidence=float(parsed.get("confidence", 0.5)),
|
|
122
|
+
entities=[str(e) for e in parsed.get("entities", []) if str(e).strip()],
|
|
123
|
+
categories=[str(c) for c in parsed.get("categories", ["recon", "social"])],
|
|
124
|
+
raw_query=query,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# ── Main entry point ─────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
async def classify_intent(query: str, call_llm=None) -> Intent:
|
|
130
|
+
"""Classify investigation intent. Uses deterministic rules first, LLM as fallback."""
|
|
131
|
+
det = _deterministic_classify(query)
|
|
132
|
+
if det:
|
|
133
|
+
return det
|
|
134
|
+
|
|
135
|
+
if call_llm:
|
|
136
|
+
return await _llm_classify(query, call_llm)
|
|
137
|
+
|
|
138
|
+
return Intent(focus=query, target_type="topic", confidence=0.4,
|
|
139
|
+
entities=[], categories=["recon", "social"], raw_query=query)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Intent classifier — determines if a message is an investigation or chat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable, Coroutine, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def classify_intent(
|
|
9
|
+
msg: str,
|
|
10
|
+
call_llm: Callable[..., Coroutine],
|
|
11
|
+
) -> Tuple[str, float, str]:
|
|
12
|
+
"""Classify a message as 'investigate' or 'chat' using LLM fallback.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
msg: The user message to classify.
|
|
16
|
+
call_llm: Async function to call the LLM.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Tuple of (intent: str, confidence: float, reason: str)
|
|
20
|
+
"""
|
|
21
|
+
prompt = (
|
|
22
|
+
"Classify this message as 'investigate' or 'chat'.\n"
|
|
23
|
+
"'investigate' means the user wants to investigate a person, domain, company, "
|
|
24
|
+
"crypto address, IP, email, or similar target.\n"
|
|
25
|
+
"'chat' means it's a conversational question about OSINT, methodology, or general knowledge.\n\n"
|
|
26
|
+
f"Message: \"{msg[:500]}\"\n\n"
|
|
27
|
+
"Respond with only one word: investigate or chat"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
result = await call_llm(
|
|
32
|
+
messages=[{"role": "user", "content": prompt}],
|
|
33
|
+
max_tokens=10,
|
|
34
|
+
temperature=0.0,
|
|
35
|
+
)
|
|
36
|
+
answer = result.strip().lower()
|
|
37
|
+
if "investigate" in answer:
|
|
38
|
+
return "investigate", 0.65, "llm_classifier"
|
|
39
|
+
elif "chat" in answer:
|
|
40
|
+
return "chat", 0.65, "llm_classifier"
|
|
41
|
+
else:
|
|
42
|
+
# Default: ambiguous → treat as chat for safety
|
|
43
|
+
return "chat", 0.5, "llm_ambiguous"
|
|
44
|
+
except Exception:
|
|
45
|
+
return "chat", 0.5, "llm_unavailable"
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""LLM configuration — call any OpenAI-compatible API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import os, json, logging, re
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger("watson.llm")
|
|
7
|
+
|
|
8
|
+
# ── Provider config ──────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
_PROVIDER_CONFIG = {
|
|
11
|
+
"deepseek": {
|
|
12
|
+
"base_url": "https://api.deepseek.com/v1",
|
|
13
|
+
"api_key_env": "DEEPSEEK_API_KEY",
|
|
14
|
+
"default_model": os.environ.get("DEEPSEEK_MODEL", "deepseek-v4-pro"),
|
|
15
|
+
},
|
|
16
|
+
"openai": {
|
|
17
|
+
"base_url": "https://api.openai.com/v1",
|
|
18
|
+
"api_key_env": "OPENAI_API_KEY",
|
|
19
|
+
"default_model": "gpt-4o",
|
|
20
|
+
},
|
|
21
|
+
"anthropic": {
|
|
22
|
+
"base_url": "https://api.anthropic.com/v1",
|
|
23
|
+
"api_key_env": "ANTHROPIC_API_KEY",
|
|
24
|
+
"default_model": "claude-sonnet-4-20250514",
|
|
25
|
+
},
|
|
26
|
+
"hermes": {
|
|
27
|
+
"base_url": os.environ.get("HERMES_API_BASE", "http://localhost:8080/v1"),
|
|
28
|
+
"api_key_env": "HERMES_API_KEY",
|
|
29
|
+
"default_model": "hermes",
|
|
30
|
+
},
|
|
31
|
+
"openrouter": {
|
|
32
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
33
|
+
"api_key_env": "OPENROUTER_API_KEY",
|
|
34
|
+
"default_model": "deepseek/deepseek-chat",
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# ── Main call function ───────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def _auto_detect_provider() -> str:
|
|
41
|
+
"""Auto-detect which LLM provider has an API key configured.
|
|
42
|
+
|
|
43
|
+
Checks environment variables and the Watson key store.
|
|
44
|
+
Returns the first available provider name, or '' if none found.
|
|
45
|
+
"""
|
|
46
|
+
# Check env vars for each provider
|
|
47
|
+
for provider_name, cfg in _PROVIDER_CONFIG.items():
|
|
48
|
+
if os.environ.get(cfg["api_key_env"], ""):
|
|
49
|
+
return provider_name
|
|
50
|
+
|
|
51
|
+
# Check the Watson UI key store
|
|
52
|
+
try:
|
|
53
|
+
from watson.api_keys import list_keys
|
|
54
|
+
stored = list_keys()
|
|
55
|
+
for entry in stored:
|
|
56
|
+
if entry.get("configured") and entry.get("slug") in _PROVIDER_CONFIG:
|
|
57
|
+
return entry["slug"]
|
|
58
|
+
except (ImportError, Exception):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
async def call_llm(
|
|
64
|
+
prompt: str,
|
|
65
|
+
timeout: int = 60,
|
|
66
|
+
max_tokens: int = 2048,
|
|
67
|
+
model: str | None = None,
|
|
68
|
+
provider: str | None = None,
|
|
69
|
+
system: str = "",
|
|
70
|
+
) -> str | None:
|
|
71
|
+
"""Call an LLM via OpenAI-compatible API. Returns text or None."""
|
|
72
|
+
|
|
73
|
+
# Resolve provider: explicit > env var > auto-detect
|
|
74
|
+
if not provider:
|
|
75
|
+
provider = os.environ.get("WATSON_LLM_PROVIDER", "")
|
|
76
|
+
if not provider:
|
|
77
|
+
provider = _auto_detect_provider()
|
|
78
|
+
if not provider:
|
|
79
|
+
logger.warning(
|
|
80
|
+
"llm: no LLM provider configured. Set one of: "
|
|
81
|
+
"DEEPSEEK_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, "
|
|
82
|
+
"OPENROUTER_API_KEY, or WATSON_LLM_PROVIDER. "
|
|
83
|
+
"See .env.example for setup instructions."
|
|
84
|
+
)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
cfg = _PROVIDER_CONFIG.get(provider)
|
|
88
|
+
if not cfg:
|
|
89
|
+
logger.warning("llm: unknown provider '%s' — known: %s", provider, list(_PROVIDER_CONFIG.keys()))
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
api_key = os.environ.get(cfg["api_key_env"], "")
|
|
93
|
+
|
|
94
|
+
# Fallback: check the Watson key store (user-configured via UI)
|
|
95
|
+
if not api_key:
|
|
96
|
+
try:
|
|
97
|
+
from watson.api_keys import get_key
|
|
98
|
+
api_key = get_key(provider) or ""
|
|
99
|
+
except ImportError:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
if not api_key:
|
|
103
|
+
logger.warning("llm: no API key for provider %s (env: %s)", provider, cfg["api_key_env"])
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
if not model:
|
|
107
|
+
model = cfg["default_model"]
|
|
108
|
+
|
|
109
|
+
url = f"{cfg['base_url']}/chat/completions"
|
|
110
|
+
|
|
111
|
+
messages = []
|
|
112
|
+
if system:
|
|
113
|
+
messages.append({"role": "system", "content": system})
|
|
114
|
+
messages.append({"role": "user", "content": prompt})
|
|
115
|
+
|
|
116
|
+
payload = {
|
|
117
|
+
"model": model,
|
|
118
|
+
"messages": messages,
|
|
119
|
+
"max_tokens": max_tokens,
|
|
120
|
+
"temperature": 0.3,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
import httpx
|
|
125
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=15.0)) as client:
|
|
126
|
+
resp = await client.post(
|
|
127
|
+
url,
|
|
128
|
+
json=payload,
|
|
129
|
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
130
|
+
)
|
|
131
|
+
if resp.status_code == 200:
|
|
132
|
+
data = resp.json()
|
|
133
|
+
msg = data["choices"][0]["message"]
|
|
134
|
+
content = msg.get("content", "") or ""
|
|
135
|
+
# Reasoning models (deepseek-v4-pro, R1) put thinking in reasoning_content
|
|
136
|
+
# and the final answer in content. If content is empty (all tokens spent
|
|
137
|
+
# on reasoning), fall back to reasoning_content.
|
|
138
|
+
if not content.strip():
|
|
139
|
+
reasoning = msg.get("reasoning_content", "") or ""
|
|
140
|
+
if reasoning.strip():
|
|
141
|
+
logger.debug("llm: using reasoning_content (content was empty)")
|
|
142
|
+
return reasoning
|
|
143
|
+
# Both empty — log response for debugging
|
|
144
|
+
logger.warning("llm: empty content AND empty reasoning_content — finish_reason=%s model=%s",
|
|
145
|
+
data["choices"][0].get("finish_reason", "?"), model)
|
|
146
|
+
# Check if there's any text anywhere in the response
|
|
147
|
+
all_keys = list(msg.keys())
|
|
148
|
+
logger.debug("llm: message keys: %s", all_keys)
|
|
149
|
+
for k in all_keys:
|
|
150
|
+
v = msg.get(k, "")
|
|
151
|
+
if isinstance(v, str) and v.strip():
|
|
152
|
+
logger.info("llm: found text in key '%s', using that", k)
|
|
153
|
+
return v
|
|
154
|
+
return content
|
|
155
|
+
logger.warning("llm: HTTP %d from %s — body: %s", resp.status_code, provider,
|
|
156
|
+
resp.text[:300] if resp.text else "(empty)")
|
|
157
|
+
return None
|
|
158
|
+
except Exception as e:
|
|
159
|
+
logger.warning("llm: request failed: %s", e)
|
|
160
|
+
return None
|