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.
@@ -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 typing import Any
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: dict[str, Any] | None = None
162
- if extra_params is not None:
163
- try:
164
- parsed_extra = json.loads(extra_params)
165
- except json.JSONDecodeError as e:
166
- console.print(f"[red]--extra-params is not valid JSON: {e}[/red]")
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(user_input):
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) + len(GEMINI_MODELS) + len(MISTRAL_MODELS) + len(GROK_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=prompt)]
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):