stratifyai 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 (57) hide show
  1. cli/__init__.py +5 -0
  2. cli/stratifyai_cli.py +1753 -0
  3. stratifyai/__init__.py +113 -0
  4. stratifyai/api_key_helper.py +372 -0
  5. stratifyai/caching.py +279 -0
  6. stratifyai/chat/__init__.py +54 -0
  7. stratifyai/chat/builder.py +366 -0
  8. stratifyai/chat/stratifyai_anthropic.py +194 -0
  9. stratifyai/chat/stratifyai_bedrock.py +200 -0
  10. stratifyai/chat/stratifyai_deepseek.py +194 -0
  11. stratifyai/chat/stratifyai_google.py +194 -0
  12. stratifyai/chat/stratifyai_grok.py +194 -0
  13. stratifyai/chat/stratifyai_groq.py +195 -0
  14. stratifyai/chat/stratifyai_ollama.py +201 -0
  15. stratifyai/chat/stratifyai_openai.py +209 -0
  16. stratifyai/chat/stratifyai_openrouter.py +201 -0
  17. stratifyai/chunking.py +158 -0
  18. stratifyai/client.py +292 -0
  19. stratifyai/config.py +1273 -0
  20. stratifyai/cost_tracker.py +257 -0
  21. stratifyai/embeddings.py +245 -0
  22. stratifyai/exceptions.py +91 -0
  23. stratifyai/models.py +59 -0
  24. stratifyai/providers/__init__.py +5 -0
  25. stratifyai/providers/anthropic.py +330 -0
  26. stratifyai/providers/base.py +183 -0
  27. stratifyai/providers/bedrock.py +634 -0
  28. stratifyai/providers/deepseek.py +39 -0
  29. stratifyai/providers/google.py +39 -0
  30. stratifyai/providers/grok.py +39 -0
  31. stratifyai/providers/groq.py +39 -0
  32. stratifyai/providers/ollama.py +43 -0
  33. stratifyai/providers/openai.py +344 -0
  34. stratifyai/providers/openai_compatible.py +372 -0
  35. stratifyai/providers/openrouter.py +39 -0
  36. stratifyai/py.typed +2 -0
  37. stratifyai/rag.py +381 -0
  38. stratifyai/retry.py +185 -0
  39. stratifyai/router.py +643 -0
  40. stratifyai/summarization.py +179 -0
  41. stratifyai/utils/__init__.py +11 -0
  42. stratifyai/utils/bedrock_validator.py +136 -0
  43. stratifyai/utils/code_extractor.py +327 -0
  44. stratifyai/utils/csv_extractor.py +197 -0
  45. stratifyai/utils/file_analyzer.py +192 -0
  46. stratifyai/utils/json_extractor.py +219 -0
  47. stratifyai/utils/log_extractor.py +267 -0
  48. stratifyai/utils/model_selector.py +324 -0
  49. stratifyai/utils/provider_validator.py +442 -0
  50. stratifyai/utils/token_counter.py +186 -0
  51. stratifyai/vectordb.py +344 -0
  52. stratifyai-0.1.0.dist-info/METADATA +263 -0
  53. stratifyai-0.1.0.dist-info/RECORD +57 -0
  54. stratifyai-0.1.0.dist-info/WHEEL +5 -0
  55. stratifyai-0.1.0.dist-info/entry_points.txt +2 -0
  56. stratifyai-0.1.0.dist-info/licenses/LICENSE +21 -0
  57. stratifyai-0.1.0.dist-info/top_level.txt +2 -0
cli/stratifyai_cli.py ADDED
@@ -0,0 +1,1753 @@
1
+ """StratifyAI CLI - Unified LLM interface via terminal."""
2
+
3
+ import os
4
+ import sys
5
+ from datetime import datetime
6
+ from typing import Optional, List
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich.prompt import Prompt, Confirm
11
+ from rich import print as rprint
12
+ from rich.spinner import Spinner
13
+ from rich.live import Live
14
+ from dotenv import load_dotenv
15
+
16
+ # Load environment variables from .env file
17
+ load_dotenv()
18
+
19
+ from stratifyai import LLMClient, ChatRequest, Message, Router, RoutingStrategy, get_cache_stats
20
+ from stratifyai.caching import get_cache_entries, clear_cache
21
+ from stratifyai.config import MODEL_CATALOG, PROVIDER_ENV_VARS
22
+ from stratifyai.exceptions import InvalidProviderError, InvalidModelError, AuthenticationError
23
+ from stratifyai.summarization import summarize_file
24
+ from stratifyai.utils.file_analyzer import analyze_file
25
+ from pathlib import Path
26
+
27
+ # Initialize Typer app and Rich console
28
+ app = typer.Typer(
29
+ name="stratifyai",
30
+ help="StratifyAI - Unified LLM CLI across 9 providers",
31
+ add_completion=True,
32
+ )
33
+ console = Console()
34
+
35
+
36
+ @app.command()
37
+ def chat(
38
+ message: Optional[str] = typer.Argument(None, help="Message to send to the LLM"),
39
+ provider: Optional[str] = typer.Option(
40
+ None,
41
+ "--provider", "-p",
42
+ envvar="STRATUMAI_PROVIDER",
43
+ help="LLM provider (openai, anthropic, google, deepseek, groq, grok, ollama, openrouter, bedrock)"
44
+ ),
45
+ model: Optional[str] = typer.Option(
46
+ None,
47
+ "--model", "-m",
48
+ envvar="STRATUMAI_MODEL",
49
+ help="Model name"
50
+ ),
51
+ temperature: Optional[float] = typer.Option(
52
+ None,
53
+ "--temperature", "-t",
54
+ min=0.0, max=2.0,
55
+ help="Temperature (0.0-2.0)"
56
+ ),
57
+ max_tokens: Optional[int] = typer.Option(
58
+ None,
59
+ "--max-tokens",
60
+ help="Maximum tokens to generate"
61
+ ),
62
+ stream: bool = typer.Option(
63
+ False,
64
+ "--stream",
65
+ help="Stream response in real-time"
66
+ ),
67
+ system: Optional[str] = typer.Option(
68
+ None,
69
+ "--system", "-s",
70
+ help="System message"
71
+ ),
72
+ file: Optional[Path] = typer.Option(
73
+ None,
74
+ "--file", "-f",
75
+ help="Load content from file",
76
+ exists=True,
77
+ file_okay=True,
78
+ dir_okay=False,
79
+ readable=True
80
+ ),
81
+ cache_control: bool = typer.Option(
82
+ False,
83
+ "--cache-control",
84
+ help="Enable prompt caching (for supported providers)"
85
+ ),
86
+ chunked: bool = typer.Option(
87
+ False,
88
+ "--chunked",
89
+ help="Enable smart chunking and summarization for large files"
90
+ ),
91
+ chunk_size: int = typer.Option(
92
+ 50000,
93
+ "--chunk-size",
94
+ help="Chunk size in characters (default: 50000)"
95
+ ),
96
+ auto_select: bool = typer.Option(
97
+ False,
98
+ "--auto-select",
99
+ help="Automatically select optimal model based on file type"
100
+ ),
101
+ ):
102
+ """Send a chat message to an LLM provider.
103
+
104
+ Note: For multi-turn conversations with context, use 'stratifyai interactive' instead.
105
+ """
106
+ return _chat_impl(message, provider, model, temperature, max_tokens, stream, system, file, cache_control, chunked, chunk_size, auto_select=auto_select)
107
+
108
+
109
+ def _chat_impl(
110
+ message: Optional[str],
111
+ provider: Optional[str],
112
+ model: Optional[str],
113
+ temperature: Optional[float],
114
+ max_tokens: Optional[int],
115
+ stream: bool,
116
+ system: Optional[str],
117
+ file: Optional[Path],
118
+ cache_control: bool,
119
+ chunked: bool = False,
120
+ chunk_size: int = 50000,
121
+ auto_select: bool = False,
122
+ _conversation_history: Optional[List[Message]] = None,
123
+ ):
124
+ """Internal implementation of chat with conversation history support."""
125
+ try:
126
+ # Auto-select model based on file type if enabled
127
+ if auto_select and file and not (provider and model):
128
+ from stratifyai.utils.model_selector import select_model_for_file
129
+
130
+ try:
131
+ auto_provider, auto_model, reasoning = select_model_for_file(file)
132
+ provider = auto_provider
133
+ model = auto_model
134
+ console.print(f"\n[cyan]🤖 Auto-selected:[/cyan] {provider}/{model}")
135
+ console.print(f"[dim] Reason: {reasoning}[/dim]\n")
136
+ except Exception as e:
137
+ console.print(f"[yellow]⚠ Auto-selection failed: {e}[/yellow]")
138
+ console.print("[dim]Falling back to manual selection...[/dim]\n")
139
+
140
+ # Track if we prompted for provider/model to determine if we should prompt for file
141
+ prompted_for_provider = False
142
+ prompted_for_model = False
143
+
144
+ # Interactive prompts if not provided
145
+ if not provider:
146
+ prompted_for_provider = True
147
+ console.print("\n[bold cyan]Select Provider[/bold cyan]")
148
+ providers_list = ["openai", "anthropic", "google", "deepseek", "groq", "grok", "ollama", "openrouter", "bedrock"]
149
+ for i, p in enumerate(providers_list, 1):
150
+ console.print(f" {i}. {p}")
151
+
152
+ # Retry loop for provider selection
153
+ max_attempts = 3
154
+ for attempt in range(max_attempts):
155
+ provider_choice = Prompt.ask("\nChoose provider", default="1")
156
+
157
+ try:
158
+ provider_idx = int(provider_choice) - 1
159
+ if 0 <= provider_idx < len(providers_list):
160
+ provider = providers_list[provider_idx]
161
+ break
162
+ else:
163
+ console.print(f"[red]✗ Invalid number.[/red] Please enter a number between 1 and {len(providers_list)}")
164
+ if attempt < max_attempts - 1:
165
+ console.print("[dim]Try again...[/dim]")
166
+ else:
167
+ console.print("[yellow]Too many invalid attempts. Using default: openai[/yellow]")
168
+ provider = "openai"
169
+ except ValueError:
170
+ console.print(f"[red]✗ Invalid input.[/red] Please enter a number, not letters (e.g., '1' not 'openai')")
171
+ if attempt < max_attempts - 1:
172
+ console.print("[dim]Try again...[/dim]")
173
+ else:
174
+ console.print("[yellow]Too many invalid attempts. Using default: openai[/yellow]")
175
+ provider = "openai"
176
+
177
+ if not model:
178
+ prompted_for_model = True
179
+ # Show available models for selected provider
180
+ if provider in MODEL_CATALOG:
181
+ console.print(f"\n[bold cyan]Available models for {provider}:[/bold cyan]")
182
+ available_models = list(MODEL_CATALOG[provider].keys())
183
+ for i, m in enumerate(available_models, 1):
184
+ model_info = MODEL_CATALOG[provider][m]
185
+ is_reasoning = model_info.get("reasoning_model", False)
186
+ label = f" {i}. {m}"
187
+ if is_reasoning:
188
+ label += " [yellow](reasoning)[/yellow]"
189
+ console.print(label)
190
+
191
+ # Retry loop for model selection
192
+ max_attempts = 3
193
+ model = None
194
+ for attempt in range(max_attempts):
195
+ model_choice = Prompt.ask("\nSelect model")
196
+
197
+ try:
198
+ model_idx = int(model_choice) - 1
199
+ if 0 <= model_idx < len(available_models):
200
+ model = available_models[model_idx]
201
+ break
202
+ else:
203
+ console.print(f"[red]✗ Invalid number.[/red] Please enter a number between 1 and {len(available_models)}")
204
+ if attempt < max_attempts - 1:
205
+ console.print("[dim]Try again...[/dim]")
206
+ except ValueError:
207
+ console.print(f"[red]✗ Invalid input.[/red] Please enter a number, not the model name (e.g., '2' not 'gpt-4o')")
208
+ if attempt < max_attempts - 1:
209
+ console.print("[dim]Try again...[/dim]")
210
+
211
+ # If still no valid model after retries, exit
212
+ if model is None:
213
+ console.print(f"[red]Too many invalid attempts. Exiting.[/red]")
214
+ raise typer.Exit(1)
215
+ else:
216
+ console.print(f"[red]No models found for provider: {provider}[/red]")
217
+ raise typer.Exit(1)
218
+
219
+ # Check if model has fixed temperature
220
+ if temperature is None:
221
+ model_info = MODEL_CATALOG.get(provider, {}).get(model, {})
222
+ fixed_temp = model_info.get("fixed_temperature")
223
+
224
+ if fixed_temp is not None:
225
+ temperature = fixed_temp
226
+ console.print(f"\n[dim]Using fixed temperature: {fixed_temp} for this model[/dim]")
227
+ else:
228
+ # Retry loop for temperature input
229
+ max_attempts = 3
230
+ temperature = None
231
+ for attempt in range(max_attempts):
232
+ temp_input = Prompt.ask(
233
+ "\n[bold cyan]Temperature[/bold cyan] (0.0-2.0, default 0.7)",
234
+ default="0.7"
235
+ )
236
+
237
+ try:
238
+ temp_value = float(temp_input)
239
+ if 0.0 <= temp_value <= 2.0:
240
+ temperature = temp_value
241
+ break
242
+ else:
243
+ console.print("[red]✗ Out of range.[/red] Temperature must be between 0.0 and 2.0")
244
+ if attempt < max_attempts - 1:
245
+ console.print("[dim]Try again...[/dim]")
246
+ except ValueError:
247
+ console.print(f"[red]✗ Invalid input.[/red] Please enter a number (e.g., '0.7' not '{temp_input}')")
248
+ if attempt < max_attempts - 1:
249
+ console.print("[dim]Try again...[/dim]")
250
+
251
+ # If still no valid temperature after retries, use default
252
+ if temperature is None:
253
+ console.print("[yellow]Too many invalid attempts. Using default: 0.7[/yellow]")
254
+ temperature = 0.7
255
+
256
+ # Prompt for file if not provided via flag (only in fully interactive mode and non-follow-up messages)
257
+ # Only prompt if we also prompted for provider AND model (fully interactive)
258
+ if not file and _conversation_history is None and prompted_for_provider and prompted_for_model:
259
+ console.print(f"\n[bold cyan]File Attachment (Optional)[/bold cyan]")
260
+ console.print(f"[dim]Attach a file to include its content in your message[/dim]")
261
+ console.print(f"[dim]Max file size: 5 MB | Leave blank to skip[/dim]")
262
+
263
+ file_path_input = Prompt.ask("\nFile path (or press Enter to skip)", default="")
264
+
265
+ if file_path_input.strip():
266
+ file = Path(file_path_input.strip()).expanduser()
267
+
268
+ # Load content from file if provided
269
+ file_content = None
270
+ if file:
271
+ try:
272
+ with open(file, 'r', encoding='utf-8') as f:
273
+ file_content = f.read()
274
+
275
+ # Get file size for display (only if file exists as Path object)
276
+ try:
277
+ if isinstance(file, Path) and file.exists():
278
+ file_size = file.stat().st_size
279
+ file_size_mb = file_size / (1024 * 1024)
280
+ file_size_kb = file_size / 1024
281
+
282
+ if file_size_kb < 1:
283
+ size_str = f"{file_size} bytes"
284
+ elif file_size_mb < 1:
285
+ size_str = f"{file_size_kb:.1f} KB"
286
+ else:
287
+ size_str = f"{file_size_mb:.2f} MB"
288
+
289
+ console.print(f"[green]✓ Loaded {file.name}[/green] [dim]({size_str}, {len(file_content):,} chars)[/dim]")
290
+
291
+ # Analyze file if chunking enabled
292
+ if chunked:
293
+ analysis = analyze_file(str(file), provider or "openai", model or "gpt-4o")
294
+ console.print(f"[cyan]File Analysis:[/cyan]")
295
+ console.print(f" Type: {analysis.file_type.value}")
296
+ console.print(f" Tokens: ~{analysis.estimated_tokens:,}")
297
+ console.print(f" Recommendation: {analysis.recommendation}")
298
+
299
+ if analysis.warning:
300
+ console.print(f"[yellow]⚠ {analysis.warning}[/yellow]")
301
+ else:
302
+ # Fallback for tests or non-Path objects
303
+ console.print(f"[dim]Loaded content from {file} ({len(file_content)} chars)[/dim]")
304
+ except:
305
+ # Fallback if stat fails (e.g., in test environments)
306
+ console.print(f"[dim]Loaded content from {file} ({len(file_content)} chars)[/dim]")
307
+ except Exception as e:
308
+ console.print(f"[red]Error reading file {file}: {e}[/red]")
309
+ raise typer.Exit(1)
310
+
311
+ if not message and not file_content:
312
+ console.print("\n[bold cyan]Enter your message:[/bold cyan]")
313
+ message = Prompt.ask("Message")
314
+
315
+ # Build messages - use conversation history if this is a follow-up
316
+ if _conversation_history is None:
317
+ messages = []
318
+ if system:
319
+ messages.append(Message(role="system", content=system))
320
+ else:
321
+ messages = _conversation_history.copy()
322
+
323
+ # Add file content or message
324
+ if file_content:
325
+ # Check if chunking is needed
326
+ if chunked:
327
+ console.print(f"\n[cyan]Chunking and summarizing file...[/cyan]")
328
+
329
+ # Create client for summarization
330
+ client = LLMClient(provider=provider)
331
+
332
+ # Summarize file
333
+ result = summarize_file(
334
+ file_content,
335
+ client,
336
+ chunk_size=chunk_size,
337
+ model=model, # Use selected model for summarization
338
+ context=f"Analyzing file: {file.name if isinstance(file, Path) else 'uploaded file'}" if message is None else message,
339
+ show_progress=True
340
+ )
341
+
342
+ # Show reduction stats
343
+ console.print(f"[green]✓ Summarization complete[/green]")
344
+ console.print(f"[dim]Original: {result['original_length']:,} chars | Summary: {result['summary_length']:,} chars | Reduction: {result['reduction_percentage']}%[/dim]")
345
+
346
+ # Use summary as content
347
+ content = f"{message}\n\nFile Summary:\n{result['summary']}" if message else f"File Summary:\n{result['summary']}"
348
+ else:
349
+ # If both file and message provided, combine them
350
+ content = f"{message}\n\n{file_content}" if message else file_content
351
+
352
+ # Add cache control for large content if requested
353
+ if cache_control and len(file_content) > 1000:
354
+ messages.append(Message(
355
+ role="user",
356
+ content=content,
357
+ cache_control={"type": "ephemeral"}
358
+ ))
359
+ else:
360
+ messages.append(Message(role="user", content=content))
361
+ else:
362
+ messages.append(Message(role="user", content=message))
363
+
364
+ # Create client and request
365
+ client = LLMClient(provider=provider)
366
+ request = ChatRequest(
367
+ model=model,
368
+ messages=messages,
369
+ temperature=temperature,
370
+ max_tokens=max_tokens
371
+ )
372
+
373
+ # Execute request
374
+ response_content = ""
375
+
376
+ # Get model info for context window
377
+ model_info = MODEL_CATALOG.get(provider, {}).get(model, {})
378
+ context_window = model_info.get("context", "N/A")
379
+
380
+ if stream:
381
+ # Display metadata before streaming
382
+ console.print(f"\n[bold]Provider:[/bold] [cyan]{provider}[/cyan] | [bold]Model:[/bold] [cyan]{model}[/cyan]")
383
+ console.print(f"[dim]Context: {context_window:,} tokens[/dim]")
384
+ console.print() # Newline before streaming
385
+
386
+ for chunk in client.chat_completion_stream(request):
387
+ print(chunk.content, end="", flush=True)
388
+ response_content += chunk.content
389
+ print() # Final newline
390
+ else:
391
+ # Show spinner while waiting for response
392
+ with console.status("[cyan]Thinking...", spinner="dots"):
393
+ response = client.chat_completion_sync(request)
394
+ response_content = response.content
395
+
396
+ # Display metadata before response
397
+ console.print(f"\n[bold]Provider:[/bold] [cyan]{provider}[/cyan] | [bold]Model:[/bold] [cyan]{model}[/cyan]")
398
+
399
+ # Build usage line with token breakdown and cache info
400
+ usage_parts = [
401
+ f"Context: {context_window:,} tokens",
402
+ f"In: {response.usage.prompt_tokens:,}",
403
+ f"Out: {response.usage.completion_tokens:,}",
404
+ f"Total: {response.usage.total_tokens:,}",
405
+ f"Cost: ${response.usage.cost_usd:.6f}"
406
+ ]
407
+
408
+ # Add latency if available
409
+ if response.latency_ms is not None:
410
+ usage_parts.append(f"Latency: {response.latency_ms:.0f}ms")
411
+
412
+ # Add cache statistics if available
413
+ if response.usage.cached_tokens > 0:
414
+ usage_parts.append(f"Cached: {response.usage.cached_tokens:,}")
415
+ if response.usage.cache_creation_tokens > 0:
416
+ usage_parts.append(f"Cache Write: {response.usage.cache_creation_tokens:,}")
417
+ if response.usage.cache_read_tokens > 0:
418
+ usage_parts.append(f"Cache Read: {response.usage.cache_read_tokens:,}")
419
+
420
+ console.print(f"[dim]{' | '.join(usage_parts)}[/dim]")
421
+
422
+ # Print response with Rich formatting
423
+ console.print(f"\n{response_content}", style="cyan")
424
+
425
+ # Add assistant response to history for multi-turn conversation
426
+ messages.append(Message(role="assistant", content=response_content))
427
+
428
+ # Ask what to do next
429
+ console.print("\n[dim]Options: [1] Continue conversation [2] Save & continue [3] Save & exit [4] Exit[/dim]")
430
+ next_action = Prompt.ask("What would you like to do?", choices=["1", "2", "3", "4"], default="1")
431
+
432
+ # Handle save requests
433
+ if next_action in ["2", "3"]:
434
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
435
+ default_filename = f"response_{timestamp}.md"
436
+
437
+ filename = Prompt.ask("\nFilename", default=default_filename)
438
+
439
+ # Ensure .md extension
440
+ if not filename.endswith(".md"):
441
+ filename += ".md"
442
+
443
+ try:
444
+ with open(filename, "w") as f:
445
+ f.write(f"# LLM Response\n\n")
446
+ f.write(f"**Provider:** {provider}\n")
447
+ f.write(f"**Model:** {model}\n")
448
+ f.write(f"**Timestamp:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
449
+ f.write(f"## Conversation\n\n")
450
+
451
+ # Write full conversation history
452
+ for msg in messages:
453
+ if msg.role == "user":
454
+ f.write(f"**You:** {msg.content}\n\n")
455
+ elif msg.role == "assistant":
456
+ f.write(f"**Assistant:** {msg.content}\n\n")
457
+
458
+ console.print(f"[green]✓ Saved to {filename}[/green]")
459
+ except Exception as e:
460
+ console.print(f"[red]Failed to save: {e}[/red]")
461
+
462
+ # Exit if requested
463
+ if next_action in ["3", "4"]:
464
+ console.print("[dim]Goodbye![/dim]")
465
+ return
466
+
467
+ # Continue conversation (options "1" or "2")
468
+ # Suggest interactive mode for better UX
469
+ if _conversation_history is None and len(messages) > 2:
470
+ console.print("\n[dim]Tip: Use 'stratifyai interactive' for a better multi-turn conversation experience[/dim]")
471
+
472
+ # Recursive call with conversation history
473
+ _chat_impl(None, provider, model, temperature, max_tokens, stream, None, None, False, chunked, chunk_size, False, messages)
474
+
475
+ except InvalidProviderError as e:
476
+ console.print(f"[red]Invalid provider:[/red] {e}")
477
+ raise typer.Exit(1)
478
+ except InvalidModelError as e:
479
+ console.print(f"[red]Invalid model:[/red] {e}")
480
+ raise typer.Exit(1)
481
+ except AuthenticationError as e:
482
+ console.print(f"\n[red]✗ Authentication Failed[/red]")
483
+ console.print(f"[yellow]Provider:[/yellow] {e.provider}")
484
+ console.print(f"[yellow]Issue:[/yellow] API key is missing or invalid\n")
485
+
486
+ # Get environment variable name for the provider
487
+ env_var = PROVIDER_ENV_VARS.get(e.provider, f"{e.provider.upper()}_API_KEY")
488
+
489
+ console.print("[bold cyan]How to fix:[/bold cyan]")
490
+ console.print(f" 1. Set the environment variable: [green]{env_var}[/green]")
491
+ console.print(f" export {env_var}=\"your-api-key-here\"")
492
+ console.print(f"\n 2. Or add to your [green].env[/green] file in the project root:")
493
+ console.print(f" {env_var}=your-api-key-here\n")
494
+
495
+ # Provider-specific instructions
496
+ if e.provider == "openai":
497
+ console.print("[dim]Get your API key from: https://platform.openai.com/api-keys[/dim]")
498
+ elif e.provider == "anthropic":
499
+ console.print("[dim]Get your API key from: https://console.anthropic.com/settings/keys[/dim]")
500
+ elif e.provider == "google":
501
+ console.print("[dim]Get your API key from: https://aistudio.google.com/app/apikey[/dim]")
502
+ elif e.provider == "deepseek":
503
+ console.print("[dim]Get your API key from: https://platform.deepseek.com/api_keys[/dim]")
504
+ elif e.provider == "groq":
505
+ console.print("[dim]Get your API key from: https://console.groq.com/keys[/dim]")
506
+ elif e.provider == "grok":
507
+ console.print("[dim]Get your API key from: https://console.x.ai/[/dim]")
508
+ elif e.provider == "openrouter":
509
+ console.print("[dim]Get your API key from: https://openrouter.ai/keys[/dim]")
510
+ elif e.provider == "ollama":
511
+ console.print("[dim]Ensure Ollama is running: ollama serve[/dim]")
512
+
513
+ raise typer.Exit(1)
514
+ except Exception as e:
515
+ console.print(f"[red]Error:[/red] {e}")
516
+ raise typer.Exit(1)
517
+
518
+
519
+ @app.command()
520
+ def models(
521
+ provider: Optional[str] = typer.Option(
522
+ None,
523
+ "--provider", "-p",
524
+ help="Filter by provider"
525
+ ),
526
+ ):
527
+ """List available models."""
528
+
529
+ try:
530
+ # Create table
531
+ table = Table(title="Available Models", show_header=True, header_style="bold magenta")
532
+ table.add_column("Provider", style="cyan", width=12)
533
+ table.add_column("Model", style="green", width=40)
534
+ table.add_column("Context", justify="right", style="yellow", width=10)
535
+
536
+ # Populate table
537
+ total_models = 0
538
+ for prov_name, models_dict in MODEL_CATALOG.items():
539
+ if provider and prov_name != provider:
540
+ continue
541
+
542
+ for model_name, model_info in models_dict.items():
543
+ context = model_info.get("context", 0)
544
+ table.add_row(
545
+ prov_name,
546
+ model_name,
547
+ f"{context:,}" if context else "N/A"
548
+ )
549
+ total_models += 1
550
+
551
+ # Display table
552
+ console.print(table)
553
+ console.print(f"\n[dim]Total: {total_models} models[/dim]")
554
+
555
+ except Exception as e:
556
+ console.print(f"[red]Error:[/red] {e}")
557
+ raise typer.Exit(1)
558
+
559
+
560
+ @app.command()
561
+ def providers():
562
+ """List all available providers."""
563
+
564
+ try:
565
+ # Create table
566
+ table = Table(title="Available Providers", show_header=True, header_style="bold magenta")
567
+ table.add_column("Provider", style="cyan", width=15)
568
+ table.add_column("Models", justify="right", style="green", width=10)
569
+ table.add_column("Example Model", style="yellow", width=40)
570
+
571
+ # Populate table
572
+ for prov_name, models_dict in MODEL_CATALOG.items():
573
+ example_model = list(models_dict.keys())[0] if models_dict else "N/A"
574
+ table.add_row(
575
+ prov_name,
576
+ str(len(models_dict)),
577
+ example_model
578
+ )
579
+
580
+ # Display table
581
+ console.print(table)
582
+ console.print(f"\n[dim]Total: {len(MODEL_CATALOG)} providers[/dim]")
583
+
584
+ except Exception as e:
585
+ console.print(f"[red]Error:[/red] {e}")
586
+ raise typer.Exit(1)
587
+
588
+
589
+ @app.command()
590
+ def route(
591
+ message: str = typer.Argument(..., help="Message to analyze for routing"),
592
+ strategy: str = typer.Option(
593
+ "hybrid",
594
+ "--strategy", "-s",
595
+ help="Routing strategy (cost, quality, latency, hybrid)"
596
+ ),
597
+ execute: bool = typer.Option(
598
+ False,
599
+ "--execute", "-e",
600
+ help="Execute with selected model"
601
+ ),
602
+ max_cost: Optional[float] = typer.Option(
603
+ None,
604
+ "--max-cost",
605
+ help="Maximum cost per 1K tokens"
606
+ ),
607
+ max_latency: Optional[int] = typer.Option(
608
+ None,
609
+ "--max-latency",
610
+ help="Maximum latency in milliseconds"
611
+ ),
612
+ capability: Optional[List[str]] = typer.Option(
613
+ None,
614
+ "--capability", "-c",
615
+ help="Required capability (vision, tools, reasoning). Can be specified multiple times."
616
+ ),
617
+ ):
618
+ """Auto-select best model using router."""
619
+
620
+ try:
621
+ # Map strategy string to enum
622
+ strategy_map = {
623
+ 'cost': RoutingStrategy.COST,
624
+ 'quality': RoutingStrategy.QUALITY,
625
+ 'latency': RoutingStrategy.LATENCY,
626
+ 'hybrid': RoutingStrategy.HYBRID,
627
+ }
628
+
629
+ if strategy not in strategy_map:
630
+ console.print(f"[red]Invalid strategy:[/red] {strategy}. Use: cost, quality, latency, or hybrid")
631
+ raise typer.Exit(1)
632
+
633
+ # Create router and route
634
+ router = Router(
635
+ strategy=strategy_map[strategy],
636
+ excluded_providers=['ollama'] # Exclude local models by default
637
+ )
638
+
639
+ messages = [Message(role="user", content=message)]
640
+
641
+ # Validate capabilities
642
+ valid_capabilities = ["vision", "tools", "reasoning"]
643
+ if capability:
644
+ for cap in capability:
645
+ if cap not in valid_capabilities:
646
+ console.print(f"[red]Invalid capability:[/red] {cap}. Use: vision, tools, or reasoning")
647
+ raise typer.Exit(1)
648
+
649
+ # Route with constraints
650
+ provider, model = router.route(
651
+ messages,
652
+ required_capabilities=capability,
653
+ max_cost_per_1k_tokens=max_cost,
654
+ max_latency_ms=max_latency
655
+ )
656
+
657
+ # Get complexity and model info
658
+ complexity = router._analyze_complexity(messages)
659
+ model_info = router.get_model_info(provider, model)
660
+
661
+ # Display routing decision
662
+ console.print(f"\n[bold]Routing Decision[/bold]")
663
+ console.print(f"Strategy: [cyan]{strategy}[/cyan]")
664
+ if capability:
665
+ console.print(f"Required: [magenta]{', '.join(capability)}[/magenta]")
666
+ console.print(f"Complexity: [yellow]{complexity:.3f}[/yellow]")
667
+ console.print(f"Selected: [green]{provider}/{model}[/green]")
668
+ if model_info.capabilities:
669
+ console.print(f"Capabilities: [magenta]{', '.join(model_info.capabilities)}[/magenta]")
670
+ console.print(f"Quality: [yellow]{model_info.quality_score:.2f}[/yellow]")
671
+ console.print(f"Latency: [yellow]{model_info.avg_latency_ms:.0f}ms[/yellow]")
672
+
673
+ # Execute if requested
674
+ if execute or Confirm.ask("\nExecute with this model?", default=True):
675
+ client = LLMClient(provider=provider)
676
+ request = ChatRequest(model=model, messages=messages)
677
+
678
+ # Show spinner while waiting for response
679
+ with console.status("[cyan]Thinking...", spinner="dots"):
680
+ response = client.chat_completion_sync(request)
681
+
682
+ # Get model info for context window
683
+ route_model_info = MODEL_CATALOG.get(provider, {}).get(model, {})
684
+ route_context = route_model_info.get("context", "N/A")
685
+
686
+ console.print(f"\n[bold]Provider:[/bold] [cyan]{provider}[/cyan] | [bold]Model:[/bold] [cyan]{model}[/cyan]")
687
+ console.print(f"[dim]Context: {route_context:,} tokens | Tokens: {response.usage.total_tokens} | Cost: ${response.usage.cost_usd:.6f}[/dim]")
688
+ console.print(f"\n{response.content}", style="cyan")
689
+
690
+ except Exception as e:
691
+ console.print(f"[red]Error:[/red] {e}")
692
+ raise typer.Exit(1)
693
+
694
+
695
+ @app.command()
696
+ def interactive(
697
+ provider: Optional[str] = typer.Option(
698
+ None,
699
+ "--provider", "-p",
700
+ envvar="STRATUMAI_PROVIDER",
701
+ help="LLM provider"
702
+ ),
703
+ model: Optional[str] = typer.Option(
704
+ None,
705
+ "--model", "-m",
706
+ envvar="STRATUMAI_MODEL",
707
+ help="Model name"
708
+ ),
709
+ file: Optional[Path] = typer.Option(
710
+ None,
711
+ "--file", "-f",
712
+ help="Load initial context from file",
713
+ exists=True,
714
+ file_okay=True,
715
+ dir_okay=False,
716
+ readable=True
717
+ ),
718
+ ):
719
+ """Start interactive chat session."""
720
+
721
+ # File upload constraints
722
+ MAX_FILE_SIZE_MB = 5
723
+ MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
724
+ LARGE_FILE_THRESHOLD_KB = 500
725
+
726
+ try:
727
+ # Helper function to load file with size validation and intelligent extraction
728
+ def load_file_content(file_path: Path, warn_large: bool = True) -> Optional[str]:
729
+ """Load file content with size restrictions, warnings, and intelligent extraction."""
730
+ try:
731
+ # Check if file exists
732
+ if not file_path.exists():
733
+ console.print(f"[red]✗ File not found: {file_path}[/red]")
734
+ return None
735
+
736
+ # Check file size
737
+ file_size = file_path.stat().st_size
738
+ file_size_mb = file_size / (1024 * 1024)
739
+ file_size_kb = file_size / 1024
740
+
741
+ # Enforce size limit
742
+ if file_size > MAX_FILE_SIZE_BYTES:
743
+ console.print(f"[red]✗ File too large: {file_size_mb:.2f} MB (max {MAX_FILE_SIZE_MB} MB)[/red]")
744
+ console.print(f"[yellow]⚠ Large files consume significant tokens and may exceed model context limits[/yellow]")
745
+ return None
746
+
747
+ # Check if file type supports intelligent extraction
748
+ extension = file_path.suffix.lower()
749
+ supports_extraction = extension in ['.csv', '.json', '.log', '.py'] or 'log' in file_path.name.lower()
750
+
751
+ # For large files that support extraction, offer schema extraction
752
+ if supports_extraction and file_size > (LARGE_FILE_THRESHOLD_KB * 1024):
753
+ console.print(f"[cyan]💡 Large {extension} file detected: {file_size_kb:.1f} KB[/cyan]")
754
+ console.print("[cyan]This file supports intelligent extraction for efficient LLM processing[/cyan]")
755
+
756
+ use_extraction = Confirm.ask(
757
+ "Extract schema/structure instead of loading full file? (Recommended)",
758
+ default=True
759
+ )
760
+
761
+ if use_extraction:
762
+ try:
763
+ from stratifyai.utils.csv_extractor import analyze_csv_file
764
+ from stratifyai.utils.json_extractor import analyze_json_file
765
+ from stratifyai.utils.log_extractor import extract_log_summary
766
+ from stratifyai.utils.code_extractor import analyze_code_file
767
+
768
+ if extension == '.csv':
769
+ result = analyze_csv_file(file_path)
770
+ content = result['schema_text']
771
+ reduction = result['token_reduction_pct']
772
+ console.print(f"[green]✓ Extracted CSV schema[/green] [dim]({reduction:.1f}% token reduction)[/dim]")
773
+ elif extension == '.json':
774
+ result = analyze_json_file(file_path)
775
+ content = result.get('schema_text', str(result))
776
+ reduction = result.get('token_reduction_pct', 0)
777
+ console.print(f"[green]✓ Extracted JSON schema[/green] [dim]({reduction:.1f}% token reduction)[/dim]")
778
+ elif extension in ['.log', '.txt'] and 'log' in file_path.name.lower():
779
+ result = extract_log_summary(file_path)
780
+ content = result['summary_text']
781
+ reduction = result['token_reduction_pct']
782
+ console.print(f"[green]✓ Extracted log summary[/green] [dim]({reduction:.1f}% token reduction)[/dim]")
783
+ elif extension == '.py':
784
+ result = analyze_code_file(file_path)
785
+ content = result['structure_text']
786
+ reduction = result['token_reduction_pct']
787
+ console.print(f"[green]✓ Extracted code structure[/green] [dim]({reduction:.1f}% token reduction)[/dim]")
788
+ else:
789
+ # Fallback to raw content
790
+ with open(file_path, 'r', encoding='utf-8') as f:
791
+ content = f.read()
792
+ console.print(f"[green]✓ Loaded {file_path.name}[/green] [dim]({file_size_kb:.1f} KB, {len(content):,} chars)[/dim]")
793
+
794
+ return content
795
+ except Exception as e:
796
+ console.print(f"[yellow]⚠ Extraction failed: {e}[/yellow]")
797
+ console.print("[dim]Falling back to loading full file...[/dim]")
798
+ # Fall through to normal loading
799
+
800
+ # Warn about large files (if not using extraction)
801
+ if warn_large and file_size > (LARGE_FILE_THRESHOLD_KB * 1024):
802
+ console.print(f"[yellow]⚠ Large file detected: {file_size_kb:.1f} KB[/yellow]")
803
+ console.print(f"[yellow]⚠ This will consume substantial tokens and may incur significant costs[/yellow]")
804
+
805
+ if not Confirm.ask("Continue loading full file?", default=False):
806
+ console.print("[dim]File load cancelled[/dim]")
807
+ return None
808
+
809
+ # Read file content normally
810
+ with open(file_path, 'r', encoding='utf-8') as f:
811
+ content = f.read()
812
+
813
+ # Display success message
814
+ if file_size_kb < 1:
815
+ size_str = f"{file_size} bytes"
816
+ elif file_size_mb < 1:
817
+ size_str = f"{file_size_kb:.1f} KB"
818
+ else:
819
+ size_str = f"{file_size_mb:.2f} MB"
820
+
821
+ console.print(f"[green]✓ Loaded {file_path.name}[/green] [dim]({size_str}, {len(content):,} chars)[/dim]")
822
+ return content
823
+
824
+ except UnicodeDecodeError:
825
+ console.print(f"[red]✗ Cannot read file: {file_path.name} (not a text file)[/red]")
826
+ return None
827
+ except Exception as e:
828
+ console.print(f"[red]✗ Error reading file: {e}[/red]")
829
+ return None
830
+
831
+ # Prompt for provider and model if not provided
832
+ if not provider:
833
+ console.print("\n[bold cyan]Select Provider[/bold cyan]")
834
+ providers_list = ["openai", "anthropic", "google", "deepseek", "groq", "grok", "ollama", "openrouter", "bedrock"]
835
+ for i, p in enumerate(providers_list, 1):
836
+ console.print(f" {i}. {p}")
837
+
838
+ # Retry loop for provider selection
839
+ max_attempts = 3
840
+ for attempt in range(max_attempts):
841
+ provider_choice = Prompt.ask("\nChoose provider", default="1")
842
+
843
+ try:
844
+ provider_idx = int(provider_choice) - 1
845
+ if 0 <= provider_idx < len(providers_list):
846
+ provider = providers_list[provider_idx]
847
+ break
848
+ else:
849
+ console.print(f"[red]✗ Invalid number.[/red] Please enter a number between 1 and {len(providers_list)}")
850
+ if attempt < max_attempts - 1:
851
+ console.print("[dim]Try again...[/dim]")
852
+ else:
853
+ console.print("[yellow]Too many invalid attempts. Using default: openai[/yellow]")
854
+ provider = "openai"
855
+ except ValueError:
856
+ console.print(f"[red]✗ Invalid input.[/red] Please enter a number, not letters (e.g., '1' not 'openai')")
857
+ if attempt < max_attempts - 1:
858
+ console.print("[dim]Try again...[/dim]")
859
+ else:
860
+ console.print("[yellow]Too many invalid attempts. Using default: openai[/yellow]")
861
+ provider = "openai"
862
+
863
+ if not model:
864
+ # Validate and display curated models for all providers
865
+ from stratifyai.utils.provider_validator import get_validated_interactive_models
866
+
867
+ # Show spinner while validating
868
+ with console.status(f"[cyan]Validating {provider} models...", spinner="dots"):
869
+ validation_data = get_validated_interactive_models(provider)
870
+
871
+ validation_result = validation_data["validation_result"]
872
+ validated_models = validation_data["models"]
873
+
874
+ # Show validation result
875
+ if validation_result["error"]:
876
+ console.print(f"[yellow]⚠ {validation_result['error']}[/yellow]")
877
+ console.print("[dim]Showing curated models (availability not confirmed)[/dim]")
878
+ else:
879
+ console.print(f"[green]✓ Validated {len(validated_models)} models[/green] [dim]({validation_result['validation_time_ms']}ms)[/dim]")
880
+
881
+ # Show any unavailable models
882
+ if validation_result["invalid_models"]:
883
+ invalid_display = []
884
+ for inv_model in validation_result["invalid_models"]:
885
+ # Get display name if available
886
+ invalid_display.append(inv_model.split("/")[-1].split(":")[0])
887
+ console.print(f"[yellow]⚠ Unavailable: {', '.join(invalid_display)}[/yellow]")
888
+
889
+ # Build display list
890
+ console.print(f"\n[bold cyan]Available {provider} models:[/bold cyan]")
891
+
892
+ # Get interactive models config for fallback
893
+ from stratifyai.config import (
894
+ INTERACTIVE_OPENAI_MODELS, INTERACTIVE_ANTHROPIC_MODELS,
895
+ INTERACTIVE_GOOGLE_MODELS, INTERACTIVE_DEEPSEEK_MODELS,
896
+ INTERACTIVE_GROQ_MODELS, INTERACTIVE_GROK_MODELS,
897
+ INTERACTIVE_OPENROUTER_MODELS, INTERACTIVE_OLLAMA_MODELS,
898
+ INTERACTIVE_BEDROCK_MODELS,
899
+ )
900
+
901
+ interactive_configs = {
902
+ "openai": INTERACTIVE_OPENAI_MODELS,
903
+ "anthropic": INTERACTIVE_ANTHROPIC_MODELS,
904
+ "google": INTERACTIVE_GOOGLE_MODELS,
905
+ "deepseek": INTERACTIVE_DEEPSEEK_MODELS,
906
+ "groq": INTERACTIVE_GROQ_MODELS,
907
+ "grok": INTERACTIVE_GROK_MODELS,
908
+ "openrouter": INTERACTIVE_OPENROUTER_MODELS,
909
+ "ollama": INTERACTIVE_OLLAMA_MODELS,
910
+ "bedrock": INTERACTIVE_BEDROCK_MODELS,
911
+ }
912
+
913
+ fallback_config = interactive_configs.get(provider, {})
914
+
915
+ # Use validated models, or fall back to interactive config
916
+ if validated_models:
917
+ available_models = list(validated_models.keys())
918
+ model_metadata = validated_models
919
+ elif fallback_config:
920
+ available_models = list(fallback_config.keys())
921
+ model_metadata = fallback_config
922
+ else:
923
+ console.print(f"[red]No models configured for provider: {provider}[/red]")
924
+ raise typer.Exit(1)
925
+
926
+ # Display with friendly names and descriptions
927
+ current_category = None
928
+ for i, m in enumerate(available_models, 1):
929
+ meta = model_metadata.get(m, {})
930
+ display_name = meta.get("display_name", m)
931
+ description = meta.get("description", "")
932
+ category = meta.get("category", "")
933
+
934
+ # Show category header if changed
935
+ if category and category != current_category:
936
+ console.print(f" [dim]── {category} ──[/dim]")
937
+ current_category = category
938
+
939
+ label = f" {i}. {display_name}"
940
+ if description:
941
+ label += f" [dim]- {description}[/dim]"
942
+ console.print(label)
943
+
944
+ # Retry loop for model selection (shared by all providers)
945
+ max_attempts = 3
946
+ model = None
947
+ for attempt in range(max_attempts):
948
+ model_choice = Prompt.ask("\nSelect model")
949
+
950
+ try:
951
+ model_idx = int(model_choice) - 1
952
+ if 0 <= model_idx < len(available_models):
953
+ model = available_models[model_idx]
954
+ break
955
+ else:
956
+ console.print(f"[red]✗ Invalid number.[/red] Please enter a number between 1 and {len(available_models)}")
957
+ if attempt < max_attempts - 1:
958
+ console.print("[dim]Try again...[/dim]")
959
+ except ValueError:
960
+ console.print(f"[red]✗ Invalid input.[/red] Please enter a number, not the model name (e.g., '2' not 'gpt-4o')")
961
+ if attempt < max_attempts - 1:
962
+ console.print("[dim]Try again...[/dim]")
963
+
964
+ # If still no valid model after retries, exit
965
+ if model is None:
966
+ console.print(f"[red]Too many invalid attempts. Exiting.[/red]")
967
+ raise typer.Exit(1)
968
+
969
+ # Initialize client
970
+ client = LLMClient(provider=provider)
971
+ messages: List[Message] = []
972
+
973
+ # Get model info for context window
974
+ model_info = MODEL_CATALOG.get(provider, {}).get(model, {})
975
+ context_window = model_info.get("context", "N/A")
976
+
977
+ # Set conversation history limit (reserve 80% for history, 20% for response)
978
+ # Use api_max_input if available (API limit), otherwise use context window
979
+ api_max_input = model_info.get("api_max_input")
980
+ if api_max_input and isinstance(api_max_input, int) and api_max_input > 0:
981
+ # Use API input limit (e.g., Anthropic's 200k limit for Claude Opus 4.5)
982
+ max_history_tokens = int(api_max_input * 0.8)
983
+ elif isinstance(context_window, int) and context_window > 0:
984
+ # Fall back to context window
985
+ max_history_tokens = int(context_window * 0.8)
986
+ else:
987
+ # Default to 128k context window if unknown
988
+ max_history_tokens = int(128000 * 0.8)
989
+
990
+ # Prompt for initial file if not provided via flag
991
+ if not file:
992
+ console.print(f"\n[bold cyan]Initial File Context (Optional)[/bold cyan]")
993
+ console.print(f"[dim]Load a file to provide context for the conversation[/dim]")
994
+ console.print(f"[dim]Max file size: {MAX_FILE_SIZE_MB} MB | Leave blank to skip[/dim]")
995
+
996
+ file_path_input = Prompt.ask("\nFile path (or press Enter to skip)", default="")
997
+
998
+ if file_path_input.strip():
999
+ file = Path(file_path_input.strip()).expanduser()
1000
+
1001
+ # Load initial file if provided
1002
+ if file:
1003
+ console.print(f"\n[bold cyan]Loading initial context...[/bold cyan]")
1004
+ file_content = load_file_content(file, warn_large=True)
1005
+ if file_content:
1006
+ messages.append(Message(
1007
+ role="user",
1008
+ content=f"[Context from {file.name}]\n\n{file_content}"
1009
+ ))
1010
+ console.print(f"[dim]File loaded as initial context[/dim]")
1011
+
1012
+ # Welcome message
1013
+ console.print(f"\n[bold green]StratifyAI Interactive Mode[/bold green]")
1014
+
1015
+ # Display context info with API limit warning if applicable
1016
+ if api_max_input and api_max_input < context_window:
1017
+ console.print(f"Provider: [cyan]{provider}[/cyan] | Model: [cyan]{model}[/cyan] | Context: [cyan]{context_window:,} tokens[/cyan] [yellow](API limit: {api_max_input:,})[/yellow]")
1018
+ else:
1019
+ console.print(f"Provider: [cyan]{provider}[/cyan] | Model: [cyan]{model}[/cyan] | Context: [cyan]{context_window:,} tokens[/cyan]")
1020
+
1021
+ console.print("[dim]Commands: /file <path> | /attach <path> | /clear | /save [path] | /provider | /help | exit[/dim]")
1022
+ console.print(f"[dim]File size limit: {MAX_FILE_SIZE_MB} MB | Ctrl+C to exit[/dim]\n")
1023
+
1024
+ # Conversation loop
1025
+ staged_file_content = None # For /attach command
1026
+ staged_file_name = None
1027
+ last_response = None # Track last assistant response for /save command
1028
+
1029
+ while True:
1030
+ # Show staged file indicator
1031
+ prompt_text = "[bold blue]You[/bold blue]"
1032
+ if staged_file_content:
1033
+ prompt_text = f"[bold blue]You[/bold blue] [dim]📎 {staged_file_name}[/dim]"
1034
+
1035
+ # Get user input
1036
+ try:
1037
+ user_input = Prompt.ask(prompt_text)
1038
+ except (KeyboardInterrupt, EOFError):
1039
+ console.print("\n[dim]Exiting...[/dim]")
1040
+ break
1041
+
1042
+ # Check for exit commands
1043
+ if user_input.lower() in ['exit', 'quit', 'q']:
1044
+ console.print("[dim]Goodbye![/dim]")
1045
+ break
1046
+
1047
+ # Handle special commands
1048
+ if user_input.startswith('/file '):
1049
+ # Load and send file immediately
1050
+ file_path_str = user_input[6:].strip()
1051
+ file_path = Path(file_path_str).expanduser()
1052
+
1053
+ file_content = load_file_content(file_path, warn_large=True)
1054
+ if file_content:
1055
+ # Send file content as user message
1056
+ user_input = f"[File: {file_path.name}]\n\n{file_content}"
1057
+ messages.append(Message(role="user", content=user_input))
1058
+ else:
1059
+ continue # Error already displayed, skip to next input
1060
+
1061
+ elif user_input.startswith('/attach '):
1062
+ # Stage file for next message
1063
+ file_path_str = user_input[8:].strip()
1064
+ file_path = Path(file_path_str).expanduser()
1065
+
1066
+ file_content = load_file_content(file_path, warn_large=True)
1067
+ if file_content:
1068
+ staged_file_content = file_content
1069
+ staged_file_name = file_path.name
1070
+ console.print(f"[green]✓ File staged[/green] [dim]- will be attached to your next message[/dim]")
1071
+ continue
1072
+
1073
+ elif user_input.lower() == '/clear':
1074
+ # Clear staged attachment
1075
+ if staged_file_content:
1076
+ console.print(f"[yellow]Cleared staged file: {staged_file_name}[/yellow]")
1077
+ staged_file_content = None
1078
+ staged_file_name = None
1079
+ else:
1080
+ console.print("[dim]No staged files to clear[/dim]")
1081
+ continue
1082
+
1083
+ elif user_input.startswith('/save'):
1084
+ # Save last response to file
1085
+ if last_response is None:
1086
+ console.print("[yellow]⚠ No response to save yet[/yellow]")
1087
+ console.print("[dim]Send a message first to get a response, then use /save[/dim]")
1088
+ continue
1089
+
1090
+ # Parse filename from command or prompt for it
1091
+ parts = user_input.split(maxsplit=1)
1092
+ if len(parts) > 1:
1093
+ save_path = Path(parts[1].strip()).expanduser()
1094
+ else:
1095
+ # Prompt for filename
1096
+ default_name = f"response_{provider}_{model.split('-')[0]}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
1097
+ filename = Prompt.ask("Save as", default=default_name)
1098
+ save_path = Path(filename).expanduser()
1099
+
1100
+ try:
1101
+ # Ensure parent directory exists
1102
+ save_path.parent.mkdir(parents=True, exist_ok=True)
1103
+
1104
+ # Prepare content with metadata
1105
+ content = f"""# AI Response
1106
+
1107
+ **Provider:** {provider}
1108
+ **Model:** {model}
1109
+ **Timestamp:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
1110
+ **Tokens:** {last_response.usage.total_tokens:,} (In: {last_response.usage.prompt_tokens:,}, Out: {last_response.usage.completion_tokens:,})
1111
+ **Cost:** ${last_response.usage.cost_usd:.6f}
1112
+
1113
+ ---
1114
+
1115
+ {last_response.content}
1116
+ """
1117
+
1118
+ # Write to file
1119
+ with open(save_path, 'w', encoding='utf-8') as f:
1120
+ f.write(content)
1121
+
1122
+ file_size = save_path.stat().st_size
1123
+ console.print(f"[green]✓ Response saved to:[/green] {save_path}")
1124
+ console.print(f"[dim] Size: {file_size:,} bytes ({len(last_response.content):,} chars)[/dim]")
1125
+
1126
+ except Exception as e:
1127
+ console.print(f"[red]✗ Error saving file: {e}[/red]")
1128
+
1129
+ continue
1130
+
1131
+ elif user_input.lower() == '/provider':
1132
+ # Switch provider and model
1133
+ console.print("\n[bold cyan]Switch Provider and Model[/bold cyan]")
1134
+ console.print("[dim]Your conversation history will be preserved[/dim]\n")
1135
+
1136
+ # Show available providers
1137
+ console.print("[bold cyan]Available providers:[/bold cyan]")
1138
+ providers_list = list(MODEL_CATALOG.keys())
1139
+ for i, p in enumerate(providers_list, 1):
1140
+ current_marker = " [green](current)[/green]" if p == provider else ""
1141
+ console.print(f" {i}. {p}{current_marker}")
1142
+
1143
+ # Get provider selection
1144
+ max_attempts = 3
1145
+ new_provider = None
1146
+ for attempt in range(max_attempts):
1147
+ provider_choice = Prompt.ask("\nSelect provider")
1148
+ try:
1149
+ provider_idx = int(provider_choice) - 1
1150
+ if 0 <= provider_idx < len(providers_list):
1151
+ new_provider = providers_list[provider_idx]
1152
+ break
1153
+ else:
1154
+ console.print(f"[red]✗ Invalid number.[/red] Please enter a number between 1 and {len(providers_list)}")
1155
+ if attempt < max_attempts - 1:
1156
+ console.print("[dim]Try again...[/dim]")
1157
+ except ValueError:
1158
+ console.print(f"[red]✗ Invalid input.[/red] Please enter a number")
1159
+ if attempt < max_attempts - 1:
1160
+ console.print("[dim]Try again...[/dim]")
1161
+
1162
+ if new_provider is None:
1163
+ console.print("[yellow]Provider not changed[/yellow]")
1164
+ continue
1165
+
1166
+ # Validate and display ALL models for the selected provider
1167
+ from stratifyai.utils.provider_validator import get_validated_interactive_models
1168
+
1169
+ with console.status(f"[cyan]Validating {new_provider} models...", spinner="dots"):
1170
+ validation_data = get_validated_interactive_models(new_provider, all_models=True)
1171
+
1172
+ validation_result = validation_data["validation_result"]
1173
+ validated_models = validation_data["models"]
1174
+
1175
+ # Show validation result
1176
+ if validation_result["error"]:
1177
+ console.print(f"[yellow]⚠ {validation_result['error']}[/yellow]")
1178
+ console.print("[dim]Showing all models (availability not confirmed)[/dim]")
1179
+ # Fall back to MODEL_CATALOG if validation fails
1180
+ available_models = list(MODEL_CATALOG[new_provider].keys())
1181
+ model_metadata = MODEL_CATALOG[new_provider]
1182
+ else:
1183
+ console.print(f"[green]✓ Validated {len(validated_models)} models[/green] [dim]({validation_result['validation_time_ms']}ms)[/dim]")
1184
+ available_models = list(validated_models.keys())
1185
+ model_metadata = validated_models
1186
+
1187
+ # Show available models for new provider
1188
+ console.print(f"\n[bold cyan]Current valid models for {new_provider}:[/bold cyan]")
1189
+ for i, m in enumerate(available_models, 1):
1190
+ meta = model_metadata.get(m, {})
1191
+ is_reasoning = meta.get("reasoning_model", False)
1192
+ current_marker = " [green](current)[/green]" if m == model and new_provider == provider else ""
1193
+ label = f" {i}. {m}{current_marker}"
1194
+ if is_reasoning:
1195
+ label += " [yellow](reasoning)[/yellow]"
1196
+ console.print(label)
1197
+
1198
+ # Get model selection
1199
+ new_model = None
1200
+ for attempt in range(max_attempts):
1201
+ model_choice = Prompt.ask("\nSelect model")
1202
+ try:
1203
+ model_idx = int(model_choice) - 1
1204
+ if 0 <= model_idx < len(available_models):
1205
+ new_model = available_models[model_idx]
1206
+ break
1207
+ else:
1208
+ console.print(f"[red]✗ Invalid number.[/red] Please enter a number between 1 and {len(available_models)}")
1209
+ if attempt < max_attempts - 1:
1210
+ console.print("[dim]Try again...[/dim]")
1211
+ except ValueError:
1212
+ console.print(f"[red]✗ Invalid input.[/red] Please enter a number")
1213
+ if attempt < max_attempts - 1:
1214
+ console.print("[dim]Try again...[/dim]")
1215
+
1216
+ if new_model is None:
1217
+ console.print("[yellow]Provider and model not changed[/yellow]")
1218
+ continue
1219
+
1220
+ # Update provider and model
1221
+ provider = new_provider
1222
+ model = new_model
1223
+ client = LLMClient(provider=provider) # Reinitialize client
1224
+
1225
+ # Update context window info
1226
+ model_info = MODEL_CATALOG.get(provider, {}).get(model, {})
1227
+ context_window = model_info.get("context", "N/A")
1228
+ api_max_input = model_info.get("api_max_input")
1229
+
1230
+ # Update history limit
1231
+ if api_max_input and isinstance(api_max_input, int) and api_max_input > 0:
1232
+ max_history_tokens = int(api_max_input * 0.8)
1233
+ elif isinstance(context_window, int) and context_window > 0:
1234
+ max_history_tokens = int(context_window * 0.8)
1235
+ else:
1236
+ max_history_tokens = int(128000 * 0.8)
1237
+
1238
+ console.print(f"\n[green]✓ Switched to:[/green] [cyan]{provider}[/cyan] | [cyan]{model}[/cyan] | [dim]Context: {context_window:,} tokens[/dim]")
1239
+ console.print("[dim]Conversation history preserved[/dim]\n")
1240
+ continue
1241
+
1242
+ elif user_input.lower() == '/help':
1243
+ # Display help information
1244
+ console.print("\n[bold cyan]Available Commands:[/bold cyan]")
1245
+ console.print(" [green]/file <path>[/green] - Load and send file immediately")
1246
+ console.print(" [green]/attach <path>[/green] - Stage file for next message")
1247
+ console.print(" [green]/clear[/green] - Clear staged attachments")
1248
+ console.print(" [green]/save [path][/green] - Save last response to file (markdown format)")
1249
+ console.print(" [green]/provider[/green] - Switch provider and model")
1250
+ console.print(" [green]/help[/green] - Show this help message")
1251
+ console.print(" [green]exit, quit, q[/green] - Exit interactive mode")
1252
+ console.print(f"\n[bold cyan]Session Info:[/bold cyan]")
1253
+ console.print(f" Provider: [cyan]{provider}[/cyan]")
1254
+ console.print(f" Model: [cyan]{model}[/cyan]")
1255
+ console.print(f" Context: [cyan]{context_window:,} tokens[/cyan]")
1256
+ console.print(f" File size limit: [cyan]{MAX_FILE_SIZE_MB} MB[/cyan]")
1257
+ if staged_file_content:
1258
+ console.print(f" Staged file: [yellow]📎 {staged_file_name}[/yellow]")
1259
+ if last_response:
1260
+ console.print(f" Last response: [green]✓ Available to save[/green]")
1261
+ console.print()
1262
+ continue
1263
+
1264
+ elif user_input.startswith('/'):
1265
+ # Unknown command
1266
+ console.print(f"[yellow]Unknown command: {user_input.split()[0]}[/yellow]")
1267
+ console.print("[dim]Available commands: /file, /attach, /clear, /save, /provider, /help | Type 'exit' to quit[/dim]")
1268
+ continue
1269
+
1270
+ # Skip empty input (unless there's a staged file)
1271
+ if not user_input.strip() and not staged_file_content:
1272
+ continue
1273
+
1274
+ # Build message content (combine text with staged file if present)
1275
+ if staged_file_content:
1276
+ if user_input.strip():
1277
+ message_content = f"{user_input}\n\n[Attached: {staged_file_name}]\n\n{staged_file_content}"
1278
+ else:
1279
+ message_content = f"[Attached: {staged_file_name}]\n\n{staged_file_content}"
1280
+
1281
+ # Clear staged file after use
1282
+ staged_file_content = None
1283
+ staged_file_name = None
1284
+ else:
1285
+ message_content = user_input
1286
+
1287
+ # Add user message to history
1288
+ messages.append(Message(role="user", content=message_content))
1289
+
1290
+ # Truncate conversation history if needed to prevent token limit errors
1291
+ # Rough approximation: 1 token ≈ 4 characters
1292
+ total_chars = sum(len(msg.content) for msg in messages)
1293
+ estimated_tokens = total_chars // 4
1294
+
1295
+ if estimated_tokens > max_history_tokens:
1296
+ # Keep system messages and remove oldest user/assistant pairs
1297
+ system_messages = [msg for msg in messages if msg.role == "system"]
1298
+ conversation_messages = [msg for msg in messages if msg.role != "system"]
1299
+
1300
+ # Calculate how many messages to keep
1301
+ while len(conversation_messages) > 2: # Keep at least the latest user message
1302
+ # Remove oldest pair (user + assistant)
1303
+ if len(conversation_messages) >= 2:
1304
+ conversation_messages = conversation_messages[2:]
1305
+
1306
+ # Recalculate tokens
1307
+ total_chars = sum(len(msg.content) for msg in system_messages + conversation_messages)
1308
+ estimated_tokens = total_chars // 4
1309
+
1310
+ if estimated_tokens <= max_history_tokens:
1311
+ break
1312
+
1313
+ # Rebuild messages list
1314
+ messages = system_messages + conversation_messages
1315
+
1316
+ # Notify user of truncation
1317
+ console.print(f"[yellow]⚠ Conversation history truncated (estimated {estimated_tokens:,} tokens)[/yellow]")
1318
+
1319
+ # Create request and get response
1320
+ request = ChatRequest(model=model, messages=messages)
1321
+
1322
+ try:
1323
+ # Show spinner while waiting for response
1324
+ with console.status("[cyan]Thinking...", spinner="dots"):
1325
+ response = client.chat_completion_sync(request)
1326
+
1327
+ # Add assistant message to history
1328
+ messages.append(Message(role="assistant", content=response.content))
1329
+
1330
+ # Store last response for /save command
1331
+ last_response = response
1332
+
1333
+ # Display metadata and response
1334
+ console.print(f"\n[bold green]Assistant[/bold green]")
1335
+ console.print(f"[bold]Provider:[/bold] [cyan]{provider}[/cyan] | [bold]Model:[/bold] [cyan]{model}[/cyan]")
1336
+
1337
+ # Build usage line with token breakdown and cache info
1338
+ usage_parts = [
1339
+ f"Context: {context_window:,} tokens",
1340
+ f"In: {response.usage.prompt_tokens:,}",
1341
+ f"Out: {response.usage.completion_tokens:,}",
1342
+ f"Total: {response.usage.total_tokens:,}",
1343
+ f"Cost: ${response.usage.cost_usd:.6f}"
1344
+ ]
1345
+
1346
+ # Add latency if available
1347
+ if response.latency_ms is not None:
1348
+ usage_parts.append(f"Latency: {response.latency_ms:.0f}ms")
1349
+
1350
+ # Add cache statistics if available
1351
+ if response.usage.cached_tokens > 0:
1352
+ usage_parts.append(f"Cached: {response.usage.cached_tokens:,}")
1353
+ if response.usage.cache_creation_tokens > 0:
1354
+ usage_parts.append(f"Cache Write: {response.usage.cache_creation_tokens:,}")
1355
+ if response.usage.cache_read_tokens > 0:
1356
+ usage_parts.append(f"Cache Read: {response.usage.cache_read_tokens:,}")
1357
+
1358
+ console.print(f"[dim]{' | '.join(usage_parts)}[/dim]")
1359
+ console.print(f"\n{response.content}", style="cyan")
1360
+ console.print("[dim]💡 Tip: Use /save to save this response to a file[/dim]\n")
1361
+
1362
+ except AuthenticationError as e:
1363
+ console.print(f"\n[red]✗ Authentication Failed[/red]")
1364
+ console.print(f"[yellow]Provider:[/yellow] {e.provider}")
1365
+ console.print(f"[yellow]Issue:[/yellow] API key is missing or invalid\n")
1366
+
1367
+ # Get environment variable name for the provider
1368
+ env_var = PROVIDER_ENV_VARS.get(e.provider, f"{e.provider.upper()}_API_KEY")
1369
+
1370
+ console.print("[bold cyan]How to fix:[/bold cyan]")
1371
+ console.print(f" 1. Set the environment variable: [green]{env_var}[/green]")
1372
+ console.print(f" export {env_var}=\"your-api-key-here\"")
1373
+ console.print(f"\n 2. Or add to your [green].env[/green] file in the project root:")
1374
+ console.print(f" {env_var}=your-api-key-here\n")
1375
+
1376
+ # Provider-specific instructions
1377
+ if e.provider == "openai":
1378
+ console.print("[dim]Get your API key from: https://platform.openai.com/api-keys[/dim]")
1379
+ elif e.provider == "anthropic":
1380
+ console.print("[dim]Get your API key from: https://console.anthropic.com/settings/keys[/dim]")
1381
+ elif e.provider == "google":
1382
+ console.print("[dim]Get your API key from: https://aistudio.google.com/app/apikey[/dim]")
1383
+ elif e.provider == "deepseek":
1384
+ console.print("[dim]Get your API key from: https://platform.deepseek.com/api_keys[/dim]")
1385
+ elif e.provider == "groq":
1386
+ console.print("[dim]Get your API key from: https://console.groq.com/keys[/dim]")
1387
+ elif e.provider == "grok":
1388
+ console.print("[dim]Get your API key from: https://console.x.ai/[/dim]")
1389
+ elif e.provider == "openrouter":
1390
+ console.print("[dim]Get your API key from: https://openrouter.ai/keys[/dim]")
1391
+ elif e.provider == "ollama":
1392
+ console.print("[dim]Ensure Ollama is running: ollama serve[/dim]")
1393
+
1394
+ # Remove failed user message from history
1395
+ messages.pop()
1396
+ console.print("[dim]You can continue the conversation after fixing the API key issue.\n[/dim]")
1397
+
1398
+ except Exception as e:
1399
+ console.print(f"[red]Error:[/red] {e}\n")
1400
+ # Remove failed user message from history
1401
+ messages.pop()
1402
+
1403
+ except AuthenticationError as e:
1404
+ console.print(f"\n[red]✗ Authentication Failed[/red]")
1405
+ console.print(f"[yellow]Provider:[/yellow] {e.provider}")
1406
+ console.print(f"[yellow]Issue:[/yellow] API key is missing or invalid\n")
1407
+
1408
+ # Get environment variable name for the provider
1409
+ env_var = PROVIDER_ENV_VARS.get(e.provider, f"{e.provider.upper()}_API_KEY")
1410
+
1411
+ console.print("[bold cyan]How to fix:[/bold cyan]")
1412
+ console.print(f" 1. Set the environment variable: [green]{env_var}[/green]")
1413
+ console.print(f" export {env_var}=\"your-api-key-here\"")
1414
+ console.print(f"\n 2. Or add to your [green].env[/green] file in the project root:")
1415
+ console.print(f" {env_var}=your-api-key-here\n")
1416
+
1417
+ # Provider-specific instructions
1418
+ if e.provider == "openai":
1419
+ console.print("[dim]Get your API key from: https://platform.openai.com/api-keys[/dim]")
1420
+ elif e.provider == "anthropic":
1421
+ console.print("[dim]Get your API key from: https://console.anthropic.com/settings/keys[/dim]")
1422
+ elif e.provider == "google":
1423
+ console.print("[dim]Get your API key from: https://aistudio.google.com/app/apikey[/dim]")
1424
+ elif e.provider == "deepseek":
1425
+ console.print("[dim]Get your API key from: https://platform.deepseek.com/api_keys[/dim]")
1426
+ elif e.provider == "groq":
1427
+ console.print("[dim]Get your API key from: https://console.groq.com/keys[/dim]")
1428
+ elif e.provider == "grok":
1429
+ console.print("[dim]Get your API key from: https://console.x.ai/[/dim]")
1430
+ elif e.provider == "openrouter":
1431
+ console.print("[dim]Get your API key from: https://openrouter.ai/keys[/dim]")
1432
+ elif e.provider == "ollama":
1433
+ console.print("[dim]Ensure Ollama is running: ollama serve[/dim]")
1434
+
1435
+ raise typer.Exit(1)
1436
+ except Exception as e:
1437
+ console.print(f"[red]Error:[/red] {e}")
1438
+ raise typer.Exit(1)
1439
+
1440
+
1441
+ @app.command()
1442
+ def analyze(
1443
+ file: Path = typer.Argument(
1444
+ ...,
1445
+ help="File to analyze",
1446
+ exists=True,
1447
+ file_okay=True,
1448
+ dir_okay=False,
1449
+ readable=True
1450
+ ),
1451
+ provider: Optional[str] = typer.Option(
1452
+ None,
1453
+ "--provider", "-p",
1454
+ help="LLM provider for future LLM-enhanced extraction"
1455
+ ),
1456
+ model: Optional[str] = typer.Option(
1457
+ None,
1458
+ "--model", "-m",
1459
+ help="Model name for future LLM-enhanced extraction"
1460
+ ),
1461
+ ):
1462
+ """Analyze file and extract structure/schema for efficient LLM processing.
1463
+
1464
+ Supports CSV, JSON, log files, and Python code. Reduces token usage by 80-99%.
1465
+ If --provider and --model are not specified, the optimal model is auto-selected.
1466
+ """
1467
+ try:
1468
+ from stratifyai.utils.csv_extractor import analyze_csv_file
1469
+ from stratifyai.utils.json_extractor import analyze_json_file
1470
+ from stratifyai.utils.log_extractor import extract_log_summary
1471
+ from stratifyai.utils.code_extractor import analyze_code_file
1472
+ from stratifyai.utils.model_selector import select_model_for_file
1473
+
1474
+ # Auto-select model if not specified
1475
+ if not provider or not model:
1476
+ try:
1477
+ auto_provider, auto_model, reasoning = select_model_for_file(file)
1478
+ provider = provider or auto_provider
1479
+ model = model or auto_model
1480
+ console.print(f"\n[cyan]🤖 Auto-selected model:[/cyan] {provider}/{model}")
1481
+ console.print(f"[dim] Reason: {reasoning}[/dim]")
1482
+ except Exception as e:
1483
+ console.print(f"[yellow]⚠ Auto-selection info: {e}[/yellow]")
1484
+
1485
+ # Detect file type
1486
+ extension = file.suffix.lower()
1487
+
1488
+ console.print(f"\n[bold cyan]Analyzing File:[/bold cyan] {file}\n")
1489
+
1490
+ try:
1491
+ if extension == '.csv':
1492
+ result = analyze_csv_file(file)
1493
+ console.print("[bold green]CSV Schema Analysis[/bold green]\n")
1494
+ console.print(result['schema_text'])
1495
+ console.print(f"\n[bold]Token Reduction:[/bold] {result['token_reduction_pct']:.1f}%")
1496
+ console.print(f"[dim]Original: {result['original_size_bytes']:,} bytes → Schema: {result['schema_size_bytes']:,} bytes[/dim]")
1497
+
1498
+ elif extension == '.json':
1499
+ result = analyze_json_file(file)
1500
+ console.print("[bold green]JSON Schema Analysis[/bold green]\n")
1501
+ console.print(result)
1502
+ console.print(f"\n[bold]Token Reduction:[/bold] {result.get('token_reduction_pct', 0):.1f}%")
1503
+
1504
+ elif extension in ['.log', '.txt'] and 'log' in file.name.lower():
1505
+ result = extract_log_summary(file)
1506
+ console.print("[bold green]Log File Analysis[/bold green]\n")
1507
+ console.print(result['summary_text'])
1508
+ console.print(f"\n[bold]Token Reduction:[/bold] {result['token_reduction_pct']:.1f}%")
1509
+ console.print(f"[dim]Original: {result['original_size_bytes']:,} bytes → Summary: {result['summary_size_bytes']:,} bytes[/dim]")
1510
+
1511
+ elif extension == '.py':
1512
+ result = analyze_code_file(file)
1513
+ console.print("[bold green]Python Code Structure Analysis[/bold green]\n")
1514
+ console.print(result['structure_text'])
1515
+ console.print(f"\n[bold]Token Reduction:[/bold] {result['token_reduction_pct']:.1f}%")
1516
+ console.print(f"[dim]Original: {result['original_size_bytes']:,} bytes → Structure: {result['structure_size_bytes']:,} bytes[/dim]")
1517
+
1518
+ else:
1519
+ console.print(f"[yellow]File type not supported for intelligent extraction: {extension}[/yellow]")
1520
+ console.print("[dim]Supported types: .csv, .json, .log, .py[/dim]")
1521
+ raise typer.Exit(1)
1522
+
1523
+ console.print(f"\n[green]✓ Analysis complete[/green]")
1524
+ console.print(f"[dim]Recommendation: Use extracted schema/structure for LLM analysis[/dim]\n")
1525
+
1526
+ except Exception as e:
1527
+ console.print(f"[red]Error analyzing file:[/red] {e}")
1528
+ raise typer.Exit(1)
1529
+
1530
+ except Exception as e:
1531
+ console.print(f"[red]Error:[/red] {e}")
1532
+ raise typer.Exit(1)
1533
+
1534
+
1535
+ @app.command(name="cache-stats")
1536
+ def cache_stats(
1537
+ detailed: bool = typer.Option(
1538
+ False,
1539
+ "--detailed", "-d",
1540
+ help="Show detailed cache entry information"
1541
+ )
1542
+ ):
1543
+ """Display cache statistics with cost savings analytics."""
1544
+
1545
+ try:
1546
+ stats = get_cache_stats()
1547
+
1548
+ console.print("\n[bold cyan]📊 Response Cache Statistics[/bold cyan]\n")
1549
+
1550
+ # Create main stats table
1551
+ table = Table(title="Cache Metrics", show_header=True)
1552
+ table.add_column("Metric", style="cyan", no_wrap=True)
1553
+ table.add_column("Value", justify="right", style="yellow")
1554
+
1555
+ table.add_row("Cache Size", f"{stats['size']:,} / {stats['max_size']:,} entries")
1556
+ table.add_row("Total Hits", f"{stats['total_hits']:,}")
1557
+ table.add_row("Total Misses", f"{stats['total_misses']:,}")
1558
+ table.add_row("Total Requests", f"{stats['total_requests']:,}")
1559
+
1560
+ # Hit rate with visual indicator
1561
+ hit_rate = stats.get('hit_rate', 0.0)
1562
+ if hit_rate >= 75:
1563
+ hit_rate_str = f"[green]{hit_rate:.1f}%[/green] 🎯"
1564
+ elif hit_rate >= 50:
1565
+ hit_rate_str = f"[yellow]{hit_rate:.1f}%[/yellow] ⚠️"
1566
+ else:
1567
+ hit_rate_str = f"[red]{hit_rate:.1f}%[/red] 📉"
1568
+ table.add_row("Hit Rate", hit_rate_str)
1569
+
1570
+ table.add_row("TTL (Time-to-Live)", f"{stats['ttl']:,} seconds")
1571
+
1572
+ console.print(table)
1573
+
1574
+ # Cost savings section
1575
+ cost_saved = stats.get('total_cost_saved', 0.0)
1576
+ if cost_saved > 0 or stats['total_hits'] > 0:
1577
+ console.print("\n[bold green]💰 Cost Savings Analysis[/bold green]")
1578
+ console.print(f"\n[green]✓[/green] Total Cost Saved: [bold green]${cost_saved:.4f}[/bold green]")
1579
+ console.print(f"[dim] ({stats['total_hits']:,} cached responses avoided API calls)[/dim]")
1580
+
1581
+ if stats['total_hits'] > 0:
1582
+ avg_savings_per_hit = cost_saved / stats['total_hits']
1583
+ console.print(f"[dim] Average savings per hit: ${avg_savings_per_hit:.6f}[/dim]")
1584
+
1585
+ # Detailed entry view
1586
+ if detailed and stats['size'] > 0:
1587
+ console.print("\n[bold cyan]📝 Cache Entries (Top 10 by hits)[/bold cyan]\n")
1588
+
1589
+ entries = get_cache_entries()[:10] # Top 10
1590
+
1591
+ entry_table = Table(show_header=True)
1592
+ entry_table.add_column("Provider", style="cyan")
1593
+ entry_table.add_column("Model", style="magenta")
1594
+ entry_table.add_column("Hits", justify="right", style="yellow")
1595
+ entry_table.add_column("Cost Saved", justify="right", style="green")
1596
+ entry_table.add_column("Age", justify="right", style="blue")
1597
+ entry_table.add_column("Expires In", justify="right", style="red")
1598
+
1599
+ for entry in entries:
1600
+ age_str = f"{entry['age_seconds']}s"
1601
+ expires_str = f"{entry['expires_in']}s"
1602
+ cost_str = f"${entry['cost_saved']:.4f}" if entry['cost_saved'] > 0 else "-"
1603
+
1604
+ entry_table.add_row(
1605
+ entry['provider'],
1606
+ entry['model'],
1607
+ str(entry['hits']),
1608
+ cost_str,
1609
+ age_str,
1610
+ expires_str
1611
+ )
1612
+
1613
+ console.print(entry_table)
1614
+
1615
+ # Usage tip
1616
+ if not detailed and stats['size'] > 0:
1617
+ console.print("\n[dim]💡 Tip: Use --detailed flag to see cache entry information[/dim]")
1618
+
1619
+ console.print()
1620
+
1621
+ except Exception as e:
1622
+ console.print(f"[red]Error getting cache stats:[/red] {e}")
1623
+ raise typer.Exit(1)
1624
+
1625
+
1626
+ @app.command(name="cache-clear")
1627
+ def cache_clear(
1628
+ force: bool = typer.Option(
1629
+ False,
1630
+ "--force", "-f",
1631
+ help="Skip confirmation prompt"
1632
+ )
1633
+ ):
1634
+ """Clear all cache entries."""
1635
+
1636
+ try:
1637
+ stats = get_cache_stats()
1638
+
1639
+ if stats['size'] == 0:
1640
+ console.print("\n[yellow]Cache is already empty.[/yellow]\n")
1641
+ return
1642
+
1643
+ # Show what will be cleared
1644
+ console.print(f"\n[yellow]⚠️ About to clear:[/yellow]")
1645
+ console.print(f" - {stats['size']:,} cache entries")
1646
+ console.print(f" - {stats['total_hits']:,} total hits")
1647
+ if stats.get('total_cost_saved', 0) > 0:
1648
+ console.print(f" - ${stats['total_cost_saved']:.4f} saved cost data")
1649
+
1650
+ # Confirm unless --force
1651
+ if not force:
1652
+ confirm = Confirm.ask("\nAre you sure you want to clear the cache?", default=False)
1653
+ if not confirm:
1654
+ console.print("\n[dim]Cache clear cancelled.[/dim]\n")
1655
+ return
1656
+
1657
+ # Clear cache
1658
+ clear_cache()
1659
+ console.print("\n[green]✓ Cache cleared successfully[/green]\n")
1660
+
1661
+ except Exception as e:
1662
+ console.print(f"[red]Error clearing cache:[/red] {e}")
1663
+ raise typer.Exit(1)
1664
+
1665
+
1666
+ @app.command()
1667
+ def setup():
1668
+ """
1669
+ Interactive API key setup wizard.
1670
+
1671
+ Shows which providers have API keys configured and provides
1672
+ links to get API keys for providers you want to use.
1673
+ """
1674
+ from stratifyai.api_key_helper import (
1675
+ APIKeyHelper,
1676
+ print_setup_instructions
1677
+ )
1678
+
1679
+ console.print("\n[bold cyan]🔑 StratifyAI Setup Wizard[/bold cyan]\n")
1680
+
1681
+ # Create .env from .env.example if needed
1682
+ if APIKeyHelper.create_env_file_if_missing():
1683
+ console.print("[green]✓[/green] Created .env file from .env.example")
1684
+ console.print("[dim] Edit .env to add your API keys[/dim]\n")
1685
+ elif not Path(".env").exists():
1686
+ console.print("[yellow]⚠[/yellow] .env file not found")
1687
+ console.print("[dim] Create one by copying .env.example[/dim]\n")
1688
+
1689
+ # Show current status
1690
+ print_setup_instructions()
1691
+
1692
+ # Instructions
1693
+ console.print("\n[bold cyan]Next Steps:[/bold cyan]")
1694
+ console.print(" 1. Edit .env file and add API keys for providers you want to use")
1695
+ console.print(" 2. Run [green]stratifyai check-keys[/green] to verify your setup")
1696
+ console.print(" 3. Test with: [cyan]stratifyai chat -p openai -m gpt-4o-mini 'Hello'[/cyan]\n")
1697
+
1698
+
1699
+ @app.command(name="check-keys")
1700
+ def check_keys():
1701
+ """
1702
+ Check which providers have API keys configured.
1703
+
1704
+ Displays a status report showing which providers are ready to use
1705
+ and which ones need API keys.
1706
+ """
1707
+ from stratifyai.api_key_helper import APIKeyHelper
1708
+
1709
+ available = APIKeyHelper.check_available_providers()
1710
+
1711
+ console.print("\n[bold cyan]🔑 API Key Status[/bold cyan]\n")
1712
+
1713
+ # Count configured providers
1714
+ configured_count = sum(1 for v in available.values() if v)
1715
+ total_count = len(available)
1716
+
1717
+ # Create status table
1718
+ table = Table(show_header=True, header_style="bold magenta")
1719
+ table.add_column("Provider", style="cyan")
1720
+ table.add_column("Status", justify="center")
1721
+ table.add_column("Environment Variable", style="dim")
1722
+
1723
+ for provider in sorted(available.keys()):
1724
+ is_available = available[provider]
1725
+ status = "[green]✓ Configured[/green]" if is_available else "[red]✗ Missing[/red]"
1726
+ friendly_name = APIKeyHelper.PROVIDER_FRIENDLY_NAMES.get(provider, provider)
1727
+ env_key = APIKeyHelper.PROVIDER_ENV_KEYS.get(provider, "N/A")
1728
+
1729
+ table.add_row(friendly_name, status, env_key)
1730
+
1731
+ console.print(table)
1732
+
1733
+ # Summary
1734
+ if configured_count == 0:
1735
+ console.print(f"\n[yellow]⚠ No providers configured[/yellow]")
1736
+ console.print("[dim]Run [cyan]stratifyai setup[/cyan] to get started[/dim]\n")
1737
+ elif configured_count == total_count:
1738
+ console.print(f"\n[green]✓ All {total_count} providers configured![/green]\n")
1739
+ else:
1740
+ console.print(f"\n[cyan]{configured_count}/{total_count} providers configured[/cyan]\n")
1741
+
1742
+ # Help tip
1743
+ if configured_count < total_count:
1744
+ console.print("[dim]💡 Tip: Run [cyan]stratifyai setup[/cyan] to see how to configure missing providers[/dim]\n")
1745
+
1746
+
1747
+ def main():
1748
+ """Entry point for CLI."""
1749
+ app()
1750
+
1751
+
1752
+ if __name__ == "__main__":
1753
+ main()