From 0822783678bd000c6dd65229f3857a1c14e31cd2 Mon Sep 17 00:00:00 2001 From: julien Date: Mon, 24 Aug 2026 13:14:27 +0200 Subject: [PATCH 1/6] chore: require padwan-llm 0.9 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 39331c9..bddcb08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ ] requires-python = ">=3.14" dependencies = [ - "padwan-llm[realtime]>=0.8.0", + "padwan-llm[realtime]>=0.9.0", "piou[tui]>=0.36.0", "tracktolib>=0.74.0", ] diff --git a/uv.lock b/uv.lock index d779624..7255ed6 100644 --- a/uv.lock +++ b/uv.lock @@ -447,7 +447,7 @@ docs = [ [package.metadata] requires-dist = [ - { name = "padwan-llm", extras = ["realtime"], specifier = ">=0.8.0" }, + { name = "padwan-llm", extras = ["realtime"], specifier = ">=0.9.0" }, { name = "piou", extras = ["tui"], specifier = ">=0.36.0" }, { name = "sounddevice", marker = "extra == 'voice'", specifier = ">=0.5" }, { name = "tracktolib", specifier = ">=0.74.0" }, @@ -468,16 +468,16 @@ docs = [{ name = "zensical", specifier = ">=0.0.31" }] [[package]] name = "padwan-llm" -version = "0.8.0" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "niquests" }, { name = "qh3" }, { name = "urllib3-future" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/60/c15877fbb37a72ab11b03d387f676c3dcfe4499c54e8f8d061df45a58e80/padwan_llm-0.8.0.tar.gz", hash = "sha256:abcd54ba0d5c6d0e8eb39ec39f652b37cb7ead79ffd99ebffca80538776f236b", size = 66924, upload-time = "2026-08-19T19:50:08.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/c7/3946f8991fcd3d707654b29e125520a8323bd5fd234b1a05c4104393fbc6/padwan_llm-0.9.0.tar.gz", hash = "sha256:4f35398b761189565c58b20a3c1d05ae3f130579635cae802b1c16fa4e03a5a9", size = 86704, upload-time = "2026-08-23T06:15:57.423Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/0d/7452830179218c835cb9cb8759a0cb178adcb8de05dd00e311e65191f333/padwan_llm-0.8.0-py3-none-any.whl", hash = "sha256:6af6e811b23e998d1fb0d1e2f7e4afe7361e21e1cc11c443d7975fb531ac6c08", size = 85167, upload-time = "2026-08-19T19:50:06.947Z" }, + { url = "https://files.pythonhosted.org/packages/1d/25/116528f173a63a2593e3c4b50dcb25c46860981bb7cae9f26002ed307075/padwan_llm-0.9.0-py3-none-any.whl", hash = "sha256:903d11d7c17a052cf306a68250309d4026b057fbb56505d877dc4fa44a65cdf7", size = 108952, upload-time = "2026-08-23T06:15:56.272Z" }, ] [package.optional-dependencies] From 9a33d48a53de9aad6efa23d5a0f5e28b737d023e Mon Sep 17 00:00:00 2001 From: julien Date: Mon, 24 Aug 2026 13:44:49 +0200 Subject: [PATCH 2/6] feat: opt-in tracing (Langfuse or OTLP export) --- padwan_cli/trace.py | 70 +++++++++ pyproject.toml | 12 ++ tests/test_trace.py | 42 ++++++ uv.lock | 353 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 padwan_cli/trace.py create mode 100644 tests/test_trace.py diff --git a/padwan_cli/trace.py b/padwan_cli/trace.py new file mode 100644 index 0000000..e1b8f34 --- /dev/null +++ b/padwan_cli/trace.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import atexit +import os +from typing import Literal, get_args + +from .utils import console + +__all__ = ("TRACE_BACKENDS", "TraceBackend", "enable_tracing") + +TraceBackend = Literal["langfuse", "otlp"] +# piou only derives choices from a bare Literal, not `Literal | None`. +TRACE_BACKENDS: list[TraceBackend] = list(get_args(TraceBackend)) + + +def _missing_extra(backend: TraceBackend) -> SystemExit: + extra = "langfuse" if backend == "langfuse" else "otel" + console.print(f"[red]{backend} tracing dependencies not installed.[/red]") + console.print(f"[dim]Install the {extra} extra: `uv sync --extra {extra}`.[/dim]") + return SystemExit(1) + + +def _enable_langfuse() -> None: + from padwan_llm.langfuse import instrument + + integration = instrument() + atexit.register(integration.shutdown) + console.print("[dim]Tracing enabled (Langfuse)[/dim]") + + +def _enable_otlp() -> None: + from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( + OTLPMetricExporter, + ) + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + from padwan_llm import otel + + resource = Resource.create({"service.name": "padwan-cli"}) + tracer_provider = TracerProvider(resource=resource) + tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + meter_provider = MeterProvider( + resource=resource, + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())], + ) + otel.instrument(tracer_provider=tracer_provider, meter_provider=meter_provider) + atexit.register(meter_provider.shutdown) + atexit.register(tracer_provider.shutdown) + endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + console.print(f"[dim]Tracing enabled (OTLP → {endpoint})[/dim]") + + +def enable_tracing(backend: TraceBackend) -> None: + """Instrument padwan-llm clients for this process, exporting to `backend`. + + `langfuse` reads the standard `LANGFUSE_*` env vars; `otlp` uses the + standard `OTEL_EXPORTER_OTLP_*` env vars. Exporters are flushed at exit. + """ + try: + if backend == "langfuse": + _enable_langfuse() + else: + _enable_otlp() + except ImportError: + raise _missing_extra(backend) diff --git a/pyproject.toml b/pyproject.toml index bddcb08..42475d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,14 @@ dependencies = [ [project.optional-dependencies] # `talk` command audio I/O; also needs the PortAudio system lib (libportaudio2). voice = ["sounddevice>=0.5"] +# `--trace otlp`: OTel instrumentation with an OTLP exporter. +otel = [ + "padwan-llm[otel]>=0.9.0", + "opentelemetry-sdk>=1.44", + "opentelemetry-exporter-otlp>=1.44", +] +# `--trace langfuse`: OTel instrumentation with the Langfuse adapter. +langfuse = ["padwan-llm[langfuse]>=0.9.0"] [project.scripts] padwan-cli = "padwan_cli:main_tui" @@ -36,6 +44,10 @@ dev = [ "pytest-asyncio>=1.3.0", "pytest-cov>=7.0.0", "ruff>=0.11.0", + # keep the otel and langfuse extras type-checkable/testable in dev + "padwan-llm[langfuse]>=0.9.0", + "opentelemetry-sdk>=1.44", + "opentelemetry-exporter-otlp>=1.44", ] docs = [ "zensical>=0.0.31", diff --git a/tests/test_trace.py b/tests/test_trace.py new file mode 100644 index 0000000..3d58a14 --- /dev/null +++ b/tests/test_trace.py @@ -0,0 +1,42 @@ +import pytest + +import padwan_cli.trace as trace + + +class TestEnableTracing: + @pytest.mark.parametrize( + "backend", + [ + pytest.param("langfuse", id="langfuse"), + pytest.param("otlp", id="otlp"), + ], + ) + def test_backend_dispatch(self, monkeypatch, backend: trace.TraceBackend): + calls: list[str] = [] + monkeypatch.setattr(trace, "_enable_langfuse", lambda: calls.append("langfuse")) + monkeypatch.setattr(trace, "_enable_otlp", lambda: calls.append("otlp")) + + trace.enable_tracing(backend) + + assert calls == [backend] + + @pytest.mark.parametrize( + "backend, extra", + [ + pytest.param("langfuse", "langfuse", id="langfuse"), + pytest.param("otlp", "otel", id="otlp"), + ], + ) + def test_missing_extra_exits_with_hint( + self, monkeypatch, capsys, backend: trace.TraceBackend, extra: str + ): + def _raise() -> None: + raise ImportError("missing dependency") + + monkeypatch.setattr(trace, "_enable_langfuse", _raise) + monkeypatch.setattr(trace, "_enable_otlp", _raise) + + with pytest.raises(SystemExit): + trace.enable_tracing(backend) + + assert f"--extra {extra}" in capsys.readouterr().out diff --git a/uv.lock b/uv.lock index 7255ed6..33aecba 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "anyio" version = "4.13.0" @@ -23,6 +32,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "cffi" version = "2.0.0" @@ -210,6 +237,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/74bcab964c9a7a61f2bb71e8179b0f13e6fa98f7ce00fd168aab291e4a2e/googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071", size = 150967, upload-time = "2026-08-06T06:24:51.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/51/186c02b8549b69ccda44429cf6ff5081e4b61a602ddfe6a8020d1be31d1b/googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79", size = 300626, upload-time = "2026-08-06T06:23:46.696Z" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -219,6 +279,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.13" @@ -288,6 +376,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "langfuse" +version = "4.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/3f/d3387db1f472d2fd4b5de40eb6abedc2f59c9da2224e9d0a79d054a90b81/langfuse-4.14.4.tar.gz", hash = "sha256:48c4bdefc290f61a42881b8048a904ab6b81d37e02496473166836e71ee0ab3a", size = 389680, upload-time = "2026-08-11T17:03:20.472Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/1b/a198adc52aa4ffd0886ca0d4e9bb97d45f4f326db206d2395bac67677f5d/langfuse-4.14.4-py3-none-any.whl", hash = "sha256:78692a1d4785a8c255bf6ea967e673e1ee30ce078d646e7b5f7ff44549d45cce", size = 688351, upload-time = "2026-08-11T17:03:21.881Z" }, +] + [[package]] name = "linkify-it-py" version = "2.1.0" @@ -405,6 +512,118 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/45/7af37fe54e5d3e66e7dcd7ba8b8aeee73f202bfac909cc94b8c4e428f9ac/opentelemetry_exporter_otlp-1.44.0.tar.gz", hash = "sha256:af1cde7c33ea8ed624bf04ac49a885730fe44c1f1ad698656e592c38f70ce106", size = 6090, upload-time = "2026-07-16T15:25:34.585Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/c3/7b466a9463944e70b37b744072a0c1b88a425dade3fff0631adec66c9bcc/opentelemetry_exporter_otlp-1.44.0-py3-none-any.whl", hash = "sha256:4a498fa8d8fd8be9e8e2d175fe5524a3fe581ccffadd8509db86526a5fb97051", size = 6727, upload-time = "2026-07-16T15:25:14.445Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463", size = 27225, upload-time = "2026-07-16T15:25:38.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/29/6ae42ba32b153ae0a44ae125f0caff2188bbe62d99c82d1768da30864e72/opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e", size = 19624, upload-time = "2026-07-16T15:25:19.096Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8", size = 25806, upload-time = "2026-07-16T15:25:39.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/d0/fdeb1a98d8d3a6205f5f297c51b4a9bfe65126ab60339669bbe3dd54c2e2/opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3", size = 21850, upload-time = "2026-07-16T15:25:20.006Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -425,6 +644,14 @@ dependencies = [ ] [package.optional-dependencies] +langfuse = [ + { name = "padwan-llm", extra = ["langfuse"] }, +] +otel = [ + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, + { name = "padwan-llm", extra = ["otel"] }, +] voice = [ { name = "sounddevice" }, ] @@ -434,6 +661,9 @@ bump = [ { name = "commitizen" }, ] dev = [ + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-sdk" }, + { name = "padwan-llm", extra = ["langfuse"] }, { name = "piou", extra = ["tui-reload"] }, { name = "pyright" }, { name = "pytest" }, @@ -447,16 +677,23 @@ docs = [ [package.metadata] requires-dist = [ + { name = "opentelemetry-exporter-otlp", marker = "extra == 'otel'", specifier = ">=1.44" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.44" }, + { name = "padwan-llm", extras = ["langfuse"], marker = "extra == 'langfuse'", specifier = ">=0.9.0" }, + { name = "padwan-llm", extras = ["otel"], marker = "extra == 'otel'", specifier = ">=0.9.0" }, { name = "padwan-llm", extras = ["realtime"], specifier = ">=0.9.0" }, { name = "piou", extras = ["tui"], specifier = ">=0.36.0" }, { name = "sounddevice", marker = "extra == 'voice'", specifier = ">=0.5" }, { name = "tracktolib", specifier = ">=0.74.0" }, ] -provides-extras = ["voice"] +provides-extras = ["voice", "otel", "langfuse"] [package.metadata.requires-dev] bump = [{ name = "commitizen", specifier = ">=4.0.0" }] dev = [ + { name = "opentelemetry-exporter-otlp", specifier = ">=1.44" }, + { name = "opentelemetry-sdk", specifier = ">=1.44" }, + { name = "padwan-llm", extras = ["langfuse"], specifier = ">=0.9.0" }, { name = "piou", extras = ["tui-reload"], specifier = ">=0.36.0" }, { name = "pyright", specifier = ">=1.1.408" }, { name = "pytest", specifier = ">=8.0.0" }, @@ -481,6 +718,13 @@ wheels = [ ] [package.optional-dependencies] +langfuse = [ + { name = "langfuse" }, + { name = "opentelemetry-api" }, +] +otel = [ + { name = "opentelemetry-api" }, +] realtime = [ { name = "niquests", extra = ["ws"] }, ] @@ -537,6 +781,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, ] +[[package]] +name = "protobuf" +version = "7.36.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/e7/0553e21d25ca4d9f573135775348a372c3ec34a93a71d5f297c3bac38341/protobuf-7.36.0.tar.gz", hash = "sha256:e8e09cb0d794c6687926fa558a8a6e72aa10edb997d5ca61da0765f12a3e00ea", size = 510034, upload-time = "2026-08-20T16:34:01.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ae/58e3ca96cb2e118cc546b677359b3c6659f79a140935c08dec94c7998585/protobuf-7.36.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:9103532dffd80c6fab7e50c65a31007680a06eb57537d437bb1b35812c138a37", size = 453256, upload-time = "2026-08-20T16:33:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/f0/15/5162230af4912697f0fe406f6800f80760945babcff0e2c2fe6c84ef2d5d/protobuf-7.36.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:bf94a5917c71058262de683669bc0a797a7669d3de71f0b36d058e3194f47b44", size = 341436, upload-time = "2026-08-20T16:33:55.134Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/1670b2bfc9a45e807e520c3e9be36524db9ccc7dc05ea17af7681cabdc61/protobuf-7.36.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16", size = 354440, upload-time = "2026-08-20T16:33:56.077Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f8/bd5804695ba400e423c33fd4d9f58c28d86633d5ba1945c36ff3967d98cb/protobuf-7.36.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:70f5ec8eb0da81a44360c0dc0beac99a0d78071d21956a7076bae8bd2051841b", size = 340439, upload-time = "2026-08-20T16:33:56.992Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/acd02338235a3e7d03168c4303478347b7624fc8189ff4e7f0d2654bbe86/protobuf-7.36.0-cp310-abi3-win32.whl", hash = "sha256:7326fd717bdc419162a735938d89d4032332bcc3408804012b24ff3a37086071", size = 440216, upload-time = "2026-08-20T16:33:57.99Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4e/12cb93270967a2affff5b3f720694700d4d87712a67afd05c8cb3f6fa52c/protobuf-7.36.0-cp310-abi3-win_amd64.whl", hash = "sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488", size = 453731, upload-time = "2026-08-20T16:33:58.951Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/629999e78d46c1115c11886d51c6bd68c17ce4a944f1ea3e153a91316a33/protobuf-7.36.0-py3-none-any.whl", hash = "sha256:53374d53fc29a67f7dbbf0ade47d7526a0f0137bf0f9c90e48d8a60790ef748c", size = 177024, upload-time = "2026-08-20T16:34:00.053Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -546,6 +805,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -703,6 +1018,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "rich" version = "14.3.4" @@ -837,6 +1167,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "uc-micro-py" version = "2.0.0" @@ -846,6 +1188,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, ] +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + [[package]] name = "urllib3-future" version = "2.19.913" From a3607546db78e1d341a3e902f63765293536aae2 Mon Sep 17 00:00:00 2001 From: julien Date: Mon, 24 Aug 2026 13:44:49 +0200 Subject: [PATCH 3/6] feat: list Anthropic models in models, info, and model choices --- docs/index.md | 2 +- padwan_cli/run.py | 10 +++++++++- padwan_cli/utils.py | 18 ++++++++++++++++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index ed9e1c2..9bf5c39 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/padwan_cli/run.py b/padwan_cli/run.py index c7a523f..4f8f423 100644 --- a/padwan_cli/run.py +++ b/padwan_cli/run.py @@ -10,6 +10,7 @@ from rich.table import Table from padwan_llm import ( + ANTHROPIC_MODELS, GEMINI_MODELS, GROK_MODELS, MISTRAL_MODELS, @@ -64,6 +65,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]") @@ -91,9 +94,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]") diff --git a/padwan_cli/utils.py b/padwan_cli/utils.py index 3796a6f..505450d 100644 --- a/padwan_cli/utils.py +++ b/padwan_cli/utils.py @@ -1,7 +1,21 @@ from rich.console import Console -from padwan_llm import GEMINI_MODELS, GROK_MODELS, MISTRAL_MODELS, OPENAI_MODELS +from padwan_llm import ( + ANTHROPIC_MODELS, + GEMINI_MODELS, + GROK_MODELS, + MISTRAL_MODELS, + OPENAI_MODELS, +) console = Console() -ALL_MODELS = sorted([*OPENAI_MODELS, *GEMINI_MODELS, *MISTRAL_MODELS, *GROK_MODELS]) +ALL_MODELS = sorted( + [ + *OPENAI_MODELS, + *GEMINI_MODELS, + *MISTRAL_MODELS, + *GROK_MODELS, + *ANTHROPIC_MODELS, + ] +) From c3a130e9736bff356cf4c0fe77765016be2a6d99 Mon Sep 17 00:00:00 2001 From: julien Date: Mon, 24 Aug 2026 13:44:49 +0200 Subject: [PATCH 4/6] feat(chat): audio file attachments gated by supports_audio --- docs/commands/chat.md | 10 +++-- padwan_cli/chat.py | 77 ++++++++++++++++++++++---------- padwan_cli/widgets.py | 9 ++-- tests/test_chat.py | 99 ++++++++++++++++++++++++++++++++---------- tests/test_chat_tui.py | 8 ++-- tests/test_widgets.py | 12 ++--- 6 files changed, 152 insertions(+), 63 deletions(-) diff --git a/docs/commands/chat.md b/docs/commands/chat.md index 23e2b59..b6fc3be 100644 --- a/docs/commands/chat.md +++ b/docs/commands/chat.md @@ -50,9 +50,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. diff --git a/padwan_cli/chat.py b/padwan_cli/chat.py index 35fd702..ab94b93 100644 --- a/padwan_cli/chat.py +++ b/padwan_cli/chat.py @@ -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 @@ -19,17 +19,21 @@ 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 .utils import ALL_MODELS, console from .widgets import ( Attachment, AttachmentBadge, + AttachmentKind, ErrorMessage, StreamingMessage, ThoughtMessage, @@ -95,15 +99,37 @@ 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 @@ -111,9 +137,15 @@ def _build_user_content( 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)) @@ -261,24 +293,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" ) @@ -298,13 +332,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) diff --git a/padwan_cli/widgets.py b/padwan_cli/widgets.py index 20cf5ea..ff42432 100644 --- a/padwan_cli/widgets.py +++ b/padwan_cli/widgets.py @@ -3,7 +3,7 @@ import json from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Literal, cast from rich import box from rich.columns import Columns @@ -29,6 +29,9 @@ CHIP_BORDER = "#3a3f55" +AttachmentKind = Literal["image", "audio", "text"] + + @dataclass class Attachment: """A file dropped into the chat, queued to send with the next message.""" @@ -36,8 +39,8 @@ class Attachment: path: Path name: str size: int - is_image: bool - supported: bool # whether the active model can use it (images need vision) + kind: AttachmentKind + supported: bool # whether the active model can use it (images/audio need support) def human_size(num_bytes: int) -> str: diff --git a/tests/test_chat.py b/tests/test_chat.py index 680dcab..497ae6d 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -6,16 +6,18 @@ from padwan_llm import AgentSession, LLMClientBase from padwan_llm.models import UsageToken -from padwan_cli.chat import _build_user_content, _format_tokens -from padwan_cli.widgets import Attachment +from padwan_cli.chat import _build_user_content, _format_tokens, _unsupported_warning +from padwan_cli.widgets import Attachment, AttachmentKind -def _attachment(path: Path, *, is_image: bool, supported: bool) -> Attachment: +def _attachment( + path: Path, *, kind: AttachmentKind = "text", supported: bool = True +) -> Attachment: return Attachment( - path=str(path), + path=path, name=path.name, - size=path.stat().st_size, - is_image=is_image, + size=path.stat().st_size if path.exists() else 0, + kind=kind, supported=supported, ) @@ -71,38 +73,87 @@ def test_no_attachments_returns_plain_text(self): def test_text_and_supported_image(self, tmp_path): img = tmp_path / "shot.png" img.write_bytes(b"\x89PNG\r\n") - content = _build_user_content( - "look", [_attachment(img, is_image=True, supported=True)] - ) + content = _build_user_content("look", [_attachment(img, kind="image")]) assert isinstance(content, list) assert content[0] == {"type": "text", "text": "look"} assert content[1]["type"] == "image_url" assert content[1]["image_url"]["url"].startswith("data:image/png;base64,") - def test_unsupported_image_dropped_from_payload(self, tmp_path): - img = tmp_path / "shot.png" - img.write_bytes(b"\x89PNG\r\n") + def test_text_and_supported_audio(self, tmp_path): + clip = tmp_path / "clip.mp3" + clip.write_bytes(b"ID3\x04") + content = _build_user_content("listen", [_attachment(clip, kind="audio")]) + + assert isinstance(content, list) + assert content[1]["type"] == "input_audio" + assert content[1]["input_audio"]["format"] == "mp3" + + @pytest.mark.parametrize( + "data, name, kind, supported", + [ + pytest.param(b"\x89PNG\r\n", "shot.png", "image", False, id="blind-image"), + pytest.param(b"ID3\x04", "clip.mp3", "audio", False, id="deaf-audio"), + pytest.param(b"ID3\x04", "clip.weird", "audio", True, id="bad-audio-fmt"), + pytest.param(b"\xff\xfe\x00\x01", "blob.bin", "text", True, id="binary"), + ], + ) + def test_attachment_dropped_from_payload( + self, tmp_path, data: bytes, name: str, kind: AttachmentKind, supported: bool + ): + f = tmp_path / name + f.write_bytes(data) content = _build_user_content( - "look", [_attachment(img, is_image=True, supported=False)] + "hi", [_attachment(f, kind=kind, supported=supported)] ) - assert content == [{"type": "text", "text": "look"}] + assert content == [{"type": "text", "text": "hi"}] def test_text_file_inlined_with_header(self, tmp_path): f = tmp_path / "notes.md" f.write_text("# Hi") - content = _build_user_content( - "read", [_attachment(f, is_image=False, supported=True)] - ) + content = _build_user_content("read", [_attachment(f)]) assert content[1] == {"type": "text", "text": "--- notes.md ---\n# Hi"} - def test_undecodable_file_skipped(self, tmp_path): - f = tmp_path / "blob.bin" - f.write_bytes(b"\xff\xfe\x00\x01") - content = _build_user_content( - "read", [_attachment(f, is_image=False, supported=True)] - ) - assert content == [{"type": "text", "text": "read"}] +class TestUnsupportedWarning: + @pytest.mark.parametrize( + "attachments, expected", + [ + pytest.param( + [("a.txt", "text", True), ("b.png", "image", True)], + None, + id="all-supported", + ), + pytest.param( + [("a.png", "image", False)], + "gpt-x can't read images — 1 will be skipped", + id="image", + ), + pytest.param( + [("a.mp3", "audio", False)], + "gpt-x can't read audio — 1 will be skipped", + id="audio", + ), + pytest.param( + [("a.png", "image", False), ("b.mp3", "audio", False)], + "gpt-x can't read images/audio — 2 will be skipped", + id="mixed", + ), + ], + ) + def test_warning( + self, + tmp_path, + attachments: list[tuple[str, AttachmentKind, bool]], + expected: str | None, + ): + atts = [ + _attachment(tmp_path / name, kind=kind, supported=supported) + for name, kind, supported in attachments + ] + + warning = _unsupported_warning("gpt-x", atts, suffix="will be skipped") + + assert warning == expected diff --git a/tests/test_chat_tui.py b/tests/test_chat_tui.py index c61c4ec..334e4cc 100644 --- a/tests/test_chat_tui.py +++ b/tests/test_chat_tui.py @@ -37,14 +37,14 @@ async def test_dropped_file_shows_chip_and_keeps_input_clean(self, tmp_path): app = _app() async with app.run_test() as pilot: # Mirror the chat command's drop handler over the real framework path. - def on_drop(paths: list[str]) -> None: + def on_drop(paths: list[Path]) -> None: for p in paths: pending.append( Attachment( path=p, - name=Path(p).name, - size=Path(p).stat().st_size, - is_image=False, + name=p.name, + size=p.stat().st_size, + kind="text", supported=True, ) ) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index a75fb47..a986864 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from pathlib import Path import pytest from textual.app import App, ComposeResult @@ -10,6 +11,7 @@ USER_COLOR, Attachment, AttachmentBadge, + AttachmentKind, BatchProgressWidget, BatchResultWidget, UserMessage, @@ -30,14 +32,14 @@ def _render_text(renderable, width: int = 200) -> str: def _attachment( name: str = "f.txt", size: int = 1024, - is_image: bool = False, + kind: AttachmentKind = "text", supported: bool = True, ) -> Attachment: return Attachment( - path=f"/tmp/{name}", + path=Path(f"/tmp/{name}"), name=name, size=size, - is_image=is_image, + kind=kind, supported=supported, ) @@ -188,7 +190,7 @@ class TestRenderAttachments: def test_warning_line_above_chips(self): text = _render_text( render_attachments( - [_attachment(name="x.png", is_image=True, supported=False)], + [_attachment(name="x.png", kind="image", supported=False)], warning="gpt-x can't read images — 1 will be skipped", ) ) @@ -212,7 +214,7 @@ async def test_renders_name_and_size(self): async def test_shows_vision_warning(self): app = WidgetApp( lambda: AttachmentBadge( - [_attachment(name="img.png", is_image=True, supported=False)], + [_attachment(name="img.png", kind="image", supported=False)], warning="gpt-x can't read images — 1 skipped", ) ) From 545ae75ccfbab6a87905cab2852b4cb8bc3ff226 Mon Sep 17 00:00:00 2001 From: julien Date: Mon, 24 Aug 2026 13:45:22 +0200 Subject: [PATCH 5/6] feat: wire --trace into chat, talk, and the one-shot command --- README.md | 14 ++++++++++++++ docs/commands/chat.md | 1 + docs/commands/talk.md | 1 + padwan_cli/chat.py | 10 ++++++++++ padwan_cli/run.py | 10 ++++++++++ padwan_cli/talk.py | 10 ++++++++++ 6 files changed, 46 insertions(+) diff --git a/README.md b/README.md index 13f7629..3cd2e9b 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,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 ` (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 +``` + diff --git a/docs/commands/chat.md b/docs/commands/chat.md index b6fc3be..c9751ac 100644 --- a/docs/commands/chat.md +++ b/docs/commands/chat.md @@ -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. diff --git a/docs/commands/talk.md b/docs/commands/talk.md index 2d99cfb..b446e4d 100644 --- a/docs/commands/talk.md +++ b/docs/commands/talk.md @@ -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 diff --git a/padwan_cli/chat.py b/padwan_cli/chat.py index ab94b93..ade3e8e 100644 --- a/padwan_cli/chat.py +++ b/padwan_cli/chat.py @@ -29,6 +29,7 @@ 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, @@ -190,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.""" @@ -246,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): diff --git a/padwan_cli/run.py b/padwan_cli/run.py index 4f8f423..921d04e 100644 --- a/padwan_cli/run.py +++ b/padwan_cli/run.py @@ -25,6 +25,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 { @@ -127,6 +128,12 @@ async def oneshot( "--stream-thinking", help="Stream model reasoning/thinking tokens to stderr (requires --stream)", ), + 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 @@ -146,6 +153,9 @@ def _thought_streamer(chunk: str) -> None: on_thought: OnThought | None = _thought_streamer if stream_thinking else None + if trace: + enable_tracing(trace) + client = LLMClient(model=model, base_url=base_url, on_thought=on_thought) messages: list[Message] = [Message(role="user", content=prompt)] async with client: diff --git a/padwan_cli/talk.py b/padwan_cli/talk.py index 643364b..e790e9e 100644 --- a/padwan_cli/talk.py +++ b/padwan_cli/talk.py @@ -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 @@ -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, @@ -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( From 272d20017cc50ff13f2f7b5505475448d97db470 Mon Sep 17 00:00:00 2001 From: julien Date: Mon, 24 Aug 2026 13:45:22 +0200 Subject: [PATCH 6/6] feat: one-shot -f/--file attachments routed by content type --- README.md | 3 +++ padwan_cli/run.py | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3cd2e9b..5202abb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/padwan_cli/run.py b/padwan_cli/run.py index 921d04e..caf5793 100644 --- a/padwan_cli/run.py +++ b/padwan_cli/run.py @@ -2,6 +2,7 @@ import json import sys +from pathlib import Path from typing import Any from piou import Cli, Option @@ -15,7 +16,9 @@ GROK_MODELS, MISTRAL_MODELS, OPENAI_MODELS, + ContentPart, LLMClient, + content_parts, ) from padwan_llm._base import OnThought from padwan_llm.conversation import Message @@ -128,6 +131,12 @@ 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", @@ -156,8 +165,16 @@ def _thought_streamer(chunk: str) -> 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):