padwan-cli 0.2.1__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.
- padwan_cli/__init__.py +5 -0
- padwan_cli/__main__.py +3 -0
- padwan_cli/batch/__init__.py +5 -0
- padwan_cli/batch/format.py +62 -0
- padwan_cli/batch/run.py +282 -0
- padwan_cli/batch/utils.py +87 -0
- padwan_cli/chat.py +144 -0
- padwan_cli/conversation.py +94 -0
- padwan_cli/run.py +122 -0
- padwan_cli/utils.py +7 -0
- padwan_cli/widgets.py +147 -0
- padwan_cli-0.2.1.dist-info/METADATA +43 -0
- padwan_cli-0.2.1.dist-info/RECORD +15 -0
- padwan_cli-0.2.1.dist-info/WHEEL +4 -0
- padwan_cli-0.2.1.dist-info/entry_points.txt +3 -0
padwan_cli/__init__.py
ADDED
padwan_cli/__main__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from ..utils import console
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from padwan_llm.gemini import BatchJob
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def format_job(job: BatchJob) -> None:
|
|
14
|
+
"""Pretty print a batch job with colored state and key fields."""
|
|
15
|
+
from ..widgets import STATE_COLORS
|
|
16
|
+
|
|
17
|
+
state_color = STATE_COLORS.get(job.state, "white")
|
|
18
|
+
console.print(f" Name: {job.name}")
|
|
19
|
+
console.print(f" State: [{state_color}]{job.state}[/{state_color}]")
|
|
20
|
+
if job.display_name:
|
|
21
|
+
console.print(f" Display: {job.display_name}")
|
|
22
|
+
if job.model:
|
|
23
|
+
console.print(f" Model: {job.model}")
|
|
24
|
+
if job.create_time:
|
|
25
|
+
console.print(f" Created: {job.create_time}")
|
|
26
|
+
if job.stats:
|
|
27
|
+
stats = job.stats
|
|
28
|
+
succeeded = stats.get("successfulRequestCount", 0)
|
|
29
|
+
total = stats.get("requestCount", 0)
|
|
30
|
+
console.print(f" Stats: {succeeded}/{total} succeeded")
|
|
31
|
+
if job.error:
|
|
32
|
+
console.print(f" [red]Error: {job.error}[/red]")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def format_job_table(jobs: list[BatchJob]) -> Table:
|
|
36
|
+
"""Create a rich table displaying batch jobs with state colors and stats."""
|
|
37
|
+
from ..widgets import STATE_COLORS
|
|
38
|
+
|
|
39
|
+
table = Table(title="Batch Jobs")
|
|
40
|
+
table.add_column("Name", style="cyan", no_wrap=True)
|
|
41
|
+
table.add_column("State", style="bold")
|
|
42
|
+
table.add_column("Display Name")
|
|
43
|
+
table.add_column("Model")
|
|
44
|
+
table.add_column("Stats", justify="right")
|
|
45
|
+
|
|
46
|
+
for job in jobs:
|
|
47
|
+
state_color = STATE_COLORS.get(job.state, "white")
|
|
48
|
+
stats_str = ""
|
|
49
|
+
if job.stats:
|
|
50
|
+
succeeded = job.stats.get("successfulRequestCount", 0)
|
|
51
|
+
total = job.stats.get("requestCount", 0)
|
|
52
|
+
stats_str = f"{succeeded}/{total}"
|
|
53
|
+
|
|
54
|
+
table.add_row(
|
|
55
|
+
job.name.split("/")[-1], # Just the job ID
|
|
56
|
+
f"[{state_color}]{job.state.replace('JOB_STATE_', '')}[/{state_color}]",
|
|
57
|
+
job.display_name or "",
|
|
58
|
+
job.model or "",
|
|
59
|
+
stats_str,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return table
|
padwan_cli/batch/run.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from piou import CommandGroup, Option
|
|
7
|
+
from piou.tui import TuiContext, TuiOption
|
|
8
|
+
|
|
9
|
+
from padwan_llm import GEMINI_MODELS
|
|
10
|
+
from padwan_llm.gemini import BatchRequest, BatchResult, GeminiClient
|
|
11
|
+
from ..widgets import BatchProgressWidget, BatchResultWidget
|
|
12
|
+
from .format import console, format_job, format_job_table
|
|
13
|
+
from .utils import load_prompts_from_file, save_results_to_file
|
|
14
|
+
|
|
15
|
+
batch_group = CommandGroup(name="batch", help="Gemini batch operations")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _print_results(results: list[BatchResult]) -> None:
|
|
19
|
+
"""Print batch results as plain text for CLI mode."""
|
|
20
|
+
for result in results:
|
|
21
|
+
console.print(
|
|
22
|
+
f"[cyan][{result.key}][/cyan] "
|
|
23
|
+
f"[dim]({result.input_tokens} in, {result.output_tokens} out, "
|
|
24
|
+
f"{result.total_tokens} total tokens)[/dim]"
|
|
25
|
+
)
|
|
26
|
+
preview = result.content[:300].replace("\n", " ")
|
|
27
|
+
if len(result.content) > 300:
|
|
28
|
+
preview += "..."
|
|
29
|
+
console.print(f" {preview}\n")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@batch_group.command("create", help="Create a batch job with prompts")
|
|
33
|
+
async def batch_create(
|
|
34
|
+
prompts: list[str] | None = Option(
|
|
35
|
+
None, "-p", "--prompt", help="Inline prompts (multiple)"
|
|
36
|
+
),
|
|
37
|
+
file: str | None = Option(
|
|
38
|
+
None, "-f", "--file", help="Read prompts from JSON/text file"
|
|
39
|
+
),
|
|
40
|
+
model: str = Option(
|
|
41
|
+
"gemini-2.0-flash",
|
|
42
|
+
"-m",
|
|
43
|
+
"--model",
|
|
44
|
+
help="Model to use",
|
|
45
|
+
choices=sorted(GEMINI_MODELS),
|
|
46
|
+
),
|
|
47
|
+
name: str = Option("cli-batch", "-n", "--name", help="Display name for the batch"),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Create a batch job with inline prompts or from a file.
|
|
50
|
+
|
|
51
|
+
Prompts can be specified inline with -p (multiple times) or loaded from
|
|
52
|
+
a file with -f. The file can be JSON (array of strings or objects with
|
|
53
|
+
'prompt' and 'key' fields) or plain text (one prompt per line).
|
|
54
|
+
"""
|
|
55
|
+
requests: list[BatchRequest] = []
|
|
56
|
+
|
|
57
|
+
if file:
|
|
58
|
+
requests = load_prompts_from_file(file)
|
|
59
|
+
elif prompts:
|
|
60
|
+
requests = [
|
|
61
|
+
BatchRequest(
|
|
62
|
+
contents=[{"role": "user", "parts": [{"text": prompt}]}],
|
|
63
|
+
key=f"prompt-{i}",
|
|
64
|
+
)
|
|
65
|
+
for i, prompt in enumerate(prompts)
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
if not requests:
|
|
69
|
+
console.print(
|
|
70
|
+
"[red]No prompts provided. Use -p or -f to specify prompts.[/red]"
|
|
71
|
+
)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
async with GeminiClient(model=model) as client: # pyright: ignore[reportArgumentType]
|
|
75
|
+
console.print(f"Creating batch with {len(requests)} requests...")
|
|
76
|
+
job = await client.create_batch(requests, display_name=name)
|
|
77
|
+
console.print("[green]Batch created:[/green]")
|
|
78
|
+
format_job(job)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@batch_group.command("status", help="Get status of a batch job")
|
|
82
|
+
async def batch_status(
|
|
83
|
+
job_name: str = Option(..., "-j", "--job", help="Batch job name"),
|
|
84
|
+
show_results: bool = Option(
|
|
85
|
+
False, "-r", "--results", help="Show results if completed"
|
|
86
|
+
),
|
|
87
|
+
ctx: TuiContext = TuiOption(),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Get the current status of a batch job with optional results display."""
|
|
90
|
+
async with GeminiClient() as client:
|
|
91
|
+
job = await client.get_batch(job_name)
|
|
92
|
+
console.print("[bold]Batch status:[/bold]")
|
|
93
|
+
format_job(job)
|
|
94
|
+
|
|
95
|
+
if show_results and job.succeeded and job.inlined_responses:
|
|
96
|
+
console.print("\n[bold]Results:[/bold]")
|
|
97
|
+
results = [
|
|
98
|
+
BatchResult.from_inlined_response(r) for r in job.inlined_responses
|
|
99
|
+
]
|
|
100
|
+
if ctx.is_tui:
|
|
101
|
+
ctx.mount_widget(BatchResultWidget(results))
|
|
102
|
+
else:
|
|
103
|
+
_print_results(results)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@batch_group.command("list", help="List batch jobs")
|
|
107
|
+
async def batch_list(
|
|
108
|
+
limit: int = Option(10, "-l", "--limit", help="Maximum jobs to show"),
|
|
109
|
+
) -> None:
|
|
110
|
+
"""List recent batch jobs with their status."""
|
|
111
|
+
async with GeminiClient() as client:
|
|
112
|
+
jobs, next_token = await client.list_batches(page_size=limit)
|
|
113
|
+
|
|
114
|
+
if not jobs:
|
|
115
|
+
console.print("No batch jobs found.")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
table = format_job_table(jobs)
|
|
119
|
+
console.print(table)
|
|
120
|
+
|
|
121
|
+
if next_token:
|
|
122
|
+
console.print(
|
|
123
|
+
f"\n[dim]More jobs available (next_token: {next_token})[/dim]"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@batch_group.command("cancel", help="Cancel a batch job")
|
|
128
|
+
async def batch_cancel(
|
|
129
|
+
job_name: str = Option(..., "-j", "--job", help="Batch job name to cancel"),
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Cancel a pending or running batch job."""
|
|
132
|
+
async with GeminiClient() as client:
|
|
133
|
+
console.print(f"Cancelling batch job: {job_name}")
|
|
134
|
+
await client.cancel_batch(job_name)
|
|
135
|
+
console.print("[green]Batch job cancelled.[/green]")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@batch_group.command("poll", help="Poll a batch job until completion")
|
|
139
|
+
async def batch_poll(
|
|
140
|
+
job_name: str = Option(..., "-j", "--job", help="Batch job name to poll"),
|
|
141
|
+
interval: int = Option(5, "-i", "--interval", help="Poll interval in seconds"),
|
|
142
|
+
timeout: int | None = Option(
|
|
143
|
+
None, "-t", "--timeout", help="Max wait time in seconds"
|
|
144
|
+
),
|
|
145
|
+
show_results: bool = Option(
|
|
146
|
+
True, "-r", "--results", help="Show results on completion"
|
|
147
|
+
),
|
|
148
|
+
output: str | None = Option(None, "-o", "--output", help="Save results to file"),
|
|
149
|
+
ctx: TuiContext = TuiOption(),
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Poll a batch job until it reaches a terminal state.
|
|
152
|
+
|
|
153
|
+
Displays a progress widget showing the job state and stats.
|
|
154
|
+
Can optionally save results to a file when complete.
|
|
155
|
+
"""
|
|
156
|
+
async with GeminiClient() as client:
|
|
157
|
+
console.print(f"Polling batch job: {job_name} (every {interval}s)")
|
|
158
|
+
job = await client.get_batch(job_name)
|
|
159
|
+
|
|
160
|
+
widget = None
|
|
161
|
+
if ctx.is_tui:
|
|
162
|
+
widget = BatchProgressWidget(job)
|
|
163
|
+
ctx.mount_widget(widget)
|
|
164
|
+
|
|
165
|
+
elapsed = 0
|
|
166
|
+
while not job.is_terminal:
|
|
167
|
+
if timeout and elapsed >= timeout:
|
|
168
|
+
console.print(f"[yellow]Timeout reached after {elapsed}s[/yellow]")
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
await asyncio.sleep(interval)
|
|
172
|
+
elapsed += interval
|
|
173
|
+
job = await client.get_batch(job_name)
|
|
174
|
+
if widget:
|
|
175
|
+
widget.update_job(job)
|
|
176
|
+
else:
|
|
177
|
+
console.print(f" [{job.state}] {elapsed}s elapsed")
|
|
178
|
+
|
|
179
|
+
if job.is_terminal:
|
|
180
|
+
console.print("\n[bold]Batch job completed:[/bold]")
|
|
181
|
+
else:
|
|
182
|
+
console.print("\n[bold]Batch job status (timed out):[/bold]")
|
|
183
|
+
format_job(job)
|
|
184
|
+
|
|
185
|
+
if job.succeeded and job.inlined_responses:
|
|
186
|
+
results = [
|
|
187
|
+
BatchResult.from_inlined_response(r) for r in job.inlined_responses
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
if output:
|
|
191
|
+
fmt = Path(output).suffix.lstrip(".") or "json"
|
|
192
|
+
if fmt not in ("json", "csv", "txt"):
|
|
193
|
+
fmt = "json"
|
|
194
|
+
save_results_to_file(results, output, fmt)
|
|
195
|
+
console.print(f"[green]Results saved to: {output}[/green]")
|
|
196
|
+
|
|
197
|
+
if show_results:
|
|
198
|
+
console.print("\n[bold]Results:[/bold]")
|
|
199
|
+
if ctx.is_tui:
|
|
200
|
+
ctx.mount_widget(BatchResultWidget(results))
|
|
201
|
+
else:
|
|
202
|
+
_print_results(results)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@batch_group.command("retry", help="Retry failed requests from a batch")
|
|
206
|
+
async def batch_retry(
|
|
207
|
+
job_name: str = Option(..., "-j", "--job", help="Original job name"),
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Create a new batch with only the failed requests from an original job.
|
|
210
|
+
|
|
211
|
+
Extracts failed request keys from the original job and creates a new
|
|
212
|
+
batch job to retry just those requests.
|
|
213
|
+
"""
|
|
214
|
+
async with GeminiClient() as client:
|
|
215
|
+
original = await client.get_batch(job_name)
|
|
216
|
+
|
|
217
|
+
if not original.is_terminal:
|
|
218
|
+
console.print("[red]Original job has not completed yet.[/red]")
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
if not original.inlined_responses:
|
|
222
|
+
console.print("[red]No responses found in original job.[/red]")
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
# Find failed requests by checking for missing or error responses
|
|
226
|
+
failed_keys: list[str] = []
|
|
227
|
+
all_keys: set[str] = set()
|
|
228
|
+
|
|
229
|
+
for resp in original.inlined_responses:
|
|
230
|
+
key = resp.get("key", "")
|
|
231
|
+
all_keys.add(key)
|
|
232
|
+
response = resp.get("response", {})
|
|
233
|
+
# Check if response has an error or no candidates
|
|
234
|
+
if response.get("error") or not response.get("candidates"):
|
|
235
|
+
failed_keys.append(key)
|
|
236
|
+
|
|
237
|
+
if not failed_keys:
|
|
238
|
+
console.print("[green]No failed requests to retry.[/green]")
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
console.print(
|
|
242
|
+
f"Found {len(failed_keys)} failed requests out of {len(all_keys)}"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# For now, we can't easily recreate the original requests without
|
|
246
|
+
# storing them. This command would need the original prompts.
|
|
247
|
+
console.print(
|
|
248
|
+
"[yellow]Note: Retry requires original prompts to be available.[/yellow]"
|
|
249
|
+
)
|
|
250
|
+
console.print("Failed keys: " + ", ".join(failed_keys))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@batch_group.command("export", help="Export batch results to a file")
|
|
254
|
+
async def batch_export(
|
|
255
|
+
job_name: str = Option(..., "-j", "--job", help="Batch job name"),
|
|
256
|
+
output: str = Option(..., "-o", "--output", help="Output file path"),
|
|
257
|
+
fmt: str = Option("json", "-f", "--format", help="Format: json, csv, txt"),
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Export completed batch results to a file.
|
|
260
|
+
|
|
261
|
+
Supports JSON (structured with all fields), CSV (tabular), and TXT
|
|
262
|
+
(key-content pairs) formats.
|
|
263
|
+
"""
|
|
264
|
+
if fmt not in ("json", "csv", "txt"):
|
|
265
|
+
console.print(f"[red]Unknown format: {fmt}. Use json, csv, or txt.[/red]")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
async with GeminiClient() as client:
|
|
269
|
+
job = await client.get_batch(job_name)
|
|
270
|
+
|
|
271
|
+
if not job.succeeded:
|
|
272
|
+
console.print(f"[red]Job not successful. State: {job.state}[/red]")
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
if not job.inlined_responses:
|
|
276
|
+
console.print("[red]No results found in job.[/red]")
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
results = [BatchResult.from_inlined_response(r) for r in job.inlined_responses]
|
|
280
|
+
save_results_to_file(results, output, fmt)
|
|
281
|
+
|
|
282
|
+
console.print(f"[green]Exported {len(results)} results to: {output}[/green]")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from padwan_llm.gemini import BatchRequest, BatchResult
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_prompts_from_file(file_path: str) -> list[BatchRequest]:
|
|
15
|
+
"""Load prompts from a JSON array or newline-delimited text file.
|
|
16
|
+
|
|
17
|
+
For JSON files, accepts either a simple string array or an array of objects
|
|
18
|
+
with 'prompt' and optional 'key' fields. For text files, each line becomes
|
|
19
|
+
a separate prompt.
|
|
20
|
+
"""
|
|
21
|
+
path = Path(file_path)
|
|
22
|
+
content = path.read_text()
|
|
23
|
+
|
|
24
|
+
if path.suffix.lower() == ".json":
|
|
25
|
+
data: list[str | dict[str, Any]] = json.loads(content)
|
|
26
|
+
requests: list[BatchRequest] = []
|
|
27
|
+
for i, item in enumerate(data):
|
|
28
|
+
if isinstance(item, str):
|
|
29
|
+
requests.append(
|
|
30
|
+
BatchRequest(
|
|
31
|
+
contents=[{"role": "user", "parts": [{"text": item}]}],
|
|
32
|
+
key=f"prompt-{i}",
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
else:
|
|
36
|
+
prompt = item.get("prompt", "")
|
|
37
|
+
key = item.get("key", f"prompt-{i}")
|
|
38
|
+
requests.append(
|
|
39
|
+
BatchRequest(
|
|
40
|
+
contents=[{"role": "user", "parts": [{"text": prompt}]}],
|
|
41
|
+
key=key,
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
return requests
|
|
45
|
+
|
|
46
|
+
# Text file: one prompt per line
|
|
47
|
+
lines = [line.strip() for line in content.splitlines() if line.strip()]
|
|
48
|
+
return [
|
|
49
|
+
BatchRequest(
|
|
50
|
+
contents=[{"role": "user", "parts": [{"text": line}]}],
|
|
51
|
+
key=f"prompt-{i}",
|
|
52
|
+
)
|
|
53
|
+
for i, line in enumerate(lines)
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def save_results_to_file(results: list[BatchResult], path: str, fmt: str) -> None:
|
|
58
|
+
"""Export batch results in the specified format (json, csv, or txt)."""
|
|
59
|
+
output = Path(path)
|
|
60
|
+
|
|
61
|
+
if fmt == "json":
|
|
62
|
+
data = [
|
|
63
|
+
{
|
|
64
|
+
"key": r.key,
|
|
65
|
+
"content": r.content,
|
|
66
|
+
"input_tokens": r.input_tokens,
|
|
67
|
+
"output_tokens": r.output_tokens,
|
|
68
|
+
"total_tokens": r.total_tokens,
|
|
69
|
+
}
|
|
70
|
+
for r in results
|
|
71
|
+
]
|
|
72
|
+
output.write_text(json.dumps(data, indent=2))
|
|
73
|
+
|
|
74
|
+
elif fmt == "csv":
|
|
75
|
+
with output.open("w", newline="") as f:
|
|
76
|
+
writer = csv.writer(f)
|
|
77
|
+
writer.writerow(
|
|
78
|
+
["key", "content", "input_tokens", "output_tokens", "total_tokens"]
|
|
79
|
+
)
|
|
80
|
+
for r in results:
|
|
81
|
+
writer.writerow(
|
|
82
|
+
[r.key, r.content, r.input_tokens, r.output_tokens, r.total_tokens]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
else: # txt
|
|
86
|
+
lines = [f"[{r.key}]\n{r.content}\n" for r in results]
|
|
87
|
+
output.write_text("\n".join(lines))
|
padwan_cli/chat.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from piou import Option, CommandGroup
|
|
6
|
+
from piou.tui import PromptStyle, TuiContext, TuiOption
|
|
7
|
+
|
|
8
|
+
from padwan_llm import LLMClient
|
|
9
|
+
|
|
10
|
+
from .utils import ALL_MODELS, console
|
|
11
|
+
from .conversation import Conversation, Message
|
|
12
|
+
from .widgets import StreamingMessage, UserMessage
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from padwan_llm import LLMClientBase
|
|
16
|
+
|
|
17
|
+
chat_group = CommandGroup(name="chat", help="Chat with an LLM")
|
|
18
|
+
|
|
19
|
+
# Persistent conversation sessions keyed by model name
|
|
20
|
+
_sessions: dict[str, tuple[LLMClientBase, Conversation]] = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_or_create_session(
|
|
24
|
+
model: str,
|
|
25
|
+
system: str | None = None,
|
|
26
|
+
) -> tuple[LLMClientBase, Conversation]:
|
|
27
|
+
"""Get existing session or create a new one for the given model."""
|
|
28
|
+
if model in _sessions:
|
|
29
|
+
client, conv = _sessions[model]
|
|
30
|
+
# Update system prompt if provided and different
|
|
31
|
+
if system and conv.system != system:
|
|
32
|
+
conv.system = system
|
|
33
|
+
# Rebuild messages with new system prompt
|
|
34
|
+
old_messages = [m for m in conv.messages if m["role"] != "system"]
|
|
35
|
+
conv.messages = []
|
|
36
|
+
if system:
|
|
37
|
+
conv.messages.append(Message(role="system", content=system))
|
|
38
|
+
conv.messages.extend(old_messages)
|
|
39
|
+
return client, conv
|
|
40
|
+
|
|
41
|
+
client = LLMClient(model=model)
|
|
42
|
+
conv = Conversation(client, system=system)
|
|
43
|
+
_sessions[model] = (client, conv)
|
|
44
|
+
return client, conv
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
CHAT_PROMPT = PromptStyle(text="You: ", css_class="chat-mode")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _format_tokens(conv: Conversation, ctx: TuiContext | None = None) -> str:
|
|
51
|
+
"""Format token usage for display."""
|
|
52
|
+
if not conv.last_usage:
|
|
53
|
+
return ""
|
|
54
|
+
last = conv.last_usage
|
|
55
|
+
total = conv.total_usage
|
|
56
|
+
parts = [f"in: {last['input']}", f"out: {last['output']}"]
|
|
57
|
+
if "cached" in last:
|
|
58
|
+
parts.append(f"cached: {last['cached']}")
|
|
59
|
+
parts.append(f"| session: {total['total']}")
|
|
60
|
+
if ctx and ctx.pending_count > 0:
|
|
61
|
+
parts.append(f"| {ctx.pending_count} queued")
|
|
62
|
+
return " ".join(parts)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@chat_group.command("send", help="Send a message to the LLM")
|
|
66
|
+
async def chat_send_fn(
|
|
67
|
+
message: str = Option(..., help="Message to send"),
|
|
68
|
+
model: str = Option(
|
|
69
|
+
"gpt-4o-mini", "-m", "--model", help="Model to use", choices=ALL_MODELS
|
|
70
|
+
),
|
|
71
|
+
ctx: TuiContext = TuiOption(),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Start a conversation. Use Ctrl+C to exit."""
|
|
74
|
+
try:
|
|
75
|
+
client, conv = _get_or_create_session(model)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
console.print(f"[red]Error creating session for {model}: {e}[/red]")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
async with client:
|
|
81
|
+
user_input: str | None = message
|
|
82
|
+
original_style = ctx.set_prompt_style(CHAT_PROMPT)
|
|
83
|
+
ctx.set_hint("Chat mode - press Ctrl+C to exit")
|
|
84
|
+
ctx.set_rule_above(add_class="chat-mode")
|
|
85
|
+
ctx.set_rule_below(add_class="chat-mode")
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
while user_input:
|
|
89
|
+
if ctx.is_tui:
|
|
90
|
+
ctx.mount_widget(UserMessage(user_input))
|
|
91
|
+
widget = StreamingMessage()
|
|
92
|
+
ctx.mount_widget(widget)
|
|
93
|
+
ctx.set_hint("Responding...")
|
|
94
|
+
ctx.set_silent_queue(True)
|
|
95
|
+
async for chunk in conv.stream(user_input):
|
|
96
|
+
widget.append(chunk)
|
|
97
|
+
if (n := ctx.pending_count) > 0:
|
|
98
|
+
ctx.set_hint(f"Responding... ({n} queued)")
|
|
99
|
+
ctx.set_silent_queue(False)
|
|
100
|
+
ctx.set_hint("Chat mode - press Ctrl+C to exit")
|
|
101
|
+
ctx.set_status_above(_format_tokens(conv, ctx) or None)
|
|
102
|
+
user_input = await ctx.prompt()
|
|
103
|
+
else:
|
|
104
|
+
async for chunk in conv.stream(user_input):
|
|
105
|
+
console.print(chunk, end="")
|
|
106
|
+
console.print()
|
|
107
|
+
if tokens := _format_tokens(conv):
|
|
108
|
+
console.print(f"[dim]{tokens}[/dim]")
|
|
109
|
+
break
|
|
110
|
+
finally:
|
|
111
|
+
ctx.set_silent_queue(False)
|
|
112
|
+
ctx.clear_queue() # Discard any messages typed during streaming
|
|
113
|
+
ctx.set_status_above(None)
|
|
114
|
+
ctx.set_hint(None)
|
|
115
|
+
ctx.set_rule_above(remove_class="chat-mode")
|
|
116
|
+
ctx.set_rule_below(remove_class="chat-mode")
|
|
117
|
+
if original_style:
|
|
118
|
+
ctx.set_prompt_style(original_style)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@chat_group.command("clear", help="Clear conversation history")
|
|
122
|
+
async def chat_clear_fn(
|
|
123
|
+
model: str | None = Option(
|
|
124
|
+
None,
|
|
125
|
+
"-m",
|
|
126
|
+
"--model",
|
|
127
|
+
help="Model session to clear (all if omitted)",
|
|
128
|
+
choices=ALL_MODELS,
|
|
129
|
+
),
|
|
130
|
+
ctx: TuiContext = TuiOption(),
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Clear conversation history for a model or all models."""
|
|
133
|
+
if model:
|
|
134
|
+
if model in _sessions:
|
|
135
|
+
_sessions[model][1].clear()
|
|
136
|
+
ctx.notify(f"Cleared history for {model}", title="Chat")
|
|
137
|
+
print(f"Cleared conversation history for {model}")
|
|
138
|
+
else:
|
|
139
|
+
print(f"No active session for {model}")
|
|
140
|
+
else:
|
|
141
|
+
for _, conv in _sessions.values():
|
|
142
|
+
conv.clear()
|
|
143
|
+
ctx.notify("Cleared all chat history", title="Chat")
|
|
144
|
+
print("Cleared all conversation history")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from padwan_llm._base import LLMClientBase
|
|
7
|
+
from padwan_llm.conversation import ConversationState, Message
|
|
8
|
+
from padwan_llm.models import UsageToken
|
|
9
|
+
|
|
10
|
+
__all__ = ("Conversation", "Message")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Conversation:
|
|
15
|
+
"""Manages a multi-turn conversation with an LLM client.
|
|
16
|
+
|
|
17
|
+
The Conversation class wraps ConversationState and provides streaming
|
|
18
|
+
interface for chat interactions. Provider-specific details are handled
|
|
19
|
+
by the client's stream_chat method.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
async with LLMClient(model="gpt-4o") as client:
|
|
23
|
+
conv = Conversation(client, system="You are a helpful assistant.")
|
|
24
|
+
async for chunk in conv.stream("Hello!"):
|
|
25
|
+
print(chunk, end="", flush=True)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
client: LLMClientBase
|
|
29
|
+
system: str | None = None
|
|
30
|
+
_state: ConversationState = field(init=False)
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
self._state = ConversationState(system=self.system)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def messages(self) -> list[Message]:
|
|
37
|
+
"""Access the conversation message history."""
|
|
38
|
+
return self._state.messages
|
|
39
|
+
|
|
40
|
+
@messages.setter
|
|
41
|
+
def messages(self, value: list[Message]) -> None:
|
|
42
|
+
"""Set the conversation message history."""
|
|
43
|
+
self._state.messages = value
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def last_usage(self) -> UsageToken | None:
|
|
47
|
+
"""Token usage from the last request."""
|
|
48
|
+
return self._state.last_usage
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def total_usage(self) -> UsageToken:
|
|
52
|
+
"""Cumulative token usage across all requests."""
|
|
53
|
+
return self._state.total_usage
|
|
54
|
+
|
|
55
|
+
def add_user_message(self, content: str) -> Message:
|
|
56
|
+
"""Add a user message to the conversation history."""
|
|
57
|
+
return self._state.add_user_message(content)
|
|
58
|
+
|
|
59
|
+
def add_assistant_message(self, content: str) -> Message:
|
|
60
|
+
"""Add an assistant message to the conversation history."""
|
|
61
|
+
return self._state.add_assistant_message(content)
|
|
62
|
+
|
|
63
|
+
def clear(self) -> None:
|
|
64
|
+
"""Clear conversation history, keeping only the system message if set."""
|
|
65
|
+
self._state.clear()
|
|
66
|
+
|
|
67
|
+
async def stream(self, user_input: str) -> AsyncIterator[str]:
|
|
68
|
+
"""Send a user message and stream the assistant's response.
|
|
69
|
+
|
|
70
|
+
Adds the user message to history, streams the response via the client's
|
|
71
|
+
stream_chat method, yields text as it arrives, and saves the complete
|
|
72
|
+
response to history when done. Also tracks token usage.
|
|
73
|
+
"""
|
|
74
|
+
self.add_user_message(user_input)
|
|
75
|
+
|
|
76
|
+
response_chunks: list[str] = []
|
|
77
|
+
chat_stream = self.client.stream_chat(list(self.messages))
|
|
78
|
+
|
|
79
|
+
async for text in chat_stream:
|
|
80
|
+
response_chunks.append(text)
|
|
81
|
+
yield text
|
|
82
|
+
|
|
83
|
+
if chat_stream.usage:
|
|
84
|
+
self._state.accumulate_usage(chat_stream.usage)
|
|
85
|
+
|
|
86
|
+
full_response = "".join(response_chunks)
|
|
87
|
+
self.add_assistant_message(full_response)
|
|
88
|
+
|
|
89
|
+
async def send(self, user_input: str) -> str:
|
|
90
|
+
"""Send a user message and return the complete response (non-streaming)."""
|
|
91
|
+
chunks = []
|
|
92
|
+
async for chunk in self.stream(user_input):
|
|
93
|
+
chunks.append(chunk)
|
|
94
|
+
return "".join(chunks)
|
padwan_cli/run.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from piou import Cli, Option
|
|
6
|
+
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from padwan_llm import (
|
|
10
|
+
GEMINI_MODELS,
|
|
11
|
+
GROK_MODELS,
|
|
12
|
+
MISTRAL_MODELS,
|
|
13
|
+
OPENAI_MODELS,
|
|
14
|
+
LLMClient,
|
|
15
|
+
)
|
|
16
|
+
from padwan_llm.conversation import Message
|
|
17
|
+
from padwan_llm.errors import Provider
|
|
18
|
+
|
|
19
|
+
from .utils import ALL_MODELS, console
|
|
20
|
+
from .batch import batch_group
|
|
21
|
+
from .chat import chat_group
|
|
22
|
+
|
|
23
|
+
CUSTOM_CSS = """
|
|
24
|
+
Rule.chat-mode {
|
|
25
|
+
color: #87ceeb;
|
|
26
|
+
}
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
cli = Cli(description="Padwan CLI for the unified LLM client library")
|
|
30
|
+
|
|
31
|
+
cli.add_command_group(batch_group)
|
|
32
|
+
cli.add_command_group(chat_group)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@cli.command("models", help="List available models")
|
|
36
|
+
def list_models(
|
|
37
|
+
provider: Provider | None = Option(
|
|
38
|
+
None, "-p", "--provider", help="Filter by provider"
|
|
39
|
+
),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""List all available models, optionally filtered by provider."""
|
|
42
|
+
provider_models: dict[str, list[str]] = {}
|
|
43
|
+
if not provider or provider == "openai":
|
|
44
|
+
provider_models["OpenAI"] = list(OPENAI_MODELS)
|
|
45
|
+
if not provider or provider == "gemini":
|
|
46
|
+
provider_models["Gemini"] = list(GEMINI_MODELS)
|
|
47
|
+
if not provider or provider == "mistral":
|
|
48
|
+
provider_models["Mistral"] = list(MISTRAL_MODELS)
|
|
49
|
+
if not provider or provider == "grok":
|
|
50
|
+
provider_models["Grok"] = list(GROK_MODELS)
|
|
51
|
+
|
|
52
|
+
if not provider_models:
|
|
53
|
+
console.print(f"[red]Unknown provider: {provider}[/red]")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
table = Table(title="Available Models")
|
|
57
|
+
table.add_column("Provider", style="cyan")
|
|
58
|
+
table.add_column("Model", style="green")
|
|
59
|
+
|
|
60
|
+
for prov, models in provider_models.items():
|
|
61
|
+
for i, model in enumerate(sorted(models)):
|
|
62
|
+
table.add_row(prov if i == 0 else "", model)
|
|
63
|
+
|
|
64
|
+
console.print(table)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cli.command("info", help="Show library information")
|
|
68
|
+
def info() -> None:
|
|
69
|
+
"""Show library information."""
|
|
70
|
+
table = Table(title="Padwan LLM Library")
|
|
71
|
+
table.add_column("Provider", style="cyan")
|
|
72
|
+
table.add_column("Models", style="green", justify="right")
|
|
73
|
+
|
|
74
|
+
table.add_row("OpenAI", str(len(OPENAI_MODELS)))
|
|
75
|
+
table.add_row("Gemini", str(len(GEMINI_MODELS)))
|
|
76
|
+
table.add_row("Mistral", str(len(MISTRAL_MODELS)))
|
|
77
|
+
table.add_row("Grok", str(len(GROK_MODELS)))
|
|
78
|
+
|
|
79
|
+
total = (
|
|
80
|
+
len(OPENAI_MODELS) + len(GEMINI_MODELS) + len(MISTRAL_MODELS) + len(GROK_MODELS)
|
|
81
|
+
)
|
|
82
|
+
table.add_section()
|
|
83
|
+
table.add_row("[bold]Total[/bold]", f"[bold]{total}[/bold]")
|
|
84
|
+
|
|
85
|
+
console.print(table)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@cli.main(help="One-shot LLM query")
|
|
89
|
+
async def oneshot(
|
|
90
|
+
prompt: str = Option(..., help="Prompt to send"),
|
|
91
|
+
model: str = Option(
|
|
92
|
+
"gpt-4o-mini", "-m", "--model", help="Model to use", choices=ALL_MODELS
|
|
93
|
+
),
|
|
94
|
+
stream: bool = Option(False, "--stream", "-s", help="Stream output as it arrives"),
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Send a single prompt and print the response."""
|
|
97
|
+
client = LLMClient(model=model)
|
|
98
|
+
messages: list[Message] = [Message(role="user", content=prompt)]
|
|
99
|
+
async with client:
|
|
100
|
+
if stream:
|
|
101
|
+
async for chunk in client.stream_chat(messages):
|
|
102
|
+
sys.stdout.write(chunk)
|
|
103
|
+
sys.stdout.flush()
|
|
104
|
+
else:
|
|
105
|
+
text, _ = await client.complete_chat(messages)
|
|
106
|
+
print(text)
|
|
107
|
+
print()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main() -> None:
|
|
111
|
+
"""Entry point for the CLI."""
|
|
112
|
+
cli.run(css=CUSTOM_CSS)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main_tui() -> None:
|
|
116
|
+
"""Entry point for the TUI CLI."""
|
|
117
|
+
cli.tui = True
|
|
118
|
+
main()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
main()
|
padwan_cli/utils.py
ADDED
padwan_cli/widgets.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, cast
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.widgets import Static
|
|
7
|
+
|
|
8
|
+
from piou.tui import StreamingMessage as _StreamingMessage
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from padwan_llm.gemini import BatchJob, BatchResult
|
|
12
|
+
|
|
13
|
+
# Colors for chat messages
|
|
14
|
+
USER_COLOR = "#87ceeb"
|
|
15
|
+
ASSISTANT_COLOR = "#98fb98"
|
|
16
|
+
|
|
17
|
+
# Colors for batch job states
|
|
18
|
+
STATE_COLORS = {
|
|
19
|
+
"JOB_STATE_PENDING": "#ffa500",
|
|
20
|
+
"JOB_STATE_QUEUED": "#ffff00",
|
|
21
|
+
"JOB_STATE_RUNNING": "#00bfff",
|
|
22
|
+
"JOB_STATE_SUCCEEDED": "#98fb98",
|
|
23
|
+
"JOB_STATE_FAILED": "#ff6b6b",
|
|
24
|
+
"JOB_STATE_CANCELLED": "#808080",
|
|
25
|
+
"JOB_STATE_EXPIRED": "#808080",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UserMessage(Static):
|
|
30
|
+
"""A styled widget for displaying user messages."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, content: str, **kwargs) -> None:
|
|
33
|
+
text = Text(content, style=USER_COLOR)
|
|
34
|
+
super().__init__(text, **kwargs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StreamingMessage(_StreamingMessage):
|
|
38
|
+
"""Streaming message with assistant color styling.
|
|
39
|
+
|
|
40
|
+
Extends piou's StreamingMessage (which handles autoscroll) to render
|
|
41
|
+
text in the assistant color.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def append(self, text: str) -> None:
|
|
45
|
+
self._text += text
|
|
46
|
+
self.update(Text(self._text, style=ASSISTANT_COLOR))
|
|
47
|
+
from piou.tui import TuiApp
|
|
48
|
+
|
|
49
|
+
if isinstance(self.app, TuiApp):
|
|
50
|
+
self.call_after_refresh(self.app._scroll_to_bottom)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BatchProgressWidget(Static):
|
|
54
|
+
"""A widget that displays batch job progress with state coloring.
|
|
55
|
+
|
|
56
|
+
Shows job name, current state (with color), progress bar showing
|
|
57
|
+
succeeded/total requests, and elapsed time. Call update() to refresh
|
|
58
|
+
with new job data.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, job: BatchJob, **kwargs) -> None:
|
|
62
|
+
super().__init__(**kwargs)
|
|
63
|
+
self._job = job
|
|
64
|
+
self._state_history: list[str] = [job.state]
|
|
65
|
+
self._refresh_display()
|
|
66
|
+
|
|
67
|
+
def _refresh_display(self) -> None:
|
|
68
|
+
"""Update the display with current job state."""
|
|
69
|
+
job = self._job
|
|
70
|
+
state_color = STATE_COLORS.get(job.state, "white")
|
|
71
|
+
|
|
72
|
+
# Build progress info
|
|
73
|
+
succeeded = 0
|
|
74
|
+
total = 0
|
|
75
|
+
if job.stats:
|
|
76
|
+
succeeded = cast(int, job.stats.get("successfulRequestCount", 0))
|
|
77
|
+
total = cast(int, job.stats.get("requestCount", 0))
|
|
78
|
+
|
|
79
|
+
# Create progress bar
|
|
80
|
+
bar_width = 30
|
|
81
|
+
if total > 0:
|
|
82
|
+
filled = int((succeeded / total) * bar_width)
|
|
83
|
+
else:
|
|
84
|
+
filled = 0
|
|
85
|
+
bar = "\u2588" * filled + "\u2591" * (bar_width - filled)
|
|
86
|
+
|
|
87
|
+
# Build display text
|
|
88
|
+
lines = [
|
|
89
|
+
f"Job: {job.name.split('/')[-1]}",
|
|
90
|
+
f"State: [{state_color}]{job.state}[/{state_color}]",
|
|
91
|
+
f"Progress: [{bar}] {succeeded}/{total}",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
if job.display_name:
|
|
95
|
+
lines.insert(1, f"Name: {job.display_name}")
|
|
96
|
+
|
|
97
|
+
# Show state history if there were transitions
|
|
98
|
+
if len(self._state_history) > 1:
|
|
99
|
+
history = " -> ".join(
|
|
100
|
+
s.replace("JOB_STATE_", "") for s in self._state_history
|
|
101
|
+
)
|
|
102
|
+
lines.append(f"History: {history}")
|
|
103
|
+
|
|
104
|
+
text = Text("\n".join(lines))
|
|
105
|
+
self.update(text)
|
|
106
|
+
|
|
107
|
+
def update_job(self, job: BatchJob) -> None:
|
|
108
|
+
"""Update with new job data and refresh display."""
|
|
109
|
+
if job.state != self._job.state:
|
|
110
|
+
self._state_history.append(job.state)
|
|
111
|
+
self._job = job
|
|
112
|
+
self._refresh_display()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class BatchResultWidget(Static):
|
|
116
|
+
"""A widget that displays batch results in a styled format.
|
|
117
|
+
|
|
118
|
+
Shows each result with its key, token counts, and content preview.
|
|
119
|
+
Useful for viewing results in TUI mode.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, results: list[BatchResult], **kwargs) -> None:
|
|
123
|
+
super().__init__(**kwargs)
|
|
124
|
+
self._results = results
|
|
125
|
+
self._refresh_display()
|
|
126
|
+
|
|
127
|
+
def _refresh_display(self) -> None:
|
|
128
|
+
"""Update the display with results."""
|
|
129
|
+
lines: list[str] = []
|
|
130
|
+
|
|
131
|
+
for result in self._results:
|
|
132
|
+
# Header with key and token info
|
|
133
|
+
lines.append(
|
|
134
|
+
f"[cyan][{result.key}][/cyan] "
|
|
135
|
+
f"[dim]({result.input_tokens} in, {result.output_tokens} out, "
|
|
136
|
+
f"{result.total_tokens} total tokens)[/dim]"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Content preview (truncated)
|
|
140
|
+
preview = result.content[:300].replace("\n", " ")
|
|
141
|
+
if len(result.content) > 300:
|
|
142
|
+
preview += "..."
|
|
143
|
+
lines.append(f" {preview}")
|
|
144
|
+
lines.append("")
|
|
145
|
+
|
|
146
|
+
text = Text.from_markup("\n".join(lines))
|
|
147
|
+
self.update(text)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: padwan-cli
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: CLI and TUI for padwan-llm
|
|
5
|
+
Author-email: julien.brayere@polarsen.com
|
|
6
|
+
Requires-Dist: padwan-llm>=0.1.0
|
|
7
|
+
Requires-Dist: piou[tui]>=0.32.0
|
|
8
|
+
Requires-Python: >=3.14
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Padwan CLI
|
|
12
|
+
|
|
13
|
+
Interactive CLI/TUI for `padwan-llm`.
|
|
14
|
+
|
|
15
|
+
<img alt="Chat demo" src="https://github.com/Polarsen/padwan-cli/raw/main/docs/static/chat.gif" width="800"/>
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
export OPENAI_API_KEY=...
|
|
21
|
+
uvx padwan-cli
|
|
22
|
+
# then /help for commands
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install padwan-cli
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Local Development
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv sync --group dev
|
|
36
|
+
uv run padwan-cli
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## One-shot Mode
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uvx padwan-cli "Hello" -m gpt-4o-mini
|
|
43
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
padwan_cli/__init__.py,sha256=46u382aEbK6On_K1PIBA02P8pGz3Ua2gSD-GfKHZSJ0,112
|
|
2
|
+
padwan_cli/__main__.py,sha256=quL6_aGFD0gBUGl3o6A09WaBgLhi8Y9BMt_MHCFWBXs,36
|
|
3
|
+
padwan_cli/batch/__init__.py,sha256=OA-_Oh8r_pPiLLmJ3buEoiyoVOD0Ho3UwX0UKuWxU_8,92
|
|
4
|
+
padwan_cli/batch/format.py,sha256=B69p_N9Cc3FlN1hw69sQKvrO8aqE3ZHpDA6KL137isY,2039
|
|
5
|
+
padwan_cli/batch/run.py,sha256=xHTqKc2PEP1Yr2Jmv_hTDcSBcktPGFPLcoBLkglFowE,10237
|
|
6
|
+
padwan_cli/batch/utils.py,sha256=fCxNWv469S-KMFBMGnvJPau8MMObDi9Z5m1zuwyWsUY,2817
|
|
7
|
+
padwan_cli/chat.py,sha256=qPOpc7_qcc3BkScafafRhnCuc72ndzZHsZZBvzuWPNE,5241
|
|
8
|
+
padwan_cli/conversation.py,sha256=ZlOaBDtJQCbRzJKLn6I-XVTUOoPJux4CXY4zNUwPrXc,3258
|
|
9
|
+
padwan_cli/run.py,sha256=zcfDginL4bE5L4mlE03yX_Bm1o-8pZkfFeCwr98kYow,3444
|
|
10
|
+
padwan_cli/utils.py,sha256=N65RWM1ytdcZO-L_CWrNxPlreCuERNSsm6OAU64xbxs,222
|
|
11
|
+
padwan_cli/widgets.py,sha256=ctE30HMySxVwDNy4ecg5fuzL0YcBN8mXOAa3752TQos,4560
|
|
12
|
+
padwan_cli-0.2.1.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
|
|
13
|
+
padwan_cli-0.2.1.dist-info/entry_points.txt,sha256=r61oju2P2nrtgTOfnm4_aCh00RpTr1K7yTNVv8ZLR7E,52
|
|
14
|
+
padwan_cli-0.2.1.dist-info/METADATA,sha256=fHHkiAomzF86iwACCzhhxqTlSWU5IFkCm7nJHzNejXY,713
|
|
15
|
+
padwan_cli-0.2.1.dist-info/RECORD,,
|