cloudshellgpt 1.0.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.
@@ -0,0 +1,525 @@
1
+ """Bedrock translator — converts Intent objects into AWS CLI commands via Claude 3.5 Sonnet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import random
8
+ import time
9
+ from enum import StrEnum
10
+ from typing import Any
11
+
12
+ import boto3
13
+ from botocore.exceptions import ClientError, ReadTimeoutError
14
+ from pydantic import BaseModel, Field
15
+
16
+ from cloudshellgpt.intent import Intent
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Error types (defined before BedrockTranslator so they can be referenced)
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ class BedrockErrorType(StrEnum):
27
+ """Categories of Bedrock errors for user-facing messaging."""
28
+
29
+ CREDENTIALS = "credentials"
30
+ THROTTLING = "throttling"
31
+ TIMEOUT = "timeout"
32
+ MODEL_NOT_AVAILABLE = "model_not_available"
33
+ INVALID_RESPONSE = "invalid_response"
34
+ GENERIC = "generic"
35
+
36
+
37
+ class BedrockError(Exception):
38
+ """Raised when Bedrock translation fails.
39
+
40
+ Provides structured error information with user-facing messages
41
+ and actionable suggestions.
42
+
43
+ Attributes:
44
+ error_type: Category of the error for classification.
45
+ user_message: Human-readable message explaining what went wrong.
46
+ suggestion: Actionable advice on how to resolve the issue.
47
+ technical_detail: Raw error message for debugging (optional).
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ user_message: str,
53
+ *,
54
+ error_type: BedrockErrorType = BedrockErrorType.GENERIC,
55
+ suggestion: str = "",
56
+ technical_detail: str = "",
57
+ ) -> None:
58
+ self.error_type = error_type
59
+ self.user_message = user_message
60
+ self.suggestion = suggestion
61
+ self.technical_detail = technical_detail
62
+ super().__init__(user_message)
63
+
64
+ @property
65
+ def is_retryable(self) -> bool:
66
+ """Whether this error is transient and safe to retry.
67
+
68
+ Returns:
69
+ True if the error type is throttling or timeout.
70
+ """
71
+ return self.error_type in _RETRYABLE_ERROR_TYPES
72
+
73
+
74
+ # Tipos de error que son transitorios y seguros para reintentar
75
+ _RETRYABLE_ERROR_TYPES: frozenset[BedrockErrorType] = frozenset(
76
+ {
77
+ BedrockErrorType.THROTTLING,
78
+ BedrockErrorType.TIMEOUT,
79
+ }
80
+ )
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Translation model
85
+ # ---------------------------------------------------------------------------
86
+
87
+
88
+ class Translation(BaseModel):
89
+ """A translated AWS CLI command with metadata."""
90
+
91
+ command: str
92
+ explanation: str
93
+ detailed_explanation: str
94
+ risk_level: str = "low"
95
+ estimated_cost: str = "$0.00"
96
+ requires_dry_run: bool = False
97
+ affected_resources: list[str] = Field(default_factory=list)
98
+ flags_used: dict[str, str] = Field(default_factory=dict)
99
+ tip: str | None = None
100
+ related_commands: list[dict[str, str]] = Field(default_factory=list)
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Translator
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ class BedrockTranslator:
109
+ """Translates natural language intents to AWS CLI commands using Amazon Bedrock.
110
+
111
+ Uses Claude 3.5 Sonnet via the Bedrock Converse API. Implements a
112
+ few-shot system prompt with best practices for AWS CLI generation.
113
+ Retries transient errors (throttling, timeouts) with exponential backoff.
114
+ """
115
+
116
+ MODEL_ID = "us.anthropic.claude-sonnet-4-6"
117
+ REGION = "us-east-1"
118
+
119
+ # --- Retry configuration constants ---
120
+ MAX_RETRIES = 3
121
+ BASE_DELAY = 1.0 # seconds
122
+ MAX_JITTER = 0.5 # seconds
123
+
124
+ # --- Inference configuration constants ---
125
+ # Temperature por tipo de intención (menor = más preciso)
126
+ TRANSLATION_TEMPERATURE = 0.2
127
+ EXPLANATION_TEMPERATURE = 0.3
128
+ CODE_GENERATION_TEMPERATURE = 0.2
129
+ ARCHITECTURE_REVIEW_TEMPERATURE = 0.2
130
+ DEFAULT_TEMPERATURE = 0.2
131
+
132
+ # Max tokens por tipo de intención
133
+ TRANSLATION_MAX_TOKENS = 2048
134
+ EXPLANATION_MAX_TOKENS = 1024
135
+ CODE_GENERATION_MAX_TOKENS = 4096
136
+ ARCHITECTURE_REVIEW_MAX_TOKENS = 4096
137
+ DEFAULT_MAX_TOKENS = 4096
138
+
139
+ # Top-P compartido para todas las intenciones
140
+ TOP_P = 0.9
141
+
142
+ SYSTEM_PROMPT = """Eres un experto en AWS con 15 años de experiencia.
143
+ Tu trabajo es traducir intenciones en lenguaje natural a comandos AWS CLI exactos, seguros y eficientes.
144
+
145
+ REGLAS CRÍTICAS:
146
+ 1. SIEMPRE devuelve JSON válido con esta estructura:
147
+ {
148
+ "command": "aws <service> <action> ...flags",
149
+ "explanation": "Resumen de 1 línea en el mismo idioma del input",
150
+ "detailed_explanation": "Explicación de 3-5 líneas de qué hace cada flag importante",
151
+ "risk_level": "low|medium|high|critical",
152
+ "estimated_cost": "$X.XX per month o per request",
153
+ "requires_dry_run": boolean,
154
+ "affected_resources": ["lista de recursos afectados"],
155
+ "flags_used": {"flag_name": "explicación breve"}
156
+ }
157
+
158
+ 2. Si la intención es ambigua, devuelve:
159
+ {
160
+ "clarification_needed": true,
161
+ "clarification_question": "Pregunta específica para clarificar"
162
+ }
163
+
164
+ 3. Riesgo:
165
+ - low: SIEMPRE para read-only (list, describe, get, head, wait, show, ls)
166
+ - medium: create/update reversible
167
+ - high: delete de UN recurso específico, terminate
168
+ - critical: delete recursivo, force sin confirmación, o que afecta múltiples recursos/producción
169
+ IMPORTANTE: los comandos describe-* y list-* SIEMPRE son "low", sin importar los filtros o queries.
170
+
171
+ 4. Comandos destructivos DEBEN marcar requires_dry_run: true
172
+
173
+ 5. NUNCA generes comandos con shell operators: |, &&, ;, xargs, $(), backticks.
174
+ Solo genera UN ÚNICO comando `aws` puro. Si la operación requiere múltiples IDs,
175
+ pásalos como argumentos separados por espacio (ej: --instance-ids id1 id2 id3).
176
+ Si la operación NO se puede hacer con un solo comando, usa "clarification_needed" y
177
+ explica qué pasos seguir manualmente.
178
+ EJEMPLO INCORRECTO: aws iam generate-credential-report && aws iam get-credential-report
179
+ EJEMPLO INCORRECTO: aws ec2 describe-instances | xargs aws ec2 terminate-instances
180
+ EJEMPLO INCORRECTO: aws s3 ls $(aws sts get-caller-identity --query Account --output text)
181
+ EJEMPLO CORRECTO: aws iam get-credential-report --output json
182
+ EJEMPLO CORRECTO: aws ec2 terminate-instances --instance-ids i-123 i-456 i-789
183
+
184
+ Si el usuario pide una operación que necesita múltiples pasos (ej: "elimina todas las instancias activas"),
185
+ responde con clarification_needed: true y explica los pasos que debe seguir manualmente.
186
+
187
+ 6. Usa flags modernos:
188
+ - --output json por defecto (el usuario quiere parseable)
189
+ - --output table cuando el usuario pide listar/mostrar (más legible)
190
+ - --no-paginate cuando el resultado es chico
191
+ - --query para filtrar server-side (IMPORTANTE: los alias/keys en --query SIEMPRE en ASCII, nunca usar caracteres Unicode. Ej: {Name:Name,Created:CreationDate} NO {名称:Name})
192
+ - --filters en lugar de client-side
193
+
194
+ 7. Idioma: TODAS las cadenas de texto dirigidas al usuario (explanation, detailed_explanation,
195
+ estimated_cost, tip, related_commands descriptions) deben estar en el MISMO idioma del input.
196
+ Los comandos AWS y nombres de flags permanecen en inglés.
197
+
198
+ 8. Incluye siempre estos campos adicionales en tu respuesta JSON:
199
+ "tip": "Un consejo educativo breve relacionado al comando (en el idioma del input)",
200
+ "related_commands": [{"command": "aws ...", "description": "breve descripción en el idioma del input"}]
201
+
202
+ EJEMPLOS:
203
+
204
+ Input: "lista los buckets de S3"
205
+ Output: {
206
+ "command": "aws s3api list-buckets --query 'Buckets[].{Name:Name,Created:CreationDate}' --output table",
207
+ "explanation": "Lista todos los buckets de S3 con nombre y fecha de creación",
208
+ "detailed_explanation": "Usa s3api (API directa) en lugar de s3 (alto nivel) para mejor control. --query filtra server-side y --output table da formato legible.",
209
+ "risk_level": "low",
210
+ "estimated_cost": "$0.00",
211
+ "requires_dry_run": false,
212
+ "affected_resources": [],
213
+ "flags_used": {"--query": "JMESPath filter", "--output": "table format"}
214
+ }
215
+
216
+ Input: "borra todos los objetos del bucket de logs"
217
+ Output: {
218
+ "command": "aws s3 rm s3://logs-bucket-name/ --recursive",
219
+ "explanation": "Elimina TODOS los objetos del bucket de logs recursivamente",
220
+ "detailed_explanation": "⚠️ ESTA ACCIÓN ES IRREVERSIBLE. Borra todos los objetos pero NO el bucket. Para borrar el bucket también usa s3api delete-bucket. Recomendamos primero: aws s3 ls s3://logs-bucket-name/ --recursive | wc -l para contar archivos.",
221
+ "risk_level": "critical",
222
+ "estimated_cost": "$0.00",
223
+ "requires_dry_run": true,
224
+ "affected_resources": ["s3://logs-bucket-name/*"],
225
+ "flags_used": {"--recursive": "Borra todos los objetos, no solo el primero"}
226
+ }
227
+
228
+ Input: "muéstrame las lambdas que fallaron ayer"
229
+ Output: {
230
+ "command": "aws lambda list-functions --query 'Functions[?State==`Failed`].{Name:FunctionName,Runtime:Runtime,LastModified:LastModified}' --output table",
231
+ "explanation": "Lista funciones Lambda en estado Failed",
232
+ "detailed_explanation": "Filtra server-side usando --query. Para más detalles de un fallo específico: aws logs filter-log-events --log-group-name /aws/lambda/FUNCTION_NAME",
233
+ "risk_level": "low",
234
+ "estimated_cost": "$0.00",
235
+ "requires_dry_run": false,
236
+ "affected_resources": [],
237
+ "flags_used": {"--query": "Filter by state", "--output": "table"}
238
+ }
239
+ """
240
+
241
+ def __init__(
242
+ self,
243
+ region: str = REGION,
244
+ *,
245
+ model_id: str = MODEL_ID,
246
+ max_retries: int = MAX_RETRIES,
247
+ base_delay: float = BASE_DELAY,
248
+ ) -> None:
249
+ self.client = boto3.client("bedrock-runtime", region_name=region)
250
+ self.region = region
251
+ self.model_id = model_id
252
+ self.max_retries = max_retries
253
+ self.base_delay = base_delay
254
+
255
+ def translate(self, intent: Intent) -> Translation:
256
+ """Translate an Intent into an AWS CLI command.
257
+
258
+ Retries transient errors (throttling, timeouts) with exponential
259
+ backoff and jitter. Non-transient errors fail immediately.
260
+
261
+ Args:
262
+ intent: The parsed user intent
263
+
264
+ Returns:
265
+ Translation object with the command and metadata
266
+
267
+ Raises:
268
+ BedrockError: If translation fails after all retries (for transient)
269
+ or immediately (for non-transient errors).
270
+ """
271
+ user_message = self._build_user_message(intent)
272
+ response = self._call_converse_with_retry(user_message)
273
+ return self._parse_response(response)
274
+
275
+ def _call_converse_with_retry(self, user_message: str) -> dict[str, Any]:
276
+ """Call Bedrock Converse API with exponential backoff for transient errors.
277
+
278
+ Args:
279
+ user_message: The formatted user message to send.
280
+
281
+ Returns:
282
+ The raw Bedrock Converse API response dict.
283
+
284
+ Raises:
285
+ BedrockError: If a non-retryable error occurs, or after exhausting retries.
286
+ """
287
+ last_error: BedrockError | None = None
288
+
289
+ for attempt in range(self.max_retries + 1):
290
+ try:
291
+ return self.client.converse( # type: ignore[no-any-return]
292
+ modelId=self.model_id,
293
+ messages=[
294
+ {
295
+ "role": "user",
296
+ "content": [{"text": user_message}],
297
+ }
298
+ ],
299
+ system=[{"text": self.SYSTEM_PROMPT}],
300
+ inferenceConfig={
301
+ "maxTokens": self.TRANSLATION_MAX_TOKENS,
302
+ "temperature": self.TRANSLATION_TEMPERATURE,
303
+ },
304
+ )
305
+ except ClientError as e:
306
+ error_code = e.response.get("Error", {}).get("Code", "")
307
+ bedrock_error = self._map_client_error(error_code, e)
308
+ last_error = bedrock_error
309
+
310
+ if not bedrock_error.is_retryable:
311
+ raise bedrock_error from e
312
+
313
+ if attempt < self.max_retries:
314
+ delay = self._calculate_backoff(attempt)
315
+ logger.warning(
316
+ "Transient error '%s' on attempt %d/%d, retrying in %.2fs",
317
+ error_code,
318
+ attempt + 1,
319
+ self.max_retries + 1,
320
+ delay,
321
+ )
322
+ time.sleep(delay)
323
+ else:
324
+ raise bedrock_error from e
325
+
326
+ except ReadTimeoutError as e:
327
+ last_error = BedrockError(
328
+ "Request to Bedrock timed out",
329
+ error_type=BedrockErrorType.TIMEOUT,
330
+ suggestion="Check your network connection and try again",
331
+ technical_detail=str(e),
332
+ )
333
+
334
+ if attempt < self.max_retries:
335
+ delay = self._calculate_backoff(attempt)
336
+ logger.warning(
337
+ "Read timeout on attempt %d/%d, retrying in %.2fs",
338
+ attempt + 1,
339
+ self.max_retries + 1,
340
+ delay,
341
+ )
342
+ time.sleep(delay)
343
+ else:
344
+ raise last_error from e
345
+
346
+ except Exception as e:
347
+ # Non-transient unexpected errors fail immediately
348
+ raise BedrockError(
349
+ "Unexpected error communicating with Bedrock",
350
+ error_type=BedrockErrorType.GENERIC,
351
+ suggestion="Check AWS connectivity and try again",
352
+ technical_detail=str(e),
353
+ ) from e
354
+
355
+ # Nunca debería llegar aquí, pero por seguridad de tipos
356
+ assert last_error is not None # noqa: S101
357
+ raise last_error
358
+
359
+ def _calculate_backoff(self, attempt: int) -> float:
360
+ """Calculate exponential backoff delay with jitter.
361
+
362
+ Formula: base_delay * 2^attempt + random_jitter
363
+
364
+ Args:
365
+ attempt: Zero-indexed attempt number.
366
+
367
+ Returns:
368
+ Delay in seconds before the next retry.
369
+ """
370
+ exponential_delay = self.base_delay * (2**attempt)
371
+ jitter = random.uniform(0, self.MAX_JITTER) # noqa: S311
372
+ return exponential_delay + jitter # type: ignore[no-any-return]
373
+
374
+ def _parse_response(self, response: dict[str, Any]) -> Translation:
375
+ """Parse and validate the Bedrock Converse API response.
376
+
377
+ Args:
378
+ response: Raw response from client.converse().
379
+
380
+ Returns:
381
+ A Translation model with the parsed data.
382
+
383
+ Raises:
384
+ BedrockError: If response structure is invalid or JSON is malformed.
385
+ """
386
+ try:
387
+ response_text = response["output"]["message"]["content"][0]["text"]
388
+ except (KeyError, IndexError) as e:
389
+ raise BedrockError(
390
+ "Bedrock returned an empty or malformed response",
391
+ error_type=BedrockErrorType.INVALID_RESPONSE,
392
+ suggestion="Try rephrasing your request or try again later",
393
+ technical_detail=str(e),
394
+ ) from e
395
+
396
+ try:
397
+ data = self._extract_json(response_text)
398
+ except json.JSONDecodeError as e:
399
+ raise BedrockError(
400
+ "Model returned invalid JSON — could not parse translation",
401
+ error_type=BedrockErrorType.INVALID_RESPONSE,
402
+ suggestion="Try rephrasing your request with more specific details",
403
+ technical_detail=f"JSON parse error: {e}",
404
+ ) from e
405
+
406
+ # Handle clarification requests from the LLM
407
+ if data.get("clarification_needed"):
408
+ raise BedrockError(
409
+ data.get(
410
+ "clarification_question", "Please be more specific about what you want to do."
411
+ ),
412
+ error_type=BedrockErrorType.INVALID_RESPONSE,
413
+ suggestion=data.get("clarification_question", "Try specifying exact resource IDs."),
414
+ technical_detail="LLM requested clarification instead of generating a command",
415
+ )
416
+
417
+ return Translation(
418
+ command=data["command"],
419
+ explanation=data.get("explanation", ""),
420
+ detailed_explanation=data.get("detailed_explanation", ""),
421
+ risk_level=data.get("risk_level", "low"),
422
+ estimated_cost=data.get("estimated_cost", "$0.00"),
423
+ requires_dry_run=data.get("requires_dry_run", False),
424
+ affected_resources=data.get("affected_resources", []),
425
+ flags_used=data.get("flags_used", {}),
426
+ tip=data.get("tip"),
427
+ related_commands=data.get("related_commands", []),
428
+ )
429
+
430
+ def _map_client_error(self, error_code: str, exc: ClientError) -> BedrockError:
431
+ """Map a botocore ClientError code to a structured BedrockError."""
432
+ error_map: dict[str, tuple[BedrockErrorType, str, str]] = {
433
+ "AccessDeniedException": (
434
+ BedrockErrorType.CREDENTIALS,
435
+ "Access denied — insufficient permissions to invoke Bedrock",
436
+ "Check that your IAM role has bedrock:InvokeModel permission",
437
+ ),
438
+ "UnrecognizedClientException": (
439
+ BedrockErrorType.CREDENTIALS,
440
+ "AWS credentials are invalid or expired",
441
+ "Run 'aws sts get-caller-identity' to verify your credentials",
442
+ ),
443
+ "ExpiredTokenException": (
444
+ BedrockErrorType.CREDENTIALS,
445
+ "AWS session token has expired",
446
+ "Refresh your credentials (e.g., re-run 'aws sso login')",
447
+ ),
448
+ "ThrottlingException": (
449
+ BedrockErrorType.THROTTLING,
450
+ "Request was throttled by Bedrock",
451
+ "Try again in a few seconds. If you hit the daily token limit, wait until tomorrow or request a quota increase in the AWS console (Service Quotas → Bedrock)",
452
+ ),
453
+ "ServiceQuotaExceededException": (
454
+ BedrockErrorType.THROTTLING,
455
+ "Bedrock service quota exceeded",
456
+ "Wait a moment or request a quota increase in the AWS console",
457
+ ),
458
+ "ModelTimeoutException": (
459
+ BedrockErrorType.TIMEOUT,
460
+ "Model took too long to respond",
461
+ "Try again — the model may be under heavy load",
462
+ ),
463
+ "ModelNotReadyException": (
464
+ BedrockErrorType.MODEL_NOT_AVAILABLE,
465
+ "The model is not ready to serve requests",
466
+ "Wait a few minutes and try again",
467
+ ),
468
+ "ResourceNotFoundException": (
469
+ BedrockErrorType.MODEL_NOT_AVAILABLE,
470
+ "Model not found — it may not be available in your region",
471
+ (f"Verify that model '{self.model_id}' is enabled in region '{self.region}'"),
472
+ ),
473
+ "ValidationException": (
474
+ BedrockErrorType.INVALID_RESPONSE,
475
+ "Request validation failed",
476
+ "Try rephrasing your request with fewer special characters",
477
+ ),
478
+ }
479
+
480
+ if error_code in error_map:
481
+ err_type, msg, suggestion = error_map[error_code]
482
+ return BedrockError(
483
+ msg,
484
+ error_type=err_type,
485
+ suggestion=suggestion,
486
+ technical_detail=str(exc),
487
+ )
488
+
489
+ return BedrockError(
490
+ f"AWS error: {error_code}",
491
+ error_type=BedrockErrorType.GENERIC,
492
+ suggestion="Check AWS service status and try again",
493
+ technical_detail=str(exc),
494
+ )
495
+
496
+ def _build_user_message(self, intent: Intent) -> str:
497
+ """Build the user message for Bedrock."""
498
+ from datetime import date, timedelta
499
+
500
+ today = date.today()
501
+ seven_days_ago = today - timedelta(days=7)
502
+
503
+ return f"""Input: "{intent.raw_input}"
504
+
505
+ Language detected: {intent.detected_language}
506
+ Service detected: {intent.service}
507
+ Action detected: {intent.action}
508
+ Region: {intent.region or "default (us-east-1)"}
509
+ Today's date: {today.isoformat()}
510
+ Seven days ago: {seven_days_ago.isoformat()}
511
+ Response language: ALL user-facing text MUST be in "{intent.detected_language}"
512
+
513
+ Translate this to an AWS CLI command. Return ONLY the JSON object."""
514
+
515
+ def _extract_json(self, text: str) -> dict[str, Any]:
516
+ """Extract JSON from Claude's response (handles markdown code blocks)."""
517
+ text = text.strip()
518
+
519
+ # Strip markdown code fences if present
520
+ if text.startswith("```"):
521
+ lines = text.split("\n")
522
+ text = "\n".join(lines[1:-1]) if lines[-1].startswith("```") else "\n".join(lines[1:])
523
+
524
+ result: dict[str, Any] = json.loads(text)
525
+ return result