agentic-ops 0.1.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 (71) hide show
  1. agentic_ops/adapters/cac_pull_collector.py +413 -0
  2. agentic_ops/adapters/datadog_adapter.py +33 -0
  3. agentic_ops/adapters/kubernetes_adapter.py +73 -0
  4. agentic_ops/adapters/llm_protocol.py +366 -0
  5. agentic_ops/adapters/llm_provider_registry.py +510 -0
  6. agentic_ops/adapters/protocol.py +19 -0
  7. agentic_ops/adapters/registry_adapter.py +70 -0
  8. agentic_ops/adapters/runbook_rag.py +21 -0
  9. agentic_ops/adapters/slack_adapter.py +78 -0
  10. agentic_ops/cli/compile_taxonomy.py +66 -0
  11. agentic_ops/cli/main.py +3192 -0
  12. agentic_ops/cli/trust_ledger.py +150 -0
  13. agentic_ops/core/__init__.py +1 -0
  14. agentic_ops/core/approvals.py +137 -0
  15. agentic_ops/core/approvers.py +32 -0
  16. agentic_ops/core/circuit_breaker.py +39 -0
  17. agentic_ops/core/contracts.py +103 -0
  18. agentic_ops/core/crypto.py +37 -0
  19. agentic_ops/core/distributed.py +29 -0
  20. agentic_ops/core/evidence.py +143 -0
  21. agentic_ops/core/executor.py +181 -0
  22. agentic_ops/core/facts.py +29 -0
  23. agentic_ops/core/governance_schemas.py +25 -0
  24. agentic_ops/core/idempotency.py +52 -0
  25. agentic_ops/core/infra_specialist.py +52 -0
  26. agentic_ops/core/model_specialist.py +51 -0
  27. agentic_ops/core/orchestrator.py +281 -0
  28. agentic_ops/core/policy.py +68 -0
  29. agentic_ops/core/policy_engine.py +166 -0
  30. agentic_ops/core/prompt_guard.py +16 -0
  31. agentic_ops/core/remediation_agent.py +49 -0
  32. agentic_ops/core/specialist.py +52 -0
  33. agentic_ops/core/state.py +20 -0
  34. agentic_ops/core/telemetry.py +47 -0
  35. agentic_ops/entrypoints/approval_sweeper.py +6 -0
  36. agentic_ops/entrypoints/grafana_webhook.py +61 -0
  37. agentic_ops/entrypoints/mcp_server.py +1149 -0
  38. agentic_ops/entrypoints/slack_verify.py +24 -0
  39. agentic_ops/entrypoints/slack_webhook.py +59 -0
  40. agentic_ops/evidence_store/EP-0001.json +64 -0
  41. agentic_ops/evidence_store/EP-0002.json +58 -0
  42. agentic_ops/evidence_store/EP-0003.json +58 -0
  43. agentic_ops/evidence_store/EP-0004.json +64 -0
  44. agentic_ops/evidence_store/EP-0005.json +65 -0
  45. agentic_ops/evidence_store/EP-CHAOS-31c6e006.json +27 -0
  46. agentic_ops/evidence_store/EP-CHAOS-5950095d.json +28 -0
  47. agentic_ops/evidence_store/EP-CHAOS-d6c18601.json +28 -0
  48. agentic_ops/evidence_store/_index.json +4 -0
  49. agentic_ops/governance/action_catalog.yaml +26 -0
  50. agentic_ops/governance/approvers.yaml +7 -0
  51. agentic_ops/governance/autonomy_policy.yaml +23 -0
  52. agentic_ops/governance/risk_register.yaml +6 -0
  53. agentic_ops/governance/shadow_policy.yaml +23 -0
  54. agentic_ops/knowledge_base/runbooks/DATA-042.md +8 -0
  55. agentic_ops/knowledge_base/runbooks/INFRA-001.md +8 -0
  56. agentic_ops/schemas/action.schema.json +13 -0
  57. agentic_ops/schemas/diagnosis.schema.json +11 -0
  58. agentic_ops/schemas/evidence.schema.json +12 -0
  59. agentic_ops/schemas/risk.schema.json +14 -0
  60. agentic_ops/tools/__init__.py +0 -0
  61. agentic_ops/tools/base.py +80 -0
  62. agentic_ops/tools/data.py +77 -0
  63. agentic_ops/tools/github.py +30 -0
  64. agentic_ops/tools/infra.py +51 -0
  65. agentic_ops/tools/model.py +29 -0
  66. agentic_ops/tools/observability.py +56 -0
  67. agentic_ops-0.1.0.dist-info/METADATA +1489 -0
  68. agentic_ops-0.1.0.dist-info/RECORD +71 -0
  69. agentic_ops-0.1.0.dist-info/WHEEL +4 -0
  70. agentic_ops-0.1.0.dist-info/entry_points.txt +4 -0
  71. agentic_ops-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,366 @@
1
+ """
2
+ LLM Adapters
3
+ ============
4
+ Définit le Protocol LLMPort (port hexagonal), LiteLLMAdapter (universel),
5
+ OllamaAdapter (local/Tailscale/Cloudflare), FallbackLLMAdapter (chaîne de
6
+ secours automatique), et _ProbeResponse (healthcheck).
7
+
8
+ Métriques Prometheus instrumentées :
9
+ agentic_ops_llm_requests_total (provider, model, status)
10
+ agentic_ops_llm_latency_seconds (provider, model)
11
+ agentic_ops_llm_tokens_total (provider, model, token_type)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import re
19
+ import time
20
+ from typing import Protocol, TypeVar
21
+
22
+ import httpx
23
+ from pydantic import BaseModel
24
+ from prometheus_client import Counter, Histogram
25
+ from tenacity import (
26
+ retry,
27
+ retry_if_exception_type,
28
+ stop_after_attempt,
29
+ wait_exponential,
30
+ )
31
+
32
+ logger = logging.getLogger("LLMAdapter")
33
+
34
+ T = TypeVar("T", bound=BaseModel)
35
+
36
+
37
+ def _strip_think_tags(text: str) -> str:
38
+ """
39
+ Règle de l'Honnêteté Radicale : Les modèles de raisonnement pur (comme DeepSeek-R1)
40
+ émettent des balises <think>...</think> contenant leur chaîne de pensée.
41
+ Cette fonction élimine ces blocs pour isoler le JSON pur attendu par Pydantic.
42
+ """
43
+ if not text:
44
+ return text
45
+ # Enlève tout le bloc <think>...</think> (multiligne)
46
+ cleaned = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL)
47
+ # Enlève d'éventuels backticks markdown ```json ... ```
48
+ cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned.strip(), flags=re.MULTILINE)
49
+ cleaned = re.sub(r"\s*```$", "", cleaned.strip())
50
+ return cleaned.strip()
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Prometheus metrics
55
+ # ---------------------------------------------------------------------------
56
+
57
+ LLM_REQUESTS = Counter(
58
+ "agentic_ops_llm_requests_total",
59
+ "Total LLM requests by role, provider, model and status",
60
+ ["role", "provider", "model", "status"],
61
+ )
62
+
63
+ LLM_LATENCY = Histogram(
64
+ "agentic_ops_llm_latency_seconds",
65
+ "LLM request latency in seconds",
66
+ ["role", "provider", "model"],
67
+ buckets=[0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 30.0, 60.0],
68
+ )
69
+
70
+ LLM_TOKENS = Counter(
71
+ "agentic_ops_llm_tokens_total",
72
+ "Total LLM tokens consumed by role, provider, model and token type",
73
+ ["role", "provider", "model", "token_type"],
74
+ )
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Protocol
79
+ # ---------------------------------------------------------------------------
80
+
81
+ class LLMPort(Protocol):
82
+ def complete(
83
+ self,
84
+ *,
85
+ system: str,
86
+ user: str,
87
+ response_model: type[T],
88
+ temperature: float = 0.0,
89
+ ) -> T:
90
+ ...
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Probe response model (minimal, used by /api/llm/probe)
95
+ # ---------------------------------------------------------------------------
96
+
97
+ class _ProbeResponse(BaseModel):
98
+ answer: str
99
+
100
+
101
+ class _ChatResponse(BaseModel):
102
+ answer: str
103
+ action_type: str = "investigate"
104
+ confidence_score: float = 0.95
105
+ rationale: str = ""
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # LiteLLMAdapter — universal adapter (OpenRouter, OpenAI, Gemini, Anthropic,
110
+ # Azure, Mistral, HuggingFace, ...)
111
+ # ---------------------------------------------------------------------------
112
+
113
+ class LiteLLMAdapter:
114
+ """
115
+ Adaptateur LiteLLM universel avec support de Structured Output via
116
+ Instructor, Prometheus metrics, et retry exponentiel.
117
+
118
+ Args:
119
+ model : Modèle au format LiteLLM (ex: "openrouter/deepseek/deepseek-r1:free")
120
+ temperature : Température de génération (0.0 = déterministe)
121
+ max_tokens : Limite de tokens de sortie
122
+ timeout : Timeout réseau par appel (secondes)
123
+ max_retries : Nombre de tentatives avant exception
124
+ seed : Graine pour la reproductibilité (si supporté par le modèle)
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ model: str = "openrouter/deepseek/deepseek-r1:free",
130
+ temperature: float = 0.0,
131
+ max_tokens: int = 1024,
132
+ timeout: int = 30,
133
+ max_retries: int = 3,
134
+ seed: int | None = None,
135
+ role: str = "global",
136
+ ):
137
+ import instructor
138
+ import litellm
139
+
140
+ self.model = model
141
+ self.temperature = temperature
142
+ self.max_tokens = max_tokens
143
+ self.timeout = timeout
144
+ self.max_retries = max_retries
145
+ self.seed = seed
146
+ self.role = role
147
+
148
+ # Derive provider label for Prometheus (first segment before '/')
149
+ self._provider_label = model.split("/")[0] if "/" in model else "unknown"
150
+ # Safe model label (strip :free tag for readability)
151
+ self._model_label = model.split(":")[0].split("/")[-1]
152
+
153
+ litellm.request_timeout = timeout
154
+ self._client = instructor.from_litellm(litellm.completion)
155
+
156
+ def complete(
157
+ self,
158
+ *,
159
+ system: str,
160
+ user: str,
161
+ response_model: type[T],
162
+ temperature: float | None = None,
163
+ ) -> T:
164
+ return self._complete_with_metrics(
165
+ system=system,
166
+ user=user,
167
+ response_model=response_model,
168
+ temperature=temperature if temperature is not None else self.temperature,
169
+ )
170
+
171
+ @retry(
172
+ stop=stop_after_attempt(3),
173
+ wait=wait_exponential(multiplier=1, min=2, max=10),
174
+ retry=retry_if_exception_type(Exception),
175
+ reraise=True,
176
+ )
177
+ def _complete_with_metrics(
178
+ self,
179
+ *,
180
+ system: str,
181
+ user: str,
182
+ response_model: type[T],
183
+ temperature: float,
184
+ ) -> T:
185
+ t0 = time.monotonic()
186
+ try:
187
+ kwargs: dict = dict(
188
+ model=self.model,
189
+ response_model=response_model,
190
+ temperature=temperature,
191
+ max_tokens=self.max_tokens,
192
+ messages=[
193
+ {"role": "system", "content": system},
194
+ {"role": "user", "content": user},
195
+ ],
196
+ )
197
+ if self.seed is not None:
198
+ kwargs["seed"] = self.seed
199
+
200
+ result = self._client.chat.completions.create(**kwargs)
201
+ elapsed = time.monotonic() - t0
202
+ LLM_REQUESTS.labels(
203
+ role=self.role,
204
+ provider=self._provider_label,
205
+ model=self._model_label,
206
+ status="success",
207
+ ).inc()
208
+ LLM_LATENCY.labels(
209
+ role=self.role, provider=self._provider_label, model=self._model_label
210
+ ).observe(elapsed)
211
+ return result
212
+ except Exception as exc:
213
+ elapsed = time.monotonic() - t0
214
+ LLM_REQUESTS.labels(
215
+ role=self.role,
216
+ provider=self._provider_label,
217
+ model=self._model_label,
218
+ status="error",
219
+ ).inc()
220
+ LLM_LATENCY.labels(
221
+ role=self.role, provider=self._provider_label, model=self._model_label
222
+ ).observe(elapsed)
223
+ logger.warning(
224
+ "LiteLLMAdapter error on model=%s: %s", self.model, exc
225
+ )
226
+ raise
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # OllamaAdapter — appel direct httpx (local, Tailscale, Cloudflare, Vast.ai)
231
+ # ---------------------------------------------------------------------------
232
+
233
+ class OllamaAdapter:
234
+ """
235
+ Adaptateur Ollama léger via httpx.
236
+
237
+ Fonctionne avec :
238
+ - Ollama local : OLLAMA_BASE_URL=http://localhost:11434
239
+ - Ollama via Tailscale : OLLAMA_BASE_URL=http://100.X.X.X:11434
240
+ - Ollama via Cloudflare Tunnel : OLLAMA_BASE_URL=https://ollama.mondomaine.com
241
+ - Ollama sur Vast.ai / VPS : OLLAMA_BASE_URL=http://IP_PUBLIQUE:11434
242
+
243
+ Aucune clé API requise.
244
+ """
245
+
246
+ def __init__(
247
+ self,
248
+ base_url: str = "http://localhost:11434",
249
+ model: str = "llama3.2:3b",
250
+ timeout: int = 60,
251
+ role: str = "global",
252
+ ):
253
+ self.base_url = base_url.rstrip("/")
254
+ self.model = model
255
+ self.timeout = timeout
256
+ self.role = role
257
+ self._provider_label = "ollama"
258
+ self._model_label = model.split(":")[0]
259
+
260
+ def complete(
261
+ self,
262
+ *,
263
+ system: str,
264
+ user: str,
265
+ response_model: type[T],
266
+ temperature: float = 0.0,
267
+ ) -> T:
268
+ import json
269
+
270
+ payload = {
271
+ "model": self.model,
272
+ "messages": [
273
+ {"role": "system", "content": system},
274
+ {"role": "user", "content": user},
275
+ ],
276
+ "stream": False,
277
+ "options": {"temperature": temperature},
278
+ "format": "json",
279
+ }
280
+ t0 = time.monotonic()
281
+ try:
282
+ with httpx.Client(timeout=self.timeout) as client:
283
+ resp = client.post(
284
+ f"{self.base_url}/api/chat",
285
+ json=payload,
286
+ )
287
+ resp.raise_for_status()
288
+ data = resp.json()
289
+ content = data.get("message", {}).get("content", "")
290
+ # [Honnêteté Radicale / Modèles de Raisonnement R1]
291
+ # Nettoyer les balises <think>...</think> avant parsing JSON
292
+ content = _strip_think_tags(content)
293
+ # Parse JSON and validate against response_model
294
+ parsed = json.loads(content)
295
+ result = response_model.model_validate(parsed)
296
+ elapsed = time.monotonic() - t0
297
+ LLM_REQUESTS.labels(
298
+ role=self.role, provider="ollama", model=self._model_label, status="success"
299
+ ).inc()
300
+ LLM_LATENCY.labels(
301
+ role=self.role, provider="ollama", model=self._model_label
302
+ ).observe(elapsed)
303
+ return result
304
+ except Exception as exc:
305
+ elapsed = time.monotonic() - t0
306
+ LLM_REQUESTS.labels(
307
+ role=self.role, provider="ollama", model=self._model_label, status="error"
308
+ ).inc()
309
+ LLM_LATENCY.labels(
310
+ role=self.role, provider="ollama", model=self._model_label
311
+ ).observe(elapsed)
312
+ logger.warning("OllamaAdapter error on %s/%s: %s", self.base_url, self.model, exc)
313
+ raise
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # FallbackLLMAdapter — chaîne de secours automatique
318
+ # ---------------------------------------------------------------------------
319
+
320
+ class FallbackLLMAdapter:
321
+ """
322
+ Chaîne de secours : si le modèle primaire échoue (RateLimitError, timeout,
323
+ erreur 5xx), tente automatiquement chaque modèle de la liste de fallback
324
+ dans l'ordre jusqu'à obtenir une réponse valide.
325
+
326
+ Prometheus : chaque tentative est instrumentée individuellement.
327
+ """
328
+
329
+ def __init__(
330
+ self,
331
+ primary: LiteLLMAdapter | OllamaAdapter,
332
+ fallbacks: list[LiteLLMAdapter | OllamaAdapter] | None = None,
333
+ ):
334
+ self.primary = primary
335
+ self.fallbacks = fallbacks or []
336
+
337
+ def complete(
338
+ self,
339
+ *,
340
+ system: str,
341
+ user: str,
342
+ response_model: type[T],
343
+ temperature: float = 0.0,
344
+ ) -> T:
345
+ chain = [self.primary] + self.fallbacks
346
+ last_exc: Exception | None = None
347
+ for i, adapter in enumerate(chain):
348
+ label = getattr(adapter, "model", "unknown")
349
+ try:
350
+ logger.debug("FallbackLLMAdapter: trying adapter %d (%s)", i, label)
351
+ return adapter.complete(
352
+ system=system,
353
+ user=user,
354
+ response_model=response_model,
355
+ temperature=temperature,
356
+ )
357
+ except Exception as exc:
358
+ last_exc = exc
359
+ logger.warning(
360
+ "FallbackLLMAdapter: adapter %d (%s) failed: %s — trying next.",
361
+ i, label, exc,
362
+ )
363
+ raise RuntimeError(
364
+ f"All {len(chain)} LLM adapters in the fallback chain failed. "
365
+ f"Last error: {last_exc}"
366
+ ) from last_exc