padwan-cli 0.4.0__tar.gz → 0.6.0__tar.gz
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-0.6.0/PKG-INFO +81 -0
- padwan_cli-0.6.0/README.md +62 -0
- {padwan_cli-0.4.0 → padwan_cli-0.6.0}/padwan_cli/chat.py +169 -12
- {padwan_cli-0.4.0 → padwan_cli-0.6.0}/padwan_cli/run.py +43 -2
- padwan_cli-0.6.0/padwan_cli/talk.py +411 -0
- padwan_cli-0.6.0/padwan_cli/trace.py +70 -0
- padwan_cli-0.6.0/padwan_cli/utils.py +21 -0
- {padwan_cli-0.4.0 → padwan_cli-0.6.0}/padwan_cli/widgets.py +80 -2
- {padwan_cli-0.4.0 → padwan_cli-0.6.0}/pyproject.toml +32 -25
- padwan_cli-0.6.0/pyproject.toml.orig +67 -0
- padwan_cli-0.4.0/PKG-INFO +0 -46
- padwan_cli-0.4.0/README.md +0 -36
- padwan_cli-0.4.0/padwan_cli/utils.py +0 -7
- {padwan_cli-0.4.0 → padwan_cli-0.6.0}/padwan_cli/__init__.py +0 -0
- {padwan_cli-0.4.0 → padwan_cli-0.6.0}/padwan_cli/__main__.py +0 -0
- {padwan_cli-0.4.0 → padwan_cli-0.6.0}/padwan_cli/batch/__init__.py +0 -0
- {padwan_cli-0.4.0 → padwan_cli-0.6.0}/padwan_cli/batch/format.py +0 -0
- {padwan_cli-0.4.0 → padwan_cli-0.6.0}/padwan_cli/batch/run.py +0 -0
- {padwan_cli-0.4.0 → padwan_cli-0.6.0}/padwan_cli/batch/utils.py +0 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: padwan-cli
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: CLI and TUI for padwan-llm
|
|
5
|
+
Author-email: julien.brayere@polarsen.com
|
|
6
|
+
Requires-Dist: padwan-llm[realtime]>=0.9.0
|
|
7
|
+
Requires-Dist: piou[tui]>=0.36.0
|
|
8
|
+
Requires-Dist: tracktolib>=0.74.0
|
|
9
|
+
Requires-Dist: padwan-llm[langfuse]>=0.9.0 ; extra == 'langfuse'
|
|
10
|
+
Requires-Dist: padwan-llm[otel]>=0.9.0 ; extra == 'otel'
|
|
11
|
+
Requires-Dist: opentelemetry-sdk>=1.44 ; extra == 'otel'
|
|
12
|
+
Requires-Dist: opentelemetry-exporter-otlp>=1.44 ; extra == 'otel'
|
|
13
|
+
Requires-Dist: sounddevice>=0.5 ; extra == 'voice'
|
|
14
|
+
Requires-Python: >=3.14
|
|
15
|
+
Provides-Extra: langfuse
|
|
16
|
+
Provides-Extra: otel
|
|
17
|
+
Provides-Extra: voice
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Padwan CLI
|
|
21
|
+
|
|
22
|
+
Interactive CLI/TUI for [`padwan-llm`](https://github.com/polarsen-io/padwan-llm).
|
|
23
|
+
|
|
24
|
+
> [!NOTE]
|
|
25
|
+
> This is a playground for experimenting with `padwan-llm` features (streaming, agents, MCP, thinking tokens, batch jobs, realtime voice) — not a production-grade tool. Expect rough edges and breaking changes.
|
|
26
|
+
|
|
27
|
+
<img alt="Chat demo" src="https://github.com/polarsen-io/padwan-cli/raw/master/docs/static/chat.gif" width="800"/>
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
export OPENAI_API_KEY=...
|
|
33
|
+
uvx padwan-cli
|
|
34
|
+
# then /help for commands
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install padwan-cli
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Local Development
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv sync --group dev
|
|
48
|
+
uv run padwan-cli
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## One-shot Mode
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uvx padwan-cli "Hello" -m gpt-4o-mini
|
|
55
|
+
|
|
56
|
+
# attach files (images/audio/text, routed by type)
|
|
57
|
+
uvx padwan-cli "What's in this recording?" -m gpt-4o-audio-preview -f clip.mp3
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Voice Mode
|
|
61
|
+
|
|
62
|
+
Real-time speech-to-speech chat (needs the PortAudio system lib, e.g. `libportaudio2`):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uvx --from "padwan-cli[voice]" padwan-talk
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Tracing
|
|
69
|
+
|
|
70
|
+
Pass `--trace <backend>` (on the one-shot, `chat send`, and `talk` commands) to instrument all LLM calls with [padwan-llm's OTel GenAI telemetry](https://github.com/polarsen-io/padwan-llm/blob/master/docs/observability.md):
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Langfuse, using the standard LANGFUSE_* env vars
|
|
74
|
+
export LANGFUSE_PUBLIC_KEY=... LANGFUSE_SECRET_KEY=... LANGFUSE_BASE_URL=...
|
|
75
|
+
uvx --from "padwan-cli[langfuse]" padwan-cli "Hello" --trace langfuse
|
|
76
|
+
|
|
77
|
+
# any OTLP collector, using the standard OTEL_EXPORTER_OTLP_* env vars
|
|
78
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
|
|
79
|
+
uvx --from "padwan-cli[otel]" padwan-cli "Hello" --trace otlp
|
|
80
|
+
```
|
|
81
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Padwan CLI
|
|
2
|
+
|
|
3
|
+
Interactive CLI/TUI for [`padwan-llm`](https://github.com/polarsen-io/padwan-llm).
|
|
4
|
+
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> This is a playground for experimenting with `padwan-llm` features (streaming, agents, MCP, thinking tokens, batch jobs, realtime voice) — not a production-grade tool. Expect rough edges and breaking changes.
|
|
7
|
+
|
|
8
|
+
<img alt="Chat demo" src="https://github.com/polarsen-io/padwan-cli/raw/master/docs/static/chat.gif" width="800"/>
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
export OPENAI_API_KEY=...
|
|
14
|
+
uvx padwan-cli
|
|
15
|
+
# then /help for commands
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install padwan-cli
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Local Development
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv sync --group dev
|
|
29
|
+
uv run padwan-cli
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## One-shot Mode
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uvx padwan-cli "Hello" -m gpt-4o-mini
|
|
36
|
+
|
|
37
|
+
# attach files (images/audio/text, routed by type)
|
|
38
|
+
uvx padwan-cli "What's in this recording?" -m gpt-4o-audio-preview -f clip.mp3
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Voice Mode
|
|
42
|
+
|
|
43
|
+
Real-time speech-to-speech chat (needs the PortAudio system lib, e.g. `libportaudio2`):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uvx --from "padwan-cli[voice]" padwan-talk
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Tracing
|
|
50
|
+
|
|
51
|
+
Pass `--trace <backend>` (on the one-shot, `chat send`, and `talk` commands) to instrument all LLM calls with [padwan-llm's OTel GenAI telemetry](https://github.com/polarsen-io/padwan-llm/blob/master/docs/observability.md):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Langfuse, using the standard LANGFUSE_* env vars
|
|
55
|
+
export LANGFUSE_PUBLIC_KEY=... LANGFUSE_SECRET_KEY=... LANGFUSE_BASE_URL=...
|
|
56
|
+
uvx --from "padwan-cli[langfuse]" padwan-cli "Hello" --trace langfuse
|
|
57
|
+
|
|
58
|
+
# any OTLP collector, using the standard OTEL_EXPORTER_OTLP_* env vars
|
|
59
|
+
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
|
|
60
|
+
uvx --from "padwan-cli[otel]" padwan-cli "Hello" --trace otlp
|
|
61
|
+
```
|
|
62
|
+
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import contextlib
|
|
2
2
|
import json
|
|
3
|
+
import mimetypes
|
|
3
4
|
import time
|
|
4
5
|
from contextlib import contextmanager
|
|
5
|
-
from
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast, get_args
|
|
6
8
|
|
|
7
9
|
from piou import Option, CommandGroup
|
|
8
10
|
from piou.tui import PromptStyle, TuiContext, TuiOption
|
|
@@ -10,22 +12,35 @@ from textual.css.query import NoMatches
|
|
|
10
12
|
|
|
11
13
|
from padwan_llm import (
|
|
12
14
|
AgentSession,
|
|
15
|
+
ContentPart,
|
|
13
16
|
ConversationSnapshot,
|
|
14
17
|
LLMClient,
|
|
15
18
|
McpStreamable,
|
|
16
19
|
McpTool,
|
|
17
20
|
McpTransport,
|
|
18
21
|
ToolCallContext,
|
|
22
|
+
audio_part,
|
|
23
|
+
image_part,
|
|
24
|
+
supports_audio,
|
|
25
|
+
supports_vision,
|
|
26
|
+
text_file_part,
|
|
27
|
+
text_part,
|
|
19
28
|
)
|
|
29
|
+
from padwan_llm.content import AudioFormat
|
|
20
30
|
from padwan_llm.gemini import GeminiClient
|
|
21
31
|
from padwan_llm.gemini.models import ThinkingConfig
|
|
32
|
+
from .trace import TRACE_BACKENDS, TraceBackend, enable_tracing
|
|
22
33
|
from .utils import ALL_MODELS, console
|
|
23
34
|
from .widgets import (
|
|
35
|
+
Attachment,
|
|
36
|
+
AttachmentBadge,
|
|
37
|
+
AttachmentKind,
|
|
24
38
|
ErrorMessage,
|
|
25
39
|
StreamingMessage,
|
|
26
40
|
ThoughtMessage,
|
|
27
41
|
ToolCallMessage,
|
|
28
42
|
UserMessage,
|
|
43
|
+
render_attachments,
|
|
29
44
|
)
|
|
30
45
|
|
|
31
46
|
chat_group = CommandGroup(name="chat", help="Chat with an LLM")
|
|
@@ -85,6 +100,76 @@ def _format_tokens(session: AgentSession, ctx: TuiContext | None = None) -> str:
|
|
|
85
100
|
return " ".join(parts)
|
|
86
101
|
|
|
87
102
|
|
|
103
|
+
_AUDIO_FORMATS: tuple[AudioFormat, ...] = get_args(AudioFormat)
|
|
104
|
+
_KIND_LABELS = {"image": "images", "audio": "audio", "text": "files"}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _audio_fmt(path: Path) -> AudioFormat | None:
|
|
108
|
+
"""Derive the audio format from the file extension, if it is a known one."""
|
|
109
|
+
fmt = path.suffix.removeprefix(".").lower()
|
|
110
|
+
return cast(AudioFormat, fmt) if fmt in _AUDIO_FORMATS else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _unsupported_warning(
|
|
114
|
+
model: str, attachments: list[Attachment], *, suffix: str
|
|
115
|
+
) -> str | None:
|
|
116
|
+
"""Amber warning line for attachments the model can't consume, or None."""
|
|
117
|
+
skipped = [a for a in attachments if not a.supported]
|
|
118
|
+
if not skipped:
|
|
119
|
+
return None
|
|
120
|
+
kinds = "/".join(dict.fromkeys(_KIND_LABELS[a.kind] for a in skipped))
|
|
121
|
+
return f"{model} can't read {kinds} — {len(skipped)} {suffix}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _build_user_content(
|
|
125
|
+
text: str, attachments: list[Attachment]
|
|
126
|
+
) -> str | list[ContentPart]:
|
|
127
|
+
"""Assemble the user turn from typed text and queued attachments.
|
|
128
|
+
|
|
129
|
+
Returns the plain string when nothing is attached. Otherwise builds content
|
|
130
|
+
parts: the text, each text file inlined, and each supported image or audio
|
|
131
|
+
file as a data payload. Unsupported media (model can't see/hear it) and
|
|
132
|
+
unreadable files are dropped from the payload — they still appear in the
|
|
133
|
+
sent badge.
|
|
134
|
+
"""
|
|
135
|
+
if not attachments:
|
|
136
|
+
return text
|
|
137
|
+
parts: list[ContentPart] = []
|
|
138
|
+
if text:
|
|
139
|
+
parts.append(text_part(text))
|
|
140
|
+
for a in attachments:
|
|
141
|
+
if not a.supported:
|
|
142
|
+
continue
|
|
143
|
+
if a.kind == "image":
|
|
144
|
+
parts.append(image_part(a.path))
|
|
145
|
+
elif a.kind == "audio":
|
|
146
|
+
try:
|
|
147
|
+
parts.append(audio_part(a.path))
|
|
148
|
+
except OSError, ValueError:
|
|
149
|
+
continue
|
|
150
|
+
else:
|
|
151
|
+
try:
|
|
152
|
+
parts.append(text_file_part(a.path))
|
|
153
|
+
except OSError, UnicodeDecodeError:
|
|
154
|
+
continue
|
|
155
|
+
return parts
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _parse_extra_params(raw: str | None) -> tuple[dict[str, Any] | None, bool]:
|
|
159
|
+
"""Parse --extra-params JSON; print the error and return ok=False on failure."""
|
|
160
|
+
if raw is None:
|
|
161
|
+
return None, True
|
|
162
|
+
try:
|
|
163
|
+
parsed = json.loads(raw)
|
|
164
|
+
except json.JSONDecodeError as e:
|
|
165
|
+
console.print(f"[red]--extra-params is not valid JSON: {e}[/red]")
|
|
166
|
+
return None, False
|
|
167
|
+
if not isinstance(parsed, dict):
|
|
168
|
+
console.print("[red]--extra-params must be a JSON object[/red]")
|
|
169
|
+
return None, False
|
|
170
|
+
return parsed, True
|
|
171
|
+
|
|
172
|
+
|
|
88
173
|
@chat_group.command("send", help="Send a message to the LLM")
|
|
89
174
|
async def chat_send_fn(
|
|
90
175
|
message: str = Option(..., help="Message to send"),
|
|
@@ -106,6 +191,12 @@ async def chat_send_fn(
|
|
|
106
191
|
"--mcp",
|
|
107
192
|
help=f"Streamable-HTTP MCP server URL(s) to expose as tools (e.g. {DATAGOUV_MCP_URL})",
|
|
108
193
|
),
|
|
194
|
+
trace: TraceBackend | None = Option(
|
|
195
|
+
None,
|
|
196
|
+
"--trace",
|
|
197
|
+
help="Export LLM telemetry to this backend",
|
|
198
|
+
choices=TRACE_BACKENDS,
|
|
199
|
+
),
|
|
109
200
|
ctx: TuiContext = TuiOption(),
|
|
110
201
|
) -> None:
|
|
111
202
|
"""Start a conversation. Use Ctrl+C to exit."""
|
|
@@ -158,16 +249,12 @@ async def chat_send_fn(
|
|
|
158
249
|
else:
|
|
159
250
|
console.print(f"[green]MCP connected[/green] [dim]{transport.label}[/dim]")
|
|
160
251
|
|
|
161
|
-
parsed_extra
|
|
162
|
-
if
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return
|
|
168
|
-
if not isinstance(parsed_extra, dict):
|
|
169
|
-
console.print("[red]--extra-params must be a JSON object[/red]")
|
|
170
|
-
return
|
|
252
|
+
parsed_extra, ok = _parse_extra_params(extra_params)
|
|
253
|
+
if not ok:
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
if trace:
|
|
257
|
+
enable_tracing(trace)
|
|
171
258
|
|
|
172
259
|
try:
|
|
173
260
|
client = LLMClient(model=model, on_thought=_on_thought, base_url=base_url)
|
|
@@ -196,16 +283,81 @@ async def chat_send_fn(
|
|
|
196
283
|
ctx.set_rule_above(add_class="chat-mode")
|
|
197
284
|
ctx.set_rule_below(add_class="chat-mode")
|
|
198
285
|
|
|
286
|
+
# Files dropped onto the terminal arrive as a paste of their path(s);
|
|
287
|
+
# queue them here and send with the next typed message.
|
|
288
|
+
pending: list[Attachment] = []
|
|
289
|
+
|
|
290
|
+
def _set_attach_warning(on: bool) -> None:
|
|
291
|
+
"""Turn the input bars amber (or back to chat-mode cyan)."""
|
|
292
|
+
add, remove = (
|
|
293
|
+
("attach-warning", "chat-mode")
|
|
294
|
+
if on
|
|
295
|
+
else ("chat-mode", "attach-warning")
|
|
296
|
+
)
|
|
297
|
+
ctx.set_rule_above(add_class=add, remove_class=remove)
|
|
298
|
+
ctx.set_rule_below(add_class=add, remove_class=remove)
|
|
299
|
+
|
|
300
|
+
def _on_drop(paths: list[Path]) -> None:
|
|
301
|
+
for p in paths:
|
|
302
|
+
try:
|
|
303
|
+
size = p.stat().st_size
|
|
304
|
+
except OSError:
|
|
305
|
+
continue
|
|
306
|
+
mime = mimetypes.guess_type(p)[0] or ""
|
|
307
|
+
kind: AttachmentKind
|
|
308
|
+
if mime.startswith("image/"):
|
|
309
|
+
kind, supported = "image", supports_vision(model)
|
|
310
|
+
elif mime.startswith("audio/"):
|
|
311
|
+
kind, supported = "audio", supports_audio(model, _audio_fmt(p))
|
|
312
|
+
else:
|
|
313
|
+
kind, supported = "text", True
|
|
314
|
+
pending.append(
|
|
315
|
+
Attachment(
|
|
316
|
+
path=p,
|
|
317
|
+
name=p.name,
|
|
318
|
+
size=size,
|
|
319
|
+
kind=kind,
|
|
320
|
+
supported=supported,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
warning = _unsupported_warning(model, pending, suffix="will be skipped")
|
|
324
|
+
ctx.set_attachments(render_attachments(pending, warning=warning))
|
|
325
|
+
_set_attach_warning(warning is not None)
|
|
326
|
+
ctx.set_hint(
|
|
327
|
+
f"{len(pending)} attached — type a message · Ctrl+U clears"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def _on_clear() -> None:
|
|
331
|
+
pending.clear()
|
|
332
|
+
ctx.set_attachments(None)
|
|
333
|
+
_set_attach_warning(False)
|
|
334
|
+
ctx.set_hint("Chat mode - press Ctrl+C to exit")
|
|
335
|
+
|
|
336
|
+
ctx.register_paste_handler(_on_drop)
|
|
337
|
+
ctx.register_attachment_clear(_on_clear)
|
|
338
|
+
|
|
199
339
|
try:
|
|
200
340
|
while user_input:
|
|
201
341
|
if ctx.is_tui:
|
|
342
|
+
sent = list(pending)
|
|
202
343
|
ctx.mount_widget(UserMessage(user_input))
|
|
344
|
+
if sent:
|
|
345
|
+
badge_warning = _unsupported_warning(
|
|
346
|
+
model, sent, suffix="skipped"
|
|
347
|
+
)
|
|
348
|
+
ctx.mount_widget(
|
|
349
|
+
AttachmentBadge(sent, warning=badge_warning)
|
|
350
|
+
)
|
|
351
|
+
content = _build_user_content(user_input, sent)
|
|
352
|
+
pending.clear()
|
|
353
|
+
ctx.set_attachments(None)
|
|
354
|
+
_set_attach_warning(False)
|
|
203
355
|
ctx.set_hint("Responding...")
|
|
204
356
|
ctx.set_silent_queue(True)
|
|
205
357
|
needs_new_text_widget = True
|
|
206
358
|
widget: StreamingMessage | None = None
|
|
207
359
|
try:
|
|
208
|
-
async for chunk in session.stream(
|
|
360
|
+
async for chunk in session.stream(content):
|
|
209
361
|
if needs_new_text_widget or widget is None:
|
|
210
362
|
widget = StreamingMessage()
|
|
211
363
|
ctx.mount_widget(widget)
|
|
@@ -237,6 +389,8 @@ async def chat_send_fn(
|
|
|
237
389
|
break
|
|
238
390
|
finally:
|
|
239
391
|
session.save()
|
|
392
|
+
ctx.register_paste_handler(None)
|
|
393
|
+
ctx.register_attachment_clear(None)
|
|
240
394
|
# Cleanup is best-effort — on a double-Ctrl+C the TUI screen
|
|
241
395
|
# is already tearing down, so query_one("#status-above") and
|
|
242
396
|
# friends raise NoMatches. Don't surface that as a misleading
|
|
@@ -245,9 +399,12 @@ async def chat_send_fn(
|
|
|
245
399
|
ctx.set_silent_queue(False)
|
|
246
400
|
ctx.clear_queue() # Discard any messages typed during streaming
|
|
247
401
|
ctx.set_status_above(None)
|
|
402
|
+
ctx.set_attachments(None)
|
|
248
403
|
ctx.set_hint(None)
|
|
249
404
|
ctx.set_rule_above(remove_class="chat-mode")
|
|
250
405
|
ctx.set_rule_below(remove_class="chat-mode")
|
|
406
|
+
ctx.set_rule_above(remove_class="attach-warning")
|
|
407
|
+
ctx.set_rule_below(remove_class="attach-warning")
|
|
251
408
|
if original_style:
|
|
252
409
|
ctx.set_prompt_style(original_style)
|
|
253
410
|
except Exception as e:
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
from piou import Cli, Option
|
|
@@ -10,11 +11,14 @@ from piou.tui import get_tui_context
|
|
|
10
11
|
from rich.table import Table
|
|
11
12
|
|
|
12
13
|
from padwan_llm import (
|
|
14
|
+
ANTHROPIC_MODELS,
|
|
13
15
|
GEMINI_MODELS,
|
|
14
16
|
GROK_MODELS,
|
|
15
17
|
MISTRAL_MODELS,
|
|
16
18
|
OPENAI_MODELS,
|
|
19
|
+
ContentPart,
|
|
17
20
|
LLMClient,
|
|
21
|
+
content_parts,
|
|
18
22
|
)
|
|
19
23
|
from padwan_llm._base import OnThought
|
|
20
24
|
from padwan_llm.conversation import Message
|
|
@@ -23,11 +27,16 @@ from padwan_llm.errors import Provider
|
|
|
23
27
|
from .utils import console
|
|
24
28
|
from .batch import batch_group
|
|
25
29
|
from .chat import chat_group
|
|
30
|
+
from .talk import talk_command
|
|
31
|
+
from .trace import TRACE_BACKENDS, TraceBackend, enable_tracing
|
|
26
32
|
|
|
27
33
|
CUSTOM_CSS = """
|
|
28
34
|
Rule.chat-mode {
|
|
29
35
|
color: #87ceeb;
|
|
30
36
|
}
|
|
37
|
+
Rule.attach-warning {
|
|
38
|
+
color: #fbbf24;
|
|
39
|
+
}
|
|
31
40
|
"""
|
|
32
41
|
|
|
33
42
|
cli = Cli(description="Padwan CLI for the unified LLM client library")
|
|
@@ -35,6 +44,8 @@ cli = Cli(description="Padwan CLI for the unified LLM client library")
|
|
|
35
44
|
cli.add_command_group(batch_group)
|
|
36
45
|
cli.add_command_group(chat_group)
|
|
37
46
|
|
|
47
|
+
cli.command("talk", help="Speech-to-speech voice chat")(talk_command)
|
|
48
|
+
|
|
38
49
|
|
|
39
50
|
@cli.tui_on_ready
|
|
40
51
|
def _tui_startup_hint() -> None:
|
|
@@ -58,6 +69,8 @@ def list_models(
|
|
|
58
69
|
provider_models["Mistral"] = list(MISTRAL_MODELS)
|
|
59
70
|
if not provider or provider == "grok":
|
|
60
71
|
provider_models["Grok"] = list(GROK_MODELS)
|
|
72
|
+
if not provider or provider == "anthropic":
|
|
73
|
+
provider_models["Anthropic"] = list(ANTHROPIC_MODELS)
|
|
61
74
|
|
|
62
75
|
if not provider_models:
|
|
63
76
|
console.print(f"[red]Unknown provider: {provider}[/red]")
|
|
@@ -85,9 +98,14 @@ def info() -> None:
|
|
|
85
98
|
table.add_row("Gemini", str(len(GEMINI_MODELS)))
|
|
86
99
|
table.add_row("Mistral", str(len(MISTRAL_MODELS)))
|
|
87
100
|
table.add_row("Grok", str(len(GROK_MODELS)))
|
|
101
|
+
table.add_row("Anthropic", str(len(ANTHROPIC_MODELS)))
|
|
88
102
|
|
|
89
103
|
total = (
|
|
90
|
-
len(OPENAI_MODELS)
|
|
104
|
+
len(OPENAI_MODELS)
|
|
105
|
+
+ len(GEMINI_MODELS)
|
|
106
|
+
+ len(MISTRAL_MODELS)
|
|
107
|
+
+ len(GROK_MODELS)
|
|
108
|
+
+ len(ANTHROPIC_MODELS)
|
|
91
109
|
)
|
|
92
110
|
table.add_section()
|
|
93
111
|
table.add_row("[bold]Total[/bold]", f"[bold]{total}[/bold]")
|
|
@@ -113,6 +131,18 @@ async def oneshot(
|
|
|
113
131
|
"--stream-thinking",
|
|
114
132
|
help="Stream model reasoning/thinking tokens to stderr (requires --stream)",
|
|
115
133
|
),
|
|
134
|
+
files: list[Path] | None = Option(
|
|
135
|
+
None,
|
|
136
|
+
"-f",
|
|
137
|
+
"--file",
|
|
138
|
+
help="Attach file(s) to the prompt (images/audio/text, routed by type)",
|
|
139
|
+
),
|
|
140
|
+
trace: TraceBackend | None = Option(
|
|
141
|
+
None,
|
|
142
|
+
"--trace",
|
|
143
|
+
help="Export LLM telemetry to this backend",
|
|
144
|
+
choices=TRACE_BACKENDS,
|
|
145
|
+
),
|
|
116
146
|
) -> None:
|
|
117
147
|
"""Send a single prompt and print the response."""
|
|
118
148
|
parsed_extra: dict[str, Any] | None = None
|
|
@@ -132,8 +162,19 @@ async def oneshot(
|
|
|
132
162
|
|
|
133
163
|
on_thought: OnThought | None = _thought_streamer if stream_thinking else None
|
|
134
164
|
|
|
165
|
+
if trace:
|
|
166
|
+
enable_tracing(trace)
|
|
167
|
+
|
|
168
|
+
content: str | list[ContentPart] = prompt
|
|
169
|
+
if files:
|
|
170
|
+
try:
|
|
171
|
+
content = content_parts(prompt, *files)
|
|
172
|
+
except (OSError, ValueError) as e:
|
|
173
|
+
console.print(f"[red]--file: {e}[/red]")
|
|
174
|
+
raise SystemExit(1)
|
|
175
|
+
|
|
135
176
|
client = LLMClient(model=model, base_url=base_url, on_thought=on_thought)
|
|
136
|
-
messages: list[Message] = [Message(role="user", content=
|
|
177
|
+
messages: list[Message] = [Message(role="user", content=content)]
|
|
137
178
|
async with client:
|
|
138
179
|
if stream:
|
|
139
180
|
async for chunk in client.stream_chat(messages, extra_params=parsed_extra):
|