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.
- cli/__init__.py +5 -0
- cli/stratifyai_cli.py +1753 -0
- stratifyai/__init__.py +113 -0
- stratifyai/api_key_helper.py +372 -0
- stratifyai/caching.py +279 -0
- stratifyai/chat/__init__.py +54 -0
- stratifyai/chat/builder.py +366 -0
- stratifyai/chat/stratifyai_anthropic.py +194 -0
- stratifyai/chat/stratifyai_bedrock.py +200 -0
- stratifyai/chat/stratifyai_deepseek.py +194 -0
- stratifyai/chat/stratifyai_google.py +194 -0
- stratifyai/chat/stratifyai_grok.py +194 -0
- stratifyai/chat/stratifyai_groq.py +195 -0
- stratifyai/chat/stratifyai_ollama.py +201 -0
- stratifyai/chat/stratifyai_openai.py +209 -0
- stratifyai/chat/stratifyai_openrouter.py +201 -0
- stratifyai/chunking.py +158 -0
- stratifyai/client.py +292 -0
- stratifyai/config.py +1273 -0
- stratifyai/cost_tracker.py +257 -0
- stratifyai/embeddings.py +245 -0
- stratifyai/exceptions.py +91 -0
- stratifyai/models.py +59 -0
- stratifyai/providers/__init__.py +5 -0
- stratifyai/providers/anthropic.py +330 -0
- stratifyai/providers/base.py +183 -0
- stratifyai/providers/bedrock.py +634 -0
- stratifyai/providers/deepseek.py +39 -0
- stratifyai/providers/google.py +39 -0
- stratifyai/providers/grok.py +39 -0
- stratifyai/providers/groq.py +39 -0
- stratifyai/providers/ollama.py +43 -0
- stratifyai/providers/openai.py +344 -0
- stratifyai/providers/openai_compatible.py +372 -0
- stratifyai/providers/openrouter.py +39 -0
- stratifyai/py.typed +2 -0
- stratifyai/rag.py +381 -0
- stratifyai/retry.py +185 -0
- stratifyai/router.py +643 -0
- stratifyai/summarization.py +179 -0
- stratifyai/utils/__init__.py +11 -0
- stratifyai/utils/bedrock_validator.py +136 -0
- stratifyai/utils/code_extractor.py +327 -0
- stratifyai/utils/csv_extractor.py +197 -0
- stratifyai/utils/file_analyzer.py +192 -0
- stratifyai/utils/json_extractor.py +219 -0
- stratifyai/utils/log_extractor.py +267 -0
- stratifyai/utils/model_selector.py +324 -0
- stratifyai/utils/provider_validator.py +442 -0
- stratifyai/utils/token_counter.py +186 -0
- stratifyai/vectordb.py +344 -0
- stratifyai-0.1.0.dist-info/METADATA +263 -0
- stratifyai-0.1.0.dist-info/RECORD +57 -0
- stratifyai-0.1.0.dist-info/WHEEL +5 -0
- stratifyai-0.1.0.dist-info/entry_points.txt +2 -0
- stratifyai-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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()
|