Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ uv run padwan-cli

```bash
uvx padwan-cli "Hello" -m gpt-4o-mini

# attach files (images/audio/text, routed by type)
uvx padwan-cli "What's in this recording?" -m gpt-4o-audio-preview -f clip.mp3
```

## Voice Mode
Expand All @@ -43,3 +46,17 @@ Real-time speech-to-speech chat (needs the PortAudio system lib, e.g. `libportau
uvx --from "padwan-cli[voice]" padwan-talk
```

## Tracing

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):

```bash
# Langfuse, using the standard LANGFUSE_* env vars
export LANGFUSE_PUBLIC_KEY=... LANGFUSE_SECRET_KEY=... LANGFUSE_BASE_URL=...
uvx --from "padwan-cli[langfuse]" padwan-cli "Hello" --trace langfuse

# any OTLP collector, using the standard OTEL_EXPORTER_OTLP_* env vars
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
uvx --from "padwan-cli[otel]" padwan-cli "Hello" --trace otlp
```

11 changes: 8 additions & 3 deletions docs/commands/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ padwan-cli chat send "Hello, how are you?" -m gpt-4o-mini
| `--mcp` | *none* | Streamable-HTTP MCP server URL(s) to expose as tools (space-separated) |
| `--resume` | *none* | Resume a previous session by ID |
| `--max-tools-round` | `20` | Maximum number of tool calls per round |
| `--trace` | *off* | Export LLM telemetry to `langfuse` or `otlp` (needs the matching extra) |

In TUI mode, the command enters an interactive loop — type follow-up messages and press Enter to continue the conversation. Press **Ctrl+C** to exit chat mode. Messages you type while the model is responding are queued and processed in order.

Expand Down Expand Up @@ -50,9 +51,13 @@ the queued attachments.
- **Text files** (code, Markdown, JSON, …) are read and inlined into your message,
so they work with any model.
- **Images** are sent to the model as image input — this requires a vision-capable
model (e.g. `gpt-4o`, `gemini-2.5-flash`, `pixtral-large-latest`). If the active
model can't see images, the chip is marked `⚠ needs a vision model` and the image
is left out of the request (switch models with `-m`).
model (e.g. `gpt-4o`, `gemini-2.5-flash`, `pixtral-large-latest`).
- **Audio files** (wav, mp3, flac, ogg, aac, aiff, m4a) are sent as audio input —
this requires an audio-capable model, and format support varies per provider.

If the active model can't consume an attachment, its chip turns amber, a warning
explains what will be skipped, and the file is left out of the request (switch
models with `-m`).

You need to type at least a short message to send; dropping a file alone won't send.
Drag-and-drop is a TUI-only feature.
Expand Down
1 change: 1 addition & 0 deletions docs/commands/talk.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ python -m padwan_cli talk
| `--instructions` | *built-in* | System prompt for the voice assistant |
| `--hands-free` | *off* | Auto voice detection instead of push-to-talk |
| `--check` | *off* | List audio devices and key status, then exit |
| `--trace` | *off* | Export LLM telemetry to `langfuse` or `otlp` (needs the matching extra) |

## Push-to-talk vs hands-free

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Padwan CLI

Padwan CLI is an interactive CLI and TUI for [padwan-llm](https://github.com/Polarsen/padwan-llm), the unified LLM client library.
It provides a terminal interface for querying multiple LLM providers — OpenAI, Gemini, Mistral, and Grok — through a single tool.
It provides a terminal interface for querying multiple LLM providers — OpenAI, Gemini, Mistral, Grok, and Anthropic — through a single tool.

!!! note "Playground project"
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.
Expand Down
87 changes: 63 additions & 24 deletions padwan_cli/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from typing import Any, cast, get_args

from piou import Option, CommandGroup
from piou.tui import PromptStyle, TuiContext, TuiOption
Expand All @@ -19,17 +19,22 @@
McpTool,
McpTransport,
ToolCallContext,
audio_part,
image_part,
supports_audio,
supports_vision,
text_file_part,
text_part,
)
from padwan_llm.content import AudioFormat
from padwan_llm.gemini import GeminiClient
from padwan_llm.gemini.models import ThinkingConfig
from .trace import TRACE_BACKENDS, TraceBackend, enable_tracing
from .utils import ALL_MODELS, console
from .widgets import (
Attachment,
AttachmentBadge,
AttachmentKind,
ErrorMessage,
StreamingMessage,
ThoughtMessage,
Expand Down Expand Up @@ -95,25 +100,53 @@ def _format_tokens(session: AgentSession, ctx: TuiContext | None = None) -> str:
return " ".join(parts)


_AUDIO_FORMATS: tuple[AudioFormat, ...] = get_args(AudioFormat)
_KIND_LABELS = {"image": "images", "audio": "audio", "text": "files"}


def _audio_fmt(path: Path) -> AudioFormat | None:
"""Derive the audio format from the file extension, if it is a known one."""
fmt = path.suffix.removeprefix(".").lower()
return cast(AudioFormat, fmt) if fmt in _AUDIO_FORMATS else None


def _unsupported_warning(
model: str, attachments: list[Attachment], *, suffix: str
) -> str | None:
"""Amber warning line for attachments the model can't consume, or None."""
skipped = [a for a in attachments if not a.supported]
if not skipped:
return None
kinds = "/".join(dict.fromkeys(_KIND_LABELS[a.kind] for a in skipped))
return f"{model} can't read {kinds} — {len(skipped)} {suffix}"


def _build_user_content(
text: str, attachments: list[Attachment]
) -> str | list[ContentPart]:
"""Assemble the user turn from typed text and queued attachments.

Returns the plain string when nothing is attached. Otherwise builds content
parts: the text, each text file inlined, and each supported image as a data
URL. Unsupported images (text-only model) and unreadable files are dropped
from the payload — they still appear in the sent badge.
parts: the text, each text file inlined, and each supported image or audio
file as a data payload. Unsupported media (model can't see/hear it) and
unreadable files are dropped from the payload — they still appear in the
sent badge.
"""
if not attachments:
return text
parts: list[ContentPart] = []
if text:
parts.append(text_part(text))
for a in attachments:
if a.is_image:
if a.supported:
parts.append(image_part(a.path))
if not a.supported:
continue
if a.kind == "image":
parts.append(image_part(a.path))
elif a.kind == "audio":
try:
parts.append(audio_part(a.path))
except OSError, ValueError:
continue
else:
try:
parts.append(text_file_part(a.path))
Expand Down Expand Up @@ -158,6 +191,12 @@ async def chat_send_fn(
"--mcp",
help=f"Streamable-HTTP MCP server URL(s) to expose as tools (e.g. {DATAGOUV_MCP_URL})",
),
trace: TraceBackend | None = Option(
None,
"--trace",
help="Export LLM telemetry to this backend",
choices=TRACE_BACKENDS,
),
ctx: TuiContext = TuiOption(),
) -> None:
"""Start a conversation. Use Ctrl+C to exit."""
Expand Down Expand Up @@ -214,6 +253,9 @@ def _on_mcp_connect(transport: McpTransport) -> None:
if not ok:
return

if trace:
enable_tracing(trace)

try:
client = LLMClient(model=model, on_thought=_on_thought, base_url=base_url)
if isinstance(client, GeminiClient):
Expand Down Expand Up @@ -261,24 +303,26 @@ def _on_drop(paths: list[Path]) -> None:
size = p.stat().st_size
except OSError:
continue
is_image = (mimetypes.guess_type(p)[0] or "").startswith("image/")
mime = mimetypes.guess_type(p)[0] or ""
kind: AttachmentKind
if mime.startswith("image/"):
kind, supported = "image", supports_vision(model)
elif mime.startswith("audio/"):
kind, supported = "audio", supports_audio(model, _audio_fmt(p))
else:
kind, supported = "text", True
pending.append(
Attachment(
path=p,
name=p.name,
size=size,
is_image=is_image,
supported=(not is_image) or supports_vision(model),
kind=kind,
supported=supported,
)
)
blind = [a for a in pending if a.is_image and not a.supported]
warning = (
f"{model} can't read images — {len(blind)} will be skipped"
if blind
else None
)
warning = _unsupported_warning(model, pending, suffix="will be skipped")
ctx.set_attachments(render_attachments(pending, warning=warning))
_set_attach_warning(bool(blind))
_set_attach_warning(warning is not None)
ctx.set_hint(
f"{len(pending)} attached — type a message · Ctrl+U clears"
)
Expand All @@ -298,13 +342,8 @@ def _on_clear() -> None:
sent = list(pending)
ctx.mount_widget(UserMessage(user_input))
if sent:
skipped = [
a for a in sent if a.is_image and not a.supported
]
badge_warning = (
f"{model} can't read images — {len(skipped)} skipped"
if skipped
else None
badge_warning = _unsupported_warning(
model, sent, suffix="skipped"
)
ctx.mount_widget(
AttachmentBadge(sent, warning=badge_warning)
Expand Down
39 changes: 37 additions & 2 deletions padwan_cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import sys
from pathlib import Path
from typing import Any

from piou import Cli, Option
Expand All @@ -10,11 +11,14 @@
from rich.table import Table

from padwan_llm import (
ANTHROPIC_MODELS,
GEMINI_MODELS,
GROK_MODELS,
MISTRAL_MODELS,
OPENAI_MODELS,
ContentPart,
LLMClient,
content_parts,
)
from padwan_llm._base import OnThought
from padwan_llm.conversation import Message
Expand All @@ -24,6 +28,7 @@
from .batch import batch_group
from .chat import chat_group
from .talk import talk_command
from .trace import TRACE_BACKENDS, TraceBackend, enable_tracing

CUSTOM_CSS = """
Rule.chat-mode {
Expand Down Expand Up @@ -64,6 +69,8 @@ def list_models(
provider_models["Mistral"] = list(MISTRAL_MODELS)
if not provider or provider == "grok":
provider_models["Grok"] = list(GROK_MODELS)
if not provider or provider == "anthropic":
provider_models["Anthropic"] = list(ANTHROPIC_MODELS)

if not provider_models:
console.print(f"[red]Unknown provider: {provider}[/red]")
Expand Down Expand Up @@ -91,9 +98,14 @@ def info() -> None:
table.add_row("Gemini", str(len(GEMINI_MODELS)))
table.add_row("Mistral", str(len(MISTRAL_MODELS)))
table.add_row("Grok", str(len(GROK_MODELS)))
table.add_row("Anthropic", str(len(ANTHROPIC_MODELS)))

total = (
len(OPENAI_MODELS) + len(GEMINI_MODELS) + len(MISTRAL_MODELS) + len(GROK_MODELS)
len(OPENAI_MODELS)
+ len(GEMINI_MODELS)
+ len(MISTRAL_MODELS)
+ len(GROK_MODELS)
+ len(ANTHROPIC_MODELS)
)
table.add_section()
table.add_row("[bold]Total[/bold]", f"[bold]{total}[/bold]")
Expand All @@ -119,6 +131,18 @@ async def oneshot(
"--stream-thinking",
help="Stream model reasoning/thinking tokens to stderr (requires --stream)",
),
files: list[Path] | None = Option(
None,
"-f",
"--file",
help="Attach file(s) to the prompt (images/audio/text, routed by type)",
),
trace: TraceBackend | None = Option(
None,
"--trace",
help="Export LLM telemetry to this backend",
choices=TRACE_BACKENDS,
),
) -> None:
"""Send a single prompt and print the response."""
parsed_extra: dict[str, Any] | None = None
Expand All @@ -138,8 +162,19 @@ def _thought_streamer(chunk: str) -> None:

on_thought: OnThought | None = _thought_streamer if stream_thinking else None

if trace:
enable_tracing(trace)

content: str | list[ContentPart] = prompt
if files:
try:
content = content_parts(prompt, *files)
except (OSError, ValueError) as e:
console.print(f"[red]--file: {e}[/red]")
raise SystemExit(1)

client = LLMClient(model=model, base_url=base_url, on_thought=on_thought)
messages: list[Message] = [Message(role="user", content=prompt)]
messages: list[Message] = [Message(role="user", content=content)]
async with client:
if stream:
async for chunk in client.stream_chat(messages, extra_params=parsed_extra):
Expand Down
10 changes: 10 additions & 0 deletions padwan_cli/talk.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from padwan_llm.errors import LLMError
from padwan_llm.openai.realtime import NO_TURN_DETECTION, REALTIME_SAMPLE_RATE

from .trace import TRACE_BACKENDS, TraceBackend, enable_tracing
from .utils import console

SR = REALTIME_SAMPLE_RATE # 24 kHz mono PCM16, as gpt-realtime expects
Expand Down Expand Up @@ -317,6 +318,12 @@ async def talk_command(
check: bool = Option(
False, "--check", help="List audio devices and key status, then exit"
),
trace: TraceBackend | None = Option(
None,
"--trace",
help="Export LLM telemetry to this backend",
choices=TRACE_BACKENDS,
),
) -> None:
"""Talk with a real-time voice assistant (speech-to-speech)."""
# A live mic/speaker session needs the whole terminal; the Textual TUI owns it,
Expand All @@ -343,6 +350,9 @@ async def talk_command(
console.print(f"model={model} voice={voice}")
return

if trace:
enable_tracing(trace)

push_to_talk = not hands_free
if push_to_talk and not sys.stdin.isatty():
console.print(
Expand Down
Loading
Loading